Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 105 additions & 4 deletions .claude/skills/int-evolution-go/scripts/evolution_go_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,72 @@ def get_config():
return url.rstrip("/"), key


def _retry_http_call_client(do_call, max_attempts=3, base_delay=2.0, max_delay=8.0):
"""Exponential backoff + jitter for Evolution Go API calls.

Retries on HTTP 5xx, urllib.error.URLError, and socket.timeout (transient).
NEVER retries on HTTP 4xx (deterministic client errors).

Returns the result of do_call() on success.
Raises the last exception after max_attempts are exhausted.
Raises immediately on HTTP 4xx (no retry).
"""
last_exc = None
for attempt in range(max_attempts):
try:
return do_call()
except urllib.error.HTTPError as e:
if e.code < 500:
# 4xx — deterministic, raise immediately (caller decides sys.exit vs raise)
raise
last_exc = e
if attempt < max_attempts - 1:
delay = min(base_delay ** attempt + random.uniform(0, 0.5), max_delay)
print(
json.dumps({
"evt": "api_request_retry",
"attempt": attempt + 1,
"max_attempts": max_attempts,
"http_status": e.code,
"delay_s": round(delay, 2),
})
)
time.sleep(delay)
else:
print(
json.dumps({
"evt": "api_request_failed",
"attempt": attempt + 1,
"max_attempts": max_attempts,
"http_status": e.code,
"category": "transient",
})
)
except (urllib.error.URLError, socket.timeout) as e:
last_exc = e
if attempt < max_attempts - 1:
delay = min(base_delay ** attempt + random.uniform(0, 0.5), max_delay)
print(
json.dumps({
"evt": "api_request_retry",
"attempt": attempt + 1,
"max_attempts": max_attempts,
"error": str(e),
"delay_s": round(delay, 2),
})
)
time.sleep(delay)
else:
print(
json.dumps({
"evt": "api_request_failed",
"attempt": attempt + 1,
"max_attempts": max_attempts,
"error": str(e),
"category": "transient",
})
)
raise last_exc
def api_request(method, path, data=None):
"""Make an HTTP request to the Evolution Go API."""
base_url, api_key = get_config()
Expand Down Expand Up @@ -192,6 +258,30 @@ def cmd_summary(args):
print(json.dumps(instances, indent=2))


# ── Proxy Management ────────────────────────────────────────────────

def cmd_set_proxy(args):
"""Set proxy on an instance."""
proxy = {
"host": args.host,
"port": int(args.port),
"protocol": args.protocol,
"username": args.username,
"password": args.password,
}
result = api_request("POST", f"/instance/proxy/{args.instanceId}", data=proxy)
print(json.dumps(result, indent=2))


def cmd_get_proxy(args):
"""Get proxy configuration of an instance."""
result = api_request("GET", f"/instance/proxy/{args.instanceId}")
print(json.dumps(result, indent=2))


def cmd_delete_proxy(args):
result = api_request("DELETE", f"/instance/proxy/{args.instanceId}")
print(json.dumps(result, indent=2))
# ── Send Messages ────────────────────────────────────────────────────

def cmd_send_text(args):
Expand Down Expand Up @@ -399,13 +489,24 @@ def main():
p.add_argument("instanceId", help="Instance ID to delete")
p.add_argument("--json", action="store_true")

p = sub.add_parser("delete_proxy", help="Remove proxy from instance")
p.add_argument("instanceId", help="Instance ID")
p.add_argument("--json", action="store_true")

p = sub.add_parser("summary", help="Overview of all instances with status")
p.add_argument("--json", action="store_true")

# ── Proxy Management ──
p = sub.add_parser("set_proxy", help="Set proxy on an instance")
p.add_argument("instanceId", help="Instance ID")
p.add_argument("--host", required=True, help="Proxy host")
p.add_argument("--port", required=True, help="Proxy port")
p.add_argument("--protocol", default="http", help="Proxy protocol")
p.add_argument("--username", required=True, help="Proxy username")
p.add_argument("--password", required=True, help="Proxy password")

