diff --git a/frontend/src/pages/AgentsPage.tsx b/frontend/src/pages/AgentsPage.tsx index 5af0aed0..b339aa4d 100644 --- a/frontend/src/pages/AgentsPage.tsx +++ b/frontend/src/pages/AgentsPage.tsx @@ -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', diff --git a/src/openjarvis/channels/sendblue.py b/src/openjarvis/channels/sendblue.py new file mode 100644 index 00000000..c691b05c --- /dev/null +++ b/src/openjarvis/channels/sendblue.py @@ -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"] diff --git a/src/openjarvis/server/agent_manager_routes.py b/src/openjarvis/server/agent_manager_routes.py index 26421608..16cf13aa 100644 --- a/src/openjarvis/server/agent_manager_routes.py +++ b/src/openjarvis/server/agent_manager_routes.py @@ -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) diff --git a/src/openjarvis/server/app.py b/src/openjarvis/server/app.py index 781de49e..857832c8 100644 --- a/src/openjarvis/server/app.py +++ b/src/openjarvis/server/app.py @@ -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, diff --git a/src/openjarvis/server/webhook_routes.py b/src/openjarvis/server/webhook_routes.py index 6af3a15b..0ff054a6 100644 --- a/src/openjarvis/server/webhook_routes.py +++ b/src/openjarvis/server/webhook_routes.py @@ -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