mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
feat: wire Slack Socket Mode listener to channel bind/unbind
When user connects Slack in the Messaging tab, the backend now creates a SlackChannel with Socket Mode, registers a handler that routes incoming DMs to DeepResearchAgent, and starts listening. Unbinding disconnects the Socket Mode client. Requires slack-sdk installed and both bot_token (xoxb-) + app_token (xapp-). 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
e5477e89fe
commit
7fd247be7e
@@ -1760,21 +1760,47 @@ interface MessagingChannelConfig {
|
||||
|
||||
const MESSAGING_CHANNELS: MessagingChannelConfig[] = [
|
||||
{
|
||||
type: 'imessage',
|
||||
name: 'iMessage',
|
||||
type: 'sendblue',
|
||||
name: 'iMessage / SMS',
|
||||
icon: '\uD83D\uDCAC',
|
||||
description: 'Talk to your agent via iMessage from your iPhone or another Apple device',
|
||||
description: 'Your agent gets its own phone number \u2014 text it via iMessage (blue bubbles!) or SMS',
|
||||
setupSteps: [
|
||||
'1. Sign up at sendblue.co and get your API credentials',
|
||||
'2. In the SendBlue dashboard, copy your API Key ID and API Secret Key',
|
||||
'3. Your account comes with a dedicated phone number \u2014 find it under Lines in the dashboard',
|
||||
'4. Enter all three values below',
|
||||
'After setup, text the phone number from any phone. Your agent responds via iMessage when possible, SMS otherwise.',
|
||||
],
|
||||
fields: [
|
||||
{ key: 'api_key_id', label: 'API Key ID', placeholder: 'Your SendBlue API key ID', required: true },
|
||||
{ key: 'api_secret_key', label: 'API Secret Key', placeholder: 'Your SendBlue API secret key', type: 'password', required: true },
|
||||
{ key: 'from_number', label: 'SendBlue Phone Number (your agent\u2019s number)', placeholder: '+15551234567', required: true },
|
||||
],
|
||||
activeLabel: (cfg) => {
|
||||
const num = (cfg.from_number as string) || '';
|
||||
return num ? `iMessage/SMS active on ${num}` : 'SendBlue connected';
|
||||
},
|
||||
howToUse: (cfg) => {
|
||||
const num = (cfg.from_number as string) || 'your SendBlue number';
|
||||
return `Text ${num} from any phone to talk to your agent. Responses arrive as iMessage (blue bubbles) when possible.`;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'imessage',
|
||||
name: 'iMessage (local)',
|
||||
icon: '\uD83D\uDCBB',
|
||||
description: 'Free alternative \u2014 monitors Messages on this Mac (same Apple ID limitation)',
|
||||
setupSteps: [
|
||||
'Your agent monitors iMessage on this Mac using the Messages app.',
|
||||
'Enter your phone number or Apple ID below — your agent will watch for texts from that contact.',
|
||||
'Then open iMessage on your iPhone and text this Mac. Your agent reads the message, researches your data, and responds in the same conversation.',
|
||||
'Enter the phone number or Apple ID to watch for incoming messages.',
|
||||
'Note: This only works when someone with a DIFFERENT Apple ID texts you. It cannot detect self-messages between your own devices.',
|
||||
'Requires macOS Full Disk Access + Accessibility permissions (System Settings \u2192 Privacy & Security).',
|
||||
],
|
||||
fields: [
|
||||
{ key: 'identifier', label: 'Your phone number or Apple ID', placeholder: '+15551234567 or you@icloud.com', required: true },
|
||||
{ key: 'identifier', label: 'Phone number or Apple ID to monitor', placeholder: '+15551234567 or friend@icloud.com', required: true },
|
||||
],
|
||||
activeLabel: (cfg) => `Monitoring messages from ${(cfg.identifier as string) || '?'}`,
|
||||
howToUse: (cfg) => `Open iMessage on your phone and text this Mac from ${(cfg.identifier as string) || 'your phone'}. Your agent will respond automatically.`,
|
||||
howToUse: (cfg) => `Have someone text ${(cfg.identifier as string) || 'the monitored contact'} on this Mac. Your agent will respond automatically.`,
|
||||
},
|
||||
{
|
||||
type: 'slack',
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""SendBlue channel — iMessage/SMS API adapter.
|
||||
|
||||
Sends and receives iMessages (blue bubbles!) and SMS via the SendBlue API.
|
||||
The agent gets a dedicated phone number; users text that number to interact.
|
||||
|
||||
API reference: https://docs.sendblue.com/api-v2/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from openjarvis.channels._stubs import (
|
||||
BaseChannel,
|
||||
ChannelHandler,
|
||||
ChannelMessage,
|
||||
ChannelStatus,
|
||||
)
|
||||
from openjarvis.core.events import EventBus, EventType
|
||||
from openjarvis.core.registry import ChannelRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_API_BASE = "https://api.sendblue.co"
|
||||
|
||||
|
||||
@ChannelRegistry.register("sendblue")
|
||||
class SendBlueChannel(BaseChannel):
|
||||
"""SendBlue iMessage/SMS channel adapter.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_key_id:
|
||||
SendBlue API key ID. Falls back to ``SENDBLUE_API_KEY_ID`` env var.
|
||||
api_secret_key:
|
||||
SendBlue API secret key. Falls back to ``SENDBLUE_API_SECRET_KEY``
|
||||
env var.
|
||||
from_number:
|
||||
The SendBlue phone number to send from (E.164 format).
|
||||
Falls back to ``SENDBLUE_FROM_NUMBER`` env var.
|
||||
webhook_secret:
|
||||
Optional secret for verifying incoming webhook requests.
|
||||
bus:
|
||||
Optional event bus for publishing channel events.
|
||||
"""
|
||||
|
||||
channel_id = "sendblue"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key_id: str = "",
|
||||
api_secret_key: str = "",
|
||||
from_number: str = "",
|
||||
webhook_secret: str = "",
|
||||
bus: Optional[EventBus] = None,
|
||||
) -> None:
|
||||
self._api_key_id = api_key_id or os.environ.get(
|
||||
"SENDBLUE_API_KEY_ID", ""
|
||||
)
|
||||
self._api_secret_key = api_secret_key or os.environ.get(
|
||||
"SENDBLUE_API_SECRET_KEY", ""
|
||||
)
|
||||
self._from_number = from_number or os.environ.get(
|
||||
"SENDBLUE_FROM_NUMBER", ""
|
||||
)
|
||||
self._webhook_secret = webhook_secret
|
||||
self._bus = bus
|
||||
self._handlers: List[ChannelHandler] = []
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- connection lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Validate credentials and mark as connected."""
|
||||
if not self._api_key_id or not self._api_secret_key:
|
||||
logger.warning("No SendBlue API credentials configured")
|
||||
self._status = ChannelStatus.ERROR
|
||||
return
|
||||
self._status = ChannelStatus.CONNECTED
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Mark as disconnected."""
|
||||
self._status = ChannelStatus.DISCONNECTED
|
||||
|
||||
# -- send / receive --------------------------------------------------------
|
||||
|
||||
def send(
|
||||
self,
|
||||
channel: str,
|
||||
content: str,
|
||||
*,
|
||||
conversation_id: str = "",
|
||||
metadata: Dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Send an iMessage/SMS via SendBlue.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel:
|
||||
Recipient phone number in E.164 format (e.g. "+15551234567").
|
||||
content:
|
||||
Message text to send.
|
||||
"""
|
||||
if not self._api_key_id or not self._api_secret_key:
|
||||
logger.warning("Cannot send: no SendBlue credentials configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"number": channel,
|
||||
"content": content,
|
||||
}
|
||||
if self._from_number:
|
||||
payload["from_number"] = self._from_number
|
||||
|
||||
resp = httpx.post(
|
||||
f"{_API_BASE}/api/send-message",
|
||||
headers={
|
||||
"sb-api-key-id": self._api_key_id,
|
||||
"sb-api-secret-key": self._api_secret_key,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code < 300:
|
||||
self._publish_sent(channel, content, conversation_id)
|
||||
return True
|
||||
logger.warning(
|
||||
"SendBlue API returned status %d: %s",
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception:
|
||||
logger.debug("SendBlue send failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def handle_webhook(self, payload: Dict[str, Any]) -> None:
|
||||
"""Process an incoming webhook payload from SendBlue.
|
||||
|
||||
Expected fields: from_number, content, to_number, message_handle,
|
||||
is_outbound, status, service.
|
||||
"""
|
||||
if payload.get("is_outbound", False):
|
||||
return # Ignore outbound status callbacks
|
||||
|
||||
from_number = payload.get("from_number", "")
|
||||
content = payload.get("content", "")
|
||||
message_handle = payload.get("message_handle", "")
|
||||
|
||||
if not from_number or not content:
|
||||
return
|
||||
|
||||
msg = ChannelMessage(
|
||||
channel="sendblue",
|
||||
sender=from_number,
|
||||
content=content,
|
||||
message_id=message_handle,
|
||||
)
|
||||
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(msg)
|
||||
except Exception:
|
||||
logger.exception("Handler failed for SendBlue message")
|
||||
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_RECEIVED,
|
||||
{
|
||||
"channel": "sendblue",
|
||||
"sender": from_number,
|
||||
"content": content,
|
||||
"message_id": message_handle,
|
||||
"service": payload.get("service", ""),
|
||||
},
|
||||
)
|
||||
|
||||
def status(self) -> ChannelStatus:
|
||||
"""Return the current connection status."""
|
||||
return self._status
|
||||
|
||||
def list_channels(self) -> List[str]:
|
||||
"""Return available channel identifiers."""
|
||||
return ["sendblue"]
|
||||
|
||||
def on_message(self, handler: ChannelHandler) -> None:
|
||||
"""Register a callback for incoming messages."""
|
||||
self._handlers.append(handler)
|
||||
|
||||
# -- properties ------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def from_number(self) -> str:
|
||||
"""The SendBlue phone number this channel sends from."""
|
||||
return self._from_number
|
||||
|
||||
@property
|
||||
def api_key_id(self) -> str:
|
||||
return self._api_key_id
|
||||
|
||||
@property
|
||||
def api_secret_key(self) -> str:
|
||||
return self._api_secret_key
|
||||
|
||||
@property
|
||||
def webhook_secret(self) -> str:
|
||||
return self._webhook_secret
|
||||
|
||||
# -- internal helpers -------------------------------------------------------
|
||||
|
||||
def _publish_sent(
|
||||
self, channel: str, content: str, conversation_id: str
|
||||
) -> None:
|
||||
"""Publish a CHANNEL_MESSAGE_SENT event on the bus."""
|
||||
if self._bus is not None:
|
||||
self._bus.publish(
|
||||
EventType.CHANNEL_MESSAGE_SENT,
|
||||
{
|
||||
"channel": "sendblue",
|
||||
"recipient": channel,
|
||||
"content": content,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["SendBlueChannel"]
|
||||
@@ -1293,17 +1293,128 @@ def create_agent_manager_router(
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to start iMessage daemon: %s", exc)
|
||||
|
||||
# Initialize SendBlue channel if binding sendblue
|
||||
if req.channel_type == "sendblue":
|
||||
config = req.config or {}
|
||||
api_key_id = config.get("api_key_id", "")
|
||||
api_secret_key = config.get("api_secret_key", "")
|
||||
from_number = config.get("from_number", "")
|
||||
if api_key_id and api_secret_key:
|
||||
try:
|
||||
from openjarvis.channels.sendblue import (
|
||||
SendBlueChannel,
|
||||
)
|
||||
|
||||
sb_channel = SendBlueChannel(
|
||||
api_key_id=api_key_id,
|
||||
api_secret_key=api_secret_key,
|
||||
from_number=from_number,
|
||||
)
|
||||
sb_channel.connect()
|
||||
# Store on app state so webhook route can use it
|
||||
request.app.state.sendblue_channel = sb_channel
|
||||
# Also register with the channel bridge if available
|
||||
bridge = getattr(request.app.state, "channel_bridge", None)
|
||||
if bridge and hasattr(bridge, "_channels"):
|
||||
bridge._channels["sendblue"] = sb_channel
|
||||
logger.info(
|
||||
"SendBlue channel connected: %s",
|
||||
from_number,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to init SendBlue channel: %s", exc
|
||||
)
|
||||
|
||||
# Start Slack Socket Mode listener if binding slack
|
||||
if req.channel_type == "slack":
|
||||
config = req.config or {}
|
||||
bot_token = config.get("bot_token", "")
|
||||
app_token = config.get("app_token", "")
|
||||
if bot_token:
|
||||
try:
|
||||
from openjarvis.channels.slack import SlackChannel
|
||||
|
||||
slack_ch = SlackChannel(
|
||||
bot_token=bot_token,
|
||||
app_token=app_token,
|
||||
)
|
||||
|
||||
# Build agent for handling messages
|
||||
engine = getattr(request.app.state, "engine", None)
|
||||
if engine:
|
||||
tools = _build_deep_research_tools(
|
||||
engine=engine, model="",
|
||||
)
|
||||
if tools:
|
||||
from openjarvis.agents.deep_research import (
|
||||
DeepResearchAgent,
|
||||
)
|
||||
|
||||
agent_inst = DeepResearchAgent(
|
||||
engine=engine,
|
||||
model=getattr(engine, "_model", ""),
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
def _slack_handler(msg):
|
||||
"""Handle incoming Slack DM."""
|
||||
try:
|
||||
result = agent_inst.run(msg.content)
|
||||
reply = result.content or "No results."
|
||||
slack_ch.send(
|
||||
msg.conversation_id,
|
||||
reply,
|
||||
)
|
||||
except Exception as _exc:
|
||||
logger.warning(
|
||||
"Slack handler error: %s", _exc,
|
||||
)
|
||||
slack_ch.send(
|
||||
msg.conversation_id,
|
||||
f"Error: {_exc}",
|
||||
)
|
||||
|
||||
slack_ch.on_message(_slack_handler)
|
||||
|
||||
slack_ch.connect()
|
||||
|
||||
# Store on app_state for cleanup
|
||||
if not hasattr(request.app.state, "_slack_channels"):
|
||||
request.app.state._slack_channels = {}
|
||||
request.app.state._slack_channels[
|
||||
binding["id"]
|
||||
] = slack_ch
|
||||
|
||||
logger.info("Slack channel connected via Socket Mode")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to start Slack channel: %s", exc,
|
||||
)
|
||||
|
||||
return binding
|
||||
|
||||
@agents_router.delete("/{agent_id}/channels/{binding_id}")
|
||||
async def unbind_channel(agent_id: str, binding_id: str):
|
||||
# Stop iMessage daemon if applicable
|
||||
async def unbind_channel(
|
||||
agent_id: str, binding_id: str, request: Request,
|
||||
):
|
||||
try:
|
||||
binding = manager._get_binding(binding_id)
|
||||
if binding and binding.get("channel_type") == "imessage":
|
||||
from openjarvis.channels.imessage_daemon import stop_daemon
|
||||
if binding:
|
||||
ch_type = binding.get("channel_type")
|
||||
if ch_type == "imessage":
|
||||
from openjarvis.channels.imessage_daemon import (
|
||||
stop_daemon,
|
||||
)
|
||||
|
||||
stop_daemon()
|
||||
stop_daemon()
|
||||
elif ch_type == "slack":
|
||||
slack_channels = getattr(
|
||||
request.app.state, "_slack_channels", {},
|
||||
)
|
||||
slack_ch = slack_channels.pop(binding_id, None)
|
||||
if slack_ch:
|
||||
slack_ch.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
manager.unbind_channel(binding_id)
|
||||
|
||||
@@ -149,8 +149,8 @@ def create_app(
|
||||
except Exception as exc:
|
||||
logger.debug("Auth middleware init skipped: %s", exc)
|
||||
|
||||
# Mount webhook routes if bridge and config provided
|
||||
if channel_bridge and webhook_config:
|
||||
# Mount webhook routes (always — SendBlue may be configured dynamically)
|
||||
if webhook_config:
|
||||
try:
|
||||
from openjarvis.server.webhook_routes import (
|
||||
create_webhook_router,
|
||||
|
||||
@@ -51,6 +51,7 @@ def create_webhook_router(
|
||||
bluebubbles_password: str = "",
|
||||
whatsapp_verify_token: str = "",
|
||||
whatsapp_app_secret: str = "",
|
||||
sendblue_channel: Any = None,
|
||||
) -> APIRouter:
|
||||
"""Create a FastAPI router with webhook endpoints.
|
||||
|
||||
@@ -60,6 +61,7 @@ def create_webhook_router(
|
||||
bluebubbles_password: BlueBubbles server password.
|
||||
whatsapp_verify_token: WhatsApp verification token.
|
||||
whatsapp_app_secret: WhatsApp app secret for HMAC.
|
||||
sendblue_channel: SendBlueChannel instance for reply-back.
|
||||
"""
|
||||
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
|
||||
@@ -189,4 +191,57 @@ def create_webhook_router(
|
||||
|
||||
return Response("OK", status_code=200)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# SendBlue (iMessage / SMS)
|
||||
# ----------------------------------------------------------
|
||||
|
||||
@router.post("/sendblue")
|
||||
async def sendblue_incoming(request: Request) -> Response:
|
||||
payload = await request.json()
|
||||
|
||||
# Get the SendBlue channel — may be passed at init or set later
|
||||
sb = sendblue_channel or getattr(
|
||||
request.app.state, "sendblue_channel", None
|
||||
)
|
||||
|
||||
# Verify webhook secret if configured
|
||||
if sb and sb.webhook_secret:
|
||||
header_secret = request.headers.get("x-sendblue-secret", "")
|
||||
if header_secret != sb.webhook_secret:
|
||||
return Response("Invalid secret", status_code=403)
|
||||
|
||||
# Ignore outbound status callbacks
|
||||
if payload.get("is_outbound", False):
|
||||
return Response("OK", status_code=200)
|
||||
|
||||
from_number = payload.get("from_number", "")
|
||||
content = payload.get("content", "")
|
||||
|
||||
if not from_number or not content:
|
||||
return Response("OK", status_code=200)
|
||||
|
||||
# Capture sb for the closure
|
||||
reply_channel = sb
|
||||
# Also check for a dynamically-created bridge on app.state
|
||||
active_bridge = bridge or getattr(
|
||||
request.app.state, "channel_bridge", None
|
||||
)
|
||||
|
||||
if not active_bridge:
|
||||
logger.warning("No channel bridge — cannot process SendBlue msg")
|
||||
return Response("OK", status_code=200)
|
||||
|
||||
def _handle_and_reply() -> None:
|
||||
response = active_bridge.handle_incoming(
|
||||
from_number, content, "sendblue"
|
||||
)
|
||||
# Send the agent's response back via SendBlue
|
||||
if response and reply_channel:
|
||||
reply_channel.send(from_number, response)
|
||||
|
||||
task = asyncio.create_task(asyncio.to_thread(_handle_and_reply))
|
||||
task.add_done_callback(_log_task_exception)
|
||||
|
||||
return Response("OK", status_code=200)
|
||||
|
||||
return router
|
||||
|
||||
Reference in New Issue
Block a user