p = sub.add_parser("get_proxy", help="Get proxy configuration of an instance")
p.add_argument("instanceId", help="Instance ID")

p = sub.add_parser("delete_proxy", help="Remove proxy from an instance")
p.add_argument("instanceId", help="Instance ID")

# ── Send Messages ──
p = sub.add_parser("send_text", help="Send text message")
p.add_argument("number", help="Recipient phone number")
Expand Down
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ META_APP_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=

# ── License — headless auto-activation ───────────────
# Set this to the email used in your first manual license registration.
# On startup, EvoNexus calls /v1/register/auto silently and skips the manual
# setup screen. Falls back to manual setup if the email isn't registered yet.
# Leave empty (or unset) to keep the default behavior.
# EVOLUTION_OPERATOR_EMAIL=operator@example.com

# ── Evolution API ────────────────────────────────────
# Your Evolution API instance URL and global API key
EVOLUTION_API_URL=
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Harassment, discrimination, or abusive behavior will not be tolerated.

### Reporting Bugs

1. Check existing [issues](https://github.com/EvolutionAPI/evo-nexus/issues)
1. Check existing [issues](https://github.com/evolution-foundation/evo-nexus/issues)
to avoid duplicates
2. Open a new issue with:
- Clear, descriptive title
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
</p>

<p align="center">
<a href="https://github.com/EvolutionAPI/evo-nexus/releases/latest"><img src="https://img.shields.io/github/v/release/EvolutionAPI/evo-nexus?include_prereleases&label=version&color=00ffa7" alt="Latest version" /></a>
<a href="https://github.com/evolution-foundation/evo-nexus/releases/latest"><img src="https://img.shields.io/github/v/release/evolution-foundation/evo-nexus?include_prereleases&label=version&color=00ffa7" alt="Latest version" /></a>
<a href="https://opensource.org/licenses/Apache-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache 2.0" /></a>
<a href="https://docs.evolutionfoundation.com.br"><img src="https://img.shields.io/badge/Docs-evolutionfoundation.com.br-00ffa7" alt="Documentation" /></a>
<a href="https://evolutionfoundation.com.br/community"><img src="https://img.shields.io/badge/Community-Join%20us-white" alt="Community" /></a>
Expand Down Expand Up @@ -46,7 +46,7 @@ It turns a single CLI installation into a team of **38 specialized agents** orga

## Part of the Evolution Foundation ecosystem

EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/EvolutionAPI/evo-crm-community), [Evolution API](https://github.com/EvolutionAPI/evolution-api) and [Evolution Go](https://github.com/EvolutionAPI/evolution-go).
EvoNexus is one of the projects maintained by Evolution Foundation. It is the operating layer that orchestrates the Foundation's own work — including the development of [Evo CRM Community](https://github.com/evolution-foundation/evo-crm-community), [Evolution API](https://github.com/evolution-foundation/evolution-api) and [Evolution Go](https://github.com/evolution-foundation/evolution-go).

### Why EvoNexus?

Expand Down Expand Up @@ -101,7 +101,7 @@ EvoNexus is one of the projects maintained by Evolution Foundation. It is the op
### Method 1 — Docker (no setup, runs anywhere)

```bash
curl -O https://raw.githubusercontent.com/EvolutionAPI/evo-nexus/main/docker-compose.hub.yml
curl -O https://raw.githubusercontent.com/evolution-foundation/evo-nexus/main/docker-compose.hub.yml
docker compose -f docker-compose.hub.yml up -d
open http://localhost:8080
```
Expand All @@ -117,7 +117,7 @@ npx @evoapi/evo-nexus
### Method 3 — Manual clone (developers / contributors)

```bash
git clone --depth 1 https://github.com/EvolutionAPI/evo-nexus.git
git clone --depth 1 https://github.com/evolution-foundation/evo-nexus.git
cd evo-nexus

# Interactive setup wizard
Expand Down
81 changes: 76 additions & 5 deletions dashboard/backend/licensing.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

Protocol:
POST /v1/register/direct — register with email/name, receive api_key
POST /v1/register/auto — headless register by email (must exist server-side)
POST /v1/activate — validate existing api_key on startup
GET /api/geo — geo-lookup from client IP
"""

import hashlib
import hmac as hmac_mod
import os
import socket
import uuid
import logging
Expand Down Expand Up @@ -155,6 +157,24 @@ def direct_register(email: str, name: str, instance_id: str,
return _post("/v1/register/direct", payload)


# ── Auto Registration (email-only, headless) ──

def auto_register(email: str, instance_id: str) -> dict:
"""Headless registration using only the operator email.

The customer must already exist on the licensing server (one prior manual
registration). Used by the EVOLUTION_OPERATOR_EMAIL env-var flow.

Returns {api_key, customer_id, tier, status}.
"""
return _post("/v1/register/auto", {
"email": email,
"tier": TIER,
"instance_id": instance_id,
"version": VERSION,
})


# ── Activation (startup with existing api_key) ──

def activate(instance_id: str, api_key: str) -> bool:
Expand Down Expand Up @@ -260,8 +280,54 @@ def initialize_runtime():

# ── Auto-register for existing installs ──────

def try_auto_register_from_env(instance_id: str) -> bool:
"""Headless activation via EVOLUTION_OPERATOR_EMAIL env var.

Requires the email to already exist on the licensing server (one prior
manual registration). Returns True on success.

Failures are silent — caller falls back to the existing admin-based or
manual setup flow.
"""
email = os.environ.get("EVOLUTION_OPERATOR_EMAIL", "").strip()
if not email:
return False

try:
result = auto_register(email=email, instance_id=instance_id)
except requests.HTTPError as e:
status = e.response.status_code if e.response is not None else "?"
if status == 404:
logger.info("Auto-activation skipped — email not registered yet (first time?).")
else:
logger.warning(f"Auto-activation rejected ({status}): falling back to manual flow.")
return False
except Exception as e:
logger.warning(f"Auto-activation skipped — {e}")
return False

api_key = result.get("api_key")
if not api_key:
logger.warning("Auto-activation response missing api_key")
return False

set_runtime_config("api_key", api_key)
set_runtime_config("tier", result.get("tier", TIER))
if result.get("customer_id"):
set_runtime_config("customer_id", str(result["customer_id"]))
set_runtime_config("version", VERSION)
set_runtime_config("registered_at", datetime.now(timezone.utc).isoformat())

ctx = get_context()
ctx.api_key = api_key
ctx.instance_id = instance_id
logger.info("License activated automatically via EVOLUTION_OPERATOR_EMAIL")
return True


def auto_register_if_needed():
"""If users exist but no license, register retroactively."""
"""If no license yet, try EVOLUTION_OPERATOR_EMAIL first, then fall back to
the admin-based retroactive flow."""
try:
instance_id = get_runtime_config("instance_id")
api_key = get_runtime_config("api_key")
Expand All @@ -270,6 +336,15 @@ def auto_register_if_needed():
initialize_runtime()
return

if not instance_id:
instance_id = generate_instance_id()
set_runtime_config("instance_id", instance_id)

# First-class path: silent activation from env var.
if try_auto_register_from_env(instance_id):
return

# Fallback: if there's an admin user already, register retroactively.
from models import User
if User.query.count() == 0:
return
Expand All @@ -278,10 +353,6 @@ def auto_register_if_needed():
if not admin or not admin.email:
return

if not instance_id:
instance_id = generate_instance_id()
set_runtime_config("instance_id", instance_id)

setup_perform(
email=admin.email or "",
name=admin.display_name or admin.username,
Expand Down