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:
Jon Saad-Falcon
2026-03-27 20:44:55 -07:00
co-authored by Claude Opus 4.6
parent e5477e89fe
commit 7fd247be7e
5 changed files with 440 additions and 14 deletions
+33 -7
View File
@@ -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',
+234
View File
@@ -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"]
+116 -5
View File
@@ -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)
+2 -2
View File
@@ -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,
+55
View File
@@ -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