mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
feat: complete SendBlue integration — auto-restore, CLI, health check, docs
- Register sendblue in channels/__init__.py so ChannelRegistry works - Auto-restore SendBlue bindings on server startup from database, re-creating ChannelBridge + DeepResearchAgent so webhooks survive server restarts - Add sendblue to CLI channel_cmd.py (_get_channel, help text) and SystemBuilder._resolve_channel() for config.toml support - Health check endpoint GET /v1/channels/sendblue/health returns channel_connected, bridge_wired, ready status - Frontend: SendBlueWizard checks health on mount, shows "Disconnected" badge with "Reconnect" button when bridge is dead - Docs: CLI usage, server restart behavior, ngrok re-registration, troubleshooting table, health check endpoint Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bfcc90866c
commit
9825daa334
@@ -457,6 +457,72 @@ api_secret_key = "YOUR_API_SECRET_KEY"
|
||||
from_number = "+16452468235"
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# Set credentials via environment variables
|
||||
export SENDBLUE_API_KEY_ID="your_key"
|
||||
export SENDBLUE_API_SECRET_KEY="your_secret"
|
||||
export SENDBLUE_FROM_NUMBER="+16452468235"
|
||||
|
||||
# Check channel status
|
||||
jarvis channel status --channel-type sendblue
|
||||
|
||||
# Send a message
|
||||
jarvis channel send sendblue "+15551234567" "Hello from Jarvis!"
|
||||
```
|
||||
|
||||
### Server Restart Behavior
|
||||
|
||||
SendBlue bindings are **automatically restored on server restart**. When `jarvis serve` starts:
|
||||
|
||||
1. The server checks the database for existing SendBlue channel bindings
|
||||
2. Re-creates the `SendBlueChannel` instance with stored credentials
|
||||
3. Re-wires the `ChannelBridge` with a `DeepResearchAgent`
|
||||
4. Incoming webhooks resume working immediately
|
||||
|
||||
**However, if using ngrok:** The tunnel URL changes on every ngrok restart. You must re-register the new URL with SendBlue:
|
||||
|
||||
```bash
|
||||
# Start ngrok (get new URL)
|
||||
ngrok http 8222
|
||||
|
||||
# Register the new webhook URL
|
||||
curl -X PUT https://api.sendblue.co/api/account/webhooks \
|
||||
-H "sb-api-key-id: YOUR_KEY" \
|
||||
-H "sb-api-secret-key: YOUR_SECRET" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"webhooks": {"receive": ["https://NEW-NGROK-URL.ngrok-free.dev/webhooks/sendblue"]}}'
|
||||
```
|
||||
|
||||
!!! tip "Stable tunnel URL"
|
||||
Ngrok paid plans provide a fixed subdomain that persists across restarts. Alternatively, use [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) for a free, stable URL.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| "Message received!" ack sent but no response | DeepResearch agent timed out or errored | Check server logs for errors |
|
||||
| No ack, no response | Webhook URL not reachable | Verify ngrok is running; re-register webhook URL |
|
||||
| "Disconnected" badge in Messaging tab | Server restarted without restoring bindings | Click "Reconnect" in the UI |
|
||||
| SendBlue returns 401 | Invalid API credentials | Re-enter API key and secret in Messaging tab |
|
||||
| "Contacts must text this number first" | Free tier requires verified contacts | Add the recipient in SendBlue dashboard under Contacts |
|
||||
| Messages arrive but are not processed | Channel bridge not wired | Remove and re-add the SendBlue binding in Messaging tab |
|
||||
|
||||
### Health Check
|
||||
|
||||
The server exposes `GET /v1/channels/sendblue/health` which returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_connected": true,
|
||||
"bridge_wired": true,
|
||||
"ready": true
|
||||
}
|
||||
```
|
||||
|
||||
If `ready` is `false`, the Messaging tab shows a "Disconnected" badge with a "Reconnect" button.
|
||||
|
||||
---
|
||||
|
||||
## WhatsAppBaileysChannel
|
||||
|
||||
@@ -528,6 +528,12 @@ export async function sendblueTest(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendblueHealth(): Promise<{ channel_connected: boolean; bridge_wired: boolean; ready: boolean }> {
|
||||
const res = await fetch(`${getBase()}/v1/channels/sendblue/health`);
|
||||
if (!res.ok) return { channel_connected: false, bridge_wired: false, ready: false };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchTemplates(): Promise<AgentTemplate[]> {
|
||||
const res = await fetch(`${getBase()}/v1/templates`);
|
||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sendblueVerify,
|
||||
sendblueRegisterWebhook,
|
||||
sendblueTest,
|
||||
sendblueHealth,
|
||||
} from '../lib/api';
|
||||
import type { AgentTask, ChannelBinding, AgentTemplate, AgentMessage, ManagedAgent, LearningLogEntry, AgentTrace, ToolInfo } from '../lib/api';
|
||||
import {
|
||||
@@ -1870,9 +1871,31 @@ function SendBlueWizard({
|
||||
const [testNumber, setTestNumber] = useState('');
|
||||
const [testSent, setTestSent] = useState(false);
|
||||
|
||||
const [healthy, setHealthy] = useState(true);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
|
||||
const isActive = !!binding;
|
||||
const activeNumber = (binding?.config?.from_number as string) || '';
|
||||
|
||||
// Check health on mount when active
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
sendblueHealth().then((h) => setHealthy(h.ready)).catch(() => setHealthy(false));
|
||||
}, [isActive]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
if (!binding) return;
|
||||
setReconnecting(true);
|
||||
try {
|
||||
// Re-bind to re-create the bridge
|
||||
const cfg = binding.config || {};
|
||||
await unbindAgentChannel(agentId, binding.id);
|
||||
await bindAgentChannel(agentId, 'sendblue', cfg as Record<string, unknown>);
|
||||
setHealthy(true);
|
||||
onDone();
|
||||
} catch { /* */ } finally { setReconnecting(false); }
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg-secondary)',
|
||||
border: isActive ? '1px solid #2a5a3a' : '1px dashed var(--color-border)',
|
||||
@@ -1971,15 +1994,25 @@ function SendBlueWizard({
|
||||
<span style={{ fontSize: 18, marginRight: 10 }}>{'\uD83D\uDCAC'}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>iMessage / SMS</div>
|
||||
<div style={{ fontSize: 11, color: '#4ade80' }}>
|
||||
Active on {activeNumber}
|
||||
<div style={{ fontSize: 11, color: healthy ? '#4ade80' : '#f59e0b' }}>
|
||||
{healthy ? `Active on ${activeNumber}` : `Disconnected — ${activeNumber}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{!healthy && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
style={{ ...btnPrimary, fontSize: 10, padding: '3px 10px' }}
|
||||
>
|
||||
{reconnecting ? '...' : 'Reconnect'}
|
||||
</button>
|
||||
)}
|
||||
<span style={{
|
||||
background: '#2a5a3a', color: '#4ade80',
|
||||
background: healthy ? '#2a5a3a' : '#78350f',
|
||||
color: healthy ? '#4ade80' : '#f59e0b',
|
||||
padding: '2px 8px', borderRadius: 10, fontSize: 10, fontWeight: 600,
|
||||
}}>Active</span>
|
||||
}}>{healthy ? 'Active' : 'Disconnected'}</span>
|
||||
<button onClick={() => setExpanded(true)} style={btnSecondary}>
|
||||
Details
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
_CHANNEL_TYPE_HELP = (
|
||||
"Channel type (telegram, discord, slack, webhook, email, "
|
||||
"Channel type (sendblue, telegram, discord, slack, webhook, email, "
|
||||
"whatsapp, whatsapp_baileys, signal, google_chat, irc, webchat, teams, "
|
||||
"matrix, mattermost, feishu, bluebubbles)."
|
||||
)
|
||||
@@ -134,6 +134,22 @@ def _get_channel(
|
||||
if wbc.assistant_name:
|
||||
kwargs["assistant_name"] = wbc.assistant_name
|
||||
kwargs["assistant_has_own_number"] = wbc.assistant_has_own_number
|
||||
elif key == "sendblue":
|
||||
import os
|
||||
|
||||
kwargs["api_key_id"] = os.environ.get("SENDBLUE_API_KEY_ID", "")
|
||||
kwargs["api_secret_key"] = os.environ.get(
|
||||
"SENDBLUE_API_SECRET_KEY", ""
|
||||
)
|
||||
kwargs["from_number"] = os.environ.get("SENDBLUE_FROM_NUMBER", "")
|
||||
sbc = getattr(config.channel, "sendblue", None)
|
||||
if sbc:
|
||||
if getattr(sbc, "api_key_id", ""):
|
||||
kwargs["api_key_id"] = sbc.api_key_id
|
||||
if getattr(sbc, "api_secret_key", ""):
|
||||
kwargs["api_secret_key"] = sbc.api_secret_key
|
||||
if getattr(sbc, "from_number", ""):
|
||||
kwargs["from_number"] = sbc.from_number
|
||||
|
||||
if not ChannelRegistry.contains(key):
|
||||
raise click.ClickException(f"Unknown channel type: {key}")
|
||||
|
||||
@@ -1947,6 +1947,21 @@ def create_agent_manager_router(
|
||||
detail=f"Failed to send test message: {exc}",
|
||||
)
|
||||
|
||||
@sendblue_router.get("/health")
|
||||
async def sendblue_health(request: Request):
|
||||
"""Check if the SendBlue channel bridge is wired and ready."""
|
||||
sb = getattr(request.app.state, "sendblue_channel", None)
|
||||
bridge = getattr(request.app.state, "channel_bridge", None)
|
||||
has_bridge = bridge is not None and (
|
||||
hasattr(bridge, "_channels")
|
||||
and "sendblue" in bridge._channels
|
||||
)
|
||||
return {
|
||||
"channel_connected": sb is not None,
|
||||
"bridge_wired": has_bridge,
|
||||
"ready": sb is not None and has_bridge,
|
||||
}
|
||||
|
||||
return (
|
||||
agents_router,
|
||||
templates_router,
|
||||
|
||||
@@ -18,6 +18,98 @@ from openjarvis.server.routes import router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _restore_sendblue_bindings(app: FastAPI) -> None:
|
||||
"""Restore SendBlue channel bindings from the database on startup.
|
||||
|
||||
If a SendBlue binding was created via the Messaging tab and the server
|
||||
restarts, this ensures the ChannelBridge + DeepResearchAgent are wired
|
||||
up so incoming webhooks continue to work.
|
||||
"""
|
||||
try:
|
||||
mgr = getattr(app.state, "agent_manager", None)
|
||||
if mgr is None:
|
||||
return
|
||||
|
||||
# Check all agents for sendblue bindings
|
||||
for agent in mgr.list_agents():
|
||||
agent_id = agent.get("id", agent.get("agent_id", ""))
|
||||
bindings = mgr.list_channel_bindings(agent_id)
|
||||
for b in bindings:
|
||||
if b.get("channel_type") != "sendblue":
|
||||
continue
|
||||
config = b.get("config", {})
|
||||
api_key_id = config.get("api_key_id", "")
|
||||
api_secret_key = config.get("api_secret_key", "")
|
||||
from_number = config.get("from_number", "")
|
||||
if not api_key_id or not api_secret_key:
|
||||
continue
|
||||
|
||||
from openjarvis.channels.sendblue import SendBlueChannel
|
||||
|
||||
sb = SendBlueChannel(
|
||||
api_key_id=api_key_id,
|
||||
api_secret_key=api_secret_key,
|
||||
from_number=from_number,
|
||||
)
|
||||
sb.connect()
|
||||
app.state.sendblue_channel = sb
|
||||
|
||||
# Create ChannelBridge if none exists
|
||||
bridge = getattr(app.state, "channel_bridge", None)
|
||||
if bridge and hasattr(bridge, "_channels"):
|
||||
bridge._channels["sendblue"] = sb
|
||||
else:
|
||||
from openjarvis.server.channel_bridge import ChannelBridge
|
||||
from openjarvis.server.session_store import SessionStore
|
||||
|
||||
session_store = SessionStore()
|
||||
engine = getattr(app.state, "engine", None)
|
||||
dr_agent = None
|
||||
if engine:
|
||||
from openjarvis.server.agent_manager_routes import (
|
||||
_build_deep_research_tools,
|
||||
)
|
||||
|
||||
tools = _build_deep_research_tools(
|
||||
engine=engine, model=""
|
||||
)
|
||||
if tools:
|
||||
from openjarvis.agents.deep_research import (
|
||||
DeepResearchAgent,
|
||||
)
|
||||
|
||||
model_name = getattr(
|
||||
app.state, "model", ""
|
||||
) or getattr(engine, "_model", "")
|
||||
dr_agent = DeepResearchAgent(
|
||||
engine=engine,
|
||||
model=model_name,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
bus = getattr(app.state, "bus", None)
|
||||
if bus is None:
|
||||
from openjarvis.core.events import EventBus
|
||||
|
||||
bus = EventBus()
|
||||
|
||||
app.state.channel_bridge = ChannelBridge(
|
||||
channels={"sendblue": sb},
|
||||
session_store=session_store,
|
||||
bus=bus,
|
||||
agent_manager=mgr,
|
||||
deep_research_agent=dr_agent,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Restored SendBlue channel binding: %s",
|
||||
from_number,
|
||||
)
|
||||
return # Only need one SendBlue binding
|
||||
except Exception as exc:
|
||||
logger.debug("SendBlue binding restore skipped: %s", exc)
|
||||
|
||||
# No-cache headers applied to static file responses
|
||||
_NO_CACHE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
|
||||
@@ -864,6 +864,15 @@ class SystemBuilder:
|
||||
if wbc.assistant_name:
|
||||
kwargs["assistant_name"] = wbc.assistant_name
|
||||
kwargs["assistant_has_own_number"] = wbc.assistant_has_own_number
|
||||
elif key == "sendblue":
|
||||
sbc = getattr(config.channel, "sendblue", None)
|
||||
if sbc:
|
||||
if getattr(sbc, "api_key_id", ""):
|
||||
kwargs["api_key_id"] = sbc.api_key_id
|
||||
if getattr(sbc, "api_secret_key", ""):
|
||||
kwargs["api_secret_key"] = sbc.api_secret_key
|
||||
if getattr(sbc, "from_number", ""):
|
||||
kwargs["from_number"] = sbc.from_number
|
||||
|
||||
return ChannelRegistry.create(key, **kwargs)
|
||||
except Exception as exc:
|
||||
|
||||
Reference in New Issue
Block a user