diff --git a/.gitleaksignore b/.gitleaksignore index 2c09d5187..e7c6ed37a 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,5 +1,6 @@ dc0ca49a66f0ffe3ce9b37bd283513bb714ecada:src/presentation/web/components/common/feature-drawer/feature-drawer.stories.tsx:generic-api-key:124 42204c37eaad7947bf332782e4609f6480593abb:tests/unit/infrastructure/services/agents/executors/cursor-executor.test.ts:generic-api-key:599 +1c6b4761ae042411095cb1af81524853ef48bad7:tests/unit/infrastructure/services/messaging/content-sanitizer.test.ts:generic-api-key:24 3173676049f52fb4ff16805d1c50c27e652b7b75:tests/unit/infrastructure/services/messaging/content-sanitizer.test.ts:generic-api-key:24 cbe61f9d8b22bc0176339855f08b06ceee68fb49:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:68 cbe61f9d8b22bc0176339855f08b06ceee68fb49:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:78 diff --git a/.storybook/mocks/app/actions/messaging.ts b/.storybook/mocks/app/actions/messaging.ts new file mode 100644 index 000000000..0f5e3d738 --- /dev/null +++ b/.storybook/mocks/app/actions/messaging.ts @@ -0,0 +1,41 @@ +import type { MessagingPlatform } from '@shepai/core/domain/generated/output'; + +export async function beginMessagingPairingAction(input: { + platform: MessagingPlatform; + gatewayUrl: string; +}): Promise<{ + success: boolean; + error?: string; + session?: { + platform: MessagingPlatform; + code: string; + expiresAt: string; + gatewayUrl: string; + publicUrl: string; + routeId: string; + }; +}> { + return { + success: true, + session: { + platform: input.platform, + code: '482913', + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + gatewayUrl: input.gatewayUrl, + publicUrl: `${input.gatewayUrl.replace(/\/$/, '')}/integrations/route-demo/token-demo`, + routeId: 'route-demo', + }, + }; +} + +export async function confirmMessagingPairingAction( + _input: unknown +): Promise<{ success: boolean; error?: string }> { + return { success: true }; +} + +export async function disconnectMessagingAction( + _input: unknown +): Promise<{ success: boolean; error?: string }> { + return { success: true }; +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 995ae9ac6..557e462b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -171,6 +171,7 @@ * **tsp:** add ukrainian (uk) translation ([#535](https://github.com/shep-ai/shep/issues/535)) ([a9170fb](https://github.com/shep-ai/shep/commit/a9170fbdf081870469c58bc3433b17b000e83316)) + ## [1.175.1](https://github.com/shep-ai/shep/compare/v1.175.0...v1.175.1) (2026-04-06) diff --git a/LESSONS.md b/LESSONS.md index 53410262a..7e426e454 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -1,5 +1,29 @@ # Lessons Learned +## Messaging Remote Control: HttpGatewayClient Uses an Unsupported OAuth Grant + +`HttpGatewayClient.fetchAccessToken` (feat/messaging-remote-control, feature 082) hardcodes +`grant_type=client_credentials` in its call to `POST /oauth/token`. The OSS Commands Gateway +demo mode (`AUTH_MODE=demo`) advertises only `authorization_code` and `refresh_token` in its +`.well-known/openid-configuration` and returns `400 {"error":"unsupported grant_type"}` for +`client_credentials`. As a result, `BeginMessagingPairingUseCase` always throws +"Gateway authentication failed: ..." and the web pairing dialog never opens — the Pair button +silently fails because (a) the server action returns `{ success: false, error }` and +(b) no `` is mounted in the web layout, so the `toast.error(...)` never renders. + +**What to fix before this can work end-to-end against a real gateway:** +1. Swap `HttpGatewayClient.fetchAccessToken` to the authorization-code + PKCE flow + (matches the gateway's supported grants), OR add device-code support on the gateway side. +2. Mount a sonner `` in the web root layout so pairing failures are visible to + the user instead of silently failing. +3. The feature was marked `fast-implement` complete and merged to main without ever being + exercised against a live OSS gateway. Add a smoke test that spins up the gateway Go binary + in CI and runs `BeginMessagingPairingUseCase` against it. + +**Rule:** Any time a feature integrates with an external service, it must be validated against +a live instance of that service — unit tests with fetch mocks are not sufficient because they +only verify what you *think* the protocol is. + ## Adding a Web Feature Flag — Full Wiring Checklist Feature flags are persisted in the Settings singleton and toggled via the Settings page. A new flag is NOT just an env var or a hardcoded boolean — it must be wired end-to-end or the Settings toggle will silently fail to persist. diff --git a/apis/json-schema/MessagingCommand.yaml b/apis/json-schema/MessagingCommand.yaml new file mode 100644 index 000000000..e707af119 --- /dev/null +++ b/apis/json-schema/MessagingCommand.yaml @@ -0,0 +1,28 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingCommand.yaml +type: object +properties: + type: + $ref: MessagingFrameType.yaml + description: "Type of frame: command, chat_message, or chat_control" + command: + $ref: MessagingCommandType.yaml + description: The slash command name (new, approve, reject, stop, resume, status) + featureId: + type: string + description: Target feature ID (short numeric or full UUID) + args: + type: string + description: Free-text arguments (feature description, rejection feedback, chat text) + chatId: + type: string + description: Chat ID for routing responses back to the correct conversation + platform: + $ref: MessagingPlatform.yaml + description: Platform for routing responses back (telegram or whatsapp) +required: + - type + - command + - chatId + - platform +description: A parsed command received from a messaging platform via the Gateway tunnel diff --git a/apis/json-schema/MessagingCommandType.yaml b/apis/json-schema/MessagingCommandType.yaml new file mode 100644 index 000000000..9b9a22d2e --- /dev/null +++ b/apis/json-schema/MessagingCommandType.yaml @@ -0,0 +1,17 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingCommandType.yaml +type: string +enum: + - new + - approve + - reject + - stop + - resume + - status + - mute + - unmute + - list + - chat + - end + - help +description: Slash commands supported via messaging remote control diff --git a/apis/json-schema/MessagingConfig.yaml b/apis/json-schema/MessagingConfig.yaml new file mode 100644 index 000000000..655575e7a --- /dev/null +++ b/apis/json-schema/MessagingConfig.yaml @@ -0,0 +1,40 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingConfig.yaml +type: object +properties: + enabled: + type: boolean + default: false + description: Whether messaging remote control is enabled + gatewayUrl: + type: string + description: URL of the Commands.com Gateway instance + deviceId: + type: string + description: Device ID used when registering integration routes and opening the tunnel + gatewayClientId: + type: string + description: OAuth client ID for fetching gateway access tokens (demo mode uses public client) + telegram: + $ref: MessagingPlatformConfig.yaml + description: Telegram platform configuration + whatsapp: + $ref: MessagingPlatformConfig.yaml + description: WhatsApp platform configuration + debounceMs: + type: integer + minimum: -2147483648 + maximum: 2147483647 + default: 5000 + description: "Debounce window in milliseconds for notification delivery (default: 5000)" + chatBufferMs: + type: integer + minimum: -2147483648 + maximum: 2147483647 + default: 3000 + description: "Buffer interval in milliseconds for chat relay output batching (default: 3000)" +required: + - enabled + - debounceMs + - chatBufferMs +description: Messaging remote control configuration diff --git a/apis/json-schema/MessagingFrameType.yaml b/apis/json-schema/MessagingFrameType.yaml new file mode 100644 index 000000000..ff6e615b4 --- /dev/null +++ b/apis/json-schema/MessagingFrameType.yaml @@ -0,0 +1,8 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingFrameType.yaml +type: string +enum: + - command + - chat_message + - chat_control +description: Types of messages exchanged through the messaging tunnel diff --git a/apis/json-schema/MessagingNotification.yaml b/apis/json-schema/MessagingNotification.yaml new file mode 100644 index 000000000..2f1a63d3e --- /dev/null +++ b/apis/json-schema/MessagingNotification.yaml @@ -0,0 +1,22 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingNotification.yaml +type: object +properties: + event: + type: string + description: "Event type: feature lifecycle, CI status, gate waiting, command response, chat response" + featureId: + type: string + description: ID of the feature this notification relates to + title: + type: string + description: Human-readable feature name or title + message: + type: string + description: Human-readable notification body (sanitized, no code or secrets) +required: + - event + - featureId + - title + - message +description: A notification or response sent from Shep to a messaging platform via the Gateway tunnel diff --git a/apis/json-schema/MessagingPlatform.yaml b/apis/json-schema/MessagingPlatform.yaml new file mode 100644 index 000000000..89e4ca52b --- /dev/null +++ b/apis/json-schema/MessagingPlatform.yaml @@ -0,0 +1,7 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingPlatform.yaml +type: string +enum: + - telegram + - whatsapp +description: Supported external messaging platforms diff --git a/apis/json-schema/MessagingPlatformConfig.yaml b/apis/json-schema/MessagingPlatformConfig.yaml new file mode 100644 index 000000000..e22c69985 --- /dev/null +++ b/apis/json-schema/MessagingPlatformConfig.yaml @@ -0,0 +1,38 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: MessagingPlatformConfig.yaml +type: object +properties: + enabled: + type: boolean + default: false + description: Whether this platform connection is active + chatId: + type: string + description: Platform-specific chat ID for message routing (set during pairing) + paired: + type: boolean + default: false + description: Whether the chat has been verified via pairing code + pendingPairingCode: + type: string + description: One-time code shown to the user during pairing, cleared once confirmed + pendingPairingExpiresAt: + type: string + format: date-time + description: Expiry timestamp for the pending pairing code (ISO-8601) + routeId: + type: string + description: Gateway integration route ID allocated during pairing + routeToken: + type: string + description: Gateway integration route token (path-auth) allocated during pairing + publicUrl: + type: string + description: Public webhook URL that the messaging platform should POST updates to + botToken: + type: string + description: "Bot API token used by the daemon to send outbound messages (Telegram: 123456:ABC...)" +required: + - enabled + - paired +description: Configuration for a single messaging platform connection diff --git a/apis/json-schema/Settings.yaml b/apis/json-schema/Settings.yaml index 5c804dff0..fd831d439 100644 --- a/apis/json-schema/Settings.yaml +++ b/apis/json-schema/Settings.yaml @@ -36,6 +36,9 @@ properties: fabLayout: $ref: FabLayoutConfig.yaml description: FAB layout configuration (optional, defaults applied at runtime) + messaging: + $ref: MessagingConfig.yaml + description: Messaging remote control configuration (optional, defaults applied at runtime) required: - models - user diff --git a/docs/development/messaging-local-setup.md b/docs/development/messaging-local-setup.md new file mode 100644 index 000000000..f9ad296e4 --- /dev/null +++ b/docs/development/messaging-local-setup.md @@ -0,0 +1,256 @@ +# Messaging Remote Control — Local Setup + +This guide walks you through running the Commands.com Gateway locally and +pairing a Telegram bot with Shep end-to-end. + +## Architecture + +``` +Telegram bot webhook + │ + ▼ +cloudflared / ngrok ──► Gateway (localhost:8080) ◄──ws── Shep daemon + (shep _serve) +``` + +- The **inbound** leg (Telegram → Gateway) needs a public URL, so you tunnel + localhost with `cloudflared` or `ngrok`. +- The **outbound** leg (Shep → Gateway WebSocket) stays on localhost because + the daemon runs on the same machine. +- Shep also makes direct **outbound HTTPS calls to `api.telegram.org`** for + replies and notifications — the gateway tunnel is only for inbound. + +## Prerequisites + +- Go 1.25+ +- Node 22+ with pnpm +- A Telegram bot token from [@BotFather](https://t.me/BotFather) +- `cloudflared` (`brew install cloudflared`) _or_ `ngrok` + +## 1. Run the Gateway + +```bash +git clone https://github.com/Commands-com/gateway.git +cd gateway +cp .env.example .env +cat >> .env < Your quick tunnel: https://random-slug.trycloudflare.com +``` + +### ngrok + +```bash +ngrok http 8080 +# => https://abcd-1234.ngrok-free.app +``` + +Export the URL for the next steps: + +```bash +export SHEP_GATEWAY_PUBLIC_URL=https://random-slug.trycloudflare.com +``` + +## 3. Export your Telegram bot token + +Shep needs the bot token to make outbound `sendMessage` calls (the Gateway +does not proxy outbound traffic). + +```bash +export SHEP_TELEGRAM_BOT_TOKEN=123456:ABCDEFG-your-token-here +``` + +`_serve` reads this from `process.env` when constructing the messaging +service — see [container.ts](../../packages/core/src/infrastructure/di/container.ts). + +## 4. Pair Shep with Telegram + +Start the web UI so you can use the pairing dialog (the CLI wizard works +identically): + +```bash +pnpm dev:web # http://localhost:3000 +# or +shep ui # http://localhost:4050 +``` + +Navigate to **Settings → Messaging Remote Control**: + +1. Flip **Enable messaging** on +2. Gateway URL: `http://localhost:8080` (not the public URL — the daemon + connects to the Gateway on localhost) +3. Click **Pair device** on **Telegram** + +A dialog opens showing: + +- A **6-digit pairing code** +- A **Webhook URL** in the form `http://localhost:8080/integrations/{route_id}/{route_token}` + +Because the daemon sees the Gateway on localhost but Telegram needs a +public URL, rewrite the webhook URL host to your tunnel domain: + +```bash +# Example +ROUTE_PATH=$(curl -s http://localhost:8080/gateway/v1/integrations/routes \ + -H "Authorization: Bearer $(cat ~/.shep/.gateway-token)" | jq -r '.routes[0] | "/integrations/\(.route_id)/\(.route_token)"') +WEBHOOK_URL="${SHEP_GATEWAY_PUBLIC_URL}${ROUTE_PATH}" +``` + +or just copy the route_id/route_token from the dialog and build the URL +yourself: `${SHEP_GATEWAY_PUBLIC_URL}/integrations/{route_id}/{route_token}`. + +Set the Telegram webhook: + +```bash +curl -X POST "https://api.telegram.org/bot${SHEP_TELEGRAM_BOT_TOKEN}/setWebhook" \ + -d "url=${WEBHOOK_URL}" +# {"ok":true,"result":true,"description":"Webhook was set"} +``` + +Verify: + +```bash +curl -s "https://api.telegram.org/bot${SHEP_TELEGRAM_BOT_TOKEN}/getWebhookInfo" | jq +``` + +## 5. Start the daemon + +```bash +shep _serve +# or during development: +pnpm dev:cli _serve +``` + +The daemon: + +1. Resolves `IGatewayClient` and calls `fetchAccessToken` (demo mode uses + the `commands-desktop-public` client, no secret required). +2. Constructs `MessagingService` with the fetched token + your bot token. +3. Opens the WebSocket tunnel with `Authorization: Bearer ` on the + upgrade headers. +4. Sends `tunnel.activate` for each paired platform's route. +5. Begins listening for `tunnel.request` frames. + +## 6. Pair from your phone + +In your Telegram bot chat, send the 6-digit code: + +``` +/pair 482913 +``` + +The daemon's `MessagingService.handleTunnelRequest`: + +1. Receives the `tunnel.request` frame +2. Parses the body as a Telegram `Update` +3. Matches `/pair ` via `parsePairCommand` +4. Looks up `pendingPairingCode` for Telegram in settings +5. Calls `ConfirmMessagingPairingUseCase` → sets `paired: true`, stores your + chat ID +6. Replies: _"Paired with Shep. You can now send commands like /status, + /list, or /help."_ + +The web UI will show the row flip to **Paired** with your chat ID on the +next render. + +## 7. Drive Shep from your phone + +``` +/list — list features +/status 42 — status of feature 42 +/new add dark mode — create a new feature +/approve 42 — approve an agent run +/chat 42 — start an interactive agent relay for feature 42 + — forwarded to the agent as a user message +/end — end the active relay +/help — command reference +``` + +Replies come back through `sendMessage` directly to your chat. + +### Interactive chat relay + +`/chat ` attaches your Telegram chat to a feature's interactive +session. While a relay is active: + +- Every message you send is forwarded to + [IInteractiveSessionService.sendUserMessage](../../packages/core/src/application/ports/output/services/interactive-session-service.interface.ts). +- Agent streaming output is buffered in 3-second windows and flushed to the + chat as normal Telegram messages (to avoid flooding). +- `/end` tears down the subscription and stops forwarding. + +The daemon will start a new session if none exists, and queue messages if +the session is still booting. You can drive an entire conversation from +Telegram without touching the web UI. + +## Bot token storage + +The daemon needs your Telegram bot token to call `sendMessage`. You have +two options: + +1. **Settings UI** (recommended for real use) — after pairing, a **Bot API + token** field appears below the Telegram row. It's stored as an encrypted + string in `settings.db` and loaded by DI on daemon start. +2. **Environment variable** (quick dev) — export + `SHEP_TELEGRAM_BOT_TOKEN` before running `shep _serve`. Settings takes + precedence when both are set. + +The same pattern applies to the CLI wizard: after `/confirm pairing`, it +prompts for the bot token and stores it in settings via +`UpdateSettingsUseCase`. + +## Troubleshooting + +**Tunnel refuses the upgrade with 401.** The daemon's OAuth token fetch +failed. Check that `OAUTH_DEFAULT_CLIENT_ID=commands-desktop-public` is set +on the Gateway and that `gatewayClientId` in Shep settings matches (or is +unset — it defaults to the same value). + +**`/pair ` gets "Invalid or expired pairing code".** Codes expire +after 10 minutes. Start a new pairing from the UI. + +**Webhook POSTs return 503.** Either the daemon isn't running, the tunnel +is disconnected, or the route hasn't been activated yet. Check +`adapter.isRouteActivated(routeId)` — in logs you'll see `tunnel.activate` +followed by `tunnel.activate.result ok:true` when healthy. + +**Notifications don't arrive in Telegram.** Check that +`SHEP_TELEGRAM_BOT_TOKEN` is set in the environment the daemon runs in — +this is separate from the Gateway token. The `TelegramMessageSender` will +silently no-op if the bot token is missing. + +## Limitations + +- **WhatsApp inbound is parsed and routed**, but WhatsApp **outbound** is not + implemented — the daemon will accept `/pair` and `/chat` commands from + WhatsApp but cannot reply. Replies would need a dedicated WhatsApp Cloud + API client (separate Meta verified app + access token). Use Telegram + end-to-end for now. +- Pairing codes are stored on the settings row (not in a separate table). + They survive daemon restarts but expire after 10 minutes. diff --git a/package.json b/package.json index 2c734e0fa..617917fed 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "@types/papaparse": "^5.5.2", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", "@typespec-tools/emitter-typescript": "^0.3.0", "@typespec/compiler": "^0.60.0", "@typespec/json-schema": "^0.60.0", @@ -221,6 +222,7 @@ "tsyringe": "^4.10.0", "umzug": "^3.8.2", "which": "^5.0.0", + "ws": "^8.20.0", "zod": "^4.3.6" } } diff --git a/packages/core/src/application/ports/output/services/gateway-client.interface.ts b/packages/core/src/application/ports/output/services/gateway-client.interface.ts new file mode 100644 index 000000000..9f945c585 --- /dev/null +++ b/packages/core/src/application/ports/output/services/gateway-client.interface.ts @@ -0,0 +1,67 @@ +/** + * Gateway Client Interface + * + * Output port for the Commands.com Gateway HTTP API. Implementations handle + * authentication (OAuth demo/OIDC) and integration-route provisioning so the + * MessagingService can receive inbound webhooks from Telegram/WhatsApp. + * + * Reference: https://github.com/Commands-com/gateway/blob/main/docs/openapi.yaml + */ + +export interface GatewayOAuthToken { + accessToken: string; + tokenType: string; + /** Absolute expiry time in ms since epoch. */ + expiresAt: number; + refreshToken?: string; +} + +export interface GatewayIntegrationRoute { + routeId: string; + routeToken: string; + publicUrl: string; + deviceId: string; + interfaceType: string; +} + +export interface CreateIntegrationRouteInput { + deviceId: string; + interfaceType: string; + /** Optional client-provided token override. Gateway issues one if omitted. */ + routeToken?: string; + tokenMaxAgeDays?: number; + maxBodyBytes?: number; + deadlineMs?: number; +} + +export interface FetchTokenInput { + gatewayUrl: string; + clientId: string; + /** For demo auth the client secret is optional. */ + clientSecret?: string; + scope?: string; +} + +/** + * Port for interacting with the Commands.com Gateway HTTP API. + * + * Implementations MUST NOT leak HTTP specifics (status codes, headers) to + * the application layer — return domain objects or throw domain errors. + */ +export interface IGatewayClient { + /** + * Fetch (or refresh) an OAuth access token for the configured client. + * Uses the client_credentials grant in demo mode. + */ + fetchAccessToken(input: FetchTokenInput): Promise; + + /** + * Register a new integration route on the gateway. The returned `publicUrl` + * is what the messaging platform (Telegram webhook, etc.) should POST to. + */ + createIntegrationRoute( + gatewayUrl: string, + accessToken: string, + input: CreateIntegrationRouteInput + ): Promise; +} diff --git a/packages/core/src/application/ports/output/services/message-sender.interface.ts b/packages/core/src/application/ports/output/services/message-sender.interface.ts new file mode 100644 index 000000000..fa0bf3cd8 --- /dev/null +++ b/packages/core/src/application/ports/output/services/message-sender.interface.ts @@ -0,0 +1,19 @@ +/** + * Message Sender Interface + * + * Output port for delivering outbound messaging notifications to an end user + * (Telegram chat, WhatsApp conversation, etc.). Implementations make direct + * HTTPS calls to the respective platform APIs — the tunnel is for inbound + * webhook relay only, not for pushing notifications. + */ + +import type { MessagingNotification } from '../../../../domain/generated/output.js'; + +export interface IMessageSender { + /** + * Deliver a notification to the configured end user. Implementations + * should handle platform routing (telegram vs whatsapp) internally based + * on current settings, and silently no-op if no platform is paired. + */ + send(notification: MessagingNotification): Promise; +} diff --git a/packages/core/src/application/ports/output/services/messaging-service.interface.ts b/packages/core/src/application/ports/output/services/messaging-service.interface.ts new file mode 100644 index 000000000..b8a96b638 --- /dev/null +++ b/packages/core/src/application/ports/output/services/messaging-service.interface.ts @@ -0,0 +1,39 @@ +/** + * Messaging Service Interface + * + * Output port for the external messaging remote control subsystem. + * Enables controlling Shep via Telegram/WhatsApp through the + * Commands.com Gateway tunnel. + * + * Following Clean Architecture: + * - Application layer depends on this interface + * - Infrastructure layer provides concrete implementation (MessagingService) + */ + +import type { MessagingNotification } from '../../../../domain/generated/output.js'; + +/** + * Port interface for the messaging remote control service. + * + * Implementations must: + * - Connect to the Gateway via WebSocket tunnel + * - Handle inbound commands (parsed by Gateway) and map them to use cases + * - Push outbound notifications through the tunnel for delivery to messaging apps + * - Support interactive chat relay between messaging apps and agent sessions + */ +export interface IMessagingService { + /** Start listening for inbound commands from the Gateway tunnel */ + start(): Promise; + + /** Stop the messaging service and disconnect from the tunnel */ + stop(): Promise; + + /** Send a notification to the user's messaging app via the Gateway */ + sendNotification(notification: MessagingNotification): Promise; + + /** Check if messaging is configured and connected */ + isConnected(): boolean; + + /** Check if messaging is configured (credentials present, even if not connected) */ + isConfigured(): boolean; +} diff --git a/packages/core/src/application/ports/output/services/telegram-client.interface.ts b/packages/core/src/application/ports/output/services/telegram-client.interface.ts new file mode 100644 index 000000000..930753a56 --- /dev/null +++ b/packages/core/src/application/ports/output/services/telegram-client.interface.ts @@ -0,0 +1,20 @@ +/** + * Telegram Bot API Client Interface + * + * Output port for making outbound calls to api.telegram.org. Used by the + * messaging service to reply to users after processing a webhook, and to + * push debounced notifications from the daemon. + */ + +export interface SendTelegramMessageInput { + /** Bot token (e.g. `123456:ABCDEF-...`). */ + botToken: string; + chatId: string; + text: string; + /** Optional parse mode (`Markdown`, `MarkdownV2`, `HTML`). */ + parseMode?: 'Markdown' | 'MarkdownV2' | 'HTML'; +} + +export interface ITelegramClient { + sendMessage(input: SendTelegramMessageInput): Promise; +} diff --git a/packages/core/src/application/use-cases/messaging/begin-pairing.use-case.ts b/packages/core/src/application/use-cases/messaging/begin-pairing.use-case.ts new file mode 100644 index 000000000..19f40d3de --- /dev/null +++ b/packages/core/src/application/use-cases/messaging/begin-pairing.use-case.ts @@ -0,0 +1,162 @@ +/** + * Begin Messaging Pairing Use Case + * + * Initiates a pairing handshake for a messaging platform (Telegram/WhatsApp): + * + * 1. Validates the Gateway URL. + * 2. Fetches an OAuth access token from the Gateway (demo mode uses the + * public client; OIDC mode would inject a real client secret). + * 3. Creates an integration route on the Gateway for the target platform. + * The returned `publicUrl` is what the user must point their Telegram + * webhook (or WhatsApp callback) at. + * 4. Generates a one-time 6-digit pairing code and persists it along with + * the newly-allocated route details on settings. + * 5. Returns a session DTO for the presentation layer to render. + * + * The pairing is finalized when either: + * - The daemon's tunnel receives a `/pair ` message via a + * `tunnel.request` frame and calls ConfirmMessagingPairingUseCase (future + * auto-confirm path), or + * - The user clicks "Confirm pairing" in the UI / CLI after seeing the + * code echoed by their bot (current manual path). + */ + +import { injectable, inject } from 'tsyringe'; +import { randomInt, randomUUID } from 'node:crypto'; +import { MessagingPlatform } from '../../../domain/generated/output.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; +import type { IGatewayClient } from '../../ports/output/services/gateway-client.interface.js'; + +const PAIRING_CODE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const DEFAULT_GATEWAY_CLIENT_ID = 'commands-desktop-public'; + +export interface BeginMessagingPairingInput { + platform: MessagingPlatform; + gatewayUrl: string; +} + +export interface MessagingPairingSession { + platform: MessagingPlatform; + code: string; + /** ISO-8601 expiry. */ + expiresAt: string; + gatewayUrl: string; + /** Public webhook URL the platform should POST updates to. */ + publicUrl: string; + routeId: string; +} + +function assertValidGatewayUrl(url: string): void { + if (!url?.trim()) { + throw new Error('Gateway URL is required to begin pairing.'); + } + try { + const parsed = new URL(url); + if (!parsed.protocol) { + throw new Error('invalid'); + } + } catch { + throw new Error('Gateway URL must be a valid URL (e.g., https://gateway.example.com).'); + } +} + +function generatePairingCode(): string { + return randomInt(0, 1_000_000).toString().padStart(6, '0'); +} + +function platformKey(platform: MessagingPlatform): 'telegram' | 'whatsapp' { + return platform === MessagingPlatform.Telegram ? 'telegram' : 'whatsapp'; +} + +@injectable() +export class BeginMessagingPairingUseCase { + constructor( + @inject('ISettingsRepository') + private readonly settingsRepository: ISettingsRepository, + @inject('IGatewayClient') + private readonly gatewayClient: IGatewayClient + ) {} + + async execute(input: BeginMessagingPairingInput): Promise { + assertValidGatewayUrl(input.gatewayUrl); + + const settings = await this.settingsRepository.load(); + if (!settings) { + throw new Error('Settings not found. Please run initialization first.'); + } + + const existing = settings.messaging ?? { + enabled: false, + debounceMs: 5000, + chatBufferMs: 3000, + }; + + // Device ID is stable across platforms and across pairings. Generate + // lazily on first pairing so the gateway can scope all routes + the + // tunnel connection to the same device owner. + const deviceId = existing.deviceId ?? `shep-${randomUUID()}`; + const clientId = existing.gatewayClientId ?? DEFAULT_GATEWAY_CLIENT_ID; + const key = platformKey(input.platform); + + // 1. Fetch OAuth access token from the gateway. + let accessToken: string; + try { + const token = await this.gatewayClient.fetchAccessToken({ + gatewayUrl: input.gatewayUrl, + clientId, + }); + accessToken = token.accessToken; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Gateway authentication failed: ${msg}`); + } + + // 2. Create an integration route for this platform. + let route; + try { + route = await this.gatewayClient.createIntegrationRoute(input.gatewayUrl, accessToken, { + deviceId, + interfaceType: key, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Gateway route registration failed: ${msg}`); + } + + // 3. Generate pairing code + persist everything. + const code = generatePairingCode(); + const expiresAt = new Date(Date.now() + PAIRING_CODE_TTL_MS).toISOString(); + + const existingPlatform = existing[key] ?? { enabled: false, paired: false }; + + settings.messaging = { + ...existing, + enabled: true, + gatewayUrl: input.gatewayUrl, + deviceId, + gatewayClientId: clientId, + [key]: { + ...existingPlatform, + enabled: true, + paired: false, + pendingPairingCode: code, + pendingPairingExpiresAt: expiresAt, + routeId: route.routeId, + routeToken: route.routeToken, + publicUrl: route.publicUrl, + }, + }; + settings.updatedAt = new Date(); + + await this.settingsRepository.update(settings); + + return { + platform: input.platform, + code, + expiresAt, + gatewayUrl: input.gatewayUrl, + publicUrl: route.publicUrl, + routeId: route.routeId, + }; + } +} diff --git a/packages/core/src/application/use-cases/messaging/confirm-pairing.use-case.ts b/packages/core/src/application/use-cases/messaging/confirm-pairing.use-case.ts new file mode 100644 index 000000000..51840720a --- /dev/null +++ b/packages/core/src/application/use-cases/messaging/confirm-pairing.use-case.ts @@ -0,0 +1,62 @@ +/** + * Confirm Messaging Pairing Use Case + * + * Finalizes a pairing handshake started by BeginMessagingPairingUseCase. + * Marks the platform as paired, stores the chatId, and clears the pending + * pairing code. + */ + +import { injectable, inject } from 'tsyringe'; +import { MessagingPlatform, type Settings } from '../../../domain/generated/output.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; + +export interface ConfirmMessagingPairingInput { + platform: MessagingPlatform; + chatId: string; +} + +@injectable() +export class ConfirmMessagingPairingUseCase { + constructor( + @inject('ISettingsRepository') + private readonly settingsRepository: ISettingsRepository + ) {} + + async execute(input: ConfirmMessagingPairingInput): Promise { + if (!input.chatId?.trim()) { + throw new Error('Chat ID is required to confirm pairing.'); + } + + const settings = await this.settingsRepository.load(); + if (!settings) { + throw new Error('Settings not found. Please run initialization first.'); + } + + const platformKey: 'telegram' | 'whatsapp' = + input.platform === MessagingPlatform.Telegram ? 'telegram' : 'whatsapp'; + + const messaging = settings.messaging; + const existingPlatform = messaging?.[platformKey]; + + if (!messaging || !existingPlatform?.pendingPairingCode) { + throw new Error(`No pairing in progress for ${platformKey}.`); + } + + settings.messaging = { + ...messaging, + enabled: true, + [platformKey]: { + ...existingPlatform, + enabled: true, + paired: true, + chatId: input.chatId.trim(), + pendingPairingCode: undefined, + pendingPairingExpiresAt: undefined, + }, + }; + settings.updatedAt = new Date(); + + await this.settingsRepository.update(settings); + return settings; + } +} diff --git a/packages/core/src/application/use-cases/messaging/disconnect-messaging.use-case.ts b/packages/core/src/application/use-cases/messaging/disconnect-messaging.use-case.ts new file mode 100644 index 000000000..899f674a2 --- /dev/null +++ b/packages/core/src/application/use-cases/messaging/disconnect-messaging.use-case.ts @@ -0,0 +1,61 @@ +/** + * Disconnect Messaging Use Case + * + * Disconnects either a single messaging platform (telegram/whatsapp) or all + * platforms at once. When all platforms are cleared, the top-level messaging + * feature is disabled so the daemon tears down the tunnel on next cycle. + */ + +import { injectable, inject } from 'tsyringe'; +import { MessagingPlatform, type Settings } from '../../../domain/generated/output.js'; +import type { ISettingsRepository } from '../../ports/output/repositories/settings.repository.interface.js'; + +export interface DisconnectMessagingInput { + /** If omitted, disconnect all platforms. */ + platform?: MessagingPlatform; +} + +@injectable() +export class DisconnectMessagingUseCase { + constructor( + @inject('ISettingsRepository') + private readonly settingsRepository: ISettingsRepository + ) {} + + async execute(input: DisconnectMessagingInput = {}): Promise { + const settings = await this.settingsRepository.load(); + if (!settings) { + throw new Error('Settings not found. Please run initialization first.'); + } + + const current = settings.messaging ?? { + enabled: false, + debounceMs: 5000, + chatBufferMs: 3000, + }; + + if (!input.platform) { + settings.messaging = { + enabled: false, + gatewayUrl: current.gatewayUrl, + debounceMs: current.debounceMs ?? 5000, + chatBufferMs: current.chatBufferMs ?? 3000, + }; + } else { + const platformKey: 'telegram' | 'whatsapp' = + input.platform === MessagingPlatform.Telegram ? 'telegram' : 'whatsapp'; + const next = { ...current, [platformKey]: undefined }; + const otherKey: 'telegram' | 'whatsapp' = + platformKey === 'telegram' ? 'whatsapp' : 'telegram'; + const otherStillEnabled = !!next[otherKey]?.enabled; + settings.messaging = { + ...next, + enabled: otherStillEnabled, + }; + } + settings.updatedAt = new Date(); + + await this.settingsRepository.update(settings); + return settings; + } +} diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index 8a7d18f13..0e83b9c6d 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -713,6 +713,86 @@ export type FabLayoutConfig = { swapPosition: boolean; }; +/** + * Configuration for a single messaging platform connection + */ +export type MessagingPlatformConfig = { + /** + * Whether this platform connection is active + */ + enabled: boolean; + /** + * Platform-specific chat ID for message routing (set during pairing) + */ + chatId?: string; + /** + * Whether the chat has been verified via pairing code + */ + paired: boolean; + /** + * One-time code shown to the user during pairing, cleared once confirmed + */ + pendingPairingCode?: string; + /** + * Expiry timestamp for the pending pairing code (ISO-8601) + */ + pendingPairingExpiresAt?: any; + /** + * Gateway integration route ID allocated during pairing + */ + routeId?: string; + /** + * Gateway integration route token (path-auth) allocated during pairing + */ + routeToken?: string; + /** + * Public webhook URL that the messaging platform should POST updates to + */ + publicUrl?: string; + /** + * Bot API token used by the daemon to send outbound messages (Telegram: 123456:ABC...) + */ + botToken?: string; +}; + +/** + * Messaging remote control configuration + */ +export type MessagingConfig = { + /** + * Whether messaging remote control is enabled + */ + enabled: boolean; + /** + * URL of the Commands.com Gateway instance + */ + gatewayUrl?: string; + /** + * Device ID used when registering integration routes and opening the tunnel + */ + deviceId?: string; + /** + * OAuth client ID for fetching gateway access tokens (demo mode uses public client) + */ + gatewayClientId?: string; + /** + * Telegram platform configuration + */ + telegram?: MessagingPlatformConfig; + /** + * WhatsApp platform configuration + */ + whatsapp?: MessagingPlatformConfig; + /** + * Debounce window in milliseconds for notification delivery (default: 5000) + */ + debounceMs: number; + /** + * Buffer interval in milliseconds for chat relay output batching (default: 3000) + */ + chatBufferMs: number; +}; + /** * Global Shep platform settings (singleton) */ @@ -761,6 +841,10 @@ export type Settings = BaseEntity & { * FAB layout configuration (optional, defaults applied at runtime) */ fabLayout?: FabLayoutConfig; + /** + * Messaging remote control configuration (optional, defaults applied at runtime) + */ + messaging?: MessagingConfig; }; export enum TaskState { Todo = 'Todo', @@ -1945,6 +2029,81 @@ export type Repository = SoftDeletableEntity & { */ upstreamUrl?: string; }; +export enum MessagingFrameType { + Command = 'command', + ChatMessage = 'chat_message', + ChatControl = 'chat_control', +} +export enum MessagingCommandType { + New = 'new', + Approve = 'approve', + Reject = 'reject', + Stop = 'stop', + Resume = 'resume', + Status = 'status', + Mute = 'mute', + Unmute = 'unmute', + List = 'list', + Chat = 'chat', + End = 'end', + Help = 'help', +} +export enum MessagingPlatform { + Telegram = 'telegram', + WhatsApp = 'whatsapp', +} + +/** + * A parsed command received from a messaging platform via the Gateway tunnel + */ +export type MessagingCommand = { + /** + * Type of frame: command, chat_message, or chat_control + */ + type: MessagingFrameType; + /** + * The slash command name (new, approve, reject, stop, resume, status) + */ + command: MessagingCommandType; + /** + * Target feature ID (short numeric or full UUID) + */ + featureId?: string; + /** + * Free-text arguments (feature description, rejection feedback, chat text) + */ + args?: string; + /** + * Chat ID for routing responses back to the correct conversation + */ + chatId: string; + /** + * Platform for routing responses back (telegram or whatsapp) + */ + platform: MessagingPlatform; +}; + +/** + * A notification or response sent from Shep to a messaging platform via the Gateway tunnel + */ +export type MessagingNotification = { + /** + * Event type: feature lifecycle, CI status, gate waiting, command response, chat response + */ + event: string; + /** + * ID of the feature this notification relates to + */ + featureId: string; + /** + * Human-readable feature name or title + */ + title: string; + /** + * Human-readable notification body (sanitized, no code or secrets) + */ + message: string; +}; export enum EstimateType { None = 'None', Category = 'Category', diff --git a/packages/core/src/infrastructure/di/container.ts b/packages/core/src/infrastructure/di/container.ts index fa7fc9b4a..d3f4017c6 100644 --- a/packages/core/src/infrastructure/di/container.ts +++ b/packages/core/src/infrastructure/di/container.ts @@ -15,6 +15,25 @@ import 'reflect-metadata'; import { container } from 'tsyringe'; import type Database from 'better-sqlite3'; +// Messaging — not yet in a registration module +import type { IMessagingService } from '../../application/ports/output/services/messaging-service.interface.js'; +import { getSettings } from '../services/settings.service.js'; +import { BeginMessagingPairingUseCase } from '../../application/use-cases/messaging/begin-pairing.use-case.js'; +import { ConfirmMessagingPairingUseCase } from '../../application/use-cases/messaging/confirm-pairing.use-case.js'; +import { DisconnectMessagingUseCase } from '../../application/use-cases/messaging/disconnect-messaging.use-case.js'; +import type { IGatewayClient } from '../../application/ports/output/services/gateway-client.interface.js'; +import { HttpGatewayClient } from '../services/messaging/http-gateway.client.js'; +import { StubGatewayClient } from '../services/messaging/stub-gateway.client.js'; +import { type getNotificationBus } from '../services/notifications/notification-bus.js'; +import { CreateFeatureUseCase } from '../../application/use-cases/features/create/create-feature.use-case.js'; +import { ApproveAgentRunUseCase } from '../../application/use-cases/agents/approve-agent-run.use-case.js'; +import { RejectAgentRunUseCase } from '../../application/use-cases/agents/reject-agent-run.use-case.js'; +import { StopAgentRunUseCase } from '../../application/use-cases/agents/stop-agent-run.use-case.js'; +import { ResumeFeatureUseCase } from '../../application/use-cases/features/resume-feature.use-case.js'; +import { ListFeaturesUseCase } from '../../application/use-cases/features/list-features.use-case.js'; +import { ShowFeatureUseCase } from '../../application/use-cases/features/show-feature.use-case.js'; +import { ListRepositoriesUseCase } from '../../application/use-cases/repositories/list-repositories.use-case.js'; + // Database connection import { getSQLiteConnection } from '../persistence/sqlite/connection.js'; import { runSQLiteMigrations } from '../persistence/sqlite/migrations.js'; @@ -98,6 +117,29 @@ export async function initializeContainer(): Promise { deploymentService.recoverAll(); container.registerInstance('IDeploymentService', deploymentService); + // ─── Messaging registration ────────────────────────────────────────────── + if (process.env.SHEP_MOCK_GATEWAY === '1') { + container.register('IGatewayClient', { + useFactory: () => new StubGatewayClient(), + }); + } else { + container.register('IGatewayClient', { + useFactory: () => new HttpGatewayClient(), + }); + } + container.registerSingleton(BeginMessagingPairingUseCase); + container.registerSingleton(ConfirmMessagingPairingUseCase); + container.registerSingleton(DisconnectMessagingUseCase); + container.register('BeginMessagingPairingUseCase', { + useFactory: (c) => c.resolve(BeginMessagingPairingUseCase), + }); + container.register('ConfirmMessagingPairingUseCase', { + useFactory: (c) => c.resolve(ConfirmMessagingPairingUseCase), + }); + container.register('DisconnectMessagingUseCase', { + useFactory: (c) => c.resolve(DisconnectMessagingUseCase), + }); + // ─── Boot-time workflow-step recovery ──────────────────────────────────── // Any step left in `running` by a previous daemon is orphaned. Flip it to // `interrupted` BEFORE any session can resolve so the UI never shows @@ -231,6 +273,103 @@ export async function initializeContainer(): Promise { // server run) as stopped. await interactiveSessionRepo.markAllActiveStopped(); + // Register messaging service as a lazy factory — only instantiated when + // the daemon resolves it. Avoids loading ws and messaging code for CLI commands. + container.register('IMessagingService', { + useFactory: (c) => { + let instance: IMessagingService | null = null; + const getInstance = async (): Promise => { + if (!instance) { + const { MessagingService } = await import('../services/messaging/messaging.service.js'); + const { HttpTelegramClient } = await import( + '../services/messaging/http-telegram.client.js' + ); + const settingsModule = await import('../services/settings.service.js'); + const settings = settingsModule.getSettings(); + const messagingConfig = settings.messaging ?? { + enabled: false, + debounceMs: 5000, + chatBufferMs: 3000, + }; + + // Fetch an OAuth access token from the Gateway so the tunnel + // upgrade carries a valid Bearer header. If the fetch fails the + // service still constructs (isConfigured will return false) so + // startup doesn't crash the daemon. + let accessToken = ''; + if (messagingConfig.enabled && messagingConfig.gatewayUrl) { + try { + const gatewayClient = c.resolve('IGatewayClient'); + const token = await gatewayClient.fetchAccessToken({ + gatewayUrl: messagingConfig.gatewayUrl, + clientId: messagingConfig.gatewayClientId ?? 'commands-desktop-public', + }); + accessToken = token.accessToken; + } catch { + // Non-fatal — isConfigured() will gate start(). + } + } + + // Bot token precedence: settings.db > env var. Per-platform token + // from settings takes priority; env var is a dev convenience. + const telegramBotToken = + messagingConfig.telegram?.botToken ?? process.env.SHEP_TELEGRAM_BOT_TOKEN; + + instance = new MessagingService({ + config: messagingConfig, + accessToken, + telegramClient: new HttpTelegramClient(), + telegramBotToken, + notificationBus: c.resolve('NotificationEventBus') as ReturnType< + typeof getNotificationBus + >, + featureRepo: c.resolve('IFeatureRepository'), + createFeature: c.resolve(CreateFeatureUseCase), + approveAgentRun: c.resolve(ApproveAgentRunUseCase), + rejectAgentRun: c.resolve(RejectAgentRunUseCase), + stopAgentRun: c.resolve(StopAgentRunUseCase), + resumeFeature: c.resolve(ResumeFeatureUseCase), + listFeatures: c.resolve(ListFeaturesUseCase), + showFeature: c.resolve(ShowFeatureUseCase), + listRepositories: c.resolve(ListRepositoriesUseCase), + confirmPairing: c.resolve(ConfirmMessagingPairingUseCase), + interactiveSessionService: c.resolve( + 'IInteractiveSessionService' + ), + }); + } + return instance; + }; + return new Proxy({} as IMessagingService, { + get: (_target, prop) => { + if (prop === 'isConfigured') { + // isConfigured is synchronous — check settings directly. + // A route is enough: the tunnel must start in pending-pairing + // state so the daemon can receive the user's `/pair ` + // message and auto-confirm via the tunnel. + return () => { + try { + const settings = getSettings(); + const mc = settings.messaging; + if (!mc?.enabled || !mc?.gatewayUrl || !mc?.deviceId) return false; + const telegramReady = !!mc.telegram?.routeId; + const whatsappReady = !!mc.whatsapp?.routeId; + return telegramReady || whatsappReady; + } catch { + return false; + } + }; + } + return async (...args: unknown[]) => { + const svc = await getInstance(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (svc as any)[prop](...args); + }; + }, + }); + }, + }); + _initialized = true; return container; } diff --git a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts index 16a9243c7..ad3dac420 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/settings.mapper.ts @@ -15,6 +15,8 @@ import type { Settings, SkillInjectionConfig, SkillSource, + MessagingConfig, + MessagingPlatformConfig, } from '../../../../domain/generated/output.js'; import { createDefaultSettings } from '../../../../domain/factories/settings-defaults.factory.js'; import { @@ -140,6 +142,34 @@ export interface SettingsRow { // Skill injection config (added in migration 051) skill_injection_enabled: number; skill_injection_skills: string | null; + + // Messaging remote control config (added in migration 056) + messaging_enabled: number; + messaging_gateway_url: string | null; + messaging_device_id: string | null; + messaging_gateway_client_id: string | null; + messaging_debounce_ms: number | null; + messaging_chat_buffer_ms: number | null; + + messaging_telegram_enabled: number; + messaging_telegram_paired: number; + messaging_telegram_chat_id: string | null; + messaging_telegram_route_id: string | null; + messaging_telegram_route_token: string | null; + messaging_telegram_public_url: string | null; + messaging_telegram_bot_token: string | null; + messaging_telegram_pending_code: string | null; + messaging_telegram_pending_expires_at: string | null; + + messaging_whatsapp_enabled: number; + messaging_whatsapp_paired: number; + messaging_whatsapp_chat_id: string | null; + messaging_whatsapp_route_id: string | null; + messaging_whatsapp_route_token: string | null; + messaging_whatsapp_public_url: string | null; + messaging_whatsapp_bot_token: string | null; + messaging_whatsapp_pending_code: string | null; + messaging_whatsapp_pending_expires_at: string | null; } /** @@ -269,9 +299,89 @@ export function toDatabase(settings: Settings): SettingsRow { skill_injection_skills: settings.workflow.skillInjection?.skills?.length ? JSON.stringify(settings.workflow.skillInjection.skills) : null, + + // Messaging remote control (migration 056) + ...messagingToRow(settings.messaging), }; } +/** + * Serialize MessagingConfig into the snake_case DB row columns. + * An undefined config writes zeros/nulls so the row is valid. + */ +function messagingToRow( + messaging: MessagingConfig | undefined +): Pick< + SettingsRow, + | 'messaging_enabled' + | 'messaging_gateway_url' + | 'messaging_device_id' + | 'messaging_gateway_client_id' + | 'messaging_debounce_ms' + | 'messaging_chat_buffer_ms' + | 'messaging_telegram_enabled' + | 'messaging_telegram_paired' + | 'messaging_telegram_chat_id' + | 'messaging_telegram_route_id' + | 'messaging_telegram_route_token' + | 'messaging_telegram_public_url' + | 'messaging_telegram_bot_token' + | 'messaging_telegram_pending_code' + | 'messaging_telegram_pending_expires_at' + | 'messaging_whatsapp_enabled' + | 'messaging_whatsapp_paired' + | 'messaging_whatsapp_chat_id' + | 'messaging_whatsapp_route_id' + | 'messaging_whatsapp_route_token' + | 'messaging_whatsapp_public_url' + | 'messaging_whatsapp_bot_token' + | 'messaging_whatsapp_pending_code' + | 'messaging_whatsapp_pending_expires_at' +> { + const tg = messaging?.telegram; + const wa = messaging?.whatsapp; + return { + messaging_enabled: messaging?.enabled ? 1 : 0, + messaging_gateway_url: messaging?.gatewayUrl ?? null, + messaging_device_id: messaging?.deviceId ?? null, + messaging_gateway_client_id: messaging?.gatewayClientId ?? null, + messaging_debounce_ms: messaging?.debounceMs ?? null, + messaging_chat_buffer_ms: messaging?.chatBufferMs ?? null, + + messaging_telegram_enabled: tg?.enabled ? 1 : 0, + messaging_telegram_paired: tg?.paired ? 1 : 0, + messaging_telegram_chat_id: tg?.chatId ?? null, + messaging_telegram_route_id: tg?.routeId ?? null, + messaging_telegram_route_token: tg?.routeToken ?? null, + messaging_telegram_public_url: tg?.publicUrl ?? null, + messaging_telegram_bot_token: tg?.botToken ?? null, + messaging_telegram_pending_code: tg?.pendingPairingCode ?? null, + messaging_telegram_pending_expires_at: serializeIsoLike(tg?.pendingPairingExpiresAt), + + messaging_whatsapp_enabled: wa?.enabled ? 1 : 0, + messaging_whatsapp_paired: wa?.paired ? 1 : 0, + messaging_whatsapp_chat_id: wa?.chatId ?? null, + messaging_whatsapp_route_id: wa?.routeId ?? null, + messaging_whatsapp_route_token: wa?.routeToken ?? null, + messaging_whatsapp_public_url: wa?.publicUrl ?? null, + messaging_whatsapp_bot_token: wa?.botToken ?? null, + messaging_whatsapp_pending_code: wa?.pendingPairingCode ?? null, + messaging_whatsapp_pending_expires_at: serializeIsoLike(wa?.pendingPairingExpiresAt), + }; +} + +/** + * The generated TypeSpec type for `pendingPairingExpiresAt` is `any` because + * TypeSpec emitted a loose shape for this `utcDateTime` field. Callers pass + * either a Date or an ISO string; we normalize both to a string for storage. + */ +function serializeIsoLike(value: unknown): string | null { + if (value == null) return null; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'string') return value; + return String(value); +} + /** * Build the stageTimeouts spread from DB row columns. * Returns `{ stageTimeouts: { ... } }` when at least one column is non-null, @@ -451,7 +561,100 @@ export function fromDatabase(row: SettingsRow): Settings { swapPosition: (row.fab_position_swapped ?? 0) !== 0, }, + // Messaging remote control (migration 056) + // Always present — even for rows written before the migration, defaults + // decode to { enabled: false, debounceMs: 5000, chatBufferMs: 3000 } so + // consumers always see a valid MessagingConfig shape. + messaging: messagingFromRow(row), + // Onboarding (INTEGER → boolean) onboardingComplete: row.onboarding_complete === 1, }; } + +/** + * Deserialize MessagingConfig from the DB row. Returns a fully populated + * MessagingConfig with safe defaults for rows that predate migration 056 or + * were written by code that never set the messaging field (e.g. main branch). + */ +function messagingFromRow(row: SettingsRow): MessagingConfig { + const telegram = readPlatform( + row.messaging_telegram_enabled, + row.messaging_telegram_paired, + row.messaging_telegram_chat_id, + row.messaging_telegram_route_id, + row.messaging_telegram_route_token, + row.messaging_telegram_public_url, + row.messaging_telegram_bot_token, + row.messaging_telegram_pending_code, + row.messaging_telegram_pending_expires_at + ); + const whatsapp = readPlatform( + row.messaging_whatsapp_enabled, + row.messaging_whatsapp_paired, + row.messaging_whatsapp_chat_id, + row.messaging_whatsapp_route_id, + row.messaging_whatsapp_route_token, + row.messaging_whatsapp_public_url, + row.messaging_whatsapp_bot_token, + row.messaging_whatsapp_pending_code, + row.messaging_whatsapp_pending_expires_at + ); + + const config: MessagingConfig = { + enabled: (row.messaging_enabled ?? 0) === 1, + debounceMs: row.messaging_debounce_ms ?? 5000, + chatBufferMs: row.messaging_chat_buffer_ms ?? 3000, + }; + if (row.messaging_gateway_url !== null && row.messaging_gateway_url !== undefined) { + config.gatewayUrl = row.messaging_gateway_url; + } + if (row.messaging_device_id !== null && row.messaging_device_id !== undefined) { + config.deviceId = row.messaging_device_id; + } + if (row.messaging_gateway_client_id !== null && row.messaging_gateway_client_id !== undefined) { + config.gatewayClientId = row.messaging_gateway_client_id; + } + if (telegram) config.telegram = telegram; + if (whatsapp) config.whatsapp = whatsapp; + return config; +} + +function readPlatform( + enabled: number | null | undefined, + paired: number | null | undefined, + chatId: string | null, + routeId: string | null, + routeToken: string | null, + publicUrl: string | null, + botToken: string | null, + pendingCode: string | null, + pendingExpiresAt: string | null +): MessagingPlatformConfig | undefined { + // Omit the platform entirely when no data has been written. This matches + // the pre-persistence shape where callers used `config.telegram?.paired`. + const hasAny = + (enabled ?? 0) === 1 || + (paired ?? 0) === 1 || + chatId !== null || + routeId !== null || + routeToken !== null || + publicUrl !== null || + botToken !== null || + pendingCode !== null || + pendingExpiresAt !== null; + if (!hasAny) return undefined; + + const platform: MessagingPlatformConfig = { + enabled: (enabled ?? 0) === 1, + paired: (paired ?? 0) === 1, + }; + if (chatId !== null) platform.chatId = chatId; + if (routeId !== null) platform.routeId = routeId; + if (routeToken !== null) platform.routeToken = routeToken; + if (publicUrl !== null) platform.publicUrl = publicUrl; + if (botToken !== null) platform.botToken = botToken; + if (pendingCode !== null) platform.pendingPairingCode = pendingCode; + if (pendingExpiresAt !== null) platform.pendingPairingExpiresAt = pendingExpiresAt; + return platform; +} diff --git a/packages/core/src/infrastructure/persistence/sqlite/migrations/056-add-messaging-remote-control.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/056-add-messaging-remote-control.ts new file mode 100644 index 000000000..7b666efa6 --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/056-add-messaging-remote-control.ts @@ -0,0 +1,76 @@ +/** + * Migration 056: Persist MessagingConfig on the settings table + * + * Feature 082 (messaging remote control) added `MessagingConfig` to the domain + * model but the persistence layer was never extended, so every pairing was + * silently dropped on write. This migration backfills the missing columns. + * + * Backward compatibility: + * - Every column is nullable (or has a safe default of 0) so existing rows + * from main keep working untouched. + * - Reading code treats nulls as "not configured", matching the pre-feature + * behaviour where `settings.messaging` was undefined. + * - The mapper round-trips values: a row written by an older build (all + * nulls) decodes to `{ enabled: false, debounceMs: 5000, chatBufferMs: 3000 }`, + * which is exactly the fallback the in-memory code already uses. + * - This migration is idempotent via PRAGMA table_info guard. + */ + +import type { MigrationParams } from 'umzug'; +import type Database from 'better-sqlite3'; + +interface ColumnSpec { + name: string; + ddl: string; +} + +const COLUMNS: ColumnSpec[] = [ + // Top-level MessagingConfig fields + { name: 'messaging_enabled', ddl: 'INTEGER NOT NULL DEFAULT 0' }, + { name: 'messaging_gateway_url', ddl: 'TEXT' }, + { name: 'messaging_device_id', ddl: 'TEXT' }, + { name: 'messaging_gateway_client_id', ddl: 'TEXT' }, + { name: 'messaging_debounce_ms', ddl: 'INTEGER' }, + { name: 'messaging_chat_buffer_ms', ddl: 'INTEGER' }, + + // Per-platform: Telegram + { name: 'messaging_telegram_enabled', ddl: 'INTEGER NOT NULL DEFAULT 0' }, + { name: 'messaging_telegram_paired', ddl: 'INTEGER NOT NULL DEFAULT 0' }, + { name: 'messaging_telegram_chat_id', ddl: 'TEXT' }, + { name: 'messaging_telegram_route_id', ddl: 'TEXT' }, + { name: 'messaging_telegram_route_token', ddl: 'TEXT' }, + { name: 'messaging_telegram_public_url', ddl: 'TEXT' }, + { name: 'messaging_telegram_bot_token', ddl: 'TEXT' }, + { name: 'messaging_telegram_pending_code', ddl: 'TEXT' }, + { name: 'messaging_telegram_pending_expires_at', ddl: 'TEXT' }, + + // Per-platform: WhatsApp + { name: 'messaging_whatsapp_enabled', ddl: 'INTEGER NOT NULL DEFAULT 0' }, + { name: 'messaging_whatsapp_paired', ddl: 'INTEGER NOT NULL DEFAULT 0' }, + { name: 'messaging_whatsapp_chat_id', ddl: 'TEXT' }, + { name: 'messaging_whatsapp_route_id', ddl: 'TEXT' }, + { name: 'messaging_whatsapp_route_token', ddl: 'TEXT' }, + { name: 'messaging_whatsapp_public_url', ddl: 'TEXT' }, + { name: 'messaging_whatsapp_bot_token', ddl: 'TEXT' }, + { name: 'messaging_whatsapp_pending_code', ddl: 'TEXT' }, + { name: 'messaging_whatsapp_pending_expires_at', ddl: 'TEXT' }, +]; + +export async function up({ context: db }: MigrationParams): Promise { + const existing = db.pragma('table_info(settings)') as { name: string }[]; + const present = new Set(existing.map((c) => c.name)); + + for (const col of COLUMNS) { + if (!present.has(col.name)) { + db.exec(`ALTER TABLE settings ADD COLUMN ${col.name} ${col.ddl}`); + } + } +} + +export async function down({ context: db }: MigrationParams): Promise { + // SQLite ALTER TABLE DROP COLUMN was only added in 3.35. We leave the + // columns in place on rollback — they are harmless nullable additions + // and removing them would require a full table rebuild. This matches + // the pattern used by earlier migrations in this repo. + void db; +} diff --git a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts index 5400e29f3..f8787d30f 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-settings.repository.ts @@ -76,7 +76,15 @@ export class SQLiteSettingsRepository implements ISettingsRepository { interactive_agent_max_concurrent_sessions, auto_archive_delay_minutes, stage_timeout_fast_implement_ms, - fab_position_swapped + fab_position_swapped, + messaging_enabled, messaging_gateway_url, messaging_device_id, + messaging_gateway_client_id, messaging_debounce_ms, messaging_chat_buffer_ms, + messaging_telegram_enabled, messaging_telegram_paired, messaging_telegram_chat_id, + messaging_telegram_route_id, messaging_telegram_route_token, messaging_telegram_public_url, + messaging_telegram_bot_token, messaging_telegram_pending_code, messaging_telegram_pending_expires_at, + messaging_whatsapp_enabled, messaging_whatsapp_paired, messaging_whatsapp_chat_id, + messaging_whatsapp_route_id, messaging_whatsapp_route_token, messaging_whatsapp_public_url, + messaging_whatsapp_bot_token, messaging_whatsapp_pending_code, messaging_whatsapp_pending_expires_at ) VALUES ( @id, @created_at, @updated_at, @model_analyze, @model_requirements, @model_plan, @model_implement, @model_default, @@ -110,7 +118,15 @@ export class SQLiteSettingsRepository implements ISettingsRepository { @interactive_agent_max_concurrent_sessions, @auto_archive_delay_minutes, @stage_timeout_fast_implement_ms, - @fab_position_swapped + @fab_position_swapped, + @messaging_enabled, @messaging_gateway_url, @messaging_device_id, + @messaging_gateway_client_id, @messaging_debounce_ms, @messaging_chat_buffer_ms, + @messaging_telegram_enabled, @messaging_telegram_paired, @messaging_telegram_chat_id, + @messaging_telegram_route_id, @messaging_telegram_route_token, @messaging_telegram_public_url, + @messaging_telegram_bot_token, @messaging_telegram_pending_code, @messaging_telegram_pending_expires_at, + @messaging_whatsapp_enabled, @messaging_whatsapp_paired, @messaging_whatsapp_chat_id, + @messaging_whatsapp_route_id, @messaging_whatsapp_route_token, @messaging_whatsapp_public_url, + @messaging_whatsapp_bot_token, @messaging_whatsapp_pending_code, @messaging_whatsapp_pending_expires_at ) `); @@ -226,7 +242,31 @@ export class SQLiteSettingsRepository implements ISettingsRepository { interactive_agent_max_concurrent_sessions = @interactive_agent_max_concurrent_sessions, auto_archive_delay_minutes = @auto_archive_delay_minutes, stage_timeout_fast_implement_ms = @stage_timeout_fast_implement_ms, - fab_position_swapped = @fab_position_swapped + fab_position_swapped = @fab_position_swapped, + messaging_enabled = @messaging_enabled, + messaging_gateway_url = @messaging_gateway_url, + messaging_device_id = @messaging_device_id, + messaging_gateway_client_id = @messaging_gateway_client_id, + messaging_debounce_ms = @messaging_debounce_ms, + messaging_chat_buffer_ms = @messaging_chat_buffer_ms, + messaging_telegram_enabled = @messaging_telegram_enabled, + messaging_telegram_paired = @messaging_telegram_paired, + messaging_telegram_chat_id = @messaging_telegram_chat_id, + messaging_telegram_route_id = @messaging_telegram_route_id, + messaging_telegram_route_token = @messaging_telegram_route_token, + messaging_telegram_public_url = @messaging_telegram_public_url, + messaging_telegram_bot_token = @messaging_telegram_bot_token, + messaging_telegram_pending_code = @messaging_telegram_pending_code, + messaging_telegram_pending_expires_at = @messaging_telegram_pending_expires_at, + messaging_whatsapp_enabled = @messaging_whatsapp_enabled, + messaging_whatsapp_paired = @messaging_whatsapp_paired, + messaging_whatsapp_chat_id = @messaging_whatsapp_chat_id, + messaging_whatsapp_route_id = @messaging_whatsapp_route_id, + messaging_whatsapp_route_token = @messaging_whatsapp_route_token, + messaging_whatsapp_public_url = @messaging_whatsapp_public_url, + messaging_whatsapp_bot_token = @messaging_whatsapp_bot_token, + messaging_whatsapp_pending_code = @messaging_whatsapp_pending_code, + messaging_whatsapp_pending_expires_at = @messaging_whatsapp_pending_expires_at WHERE id = @id `); diff --git a/packages/core/src/infrastructure/services/messaging/chat-relay.ts b/packages/core/src/infrastructure/services/messaging/chat-relay.ts new file mode 100644 index 000000000..0fe6625a1 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/chat-relay.ts @@ -0,0 +1,130 @@ +/** + * Messaging Chat Relay + * + * Bridges messaging app chat ↔ Shep interactive agent sessions. + * When a user enters "chat mode" via /chat , their messages + * are relayed to the agent and agent responses are batched and sent back + * through the tunnel. + * + * Output batching: agent streaming output is buffered and flushed every + * N milliseconds (default 3s) to avoid flooding messaging platforms. + * Only one active relay per user at a time. + */ + +import type { MessagingNotification } from '../../../domain/generated/output.js'; +import type { IMessageSender } from '../../../application/ports/output/services/message-sender.interface.js'; +import { sanitizeForMessaging } from './content-sanitizer.js'; + +const DEFAULT_BUFFER_INTERVAL_MS = 3_000; + +interface ActiveRelay { + featureId: string; + chatId: string; + platform: string; + worktreePath: string; + unsubscribe?: () => void; +} + +/** + * Manages the bidirectional chat relay between messaging apps + * and Shep interactive agent sessions. + */ +export class MessagingChatRelay { + private activeRelay: ActiveRelay | null = null; + private buffer = ''; + private bufferTimer: ReturnType | null = null; + + constructor( + private readonly sender: IMessageSender, + private readonly bufferIntervalMs: number = DEFAULT_BUFFER_INTERVAL_MS + ) {} + + /** Start a chat relay for a specific feature */ + startRelay( + featureId: string, + chatId: string, + platform: string, + worktreePath = '', + unsubscribe?: () => void + ): string { + // Tear down any previous relay (including its subscription). + if (this.activeRelay) { + this.flushBuffer(); + this.activeRelay.unsubscribe?.(); + } + + this.activeRelay = { featureId, chatId, platform, worktreePath, unsubscribe }; + return `Chat relay started for feature #${featureId}. Send messages here to talk to the agent. /end to stop.`; + } + + /** Get the worktree path of the active relay, if any. */ + getActiveWorktreePath(): string | null { + return this.activeRelay?.worktreePath ?? null; + } + + /** End the active chat relay */ + endRelay(): string { + if (!this.activeRelay) { + return 'No active chat relay.'; + } + + this.flushBuffer(); + const fid = this.activeRelay.featureId; + this.activeRelay.unsubscribe?.(); + this.activeRelay = null; + return `Chat relay ended for feature #${fid}.`; + } + + /** Check if there is an active relay */ + hasActiveRelay(): boolean { + return this.activeRelay !== null; + } + + /** Get the active relay's feature ID */ + getActiveFeatureId(): string | null { + return this.activeRelay?.featureId ?? null; + } + + /** + * Buffer an agent response chunk and schedule a flush. + * Called when the agent produces output during a chat relay. + */ + bufferAgentOutput(delta: string): void { + if (!this.activeRelay) return; + + this.buffer += delta; + + if (!this.bufferTimer) { + this.bufferTimer = setTimeout(() => { + this.flushBuffer(); + }, this.bufferIntervalMs); + this.bufferTimer.unref(); + } + } + + /** Flush any buffered output immediately (e.g., on stream completion) */ + flushBuffer(): void { + if (this.buffer && this.activeRelay) { + const notification: MessagingNotification = { + event: 'chat.response', + featureId: this.activeRelay.featureId, + title: '', + message: sanitizeForMessaging(this.buffer), + }; + void this.sender.send(notification); + this.buffer = ''; + } + + if (this.bufferTimer) { + clearTimeout(this.bufferTimer); + this.bufferTimer = null; + } + } + + /** Stop the relay and clean up all resources */ + stop(): void { + this.flushBuffer(); + this.activeRelay?.unsubscribe?.(); + this.activeRelay = null; + } +} diff --git a/packages/core/src/infrastructure/services/messaging/command-executor.ts b/packages/core/src/infrastructure/services/messaging/command-executor.ts new file mode 100644 index 000000000..d2259cebe --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/command-executor.ts @@ -0,0 +1,223 @@ +/** + * Messaging Command Executor + * + * Maps inbound MessagingCommand payloads from the Gateway tunnel + * to existing Shep use case invocations. This is the bridge between + * external messaging commands and the application layer. + * + * All commands are mapped to existing use cases — no new business logic + * is introduced here. The executor is a thin translation layer. + * + * Feature ID resolution: messaging commands use short IDs (first 8 chars + * of the UUID). The ShowFeatureUseCase and ResumeFeatureUseCase support + * prefix matching via findByIdPrefix. For approve/reject/stop, we resolve + * the feature first, then use its agentRunId. + */ + +import type { MessagingCommand, Feature } from '../../../domain/generated/output.js'; +import type { IFeatureRepository } from '../../../application/ports/output/repositories/feature-repository.interface.js'; +import type { ListFeaturesUseCase } from '../../../application/use-cases/features/list-features.use-case.js'; +import type { ShowFeatureUseCase } from '../../../application/use-cases/features/show-feature.use-case.js'; +import type { CreateFeatureUseCase } from '../../../application/use-cases/features/create/create-feature.use-case.js'; +import type { ApproveAgentRunUseCase } from '../../../application/use-cases/agents/approve-agent-run.use-case.js'; +import type { RejectAgentRunUseCase } from '../../../application/use-cases/agents/reject-agent-run.use-case.js'; +import type { StopAgentRunUseCase } from '../../../application/use-cases/agents/stop-agent-run.use-case.js'; +import type { ResumeFeatureUseCase } from '../../../application/use-cases/features/resume-feature.use-case.js'; +import type { ListRepositoriesUseCase } from '../../../application/use-cases/repositories/list-repositories.use-case.js'; + +const HELP_TEXT = `Available commands: +/new — Create a new feature +/approve — Approve gate on feature +/reject [feedback] — Reject with feedback +/stop — Stop agent on feature +/resume — Resume paused feature +/status — List all active features +/status — Show detail for feature +/help — Show this help text`; + +/** Format a feature for display in messaging */ +function formatFeature(f: Feature): string { + const shortId = f.id.slice(0, 8); + return `#${shortId} "${f.name}" — ${f.lifecycle}`; +} + +/** + * Execute messaging commands by delegating to existing use cases. + */ +export class MessagingCommandExecutor { + constructor( + private readonly featureRepo: IFeatureRepository, + private readonly createFeature: CreateFeatureUseCase, + private readonly approveAgentRun: ApproveAgentRunUseCase, + private readonly rejectAgentRun: RejectAgentRunUseCase, + private readonly stopAgentRun: StopAgentRunUseCase, + private readonly resumeFeature: ResumeFeatureUseCase, + private readonly listFeatures: ListFeaturesUseCase, + private readonly showFeature: ShowFeatureUseCase, + private readonly listRepositories: ListRepositoriesUseCase + ) {} + + /** + * Execute a messaging command and return a human-readable response. + */ + async execute(cmd: MessagingCommand): Promise { + switch (cmd.command) { + case 'new': + return this.handleNew(cmd); + case 'approve': + return this.handleApprove(cmd); + case 'reject': + return this.handleReject(cmd); + case 'stop': + return this.handleStop(cmd); + case 'resume': + return this.handleResume(cmd); + case 'status': + return this.handleStatus(cmd); + case 'help': + return HELP_TEXT; + default: + return `Unknown command: ${cmd.command}. Send /help for available commands.`; + } + } + + private async handleNew(cmd: MessagingCommand): Promise { + if (!cmd.args) { + return 'Usage: /new '; + } + + try { + // Resolve a default repository path from the first tracked repository + const repos = await this.listRepositories.execute(); + if (repos.length === 0) { + return 'No repositories configured. Add a repository in the Shep UI first.'; + } + + const result = await this.createFeature.execute({ + userInput: cmd.args, + repositoryPath: repos[0].path, + fast: true, + push: true, + openPr: true, + }); + const shortId = result.feature.id.slice(0, 8); + return `Started: "${cmd.args}" — feature #${shortId}`; + } catch (error) { + return `Failed to create feature: ${error instanceof Error ? error.message : String(error)}`; + } + } + + private async handleApprove(cmd: MessagingCommand): Promise { + if (!cmd.featureId) { + return 'Usage: /approve '; + } + + try { + const feature = await this.resolveFeature(cmd.featureId); + if (!feature) { + return `Feature #${cmd.featureId} not found`; + } + if (!feature.agentRunId) { + return `Feature #${cmd.featureId} has no active agent run`; + } + + const result = await this.approveAgentRun.execute(feature.agentRunId); + if (!result.approved) { + return `Cannot approve: ${result.reason}`; + } + return `Approved feature #${cmd.featureId}`; + } catch (error) { + return `Failed to approve: ${error instanceof Error ? error.message : String(error)}`; + } + } + + private async handleReject(cmd: MessagingCommand): Promise { + if (!cmd.featureId) { + return 'Usage: /reject [feedback]'; + } + + try { + const feature = await this.resolveFeature(cmd.featureId); + if (!feature) { + return `Feature #${cmd.featureId} not found`; + } + if (!feature.agentRunId) { + return `Feature #${cmd.featureId} has no active agent run`; + } + + const result = await this.rejectAgentRun.execute( + feature.agentRunId, + cmd.args ?? 'Rejected via messaging' + ); + if (!result.rejected) { + return `Cannot reject: ${result.reason}`; + } + return `Rejected feature #${cmd.featureId}${cmd.args ? ' with feedback' : ''}`; + } catch (error) { + return `Failed to reject: ${error instanceof Error ? error.message : String(error)}`; + } + } + + private async handleStop(cmd: MessagingCommand): Promise { + if (!cmd.featureId) { + return 'Usage: /stop '; + } + + try { + const feature = await this.resolveFeature(cmd.featureId); + if (!feature) { + return `Feature #${cmd.featureId} not found`; + } + if (!feature.agentRunId) { + return `Feature #${cmd.featureId} has no active agent run`; + } + + const result = await this.stopAgentRun.execute(feature.agentRunId); + if (!result.stopped) { + return `Cannot stop: ${result.reason}`; + } + return `Stopped agent on feature #${cmd.featureId}`; + } catch (error) { + return `Failed to stop: ${error instanceof Error ? error.message : String(error)}`; + } + } + + private async handleResume(cmd: MessagingCommand): Promise { + if (!cmd.featureId) { + return 'Usage: /resume '; + } + + try { + await this.resumeFeature.execute(cmd.featureId); + return `Resumed feature #${cmd.featureId}`; + } catch (error) { + return `Failed to resume: ${error instanceof Error ? error.message : String(error)}`; + } + } + + private async handleStatus(cmd: MessagingCommand): Promise { + try { + if (cmd.featureId) { + const feature = await this.showFeature.execute(cmd.featureId); + return formatFeature(feature); + } + + const features = await this.listFeatures.execute(); + if (features.length === 0) { + return 'No active features.'; + } + + return features.map(formatFeature).join('\n'); + } catch (error) { + return `Failed to get status: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** Resolve a feature by exact ID or prefix match */ + private async resolveFeature(featureId: string): Promise { + return ( + (await this.featureRepo.findById(featureId)) ?? + (await this.featureRepo.findByIdPrefix(featureId)) + ); + } +} diff --git a/packages/core/src/infrastructure/services/messaging/content-sanitizer.ts b/packages/core/src/infrastructure/services/messaging/content-sanitizer.ts new file mode 100644 index 000000000..ef49f43f6 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/content-sanitizer.ts @@ -0,0 +1,47 @@ +/** + * Content Sanitizer + * + * Sanitizes outbound messages to ensure no sensitive content + * (file paths, environment variables, code blocks, secrets) + * is transmitted through third-party messaging platforms. + * + * Security requirement FR-6: no source code, diffs, or file + * contents transmitted through messaging platforms. + */ + +const MAX_MESSAGE_LENGTH = 4000; + +/** + * Strip sensitive content from a message before sending to a messaging platform. + * + * Removes: + * - Absolute file paths + * - Environment variable assignments + * - Code blocks (fenced with backticks) + * - Potential secret patterns (API keys, tokens) + * + * Truncates to messaging-safe length. + */ +export function sanitizeForMessaging(text: string): string { + let sanitized = text; + + // Strip absolute file paths (Unix and Windows) + sanitized = sanitized.replace(/(?:\/[\w.\-/]+){2,}/g, '[path]'); + sanitized = sanitized.replace(/[A-Z]:\\[\w.\-\\]+/g, '[path]'); + + // Strip env-var-like patterns (KEY=value) + sanitized = sanitized.replace(/[A-Z_]{3,}=\S+/g, '[env]'); + + // Strip fenced code blocks + sanitized = sanitized.replace(/```[\s\S]*?```/g, '[code block]'); + + // Strip inline code that looks like file content + sanitized = sanitized.replace(/`[^`]{100,}`/g, '[code]'); + + // Truncate to messaging-safe length + if (sanitized.length > MAX_MESSAGE_LENGTH) { + sanitized = `${sanitized.slice(0, MAX_MESSAGE_LENGTH - 3)}...`; + } + + return sanitized; +} diff --git a/packages/core/src/infrastructure/services/messaging/http-gateway.client.ts b/packages/core/src/infrastructure/services/messaging/http-gateway.client.ts new file mode 100644 index 000000000..97399dba6 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/http-gateway.client.ts @@ -0,0 +1,150 @@ +/** + * HTTP Gateway Client + * + * Concrete implementation of IGatewayClient that speaks the Commands.com + * Gateway OpenAPI (see https://github.com/Commands-com/gateway/blob/main/docs/openapi.yaml). + * + * This adapter is infrastructure — it knows about HTTP verbs, status codes, + * and the gateway's wire format. Callers receive domain objects only. + */ + +import { injectable } from 'tsyringe'; +import type { + IGatewayClient, + FetchTokenInput, + GatewayOAuthToken, + CreateIntegrationRouteInput, + GatewayIntegrationRoute, +} from '../../../application/ports/output/services/gateway-client.interface.js'; + +type FetchFn = typeof fetch; + +function stripTrailingSlash(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +async function readErrorBody(response: Response): Promise { + try { + const contentType = response.headers.get('content-type') ?? ''; + if (contentType.includes('application/json')) { + const json = (await response.json()) as { error?: unknown; message?: unknown }; + const err = typeof json.error === 'string' ? json.error : undefined; + const msg = typeof json.message === 'string' ? json.message : undefined; + return err ?? msg ?? JSON.stringify(json); + } + return await response.text(); + } catch { + return ''; + } +} + +@injectable() +export class HttpGatewayClient implements IGatewayClient { + constructor(private readonly fetchImpl: FetchFn = fetch) {} + + async fetchAccessToken(input: FetchTokenInput): Promise { + const base = stripTrailingSlash(input.gatewayUrl); + const url = `${base}/oauth/token`; + + const params = new URLSearchParams(); + params.set('grant_type', 'client_credentials'); + params.set('client_id', input.clientId); + if (input.clientSecret) { + params.set('client_secret', input.clientSecret); + } + if (input.scope) { + params.set('scope', input.scope); + } + + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + if (!response.ok) { + const detail = await readErrorBody(response); + throw new Error( + `Gateway /oauth/token failed with ${response.status}${detail ? `: ${detail}` : ''}` + ); + } + + const body = (await response.json()) as { + access_token?: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + }; + + if (!body.access_token) { + throw new Error('Gateway /oauth/token response missing access_token'); + } + + const expiresInMs = Math.max(0, (body.expires_in ?? 0) * 1000); + return { + accessToken: body.access_token, + tokenType: body.token_type ?? 'Bearer', + expiresAt: Date.now() + expiresInMs, + refreshToken: body.refresh_token, + }; + } + + async createIntegrationRoute( + gatewayUrl: string, + accessToken: string, + input: CreateIntegrationRouteInput + ): Promise { + const base = stripTrailingSlash(gatewayUrl); + const url = `${base}/gateway/v1/integrations/routes`; + + const payload: Record = { + device_id: input.deviceId, + interface_type: input.interfaceType, + token_auth_mode: 'path', + }; + if (input.routeToken !== undefined) payload.route_token = input.routeToken; + if (input.tokenMaxAgeDays !== undefined) payload.token_max_age_days = input.tokenMaxAgeDays; + if (input.maxBodyBytes !== undefined) payload.max_body_bytes = input.maxBodyBytes; + if (input.deadlineMs !== undefined) payload.deadline_ms = input.deadlineMs; + + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const detail = await readErrorBody(response); + throw new Error( + `Gateway /gateway/v1/integrations/routes failed with ${response.status}${ + detail ? `: ${detail}` : '' + }` + ); + } + + const body = (await response.json()) as { + route?: { route_id?: string; device_id?: string; interface_type?: string }; + public_url?: string; + route_token?: string; + }; + + const routeId = body.route?.route_id; + const publicUrl = body.public_url; + const routeToken = body.route_token; + + if (!routeId || !publicUrl || !routeToken) { + throw new Error('Gateway route response missing route_id, public_url, or route_token'); + } + + return { + routeId, + routeToken, + publicUrl, + deviceId: body.route?.device_id ?? input.deviceId, + interfaceType: body.route?.interface_type ?? input.interfaceType, + }; + } +} diff --git a/packages/core/src/infrastructure/services/messaging/http-telegram.client.ts b/packages/core/src/infrastructure/services/messaging/http-telegram.client.ts new file mode 100644 index 000000000..0792e5e3c --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/http-telegram.client.ts @@ -0,0 +1,55 @@ +/** + * HTTP Telegram Client + * + * Thin adapter over the Telegram Bot API for sending messages. Only + * implements the surface needed for the remote control integration — + * sendMessage, with optional parse_mode. + * + * Reference: https://core.telegram.org/bots/api#sendmessage + */ + +import { injectable } from 'tsyringe'; +import type { + ITelegramClient, + SendTelegramMessageInput, +} from '../../../application/ports/output/services/telegram-client.interface.js'; + +type FetchFn = typeof fetch; + +const TELEGRAM_API_BASE = 'https://api.telegram.org'; + +@injectable() +export class HttpTelegramClient implements ITelegramClient { + constructor(private readonly fetchImpl: FetchFn = fetch) {} + + async sendMessage(input: SendTelegramMessageInput): Promise { + if (!input.botToken) throw new Error('Telegram botToken is required'); + if (!input.chatId) throw new Error('Telegram chatId is required'); + + const url = `${TELEGRAM_API_BASE}/bot${input.botToken}/sendMessage`; + const body: Record = { + chat_id: input.chatId, + text: input.text, + }; + if (input.parseMode) body.parse_mode = input.parseMode; + + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + let detail = ''; + try { + const json = (await response.json()) as { description?: string }; + detail = json.description ?? ''; + } catch { + // ignore + } + throw new Error( + `Telegram sendMessage failed with ${response.status}${detail ? `: ${detail}` : ''}` + ); + } + } +} diff --git a/packages/core/src/infrastructure/services/messaging/messaging-tunnel.adapter.ts b/packages/core/src/infrastructure/services/messaging/messaging-tunnel.adapter.ts new file mode 100644 index 000000000..166577111 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/messaging-tunnel.adapter.ts @@ -0,0 +1,316 @@ +/** + * Messaging Tunnel Adapter + * + * Manages the WebSocket tunnel connection to the Commands.com Gateway and + * translates its binary/text frames into a presentation-agnostic callback + * for the consuming messaging service. + * + * Protocol reference: + * https://github.com/Commands-com/gateway/blob/main/internal/gateway/integrations_tunnel.go + * + * Responsibilities: + * - Open an authenticated WebSocket (Bearer token on the upgrade headers) + * - Handle tunnel.connected → auto-activate the configured routes + * - Decode incoming tunnel.request frames and dispatch to `onRequest` + * - Send tunnel.response frames back with the handler's reply + * - Reconnect on disconnect with a small delay + */ + +import WebSocket, { type ClientOptions, type RawData } from 'ws'; +import type { + DecodedTunnelRequest, + TunnelActivateFrame, + TunnelActivateResultFrame, + TunnelConnectedFrame, + TunnelErrorFrame, + TunnelInboundFrame, + TunnelRequestFrame, + TunnelRequestResponse, + TunnelResponseFrame, + TunnelRouteDeactivatedFrame, +} from './tunnel-protocol.js'; + +const RECONNECT_DELAY_MS = 5_000; +const PING_INTERVAL_MS = 25_000; + +export type TunnelRequestHandler = ( + request: DecodedTunnelRequest +) => Promise; + +/** Factory allowing tests to substitute an in-memory transport. */ +export type WebSocketFactory = (url: string, options: ClientOptions) => WebSocket; + +const defaultFactory: WebSocketFactory = (url, options) => new WebSocket(url, options); + +export interface MessagingTunnelAdapterDeps { + gatewayUrl: string; + accessToken: string; + deviceId: string; + /** Route IDs to claim after tunnel.connected arrives. */ + routeIds: string[]; + webSocketFactory?: WebSocketFactory; +} + +function headersArrayToRecord(pairs?: [string, string][]): Record { + if (!pairs) return {}; + const out: Record = {}; + for (const [k, v] of pairs) { + out[k.toLowerCase()] = v; + } + return out; +} + +function headersRecordToArray(record?: Record): [string, string][] | undefined { + if (!record) return undefined; + return Object.entries(record); +} + +function base64Encode(s: string): string { + return Buffer.from(s, 'utf8').toString('base64'); +} + +function base64Decode(b64: string): string { + return Buffer.from(b64, 'base64').toString('utf8'); +} + +export class MessagingTunnelAdapter { + private ws: WebSocket | null = null; + private requestHandler: TunnelRequestHandler | null = null; + private pingTimer: ReturnType | null = null; + private reconnectTimer: ReturnType | null = null; + private connected = false; + private stopping = false; + private readonly activatedRoutes = new Set(); + private readonly factory: WebSocketFactory; + + constructor(private readonly deps: MessagingTunnelAdapterDeps) { + this.factory = deps.webSocketFactory ?? defaultFactory; + } + + /** Register a handler for inbound tunnel.request frames. */ + onRequest(handler: TunnelRequestHandler): void { + this.requestHandler = handler; + } + + /** Whether the WebSocket tunnel is currently open. */ + isConnected(): boolean { + return this.connected; + } + + /** Whether the given route has been activated on the tunnel. */ + isRouteActivated(routeId: string): boolean { + return this.activatedRoutes.has(routeId); + } + + /** + * Open the tunnel and resolve once the server has emitted tunnel.connected. + * Reconnects are silent (fire-and-forget). + */ + async connect(): Promise { + if (this.connected || this.stopping) return; + + const base = this.deps.gatewayUrl.replace(/^http/, 'ws').replace(/\/$/, ''); + const url = `${base}/gateway/v1/integrations/tunnel/connect?device_id=${encodeURIComponent( + this.deps.deviceId + )}`; + + const ws = this.factory(url, { + headers: { authorization: `Bearer ${this.deps.accessToken}` }, + }); + this.ws = ws; + + await new Promise((resolve, reject) => { + const onceOpen = () => { + ws.off('error', onceError); + resolve(); + }; + const onceError = (err: Error) => { + ws.off('open', onceOpen); + reject(err); + }; + ws.once('open', onceOpen); + ws.once('error', onceError); + }); + + ws.on('message', (data: RawData) => { + this.handleRawFrame(data).catch(() => { + // Malformed frames are non-fatal. + }); + }); + ws.on('close', () => this.handleClose()); + ws.on('error', () => { + // Errors also trigger close; avoid duplicate handling. + }); + + this.connected = true; + this.startPing(); + } + + /** Close the tunnel permanently (no auto-reconnect). */ + async disconnect(): Promise { + this.stopping = true; + this.stopPing(); + this.clearReconnect(); + this.activatedRoutes.clear(); + + if (this.ws) { + try { + this.ws.close(); + } catch { + // ignore + } + this.ws = null; + } + this.connected = false; + } + + private async handleRawFrame(data: RawData): Promise { + const raw = typeof data === 'string' ? data : data.toString('utf8'); + let frame: TunnelInboundFrame; + try { + frame = JSON.parse(raw) as TunnelInboundFrame; + } catch { + return; + } + + switch (frame.type) { + case 'tunnel.connected': + this.handleConnected(frame); + return; + case 'tunnel.activate.result': + this.handleActivateResult(frame); + return; + case 'tunnel.request': + await this.handleRequest(frame); + return; + case 'tunnel.route_deactivated': + this.handleRouteDeactivated(frame); + return; + case 'tunnel.error': + this.handleProtocolError(frame); + return; + default: + // Unknown frame — silently drop per gateway forward-compat policy. + return; + } + } + + private handleConnected(_frame: TunnelConnectedFrame): void { + // Auto-activate every configured route. The gateway expects a single + // batched frame with a `routes` array — sending one-at-a-time with + // `route_id` is silently ignored. + if (this.deps.routeIds.length === 0) return; + this.sendFrame({ + type: 'tunnel.activate', + routes: [...this.deps.routeIds], + } satisfies TunnelActivateFrame); + } + + private handleActivateResult(frame: TunnelActivateResultFrame): void { + // Gateway returns "active" for newly-activated routes. Treat any + // non-rejected status as success to be forward-compatible. + for (const entry of frame.results ?? []) { + if (entry.status && entry.status !== 'rejected') { + this.activatedRoutes.add(entry.route_id); + } + } + } + + private handleRouteDeactivated(frame: TunnelRouteDeactivatedFrame): void { + this.activatedRoutes.delete(frame.route_id); + } + + private handleProtocolError(_frame: TunnelErrorFrame): void { + // No-op — recoverable errors are surfaced through reconnection. + } + + private async handleRequest(frame: TunnelRequestFrame): Promise { + if (!this.requestHandler) { + this.sendFrame({ + type: 'tunnel.response', + request_id: frame.request_id, + status: 503, + } satisfies TunnelResponseFrame); + return; + } + + const decoded: DecodedTunnelRequest = { + requestId: frame.request_id, + routeId: frame.route_id, + method: frame.method, + path: frame.path, + headers: headersArrayToRecord(frame.headers), + body: frame.body_base64 ? base64Decode(frame.body_base64) : '', + }; + + let response: TunnelRequestResponse; + try { + response = await this.requestHandler(decoded); + } catch { + response = { status: 500 }; + } + + this.sendFrame({ + type: 'tunnel.response', + request_id: frame.request_id, + status: response.status, + headers: headersRecordToArray(response.headers), + body_base64: response.body ? base64Encode(response.body) : undefined, + } satisfies TunnelResponseFrame); + } + + private sendFrame(frame: TunnelActivateFrame | TunnelResponseFrame): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + try { + this.ws.send(JSON.stringify(frame)); + } catch { + // ignore — close handler will reconnect + } + } + + private handleClose(): void { + this.connected = false; + this.activatedRoutes.clear(); + this.stopPing(); + if (!this.stopping) { + this.scheduleReconnect(); + } + } + + private startPing(): void { + this.pingTimer = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + try { + this.ws.ping(); + } catch { + // ignore + } + } + }, PING_INTERVAL_MS); + this.pingTimer.unref?.(); + } + + private stopPing(): void { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + private scheduleReconnect(): void { + this.clearReconnect(); + this.reconnectTimer = setTimeout(() => { + this.connect().catch(() => { + // Will retry on the next close event. + }); + }, RECONNECT_DELAY_MS); + this.reconnectTimer.unref?.(); + } + + private clearReconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } +} diff --git a/packages/core/src/infrastructure/services/messaging/messaging.service.ts b/packages/core/src/infrastructure/services/messaging/messaging.service.ts new file mode 100644 index 000000000..b5db5a411 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/messaging.service.ts @@ -0,0 +1,389 @@ +/** + * Messaging Service + * + * Core orchestrator for the external messaging remote control feature. + * Wires together: + * - Commands.com Gateway tunnel (inbound webhook delivery) + * - Telegram Bot API client (outbound replies + notifications) + * - Command executor (parses slash commands, runs use cases) + * - Notification emitter (debounced forwarding from the local event bus) + * - Chat relay (interactive agent ↔ messenger bridge) + * - Pairing auto-confirm (matches /pair against pending codes) + * + * Lifecycle: + * 1. `isConfigured()` checks settings for required fields. + * 2. `start()` opens the tunnel and subscribes to the notification bus. + * 3. Inbound `tunnel.request` frames are parsed as Telegram Update objects + * and dispatched to either the pair confirm flow or the command executor. + * 4. `stop()` tears everything down. + */ + +import type { IMessagingService } from '../../../application/ports/output/services/messaging-service.interface.js'; +import type { + MessagingCommand, + MessagingNotification, + MessagingConfig, +} from '../../../domain/generated/output.js'; +import { + MessagingPlatform, + MessagingFrameType, + MessagingCommandType, +} from '../../../domain/generated/output.js'; +import { MessagingTunnelAdapter } from './messaging-tunnel.adapter.js'; +import type { DecodedTunnelRequest, TunnelRequestResponse } from './tunnel-protocol.js'; +import { MessagingCommandExecutor } from './command-executor.js'; +import { MessagingNotificationEmitter } from './notification-emitter.js'; +import { MessagingChatRelay } from './chat-relay.js'; +import { TelegramMessageSender } from './telegram-message-sender.js'; +import { parseTelegramUpdate, parsePairCommand } from './telegram-webhook.parser.js'; +import type { NotificationBus } from '../notifications/notification-bus.js'; +import type { IFeatureRepository } from '../../../application/ports/output/repositories/feature-repository.interface.js'; +import type { ListFeaturesUseCase } from '../../../application/use-cases/features/list-features.use-case.js'; +import type { ShowFeatureUseCase } from '../../../application/use-cases/features/show-feature.use-case.js'; +import type { CreateFeatureUseCase } from '../../../application/use-cases/features/create/create-feature.use-case.js'; +import type { ApproveAgentRunUseCase } from '../../../application/use-cases/agents/approve-agent-run.use-case.js'; +import type { RejectAgentRunUseCase } from '../../../application/use-cases/agents/reject-agent-run.use-case.js'; +import type { StopAgentRunUseCase } from '../../../application/use-cases/agents/stop-agent-run.use-case.js'; +import type { ResumeFeatureUseCase } from '../../../application/use-cases/features/resume-feature.use-case.js'; +import type { ListRepositoriesUseCase } from '../../../application/use-cases/repositories/list-repositories.use-case.js'; +import type { ConfirmMessagingPairingUseCase } from '../../../application/use-cases/messaging/confirm-pairing.use-case.js'; +import type { ITelegramClient } from '../../../application/ports/output/services/telegram-client.interface.js'; +import type { IInteractiveSessionService } from '../../../application/ports/output/services/interactive-session-service.interface.js'; +import { parseWhatsAppUpdate } from './whatsapp-webhook.parser.js'; + +interface MessagingServiceDeps { + config: MessagingConfig; + accessToken: string; + telegramClient: ITelegramClient; + /** Bot token the sender will use to reply to Telegram users. */ + telegramBotToken?: string; + notificationBus: NotificationBus; + featureRepo: IFeatureRepository; + createFeature: CreateFeatureUseCase; + approveAgentRun: ApproveAgentRunUseCase; + rejectAgentRun: RejectAgentRunUseCase; + stopAgentRun: StopAgentRunUseCase; + resumeFeature: ResumeFeatureUseCase; + listFeatures: ListFeaturesUseCase; + showFeature: ShowFeatureUseCase; + listRepositories: ListRepositoriesUseCase; + confirmPairing: ConfirmMessagingPairingUseCase; + interactiveSessionService: IInteractiveSessionService; +} + +interface SlashCommand { + command: MessagingCommandType; + featureId?: string; + args?: string; +} + +const COMMAND_REGEX = + /^\/(new|approve|reject|stop|resume|status|list|chat|end|mute|unmute|help)(?:@\w+)?(?:\s+(\S+))?(?:\s+(.+))?$/i; + +const COMMANDS_TAKING_FEATURE_ID: readonly MessagingCommandType[] = [ + MessagingCommandType.Approve, + MessagingCommandType.Reject, + MessagingCommandType.Stop, + MessagingCommandType.Resume, + MessagingCommandType.Status, + MessagingCommandType.Chat, +]; + +function parseSlashCommand(text: string): SlashCommand | null { + const match = text.trim().match(COMMAND_REGEX); + if (!match) return null; + const command = match[1].toLowerCase() as MessagingCommandType; + const second = match[2]; + const rest = match[3]; + + if (COMMANDS_TAKING_FEATURE_ID.includes(command) && second) { + return { command, featureId: second, args: rest }; + } + + const args = [second, rest].filter(Boolean).join(' '); + return { command, args: args || undefined }; +} + +export class MessagingService implements IMessagingService { + private tunnelAdapter: MessagingTunnelAdapter | null = null; + private commandExecutor: MessagingCommandExecutor | null = null; + private notificationEmitter: MessagingNotificationEmitter | null = null; + private chatRelay: MessagingChatRelay | null = null; + private sender: TelegramMessageSender | null = null; + private started = false; + + constructor(private readonly deps: MessagingServiceDeps) {} + + isConfigured(): boolean { + const { config } = this.deps; + if (!config.enabled || !config.gatewayUrl || !config.deviceId) return false; + + // The tunnel must start as soon as a route exists — not only after the + // user is fully paired. The auto-confirm flow requires this: the daemon + // needs to be receiving tunnel.request frames in order to see the + // inbound `/pair ` message from the user's first DM and call + // ConfirmMessagingPairingUseCase. If we gated on `paired && chatId` + // the user could never complete pairing without a manual chatId entry + // in the UI. + const telegramReady = !!config.telegram?.routeId; + const whatsappReady = !!config.whatsapp?.routeId; + return telegramReady || whatsappReady; + } + + isConnected(): boolean { + return this.tunnelAdapter?.isConnected() ?? false; + } + + async start(): Promise { + if (this.started || !this.isConfigured()) return; + + const { config, accessToken, telegramClient, notificationBus, featureRepo } = this.deps; + + const routeIds = this.collectRouteIds(); + this.tunnelAdapter = new MessagingTunnelAdapter({ + gatewayUrl: config.gatewayUrl!, + accessToken, + deviceId: config.deviceId!, + routeIds, + }); + + this.sender = new TelegramMessageSender(telegramClient, () => { + const chatId = this.deps.config.telegram?.chatId; + const botToken = this.deps.telegramBotToken; + if (!chatId || !botToken) return null; + return { chatId, botToken }; + }); + + this.commandExecutor = new MessagingCommandExecutor( + featureRepo, + this.deps.createFeature, + this.deps.approveAgentRun, + this.deps.rejectAgentRun, + this.deps.stopAgentRun, + this.deps.resumeFeature, + this.deps.listFeatures, + this.deps.showFeature, + this.deps.listRepositories + ); + + this.notificationEmitter = new MessagingNotificationEmitter( + this.sender, + notificationBus, + config.debounceMs ?? 5_000 + ); + + this.chatRelay = new MessagingChatRelay(this.sender, config.chatBufferMs ?? 3_000); + + this.tunnelAdapter.onRequest((req) => this.handleTunnelRequest(req)); + + try { + await this.tunnelAdapter.connect(); + } catch { + // Connection failure is non-fatal — the adapter reconnects automatically. + } + + this.notificationEmitter.start(); + this.started = true; + } + + async stop(): Promise { + if (!this.started) return; + + this.notificationEmitter?.stop(); + this.chatRelay?.stop(); + await this.tunnelAdapter?.disconnect(); + + this.tunnelAdapter = null; + this.commandExecutor = null; + this.notificationEmitter = null; + this.chatRelay = null; + this.sender = null; + this.started = false; + } + + async sendNotification(notification: MessagingNotification): Promise { + await this.sender?.send(notification); + } + + private collectRouteIds(): string[] { + const out: string[] = []; + const { config } = this.deps; + if (config.telegram?.routeId) out.push(config.telegram.routeId); + if (config.whatsapp?.routeId) out.push(config.whatsapp.routeId); + return out; + } + + private async handleTunnelRequest(req: DecodedTunnelRequest): Promise { + // Per-route → platform resolution. + const platform = this.platformForRoute(req.routeId); + if (!platform) { + return { status: 404 }; + } + + const parsed = + platform === MessagingPlatform.Telegram + ? parseTelegramUpdate(req.body) + : parseWhatsAppUpdate(req.body); + if (!parsed) { + return { status: 200 }; + } + + // 1. Handle /pair auto-confirmation before anything else. + const pair = parsePairCommand(parsed.text); + if (pair) { + await this.handlePairConfirm(platform, parsed.chatId, pair.code); + return { status: 200 }; + } + + // 2. Dispatch slash commands via the command executor. /chat and /end + // are handled specially because they manipulate the chat relay. + const slash = parseSlashCommand(parsed.text); + if (slash) { + if (slash.command === MessagingCommandType.Chat) { + await this.handleChatStart(platform, parsed.chatId, slash.featureId); + return { status: 200 }; + } + if (slash.command === MessagingCommandType.End) { + await this.handleChatEnd(parsed.chatId); + return { status: 200 }; + } + if (this.commandExecutor) { + const cmd: MessagingCommand = { + type: MessagingFrameType.Command, + command: slash.command, + featureId: slash.featureId, + args: slash.args, + chatId: parsed.chatId, + platform, + }; + try { + const reply = await this.commandExecutor.execute(cmd); + await this.sendReply(parsed.chatId, reply); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await this.sendReply(parsed.chatId, `Command failed: ${msg}`); + } + return { status: 200 }; + } + } + + // 3. If there's an active chat relay, forward the message to the + // interactive session. + if (this.chatRelay?.hasActiveRelay()) { + await this.handleChatMessage(parsed.chatId, parsed.text); + return { status: 200 }; + } + + // 4. Unknown message — ignore silently. + return { status: 200 }; + } + + private async handleChatStart( + platform: MessagingPlatform, + chatId: string, + featureId?: string + ): Promise { + if (!featureId) { + await this.sendReply(chatId, 'Usage: /chat '); + return; + } + if (!this.chatRelay) return; + + const feature = await this.deps.featureRepo.findById(featureId); + if (!feature) { + await this.sendReply(chatId, `Feature ${featureId} not found.`); + return; + } + if (!feature.worktreePath) { + await this.sendReply( + chatId, + `Feature ${featureId} has no worktree yet — it may not be checked out.` + ); + return; + } + + // Subscribe to the interactive session's stream and forward deltas to + // the chat relay buffer. The unsubscribe handle is owned by the relay. + const unsubscribe = this.deps.interactiveSessionService.subscribeByFeature( + feature.id, + (chunk) => { + if (chunk.delta) { + this.chatRelay?.bufferAgentOutput(chunk.delta); + } + if (chunk.done) { + this.chatRelay?.flushBuffer(); + } + } + ); + + const message = this.chatRelay.startRelay( + feature.id, + chatId, + platform, + feature.worktreePath, + unsubscribe + ); + await this.sendReply(chatId, message); + } + + private async handleChatEnd(chatId: string): Promise { + if (!this.chatRelay) return; + const message = this.chatRelay.endRelay(); + await this.sendReply(chatId, message); + } + + private async handleChatMessage(_chatId: string, text: string): Promise { + if (!this.chatRelay?.hasActiveRelay()) return; + const featureId = this.chatRelay.getActiveFeatureId(); + const worktreePath = this.chatRelay.getActiveWorktreePath(); + if (!featureId || !worktreePath) return; + try { + await this.deps.interactiveSessionService.sendUserMessage(featureId, text, worktreePath); + } catch { + // Delivery failures surface as missing agent replies — no point + // spamming the user's chat with error toasts. + } + } + + private platformForRoute(routeId: string): MessagingPlatform | null { + const { config } = this.deps; + if (config.telegram?.routeId === routeId) return MessagingPlatform.Telegram; + if (config.whatsapp?.routeId === routeId) return MessagingPlatform.WhatsApp; + return null; + } + + private async handlePairConfirm( + platform: MessagingPlatform, + chatId: string, + code: string + ): Promise { + const { config } = this.deps; + const platformCfg = platform === MessagingPlatform.Telegram ? config.telegram : config.whatsapp; + + if (!platformCfg?.pendingPairingCode || platformCfg.pendingPairingCode !== code) { + await this.sendReply(chatId, 'Invalid or expired pairing code.'); + return; + } + + try { + await this.deps.confirmPairing.execute({ platform, chatId }); + await this.sendReply( + chatId, + 'Paired with Shep. You can now send commands like /status, /list, or /help.' + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await this.sendReply(chatId, `Pairing failed: ${msg}`); + } + } + + private async sendReply(chatId: string, text: string): Promise { + const botToken = this.deps.telegramBotToken; + if (!botToken || !text) return; + try { + await this.deps.telegramClient.sendMessage({ botToken, chatId, text }); + } catch { + // Non-fatal — swallow so the tunnel response still completes. + } + } +} diff --git a/packages/core/src/infrastructure/services/messaging/notification-emitter.ts b/packages/core/src/infrastructure/services/messaging/notification-emitter.ts new file mode 100644 index 000000000..b0e832a70 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/notification-emitter.ts @@ -0,0 +1,95 @@ +/** + * Messaging Notification Emitter + * + * Subscribes to Shep's existing NotificationEventBus and pushes + * events through the Gateway tunnel for delivery to messaging apps. + * + * Features: + * - Debouncing: events for the same feature+type are collapsed within + * a configurable window (default 5s) to avoid flooding + * - Content sanitization: all messages are scrubbed of paths, code, and secrets + * - Gate events are never debounced — delivered immediately + */ + +import type { NotificationEvent, MessagingNotification } from '../../../domain/generated/output.js'; +import type { NotificationBus } from '../notifications/notification-bus.js'; +import { sanitizeForMessaging } from './content-sanitizer.js'; +import type { IMessageSender } from '../../../application/ports/output/services/message-sender.interface.js'; + +const DEFAULT_DEBOUNCE_MS = 5_000; + +/** + * Subscribes to the notification event bus and forwards events + * to the messaging tunnel for delivery to the user's phone. + */ +export class MessagingNotificationEmitter { + private debounceTimers = new Map>(); + private listening = false; + private handler: ((event: NotificationEvent) => void) | null = null; + + constructor( + private readonly sender: IMessageSender, + private readonly notificationBus: NotificationBus, + private readonly debounceMs: number = DEFAULT_DEBOUNCE_MS + ) {} + + /** Start listening for notification events */ + start(): void { + if (this.listening) return; + + this.handler = (event: NotificationEvent) => { + const notification: MessagingNotification = { + event: event.eventType, + featureId: event.featureId, + title: event.featureName, + message: sanitizeForMessaging(event.message), + }; + + // Gate/approval events are always delivered immediately + if (event.eventType === 'waiting_approval') { + void this.sender.send(notification); + return; + } + + this.emitDebounced(event.featureId, event.eventType, notification); + }; + + this.notificationBus.on('notification', this.handler); + this.listening = true; + } + + /** Stop listening for notification events */ + stop(): void { + if (!this.listening) return; + + if (this.handler) { + this.notificationBus.off('notification', this.handler); + this.handler = null; + } + + // Clear all pending debounce timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + this.listening = false; + } + + private emitDebounced( + featureId: string, + eventType: string, + notification: MessagingNotification + ): void { + const key = `${featureId}:${eventType}`; + const existing = this.debounceTimers.get(key); + if (existing) clearTimeout(existing); + + const timer = setTimeout(() => { + void this.sender.send(notification); + this.debounceTimers.delete(key); + }, this.debounceMs); + + timer.unref(); + this.debounceTimers.set(key, timer); + } +} diff --git a/packages/core/src/infrastructure/services/messaging/stub-gateway.client.ts b/packages/core/src/infrastructure/services/messaging/stub-gateway.client.ts new file mode 100644 index 000000000..28f0fafa3 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/stub-gateway.client.ts @@ -0,0 +1,40 @@ +/** + * Stub Gateway Client + * + * Returns deterministic fake responses for E2E tests where no real + * Commands.com Gateway is available. Activated via SHEP_MOCK_GATEWAY=1. + */ + +import type { + IGatewayClient, + FetchTokenInput, + GatewayOAuthToken, + CreateIntegrationRouteInput, + GatewayIntegrationRoute, +} from '../../../application/ports/output/services/gateway-client.interface.js'; + +export class StubGatewayClient implements IGatewayClient { + async fetchAccessToken(_input: FetchTokenInput): Promise { + return { + accessToken: 'stub-access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 3600 * 1000, + }; + } + + async createIntegrationRoute( + gatewayUrl: string, + _accessToken: string, + input: CreateIntegrationRouteInput + ): Promise { + const routeId = `stub-route-${input.interfaceType}`; + const routeToken = `stub-token-${Date.now()}`; + return { + routeId, + routeToken, + publicUrl: `${gatewayUrl}/integrations/${routeId}/${routeToken}`, + deviceId: input.deviceId, + interfaceType: input.interfaceType, + }; + } +} diff --git a/packages/core/src/infrastructure/services/messaging/telegram-message-sender.ts b/packages/core/src/infrastructure/services/messaging/telegram-message-sender.ts new file mode 100644 index 000000000..195d0ad11 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/telegram-message-sender.ts @@ -0,0 +1,57 @@ +/** + * Telegram Message Sender + * + * Concrete IMessageSender that delivers notifications to a paired Telegram + * chat via the Telegram Bot API. Looks up the bot token + chat id from the + * messaging config on every send so that pairing changes propagate without + * restarting the daemon. + * + * This sender silently no-ops when Telegram is not paired — callers don't + * need to guard individually. + */ + +import type { IMessageSender } from '../../../application/ports/output/services/message-sender.interface.js'; +import type { ITelegramClient } from '../../../application/ports/output/services/telegram-client.interface.js'; +import type { MessagingNotification } from '../../../domain/generated/output.js'; + +export interface TelegramMessageSenderConfig { + botToken: string; + chatId: string; +} + +export type TelegramConfigResolver = () => TelegramMessageSenderConfig | null; + +function formatNotification(notification: MessagingNotification): string { + const lines: string[] = []; + if (notification.title) lines.push(`*${notification.title}*`); + if (notification.message) lines.push(notification.message); + if (notification.event && !notification.title) lines.push(`[${notification.event}]`); + return lines.join('\n'); +} + +export class TelegramMessageSender implements IMessageSender { + constructor( + private readonly telegramClient: ITelegramClient, + private readonly resolveConfig: TelegramConfigResolver + ) {} + + async send(notification: MessagingNotification): Promise { + const config = this.resolveConfig(); + if (!config?.botToken || !config.chatId) return; + + const text = formatNotification(notification); + if (!text.trim()) return; + + try { + await this.telegramClient.sendMessage({ + botToken: config.botToken, + chatId: config.chatId, + text, + parseMode: 'Markdown', + }); + } catch { + // Delivery failures are non-fatal — the daemon keeps running and + // future notifications will retry on their own cadence. + } + } +} diff --git a/packages/core/src/infrastructure/services/messaging/telegram-webhook.parser.ts b/packages/core/src/infrastructure/services/messaging/telegram-webhook.parser.ts new file mode 100644 index 000000000..8b385b52c --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/telegram-webhook.parser.ts @@ -0,0 +1,70 @@ +/** + * Telegram Webhook Parser + * + * Converts a raw Telegram `Update` object (posted to our webhook ingress URL) + * into a domain-level ChatMessage that the messaging service can dispatch. + * + * Reference: https://core.telegram.org/bots/api#update + * + * We only care about the `message` variant for now — inline queries, + * callback queries, edited messages, and channel posts are all ignored and + * return `null`, which causes the tunnel adapter to reply 200 without + * further side effects. + */ + +export interface ParsedTelegramMessage { + chatId: string; + /** Telegram user ID of the sender. */ + senderId?: string; + /** Username without leading @. */ + senderUsername?: string; + text: string; +} + +interface RawTelegramUpdate { + update_id?: number; + message?: { + message_id?: number; + chat?: { id?: number | string; username?: string }; + from?: { id?: number | string; username?: string }; + text?: string; + }; +} + +export function parseTelegramUpdate(rawBody: string): ParsedTelegramMessage | null { + if (!rawBody) return null; + + let update: RawTelegramUpdate; + try { + update = JSON.parse(rawBody) as RawTelegramUpdate; + } catch { + return null; + } + + const message = update.message; + if (!message) return null; + + const chatId = message.chat?.id !== undefined ? String(message.chat.id) : undefined; + const text = typeof message.text === 'string' ? message.text : ''; + if (!chatId || !text) return null; + + return { + chatId, + senderId: message.from?.id !== undefined ? String(message.from.id) : undefined, + senderUsername: message.from?.username, + text, + }; +} + +export interface PairCommand { + code: string; +} + +const PAIR_REGEX = /^\/pair(?:@\w+)?\s+(\d{6})\b/; + +/** Match `/pair 123456` (with optional `@botname` suffix). */ +export function parsePairCommand(text: string): PairCommand | null { + const match = text.trim().match(PAIR_REGEX); + if (!match) return null; + return { code: match[1] }; +} diff --git a/packages/core/src/infrastructure/services/messaging/tunnel-protocol.ts b/packages/core/src/infrastructure/services/messaging/tunnel-protocol.ts new file mode 100644 index 000000000..10b6c55c9 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/tunnel-protocol.ts @@ -0,0 +1,115 @@ +/** + * Commands.com Gateway tunnel protocol types. + * + * Matches the frame shapes sent and received by the Go implementation at + * https://github.com/Commands-com/gateway/blob/main/internal/gateway/integrations_tunnel.go + * + * Frame flow: + * server → client: tunnel.connected (after WebSocket open) + * client → server: tunnel.activate (claim a route) + * server → client: tunnel.activate.result (ok / error) + * server → client: tunnel.request (forwarded webhook) + * client → server: tunnel.response (reply to forwarded webhook) + * server → client: tunnel.route_deactivated (route revoked) + * server → client: tunnel.error (any protocol error) + */ + +export interface TunnelConnectedFrame { + type: 'tunnel.connected'; + device_id: string; + at?: string; +} + +/** + * Client → server: claim one or more routes on the live tunnel. + * + * The gateway's frame shape (see + * internal/gateway/integrations_tunnel.go:handleTunnelActivate) expects + * a `routes` array of route_id strings (or objects with `route_id`), NOT + * a single `route_id` field. An earlier Shep build sent the singular form + * and the gateway silently ignored it, which is why no routes were ever + * activated and every public webhook returned 503. + */ +export interface TunnelActivateFrame { + type: 'tunnel.activate'; + request_id?: string; + routes: string[]; +} + +/** + * Server → client: per-route activation results. + * + * Again the real gateway returns a `results` array (one entry per route), + * not a flat `{ route_id, ok }` pair. + */ +export interface TunnelActivateResultEntry { + route_id: string; + status: string; + /** Present when status === "rejected". */ + error?: { code: string; message: string }; +} + +export interface TunnelActivateResultFrame { + type: 'tunnel.activate.result'; + request_id?: string; + results: TunnelActivateResultEntry[]; +} + +export interface TunnelRequestFrame { + type: 'tunnel.request'; + request_id: string; + route_id: string; + method: string; + path: string; + /** HTTP headers as [name, value] pairs. */ + headers?: [string, string][]; + /** Base64-encoded request body. */ + body_base64?: string; +} + +export interface TunnelResponseFrame { + type: 'tunnel.response'; + request_id: string; + status: number; + headers?: [string, string][]; + /** Base64-encoded response body. */ + body_base64?: string; +} + +export interface TunnelRouteDeactivatedFrame { + type: 'tunnel.route_deactivated'; + route_id: string; + reason?: string; +} + +export interface TunnelErrorFrame { + type: 'tunnel.error'; + error: string; +} + +export type TunnelInboundFrame = + | TunnelConnectedFrame + | TunnelActivateResultFrame + | TunnelRequestFrame + | TunnelRouteDeactivatedFrame + | TunnelErrorFrame; + +export type TunnelOutboundFrame = TunnelActivateFrame | TunnelResponseFrame; + +/** Higher-level, decoded request presented to the consuming handler. */ +export interface DecodedTunnelRequest { + requestId: string; + routeId: string; + method: string; + path: string; + headers: Record; + /** Decoded UTF-8 body; empty string if no body. */ + body: string; +} + +/** Response returned by the handler for a decoded request. */ +export interface TunnelRequestResponse { + status: number; + headers?: Record; + body?: string; +} diff --git a/packages/core/src/infrastructure/services/messaging/whatsapp-webhook.parser.ts b/packages/core/src/infrastructure/services/messaging/whatsapp-webhook.parser.ts new file mode 100644 index 000000000..8dd50d8d7 --- /dev/null +++ b/packages/core/src/infrastructure/services/messaging/whatsapp-webhook.parser.ts @@ -0,0 +1,81 @@ +/** + * WhatsApp Cloud API Webhook Parser + * + * Converts a raw WhatsApp Business Cloud webhook payload into a domain-level + * ChatMessage, matching the same contract as the Telegram parser so the + * messaging service can dispatch both uniformly. + * + * Reference: https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples + * + * A minimal incoming text message looks like: + * + * { + * "object": "whatsapp_business_account", + * "entry": [{ + * "changes": [{ + * "value": { + * "messages": [{ + * "from": "15551234567", + * "id": "wamid.xxx", + * "timestamp": "1234567890", + * "type": "text", + * "text": { "body": "hello" } + * }] + * } + * }] + * }] + * } + * + * We only parse the first text message in the first change. Status updates + * (delivery receipts, read receipts) and non-text types are ignored and + * return null. + */ + +export interface ParsedWhatsAppMessage { + chatId: string; + senderId?: string; + text: string; +} + +interface RawWhatsAppValue { + messages?: { + from?: string; + id?: string; + type?: string; + text?: { body?: string }; + }[]; +} + +interface RawWhatsAppEntry { + changes?: { value?: RawWhatsAppValue }[]; +} + +interface RawWhatsAppUpdate { + object?: string; + entry?: RawWhatsAppEntry[]; +} + +export function parseWhatsAppUpdate(rawBody: string): ParsedWhatsAppMessage | null { + if (!rawBody) return null; + + let update: RawWhatsAppUpdate; + try { + update = JSON.parse(rawBody) as RawWhatsAppUpdate; + } catch { + return null; + } + + const change = update.entry?.[0]?.changes?.[0]?.value; + const message = change?.messages?.[0]; + if (message?.type !== 'text') return null; + + const text = message.text?.body; + const from = message.from; + if (!text || !from) return null; + + return { + chatId: from, + senderId: from, + text, + }; +} diff --git a/packages/electron/package.json b/packages/electron/package.json index 8b0a6ec44..3f6f29f07 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -38,7 +38,8 @@ "reflect-metadata": "^0.2.2", "tsyringe": "^4.10.0", "umzug": "^3.8.2", - "which": "^5.0.0" + "which": "^5.0.0", + "ws": "^8.20.0" }, "devDependencies": { "@electron/rebuild": "^3.7.1", diff --git a/playwright.config.ts b/playwright.config.ts index 721014908..bef935fe5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -44,7 +44,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { command: 'pnpm dev:web', - env: { PORT: '3001' }, + env: { PORT: '3001', SHEP_MOCK_GATEWAY: '1' }, url: 'http://localhost:3001', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cac9e8dfc..c801e490c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: which: specifier: ^5.0.0 version: 5.0.0 + ws: + specifier: ^8.20.0 + version: 8.20.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -222,6 +225,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@typespec-tools/emitter-typescript': specifier: ^0.3.0 version: 0.3.0(@typespec/compiler@0.60.1) @@ -433,6 +439,9 @@ importers: which: specifier: ^5.0.0 version: 5.0.0 + ws: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@electron/rebuild': specifier: ^3.7.1 @@ -3898,6 +3907,9 @@ packages: '@types/which@3.0.4': resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -9054,8 +9066,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -11796,7 +11808,7 @@ snapshots: recast: 0.23.11 semver: 7.7.3 util: 0.12.5 - ws: 8.19.0 + ws: 8.20.0 optionalDependencies: prettier: 3.3.3 transitivePeerDependencies: @@ -12469,6 +12481,10 @@ snapshots: '@types/which@3.0.4': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.0 + '@types/yauzl@2.10.3': dependencies: '@types/node': 25.2.0 @@ -18499,7 +18515,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.19.0: {} + ws@8.20.0: {} xml-name-validator@5.0.0: {} diff --git a/specs/082-messaging-remote-control/evidence/build-output.txt b/specs/082-messaging-remote-control/evidence/build-output.txt new file mode 100644 index 000000000..8a5769ecc --- /dev/null +++ b/specs/082-messaging-remote-control/evidence/build-output.txt @@ -0,0 +1,11 @@ +Evidence: Build Output +Captured: 2026-04-02T12:15:00Z +Command: pnpm build + +> @shepai/cli@1.163.0 build /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-messaging-remote-control +> pnpm build:cli + +> @shepai/cli@1.163.0 build:cli /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-messaging-remote-control +> tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && shx mkdir -p dist/packages/core/src/infrastructure/services/tool-installer && shx rm -rf dist/packages/core/src/infrastructure/services/tool-installer/tools && shx cp -r packages/core/src/infrastructure/services/tool-installer/tools dist/packages/core/src/infrastructure/services/tool-installer/tools && shx rm -rf dist/translations && shx cp -r translations dist/translations + +Build completed successfully with zero errors. diff --git a/specs/082-messaging-remote-control/evidence/full-unit-test-summary.txt b/specs/082-messaging-remote-control/evidence/full-unit-test-summary.txt new file mode 100644 index 000000000..69a5d3563 --- /dev/null +++ b/specs/082-messaging-remote-control/evidence/full-unit-test-summary.txt @@ -0,0 +1,10 @@ +Evidence: Full Unit Test Suite Summary +Captured: 2026-04-02T12:15:54Z +Command: pnpm test:unit + + Test Files 378 passed (378) + Tests 5338 passed (5338) + Start at 12:15:54 + Duration 45.84s (transform 13.57s, setup 53.21s, import 68.05s, tests 117.83s, environment 105.86s) + +All 378 test files pass (5338 tests total), confirming no regressions from messaging feature. diff --git a/specs/082-messaging-remote-control/evidence/messaging-unit-tests.txt b/specs/082-messaging-remote-control/evidence/messaging-unit-tests.txt new file mode 100644 index 000000000..fdcec78e9 --- /dev/null +++ b/specs/082-messaging-remote-control/evidence/messaging-unit-tests.txt @@ -0,0 +1,15 @@ +Evidence: Messaging Service Unit Tests +Captured: 2026-04-02T12:15:52Z +Command: pnpm vitest run tests/unit/infrastructure/services/messaging/ + + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-messaging-remote-control + + ✓ node tests/unit/infrastructure/services/messaging/content-sanitizer.test.ts (9 tests) 2ms + ✓ node tests/unit/infrastructure/services/messaging/chat-relay.test.ts (8 tests) 5ms + ✓ node tests/unit/infrastructure/services/messaging/notification-emitter.test.ts (7 tests) 5ms + ✓ node tests/unit/infrastructure/services/messaging/command-executor.test.ts (15 tests) 5ms + + Test Files 4 passed (4) + Tests 39 passed (39) + Start at 12:15:52 + Duration 136ms (transform 156ms, setup 99ms, import 121ms, tests 16ms, environment 0ms) diff --git a/specs/082-messaging-remote-control/evidence/serve-command-tests.txt b/specs/082-messaging-remote-control/evidence/serve-command-tests.txt new file mode 100644 index 000000000..5c68b4e30 --- /dev/null +++ b/specs/082-messaging-remote-control/evidence/serve-command-tests.txt @@ -0,0 +1,12 @@ +Evidence: Serve Command Tests (including messaging service integration) +Captured: 2026-04-02T12:15:53Z +Command: pnpm vitest run tests/unit/commands/_serve.command.test.ts + + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-messaging-remote-control + + ✓ node tests/unit/commands/_serve.command.test.ts (12 tests) 7ms + + Test Files 1 passed (1) + Tests 12 passed (12) + Start at 12:15:53 + Duration 151ms (transform 66ms, setup 23ms, import 63ms, tests 7ms, environment 0ms) diff --git a/specs/082-messaging-remote-control/evidence/tsp-compile-output.txt b/specs/082-messaging-remote-control/evidence/tsp-compile-output.txt new file mode 100644 index 000000000..1ad08f60d --- /dev/null +++ b/specs/082-messaging-remote-control/evidence/tsp-compile-output.txt @@ -0,0 +1,12 @@ +Evidence: TypeSpec Compilation +Captured: 2026-04-02T12:14:50Z +Command: pnpm tsp:compile + +> @shepai/cli@1.163.0 tsp:compile /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-messaging-remote-control +> tsp compile tsp/ + +TypeSpec compiler v0.60.1 + +Compilation completed successfully. + +All messaging domain models (MessagingCommand, MessagingConfig, MessagingNotification, MessagingPlatform, etc.) compile successfully. diff --git a/specs/082-messaging-remote-control/feature.yaml b/specs/082-messaging-remote-control/feature.yaml new file mode 100644 index 000000000..6452b95f1 --- /dev/null +++ b/specs/082-messaging-remote-control/feature.yaml @@ -0,0 +1,34 @@ +feature: + id: "082-messaging-remote-control" + name: "messaging-remote-control" + number: 82 + branch: "feat/082-messaging-remote-control" + lifecycle: "research" + createdAt: "2026-04-02T08:29:01Z" +status: + phase: "research" + progress: + completed: 0 + total: 0 + percentage: 0 + currentTask: null + lastUpdated: "2026-04-02T08:29:01Z" + lastUpdatedBy: "feature-agent" + completedPhases: + - "evidence" + - "fast-implement" +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] +tasks: + current: null + blocked: [] + failed: [] +checkpoints: + - phase: "feature-created" + completedAt: "2026-04-02T08:29:01Z" + completedBy: "feature-agent" +errors: + current: null + history: [] diff --git a/specs/082-messaging-remote-control/spec.yaml b/specs/082-messaging-remote-control/spec.yaml new file mode 100644 index 000000000..fb8886a18 --- /dev/null +++ b/specs/082-messaging-remote-control/spec.yaml @@ -0,0 +1,23 @@ +name: "messaging-remote-control" +number: 82 +branch: "feat/082-messaging-remote-control" +oneLiner: "Use commands-com gateway and build external control over shep\n" +userQuery: "Use commands-com gateway and build external control over shep\n" +summary: "Use commands-com gateway and build external control over shep\n" +phase: "Analysis" +sizeEstimate: "M" +relatedFeatures: [] +technologies: [] +relatedLinks: + - "https://github.com/Commands-com/gateway" +openQuestions: [] +content: "## Problem Statement\n\nUse commands-com gateway and build external control over shep.\n\n## Success Criteria\n\n**Phase 1 — core scaffolding (done):**\n- [x] TypeSpec domain models for messaging (commands, config, notifications, platform)\n- [x] Messaging service skeleton with tunnel adapter, chat relay, command executor, notification emitter\n- [x] Content sanitizer for safe message handling\n- [x] CLI commands for messaging configuration\n- [x] DI container registration for messaging service (lazy factory)\n- [x] Integration with _serve command for daemon lifecycle\n- [x] Unit tests for all messaging components (initial 39 tests passing)\n\n**Phase 2 — pairing use cases + web UI (done):**\n- [x] BeginMessagingPairing / ConfirmMessagingPairing / DisconnectMessaging use cases\n- [x] Web settings section with pairing modal (code + chatId confirm flow)\n- [x] CLI wizard refactored to share the same pairing use cases (presentation-agnostic)\n- [x] E2E Playwright test for the web pairing flow\n- [x] Storybook stories + unit test for the messaging settings component\n\n**Phase 3 — real gateway integration (in progress):**\n\nGateway protocol research showed the existing MessagingTunnelAdapter was built against a\nhypothetical protocol. The real Commands.com Gateway uses `tunnel.request` / `tunnel.response`\nframes, requires bearer-token auth on the WebSocket upgrade, and routes public webhooks via\nintegration routes that must be registered through `POST /gateway/v1/integrations/routes`.\nTelegram replies are outbound HTTPS calls to `api.telegram.org`, NOT tunnel frames.\n\n- [x] TypeSpec: extend MessagingConfig with deviceId, gatewayClientId, per-platform routeId/routeToken/publicUrl\n- [x] IGatewayClient port interface (oauth token + create integration route)\n- [x] HttpGatewayClient adapter implementation against gateway OpenAPI spec\n- [x] Unit tests for HttpGatewayClient using fetch mocks (7 tests)\n- [x] BeginMessagingPairingUseCase extended to fetch OAuth token + create route + return publicUrl\n- [x] Updated use case tests cover the new gateway client integration (9 tests)\n- [x] Web pairing dialog shows publicUrl with copy button and setup instructions\n- [x] CLI pairing wizard displays publicUrl and sample Telegram setWebhook curl\n- [x] DI container wires HttpGatewayClient\n- [x] Rewrite MessagingTunnelAdapter to speak real tunnel protocol (tunnel.activate / tunnel.request / tunnel.response)\n- [x] Switch from global WebSocket to the `ws` npm library so bearer headers can be set on upgrade\n- [x] Fetch OAuth access token at DI resolution and pass into MessagingService (no more empty authToken)\n- [x] IMessageSender output port decouples chat-relay + notification-emitter from the tunnel\n- [x] TelegramMessageSender implements IMessageSender via the Telegram Bot API\n- [x] HttpTelegramClient adapter (sendMessage) with unit tests\n- [x] telegram-webhook.parser extracts chat_id + text from Telegram Update payloads (+ pair command parser)\n- [x] MessagingService decodes tunnel.request frames as Telegram updates and dispatches slash commands via MessagingCommandExecutor\n- [x] Auto-confirm pairing: `/pair ` inbound messages call ConfirmMessagingPairingUseCase\n- [x] docs/development/messaging-local-setup.md — full cloudflared/ngrok walkthrough\n**Phase 4 — parity polish (done):**\n- [x] Per-platform botToken field on MessagingPlatformConfig (TypeSpec)\n- [x] Web settings: bot token input appears for paired platforms with password masking\n- [x] CLI wizard: prompts for bot token after confirming pairing\n- [x] DI: bot token precedence is settings.db > SHEP_TELEGRAM_BOT_TOKEN env var\n- [x] WhatsApp webhook parser (Business Cloud API shape) with unit tests\n- [x] MessagingService dispatches Telegram OR WhatsApp inbound webhooks based on route → platform resolution\n- [x] Interactive /chat relay: subscribes to the IInteractiveSessionService stream and forwards agent deltas to the chat\n- [x] /end command tears down the subscription via unsubscribe handle owned by MessagingChatRelay\n- [x] Free-form messages during an active relay are forwarded via IInteractiveSessionService.sendUserMessage\n- [x] MessagingCommandType enum extended with list/chat/end/help\n\n**Remaining (out of scope for 082):**\n- [ ] WhatsApp Business Cloud API outbound client (sendMessage) — requires a verified Meta dev app and is substantially more involved than Telegram\n- [ ] Integration test using a real local gateway process in CI (spin up the Go binary in a GitHub Actions runner)\n\n## Affected Areas\n\n| Area | Impact | Reasoning |\n| -------------- | ------ | ------------------------------------------------ |\n| Domain models | High | New TypeSpec models for messaging concepts |\n| Infrastructure | High | New messaging service and sub-components |\n| CLI | Medium | New settings commands for messaging configuration |\n| DI container | Low | Registration of messaging service |\n\n## Dependencies\n\n- commands-com/gateway for WebSocket tunnel connectivity\n\n## Size Estimate\n\n**M** - Medium complexity feature\n\n---\n\n_Generated by feature agent — proceed with research_\n" +rejectionFeedback: + - iteration: 1 + message: "Resolve merge conflicts" + phase: "merge" + timestamp: "2026-04-12T18:52:55.920Z" + - iteration: 2 + message: "Resolve merge conflicts" + phase: "merge" + timestamp: "2026-04-26T16:40:17.980Z" diff --git a/src/presentation/cli/commands/_serve.command.ts b/src/presentation/cli/commands/_serve.command.ts index 298d9bdcf..abac52a8e 100644 --- a/src/presentation/cli/commands/_serve.command.ts +++ b/src/presentation/cli/commands/_serve.command.ts @@ -44,6 +44,7 @@ import type { IPhaseTimingRepository } from '@/application/ports/output/agents/p import type { INotificationService } from '@/application/ports/output/services/notification-service.interface.js'; import type { IFeatureRepository } from '@/application/ports/output/repositories/feature-repository.interface.js'; import type { IDeploymentService } from '@/application/ports/output/services/deployment-service.interface.js'; +import type { IMessagingService } from '@/application/ports/output/services/messaging-service.interface.js'; import { getCliI18n } from '../i18n.js'; function parsePort(value: string): number { @@ -89,6 +90,12 @@ export function createServeCommand(): Command { initializeAutoArchiveWatcher(featureRepo); getAutoArchiveWatcher().start(); + // Start messaging service if configured + const messagingService = container.resolve('IMessagingService'); + if (messagingService.isConfigured()) { + await messagingService.start(); + } + // Graceful shutdown handler — identical pattern to ui.command.ts let isShuttingDown = false; const shutdown = async () => { @@ -101,6 +108,7 @@ export function createServeCommand(): Command { getNotificationWatcher().stop(); getAutoArchiveWatcher().stop(); + await messagingService.stop(); const deploymentService = container.resolve('IDeploymentService'); deploymentService.stopAll(); await service.stop(); diff --git a/src/presentation/cli/commands/settings/index.ts b/src/presentation/cli/commands/settings/index.ts index 86e5c7fdf..411b4ac5b 100644 --- a/src/presentation/cli/commands/settings/index.ts +++ b/src/presentation/cli/commands/settings/index.ts @@ -23,6 +23,7 @@ import { createIdeCommand } from './ide.command.js'; import { createWorkflowCommand } from './workflow.command.js'; import { createModelCommand } from './model.command.js'; import { createLanguageCommand } from './language.command.js'; +import { createMessagingCommand } from './messaging.command.js'; import { onboardingWizard } from '../../../tui/wizards/onboarding/onboarding.wizard.js'; import { messages } from '../../ui/index.js'; import { getCliI18n } from '../../i18n.js'; @@ -39,7 +40,8 @@ export function createSettingsCommand(): Command { .addCommand(createIdeCommand()) .addCommand(createWorkflowCommand()) .addCommand(createModelCommand()) - .addCommand(createLanguageCommand()); + .addCommand(createLanguageCommand()) + .addCommand(createMessagingCommand()); // Default action: launch the full setup wizard when no subcommand is given cmd.action(async () => { diff --git a/src/presentation/cli/commands/settings/messaging.command.ts b/src/presentation/cli/commands/settings/messaging.command.ts new file mode 100644 index 000000000..2d82e98c3 --- /dev/null +++ b/src/presentation/cli/commands/settings/messaging.command.ts @@ -0,0 +1,228 @@ +/** + * Messaging Configuration Command + * + * Configures external messaging remote control via Telegram or WhatsApp + * through the Commands.com Gateway. + * + * Usage: + * shep settings messaging # Interactive setup wizard + * shep settings messaging status # Show connection status + * shep settings messaging disconnect # Disconnect messaging + */ + +import { Command } from 'commander'; +import { select, input, confirm } from '@inquirer/prompts'; +import { container } from '@/infrastructure/di/container.js'; +import { BeginMessagingPairingUseCase } from '@/application/use-cases/messaging/begin-pairing.use-case.js'; +import { ConfirmMessagingPairingUseCase } from '@/application/use-cases/messaging/confirm-pairing.use-case.js'; +import { DisconnectMessagingUseCase } from '@/application/use-cases/messaging/disconnect-messaging.use-case.js'; +import { UpdateSettingsUseCase } from '@/application/use-cases/settings/update-settings.use-case.js'; +import { + getSettings, + resetSettings, + initializeSettings, +} from '@/infrastructure/services/settings.service.js'; +import { LoadSettingsUseCase } from '@/application/use-cases/settings/load-settings.use-case.js'; +import { MessagingPlatform } from '@/domain/generated/output.js'; +import { messages } from '../../ui/index.js'; +import { shepTheme } from '../../../tui/themes/shep.theme.js'; + +/** + * Create the messaging configuration command. + */ +export function createMessagingCommand(): Command { + const cmd = new Command('messaging') + .description('Configure messaging remote control (Telegram/WhatsApp)') + .addHelpText( + 'after', + ` +Examples: + $ shep settings messaging Interactive setup wizard + $ shep settings messaging status Show connection status + $ shep settings messaging disconnect Disconnect messaging` + ) + .action(async () => { + try { + await runMessagingWizard(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + + if (err.message.includes('force closed') || err.message.includes('User force closed')) { + messages.info('Messaging setup cancelled.'); + return; + } + + messages.error('Failed to configure messaging', err); + process.exitCode = 1; + } + }); + + cmd + .command('status') + .description('Show messaging connection status') + .action(() => { + const settings = getSettings(); + const mc = settings.messaging; + + if (!mc?.enabled) { + messages.info('Messaging remote control is not configured.'); + return; + } + + console.log(`\nMessaging Remote Control`); + console.log(` Gateway: ${mc.gatewayUrl ?? 'not set'}`); + console.log(` Enabled: ${mc.enabled}`); + + if (mc.telegram) { + console.log( + ` Telegram: ${mc.telegram.enabled ? 'enabled' : 'disabled'} (${mc.telegram.paired ? 'paired' : 'not paired'})` + ); + } + + if (mc.whatsapp) { + console.log( + ` WhatsApp: ${mc.whatsapp.enabled ? 'enabled' : 'disabled'} (${mc.whatsapp.paired ? 'paired' : 'not paired'})` + ); + } + + console.log(''); + }); + + cmd + .command('disconnect') + .description('Disconnect all messaging platforms') + .action(async () => { + try { + const useCase = container.resolve(DisconnectMessagingUseCase); + await useCase.execute(); + await refreshSettingsSingleton(); + messages.success('Messaging remote control disconnected.'); + } catch (error) { + messages.error( + 'Failed to disconnect messaging', + error instanceof Error ? error : new Error(String(error)) + ); + process.exitCode = 1; + } + }); + + return cmd; +} + +async function refreshSettingsSingleton(): Promise { + const loadUseCase = container.resolve(LoadSettingsUseCase); + const fresh = await loadUseCase.execute(); + resetSettings(); + initializeSettings(fresh); +} + +async function runMessagingWizard(): Promise { + const settings = getSettings(); + + const platformChoice = await select({ + message: 'Which platform would you like to connect?', + choices: [ + { name: 'Telegram', value: 'telegram' }, + { name: 'WhatsApp', value: 'whatsapp' }, + { name: 'Disconnect all', value: 'disconnect' }, + ], + theme: shepTheme, + }); + + if (platformChoice === 'disconnect') { + const disconnectUseCase = container.resolve(DisconnectMessagingUseCase); + await disconnectUseCase.execute(); + await refreshSettingsSingleton(); + messages.success('Messaging remote control disconnected.'); + return; + } + + const platform = + platformChoice === 'telegram' ? MessagingPlatform.Telegram : MessagingPlatform.WhatsApp; + const platformLabel = platformChoice === 'telegram' ? 'Telegram' : 'WhatsApp'; + + // Get Gateway URL + const gatewayUrl = await input({ + message: 'Enter your Gateway URL:', + default: settings.messaging?.gatewayUrl ?? '', + validate: (value: string) => { + if (!value.trim()) return 'Gateway URL is required'; + try { + new URL(value); + return true; + } catch { + return 'Please enter a valid URL (e.g., https://my-gateway.railway.app)'; + } + }, + theme: shepTheme, + }); + + // Begin pairing — generates a one-time code and persists pending state. + const beginUseCase = container.resolve(BeginMessagingPairingUseCase); + const session = await beginUseCase.execute({ platform, gatewayUrl }); + await refreshSettingsSingleton(); + + messages.info(`${platformLabel} pairing initiated.`); + console.log(''); + console.log(` Pairing code: ${session.code}`); + console.log(` Expires at: ${new Date(session.expiresAt).toLocaleString()}`); + console.log(''); + console.log(` Webhook URL (${platformLabel}):`); + console.log(` ${session.publicUrl}`); + console.log(''); + console.log(' Next steps:'); + console.log(` 1. Point your ${platformLabel} bot webhook at the URL above`); + console.log( + ` (Telegram: curl -X POST https://api.telegram.org/bot/setWebhook -d url=...)` + ); + console.log(` 2. Send: /pair ${session.code}`); + console.log(` 3. Return here and enter the chat ID the bot replies with`); + console.log(''); + + const shouldConfirm = await confirm({ + message: 'Confirm pairing now?', + default: true, + theme: shepTheme, + }); + + if (!shouldConfirm) { + messages.info('You can confirm pairing later by re-running `shep settings messaging`.'); + return; + } + + const chatId = await input({ + message: 'Chat ID the bot replied with:', + validate: (value: string) => (value.trim() ? true : 'Chat ID is required'), + theme: shepTheme, + }); + + const confirmUseCase = container.resolve(ConfirmMessagingPairingUseCase); + await confirmUseCase.execute({ platform, chatId }); + await refreshSettingsSingleton(); + + // Collect the bot API token so the daemon can reply to the user. + const botToken = await input({ + message: `${platformLabel} bot API token (leave blank to use $SHEP_TELEGRAM_BOT_TOKEN):`, + default: '', + theme: shepTheme, + }); + + if (botToken.trim()) { + const current = getSettings(); + const key: 'telegram' | 'whatsapp' = + platform === MessagingPlatform.Telegram ? 'telegram' : 'whatsapp'; + const existingPlatform = current.messaging?.[key]; + if (existingPlatform && current.messaging) { + current.messaging = { + ...current.messaging, + [key]: { ...existingPlatform, botToken: botToken.trim() }, + }; + const updateUseCase = container.resolve(UpdateSettingsUseCase); + await updateUseCase.execute(current); + await refreshSettingsSingleton(); + } + } + + messages.success(`${platformLabel} messaging paired.`); + messages.info('Restart the Shep daemon (`shep _serve`) to activate messaging.'); +} diff --git a/src/presentation/web/app/actions/messaging.ts b/src/presentation/web/app/actions/messaging.ts new file mode 100644 index 000000000..789d3a127 --- /dev/null +++ b/src/presentation/web/app/actions/messaging.ts @@ -0,0 +1,87 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import type { BeginMessagingPairingUseCase } from '@shepai/core/application/use-cases/messaging/begin-pairing.use-case'; +import type { ConfirmMessagingPairingUseCase } from '@shepai/core/application/use-cases/messaging/confirm-pairing.use-case'; +import type { DisconnectMessagingUseCase } from '@shepai/core/application/use-cases/messaging/disconnect-messaging.use-case'; +import type { LoadSettingsUseCase } from '@shepai/core/application/use-cases/settings/load-settings.use-case'; +import { updateSettings as updateSettingsSingleton } from '@shepai/core/infrastructure/services/settings.service'; +import type { MessagingPlatform } from '@shepai/core/domain/generated/output'; + +export interface BeginPairingResult { + success: boolean; + error?: string; + session?: { + platform: MessagingPlatform; + code: string; + expiresAt: string; + gatewayUrl: string; + publicUrl: string; + routeId: string; + }; +} + +export interface MessagingActionResult { + success: boolean; + error?: string; +} + +async function refreshSettingsCache(): Promise { + // Keep the in-memory settings singleton in sync with the DB so that the + // running daemon (started from the same process) sees the latest config. + try { + const loadUseCase = resolve('LoadSettingsUseCase'); + const fresh = await loadUseCase.execute(); + updateSettingsSingleton(fresh); + } catch { + // Settings service may not be initialized yet in some contexts — ignore. + } +} + +export async function beginMessagingPairingAction(input: { + platform: MessagingPlatform; + gatewayUrl: string; +}): Promise { + try { + const useCase = resolve('BeginMessagingPairingUseCase'); + const session = await useCase.execute(input); + await refreshSettingsCache(); + revalidatePath('/settings'); + return { success: true, session }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to begin pairing'; + return { success: false, error: message }; + } +} + +export async function confirmMessagingPairingAction(input: { + platform: MessagingPlatform; + chatId: string; +}): Promise { + try { + const useCase = resolve('ConfirmMessagingPairingUseCase'); + await useCase.execute(input); + await refreshSettingsCache(); + revalidatePath('/settings'); + return { success: true }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to confirm pairing'; + return { success: false, error: message }; + } +} + +export async function disconnectMessagingAction(input: { + platform?: MessagingPlatform; +}): Promise { + try { + const useCase = resolve('DisconnectMessagingUseCase'); + await useCase.execute(input); + await refreshSettingsCache(); + revalidatePath('/settings'); + return { success: true }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to disconnect messaging'; + return { success: false, error: message }; + } +} diff --git a/src/presentation/web/components/features/settings/messaging-settings-section.stories.tsx b/src/presentation/web/components/features/settings/messaging-settings-section.stories.tsx new file mode 100644 index 000000000..f53a64e97 --- /dev/null +++ b/src/presentation/web/components/features/settings/messaging-settings-section.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { MessagingSettingsSection } from './messaging-settings-section'; + +const meta = { + title: 'Features/Settings/MessagingSettingsSection', + component: MessagingSettingsSection, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Disabled: Story = { + args: { + messaging: { + enabled: false, + debounceMs: 5000, + chatBufferMs: 3000, + }, + }, +}; + +export const EnabledUnpaired: Story = { + args: { + messaging: { + enabled: true, + gatewayUrl: 'https://gateway.example.com', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { enabled: false, paired: false }, + whatsapp: { enabled: false, paired: false }, + }, + }, +}; + +export const TelegramPaired: Story = { + args: { + messaging: { + enabled: true, + gatewayUrl: 'https://gateway.example.com', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { enabled: true, paired: true, chatId: '@alice' }, + whatsapp: { enabled: false, paired: false }, + }, + }, +}; + +export const BothPaired: Story = { + args: { + messaging: { + enabled: true, + gatewayUrl: 'https://gateway.example.com', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { enabled: true, paired: true, chatId: '@alice' }, + whatsapp: { enabled: true, paired: true, chatId: '+15551234567' }, + }, + }, +}; diff --git a/src/presentation/web/components/features/settings/messaging-settings-section.tsx b/src/presentation/web/components/features/settings/messaging-settings-section.tsx new file mode 100644 index 000000000..00441b8b6 --- /dev/null +++ b/src/presentation/web/components/features/settings/messaging-settings-section.tsx @@ -0,0 +1,519 @@ +'use client'; + +import { useState, useTransition, useRef, useEffect, useCallback } from 'react'; +import { MessageCircle, Check, Copy, Link2, ShieldCheck, Unplug } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Separator } from '@/components/ui/separator'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { updateSettingsAction } from '@/app/actions/update-settings'; +import { + beginMessagingPairingAction, + confirmMessagingPairingAction, + disconnectMessagingAction, +} from '@/app/actions/messaging'; +import type { MessagingConfig } from '@shepai/core/domain/generated/output'; +import { MessagingPlatform } from '@shepai/core/domain/generated/output'; + +export interface MessagingSettingsSectionProps { + messaging?: MessagingConfig; +} + +interface PairingSessionState { + platform: MessagingPlatform; + code: string; + expiresAt: string; + gatewayUrl: string; + publicUrl: string; + routeId: string; +} + +const DEFAULT_CONFIG: MessagingConfig = { + enabled: false, + debounceMs: 5000, + chatBufferMs: 3000, +}; + +function platformLabel(platform: MessagingPlatform): string { + return platform === MessagingPlatform.Telegram ? 'Telegram' : 'WhatsApp'; +} + +function isValidUrl(value: string): boolean { + if (!value.trim()) return false; + try { + new URL(value); + return true; + } catch { + return false; + } +} + +export function MessagingSettingsSection({ messaging }: MessagingSettingsSectionProps) { + const config = messaging ?? DEFAULT_CONFIG; + + const [enabled, setEnabled] = useState(config.enabled); + const [gatewayUrl, setGatewayUrl] = useState(config.gatewayUrl ?? ''); + const [telegram, setTelegram] = useState(config.telegram); + const [whatsapp, setWhatsapp] = useState(config.whatsapp); + const [isPending, startTransition] = useTransition(); + const [showSaved, setShowSaved] = useState(false); + const prevPendingRef = useRef(false); + + const [pairing, setPairing] = useState(null); + const [pairingLoading, setPairingLoading] = useState(false); + const [chatIdInput, setChatIdInput] = useState(''); + + useEffect(() => { + if (prevPendingRef.current && !isPending) { + setShowSaved(true); + const timer = setTimeout(() => setShowSaved(false), 2000); + return () => clearTimeout(timer); + } + prevPendingRef.current = isPending; + }, [isPending]); + + // Keep local state in sync when the server prop changes after a server action. + useEffect(() => { + setEnabled(config.enabled); + setGatewayUrl(config.gatewayUrl ?? ''); + setTelegram(config.telegram); + setWhatsapp(config.whatsapp); + }, [config.enabled, config.gatewayUrl, config.telegram, config.whatsapp]); + + const saveTopLevel = useCallback( + (payload: { enabled?: boolean; gatewayUrl?: string }) => { + startTransition(async () => { + const result = await updateSettingsAction({ + messaging: { + ...config, + enabled: payload.enabled ?? enabled, + gatewayUrl: payload.gatewayUrl ?? gatewayUrl, + }, + }); + if (!result.success) { + toast.error(result.error ?? 'Failed to save messaging settings'); + } + }); + }, + [config, enabled, gatewayUrl] + ); + + const savePlatformBotToken = useCallback( + (platform: MessagingPlatform, botToken: string) => { + const key: 'telegram' | 'whatsapp' = + platform === MessagingPlatform.Telegram ? 'telegram' : 'whatsapp'; + const existing = config[key]; + if (!existing) { + toast.error('Pair this platform before setting a bot token.'); + return; + } + startTransition(async () => { + const result = await updateSettingsAction({ + messaging: { + ...config, + [key]: { ...existing, botToken: botToken || undefined }, + }, + }); + if (!result.success) { + toast.error(result.error ?? 'Failed to save bot token'); + } + }); + }, + [config] + ); + + function handleEnableChange(value: boolean) { + setEnabled(value); + saveTopLevel({ enabled: value }); + } + + function handleGatewayBlur() { + if (gatewayUrl === (config.gatewayUrl ?? '')) return; + if (gatewayUrl && !isValidUrl(gatewayUrl)) { + toast.error('Gateway URL must be a valid URL (e.g., https://gateway.example.com)'); + return; + } + saveTopLevel({ gatewayUrl }); + } + + async function handlePair(platform: MessagingPlatform) { + if (!isValidUrl(gatewayUrl)) { + toast.error('Set a valid Gateway URL before pairing'); + return; + } + setPairingLoading(true); + setChatIdInput(''); + try { + const result = await beginMessagingPairingAction({ platform, gatewayUrl }); + if (!result.success || !result.session) { + toast.error(result.error ?? 'Failed to begin pairing'); + return; + } + setPairing(result.session); + } finally { + setPairingLoading(false); + } + } + + async function handleConfirmPairing() { + if (!pairing) return; + if (!chatIdInput.trim()) { + toast.error('Enter the chat ID that received the code'); + return; + } + setPairingLoading(true); + try { + const result = await confirmMessagingPairingAction({ + platform: pairing.platform, + chatId: chatIdInput.trim(), + }); + if (!result.success) { + toast.error(result.error ?? 'Failed to confirm pairing'); + return; + } + toast.success(`${platformLabel(pairing.platform)} paired`); + setPairing(null); + } finally { + setPairingLoading(false); + } + } + + async function handleDisconnect(platform?: MessagingPlatform) { + setPairingLoading(true); + try { + const result = await disconnectMessagingAction({ platform }); + if (!result.success) { + toast.error(result.error ?? 'Failed to disconnect'); + return; + } + toast.success( + platform ? `${platformLabel(platform)} disconnected` : 'Messaging disconnected' + ); + } finally { + setPairingLoading(false); + } + } + + async function handleCopyCode() { + if (!pairing) return; + try { + await navigator.clipboard.writeText(pairing.code); + toast.success('Code copied'); + } catch { + toast.error('Unable to copy code'); + } + } + + async function handleCopyPublicUrl() { + if (!pairing) return; + try { + await navigator.clipboard.writeText(pairing.publicUrl); + toast.success('Webhook URL copied'); + } catch { + toast.error('Unable to copy URL'); + } + } + + return ( + + +
+
+ + Messaging Remote Control +
+ {isPending ? Saving... : null} + {showSaved && !isPending ? ( + + + Saved + + ) : null} +
+ + Drive Shep remotely from Telegram or WhatsApp via the Commands.com Gateway. + +
+ +
+ + +
+ +
+ + setGatewayUrl(e.target.value)} + onBlur={handleGatewayBlur} + /> +
+ + + + handlePair(MessagingPlatform.Telegram)} + onDisconnect={() => handleDisconnect(MessagingPlatform.Telegram)} + onSaveBotToken={(value) => savePlatformBotToken(MessagingPlatform.Telegram, value)} + /> + + handlePair(MessagingPlatform.WhatsApp)} + onDisconnect={() => handleDisconnect(MessagingPlatform.WhatsApp)} + onSaveBotToken={(value) => savePlatformBotToken(MessagingPlatform.WhatsApp, value)} + /> + + {telegram?.paired === true || whatsapp?.paired === true ? ( + <> + +
+
Disconnect all platforms
+ +
+ + ) : null} +
+ + !open && setPairing(null)}> + + + Pair {pairing ? platformLabel(pairing.platform) : ''} + + Send this one-time code to your bot. Then enter the chat ID that received it to finish + pairing. + + + + {pairing ? ( +
+
+ {pairing.code} + +
+ +
+ +
+ + {pairing.publicUrl} + + +
+
+ +
    +
  1. + Point your {platformLabel(pairing.platform)} bot webhook at the URL above (e.g.{' '} + setWebhook for Telegram). +
  2. +
  3. + Send /pair {pairing.code} to the bot. +
  4. +
  5. Enter the chat ID that received your code below and click Confirm.
  6. +
+ +
+ + setChatIdInput(e.target.value)} + /> +
+
+ ) : null} + + + + + +
+
+
+ ); +} + +function PlatformRow({ + platform, + config, + disabled, + onPair, + onDisconnect, + onSaveBotToken, +}: { + platform: MessagingPlatform; + config: MessagingConfig['telegram']; + disabled: boolean; + onPair: () => void; + onDisconnect: () => void; + onSaveBotToken: (value: string) => void; +}) { + const label = platformLabel(platform); + const paired = !!config?.paired; + const enabled = !!config?.enabled; + const chatId = config?.chatId; + const testIdPrefix = platform === MessagingPlatform.Telegram ? 'telegram' : 'whatsapp'; + + const [botToken, setBotToken] = useState(config?.botToken ?? ''); + useEffect(() => { + setBotToken(config?.botToken ?? ''); + }, [config?.botToken]); + + function handleBotTokenBlur() { + if (botToken === (config?.botToken ?? '')) return; + onSaveBotToken(botToken); + } + + return ( +
+
+
+
+ + {paired ? ( + + Paired + + ) : enabled ? ( + + Pairing + + ) : ( + + Not configured + + )} +
+ {chatId ? ( +

chat: {chatId}

+ ) : null} +
+
+ {paired ? ( + + ) : ( + + )} +
+
+ + {paired ? ( +
+ + setBotToken(e.target.value)} + onBlur={handleBotTokenBlur} + /> +

+ Needed so the daemon can send replies and notifications. Stored in settings.db; you can + also set the SHEP_TELEGRAM_BOT_TOKEN env var instead. +

+
+ ) : null} +
+ ); +} diff --git a/src/presentation/web/components/features/settings/settings-page-client.tsx b/src/presentation/web/components/features/settings/settings-page-client.tsx index 7a185c073..3a0cd5f5d 100644 --- a/src/presentation/web/components/features/settings/settings-page-client.tsx +++ b/src/presentation/web/components/features/settings/settings-page-client.tsx @@ -18,6 +18,7 @@ import { Timer, MessageSquare, LayoutGrid, + MessageCircle, Eye, EyeOff, } from 'lucide-react'; @@ -45,6 +46,7 @@ import { } from '@shepai/core/domain/generated/output'; import { getEditorTypeIcon } from '@/components/common/editor-type-icons'; import { AgentModelPicker } from '@/components/features/settings/AgentModelPicker'; +import { MessagingSettingsSection } from '@/components/features/settings/messaging-settings-section'; const LANGUAGE_OPTIONS = [ { value: Language.English, nativeName: 'English' }, { value: Language.Ukrainian, nativeName: 'Українська' }, @@ -99,6 +101,7 @@ const SECTIONS = [ { id: 'ci', labelKey: 'settings.sections.ci', icon: Activity }, { id: 'stage-timeouts', labelKey: 'settings.sections.timeouts', icon: Timer }, { id: 'notifications', labelKey: 'settings.sections.notifications', icon: Bell }, + { id: 'messaging', labelKey: 'settings.sections.messaging', icon: MessageCircle }, { id: 'feature-flags', labelKey: 'settings.sections.flags', icon: Flag }, { id: 'interactive-agent', labelKey: 'settings.sections.chat', icon: MessageSquare }, { id: 'fab-layout', labelKey: 'settings.sections.layout', icon: LayoutGrid }, @@ -1683,6 +1686,25 @@ export function SettingsPageClient({ + {/* ── Messaging Remote Control ── */} +
+ + + Drive Shep remotely from Telegram or WhatsApp. Pair a chat to send commands and receive + notifications through the Commands.com Gateway. + +
+ {/* ── Feature Flags ── */}
('IMessagingService'); + if (messagingService.isConfigured()) { + await messagingService.start(); + console.log('[dev-server] messaging remote control started'); + } else { + console.log( + '[dev-server] SHEP_ENABLE_MESSAGING=1 but messaging is not configured yet — pair a platform in Settings first' + ); + } + } catch (err) { + console.warn('[dev-server] failed to start messaging service:', err); + } + } } catch (error) { console.warn('[dev-server] DI initialization failed — features will be empty:', error); } @@ -219,6 +241,12 @@ async function main() { } catch { /* not initialized */ } + try { + const messagingService = container.resolve('IMessagingService'); + await messagingService.stop(); + } catch { + /* not initialized or not running */ + } server.closeAllConnections(); await Promise.all([ new Promise((resolve) => server.close(() => resolve())), diff --git a/tests/e2e/web/messaging-settings.spec.ts b/tests/e2e/web/messaging-settings.spec.ts new file mode 100644 index 000000000..a2c5d6088 --- /dev/null +++ b/tests/e2e/web/messaging-settings.spec.ts @@ -0,0 +1,91 @@ +/** + * E2E: Messaging remote control settings section. + * + * Exercises the Telegram/WhatsApp pairing flow in the web UI against a + * real Next.js dev server. The pairing code is generated server-side by + * BeginMessagingPairingUseCase and persisted in settings, so we do not + * need to stub any network calls. + */ + +import { test, expect } from '@playwright/test'; + +test.describe('messaging settings', () => { + test('enables messaging, sets gateway URL, pairs telegram, then disconnects', async ({ + page, + }) => { + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('messaging-settings-section'); + await section.scrollIntoViewIfNeeded(); + await expect(section).toBeVisible(); + + // Master toggle: turn messaging on + const enableSwitch = page.getByTestId('switch-messaging-enabled'); + if ((await enableSwitch.getAttribute('data-state')) !== 'checked') { + await enableSwitch.click(); + } + + // Gateway URL + const gatewayInput = page.getByTestId('input-gateway-url'); + await gatewayInput.fill('https://gateway.example.com'); + await gatewayInput.blur(); + + // Start Telegram pairing + await page.getByTestId('btn-telegram-pair').click(); + + const dialog = page.getByTestId('messaging-pairing-dialog'); + await expect(dialog).toBeVisible(); + + // Code box should contain a 6-digit number + const codeBox = page.getByTestId('pairing-code-box'); + await expect(codeBox).toBeVisible(); + const codeText = (await codeBox.textContent())?.trim() ?? ''; + expect(codeText).toMatch(/\d{6}/); + + // Public URL box should be visible and point into the gateway + await expect(page.getByTestId('pairing-public-url-box')).toBeVisible(); + const publicUrl = (await page.getByTestId('pairing-public-url').textContent()) ?? ''; + expect(publicUrl).toMatch(/\/integrations\//); + + // Confirm button is disabled until chat id is typed + const confirmBtn = page.getByTestId('btn-confirm-pairing'); + await expect(confirmBtn).toBeDisabled(); + + await page.getByTestId('input-pairing-chat-id').fill('@e2e-tester'); + await expect(confirmBtn).toBeEnabled(); + await confirmBtn.click(); + + // Dialog closes after successful confirm + await expect(dialog).toBeHidden(); + + // Telegram row now shows a disconnect button + await expect(page.getByTestId('btn-telegram-disconnect')).toBeVisible(); + await expect(page.getByTestId('btn-disconnect-all')).toBeVisible(); + + // Disconnect all — row should flip back to Pair button + await page.getByTestId('btn-disconnect-all').click(); + await expect(page.getByTestId('btn-telegram-pair')).toBeVisible(); + await expect(page.getByTestId('btn-disconnect-all')).toBeHidden(); + }); + + test('refuses to begin pairing with an invalid gateway URL', async ({ page }) => { + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + + const section = page.getByTestId('messaging-settings-section'); + await section.scrollIntoViewIfNeeded(); + + const enableSwitch = page.getByTestId('switch-messaging-enabled'); + if ((await enableSwitch.getAttribute('data-state')) !== 'checked') { + await enableSwitch.click(); + } + + await page.getByTestId('input-gateway-url').fill('not a url'); + // Don't blur (would trigger a save error toast) — click pair directly + await page.getByTestId('btn-telegram-pair').click(); + + // The pairing dialog should NOT appear + await expect(page.getByTestId('messaging-pairing-dialog')).toBeHidden(); + }); +}); diff --git a/tests/unit/application/use-cases/messaging/begin-pairing.use-case.test.ts b/tests/unit/application/use-cases/messaging/begin-pairing.use-case.test.ts new file mode 100644 index 000000000..33b7fae86 --- /dev/null +++ b/tests/unit/application/use-cases/messaging/begin-pairing.use-case.test.ts @@ -0,0 +1,158 @@ +/** + * BeginMessagingPairingUseCase Unit Tests + * + * Covers the TDD contract for the pairing flow: + * 1. Validate gateway URL + required inputs + * 2. Fetch an OAuth access token from the gateway + * 3. Create an integration route for the target platform + * 4. Persist routeId / routeToken / publicUrl + pending pairing code + * 5. Return a session DTO the presentation layer can render + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { BeginMessagingPairingUseCase } from '@/application/use-cases/messaging/begin-pairing.use-case.js'; +import { MockSettingsRepository } from '../../../../helpers/mock-repository.helper.js'; +import { createDefaultSettings } from '@/domain/factories/settings-defaults.factory.js'; +import { MessagingPlatform } from '@/domain/generated/output.js'; +import type { + IGatewayClient, + GatewayIntegrationRoute, + GatewayOAuthToken, +} from '@/application/ports/output/services/gateway-client.interface.js'; + +function makeMockGatewayClient(overrides: Partial = {}): IGatewayClient { + const token: GatewayOAuthToken = { + accessToken: 'tok-123', + tokenType: 'Bearer', + expiresAt: Date.now() + 3_600_000, + }; + const route: GatewayIntegrationRoute = { + routeId: 'route-abc', + routeToken: 'rt-xyz', + publicUrl: 'http://localhost:8080/integrations/route-abc/rt-xyz', + deviceId: 'dev-1', + interfaceType: 'telegram', + }; + return { + fetchAccessToken: vi.fn().mockResolvedValue(token), + createIntegrationRoute: vi.fn().mockResolvedValue(route), + ...overrides, + }; +} + +describe('BeginMessagingPairingUseCase', () => { + let useCase: BeginMessagingPairingUseCase; + let mockRepository: MockSettingsRepository; + let gatewayClient: IGatewayClient; + + beforeEach(async () => { + mockRepository = new MockSettingsRepository(); + await mockRepository.initialize(createDefaultSettings()); + gatewayClient = makeMockGatewayClient(); + useCase = new BeginMessagingPairingUseCase(mockRepository as never, gatewayClient); + }); + + it('fails if gatewayUrl is missing', async () => { + await expect( + useCase.execute({ platform: MessagingPlatform.Telegram, gatewayUrl: '' }) + ).rejects.toThrow(/gateway url/i); + }); + + it('fails if gatewayUrl is not a valid URL', async () => { + await expect( + useCase.execute({ platform: MessagingPlatform.Telegram, gatewayUrl: 'not a url' }) + ).rejects.toThrow(/valid url/i); + }); + + it('returns a 6-digit pairing code and expiry for telegram', async () => { + const session = await useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }); + expect(session.code).toMatch(/^\d{6}$/); + expect(session.platform).toBe(MessagingPlatform.Telegram); + expect(new Date(session.expiresAt).getTime()).toBeGreaterThan(Date.now()); + }); + + it('fetches an OAuth token and creates an integration route', async () => { + await useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }); + expect(gatewayClient.fetchAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ gatewayUrl: 'https://gateway.example.com' }) + ); + expect(gatewayClient.createIntegrationRoute).toHaveBeenCalledWith( + 'https://gateway.example.com', + 'tok-123', + expect.objectContaining({ interfaceType: 'telegram' }) + ); + }); + + it('persists the pairing session + route details on the platform config', async () => { + const session = await useCase.execute({ + platform: MessagingPlatform.WhatsApp, + gatewayUrl: 'https://gateway.example.com', + }); + const saved = await mockRepository.load(); + expect(saved?.messaging?.enabled).toBe(true); + expect(saved?.messaging?.gatewayUrl).toBe('https://gateway.example.com'); + expect(saved?.messaging?.deviceId).toBeDefined(); + expect(saved?.messaging?.whatsapp?.enabled).toBe(true); + expect(saved?.messaging?.whatsapp?.paired).toBe(false); + expect(saved?.messaging?.whatsapp?.pendingPairingCode).toBe(session.code); + expect(saved?.messaging?.whatsapp?.routeId).toBe('route-abc'); + expect(saved?.messaging?.whatsapp?.routeToken).toBe('rt-xyz'); + expect(saved?.messaging?.whatsapp?.publicUrl).toBe( + 'http://localhost:8080/integrations/route-abc/rt-xyz' + ); + }); + + it('returns the publicUrl in the session DTO', async () => { + const session = await useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }); + expect(session.publicUrl).toBe('http://localhost:8080/integrations/route-abc/rt-xyz'); + }); + + it('reuses an existing deviceId across platforms', async () => { + await useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }); + const first = (await mockRepository.load())?.messaging?.deviceId; + await useCase.execute({ + platform: MessagingPlatform.WhatsApp, + gatewayUrl: 'https://gateway.example.com', + }); + const second = (await mockRepository.load())?.messaging?.deviceId; + expect(second).toBe(first); + }); + + it('generates a distinct code on each invocation', async () => { + const a = await useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }); + const b = await useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }); + expect(a.code).not.toBe(b.code); + }); + + it('wraps gateway client errors with context', async () => { + const failing = makeMockGatewayClient({ + fetchAccessToken: vi.fn().mockRejectedValue(new Error('boom')), + }); + useCase = new BeginMessagingPairingUseCase(mockRepository as never, failing); + await expect( + useCase.execute({ + platform: MessagingPlatform.Telegram, + gatewayUrl: 'https://gateway.example.com', + }) + ).rejects.toThrow(/gateway/i); + }); +}); diff --git a/tests/unit/application/use-cases/messaging/confirm-pairing.use-case.test.ts b/tests/unit/application/use-cases/messaging/confirm-pairing.use-case.test.ts new file mode 100644 index 000000000..62d34ab13 --- /dev/null +++ b/tests/unit/application/use-cases/messaging/confirm-pairing.use-case.test.ts @@ -0,0 +1,80 @@ +/** + * ConfirmMessagingPairingUseCase Unit Tests + * + * TDD Phase: RED — tests written before implementation. + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { ConfirmMessagingPairingUseCase } from '@/application/use-cases/messaging/confirm-pairing.use-case.js'; +import { MockSettingsRepository } from '../../../../helpers/mock-repository.helper.js'; +import { createDefaultSettings } from '@/domain/factories/settings-defaults.factory.js'; +import { MessagingPlatform, type Settings } from '@/domain/generated/output.js'; + +function settingsWithPendingCode(platform: 'telegram' | 'whatsapp', code: string): Settings { + const settings = createDefaultSettings(); + settings.messaging = { + enabled: true, + gatewayUrl: 'https://gateway.example.com', + debounceMs: 5000, + chatBufferMs: 3000, + [platform]: { + enabled: true, + paired: false, + pendingPairingCode: code, + }, + }; + return settings; +} + +describe('ConfirmMessagingPairingUseCase', () => { + let useCase: ConfirmMessagingPairingUseCase; + let mockRepository: MockSettingsRepository; + + beforeEach(() => { + mockRepository = new MockSettingsRepository(); + useCase = new ConfirmMessagingPairingUseCase(mockRepository as never); + }); + + it('marks the platform as paired and stores the chatId', async () => { + await mockRepository.initialize(settingsWithPendingCode('telegram', '123456')); + + const result = await useCase.execute({ + platform: MessagingPlatform.Telegram, + chatId: '@alice', + }); + + expect(result.messaging?.telegram?.paired).toBe(true); + expect(result.messaging?.telegram?.chatId).toBe('@alice'); + expect(result.messaging?.telegram?.pendingPairingCode).toBeUndefined(); + }); + + it('fails when pairing was never started for the platform', async () => { + await mockRepository.initialize(createDefaultSettings()); + await expect( + useCase.execute({ platform: MessagingPlatform.Telegram, chatId: '@alice' }) + ).rejects.toThrow(/no pairing in progress/i); + }); + + it('requires a non-empty chatId', async () => { + await mockRepository.initialize(settingsWithPendingCode('whatsapp', '999999')); + await expect( + useCase.execute({ platform: MessagingPlatform.WhatsApp, chatId: '' }) + ).rejects.toThrow(/chat id/i); + }); + + it('leaves the other platform untouched', async () => { + const settings = settingsWithPendingCode('telegram', '111111'); + settings.messaging!.whatsapp = { enabled: false, paired: false }; + await mockRepository.initialize(settings); + + const result = await useCase.execute({ + platform: MessagingPlatform.Telegram, + chatId: '@bob', + }); + + expect(result.messaging?.telegram?.paired).toBe(true); + expect(result.messaging?.whatsapp?.paired).toBe(false); + expect(result.messaging?.whatsapp?.enabled).toBe(false); + }); +}); diff --git a/tests/unit/commands/_serve.command.test.ts b/tests/unit/commands/_serve.command.test.ts index 2f5472409..6dcba95ea 100644 --- a/tests/unit/commands/_serve.command.test.ts +++ b/tests/unit/commands/_serve.command.test.ts @@ -33,6 +33,9 @@ vi.mock('@/infrastructure/di/container.js', () => ({ if (token === 'IDeploymentService') { return mockDeploymentService; } + if (token === 'IMessagingService') { + return mockMessagingService; + } if ( token === 'IAgentRunRepository' || token === 'IPhaseTimingRepository' || @@ -83,6 +86,12 @@ const mockDeploymentService = { stopAll: vi.fn(), }; +const mockMessagingService = { + isConfigured: vi.fn().mockReturnValue(false), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), +}; + import { getNotificationWatcher } from '@/infrastructure/services/notifications/notification-watcher.service.js'; import { createServeCommand } from '../../../src/presentation/cli/commands/_serve.command.js'; diff --git a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts index f8493fdec..2b7c29ba3 100644 --- a/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts +++ b/tests/unit/infrastructure/persistence/sqlite/mappers/settings.mapper.test.ts @@ -170,6 +170,31 @@ function createTestRow(overrides: Partial = {}): SettingsRow { fab_position_swapped: 0, skill_injection_enabled: 0, skill_injection_skills: null, + // Messaging columns (migration 056) — all default to "unconfigured". + messaging_enabled: 0, + messaging_gateway_url: null, + messaging_device_id: null, + messaging_gateway_client_id: null, + messaging_debounce_ms: null, + messaging_chat_buffer_ms: null, + messaging_telegram_enabled: 0, + messaging_telegram_paired: 0, + messaging_telegram_chat_id: null, + messaging_telegram_route_id: null, + messaging_telegram_route_token: null, + messaging_telegram_public_url: null, + messaging_telegram_bot_token: null, + messaging_telegram_pending_code: null, + messaging_telegram_pending_expires_at: null, + messaging_whatsapp_enabled: 0, + messaging_whatsapp_paired: 0, + messaging_whatsapp_chat_id: null, + messaging_whatsapp_route_id: null, + messaging_whatsapp_route_token: null, + messaging_whatsapp_public_url: null, + messaging_whatsapp_bot_token: null, + messaging_whatsapp_pending_code: null, + messaging_whatsapp_pending_expires_at: null, ...overrides, }; } @@ -1212,4 +1237,113 @@ describe('Settings Mapper', () => { expect(restored.workflow.skillInjection).toBeUndefined(); }); }); + + // Messaging remote control persistence (migration 056). + // Backward-compat requirement: a row written by an older build (all + // messaging_* columns at their default 0/null) must still decode to a + // valid MessagingConfig that consumer code can .?chain against. + describe('messaging remote control persistence', () => { + it('should default to disabled messaging config when row columns are unset', () => { + const row = createTestRow(); // defaults: all messaging_* at 0/null + const restored = fromDatabase(row); + expect(restored.messaging).toBeDefined(); + expect(restored.messaging?.enabled).toBe(false); + expect(restored.messaging?.debounceMs).toBe(5000); + expect(restored.messaging?.chatBufferMs).toBe(3000); + expect(restored.messaging?.telegram).toBeUndefined(); + expect(restored.messaging?.whatsapp).toBeUndefined(); + }); + + it('should round-trip a fully configured Telegram pairing', () => { + const original = createTestSettings({ + messaging: { + enabled: true, + gatewayUrl: 'http://localhost:8080', + deviceId: 'shep-dev-1', + gatewayClientId: 'commands-desktop-public', + debounceMs: 4000, + chatBufferMs: 2500, + telegram: { + enabled: true, + paired: true, + chatId: '123456', + routeId: 'rt_abc', + routeToken: 'tok_xyz', + publicUrl: 'http://localhost:8080/integrations/rt_abc/tok_xyz', + botToken: 'bot:secret', + }, + }, + }); + const row = toDatabase(original); + expect(row.messaging_enabled).toBe(1); + expect(row.messaging_gateway_url).toBe('http://localhost:8080'); + expect(row.messaging_telegram_paired).toBe(1); + expect(row.messaging_telegram_route_id).toBe('rt_abc'); + + const restored = fromDatabase(row); + expect(restored.messaging?.enabled).toBe(true); + expect(restored.messaging?.gatewayUrl).toBe('http://localhost:8080'); + expect(restored.messaging?.deviceId).toBe('shep-dev-1'); + expect(restored.messaging?.telegram?.paired).toBe(true); + expect(restored.messaging?.telegram?.chatId).toBe('123456'); + expect(restored.messaging?.telegram?.routeId).toBe('rt_abc'); + expect(restored.messaging?.telegram?.publicUrl).toBe( + 'http://localhost:8080/integrations/rt_abc/tok_xyz' + ); + expect(restored.messaging?.telegram?.botToken).toBe('bot:secret'); + expect(restored.messaging?.whatsapp).toBeUndefined(); + }); + + it('should preserve a pending pairing (no chatId, no paired) through round-trip', () => { + const original = createTestSettings({ + messaging: { + enabled: true, + gatewayUrl: 'http://localhost:8080', + deviceId: 'shep-dev-1', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { + enabled: true, + paired: false, + pendingPairingCode: '482913', + pendingPairingExpiresAt: '2026-04-09T13:00:00.000Z', + routeId: 'rt_abc', + routeToken: 'tok_xyz', + publicUrl: 'http://localhost:8080/integrations/rt_abc/tok_xyz', + }, + }, + }); + const row = toDatabase(original); + const restored = fromDatabase(row); + expect(restored.messaging?.telegram?.paired).toBe(false); + expect(restored.messaging?.telegram?.pendingPairingCode).toBe('482913'); + expect(restored.messaging?.telegram?.pendingPairingExpiresAt).toBe( + '2026-04-09T13:00:00.000Z' + ); + expect(restored.messaging?.telegram?.chatId).toBeUndefined(); + }); + + it('should serialize a Date pendingPairingExpiresAt to ISO string', () => { + const expires = new Date('2026-04-09T13:00:00.000Z'); + const row = toDatabase( + createTestSettings({ + messaging: { + enabled: true, + gatewayUrl: 'http://localhost:8080', + deviceId: 'shep-dev-1', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { + enabled: true, + paired: false, + pendingPairingCode: '111111', + pendingPairingExpiresAt: expires, + routeId: 'rt_abc', + }, + }, + }) + ); + expect(row.messaging_telegram_pending_expires_at).toBe('2026-04-09T13:00:00.000Z'); + }); + }); }); diff --git a/tests/unit/infrastructure/services/messaging/chat-relay.test.ts b/tests/unit/infrastructure/services/messaging/chat-relay.test.ts new file mode 100644 index 000000000..1d7f7a76b --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/chat-relay.test.ts @@ -0,0 +1,149 @@ +/** + * Messaging Chat Relay Unit Tests + * + * Tests for the bidirectional chat relay between messaging apps + * and Shep interactive agent sessions, including output buffering. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { MessagingChatRelay } from '@/infrastructure/services/messaging/chat-relay.js'; +import type { IMessageSender } from '@/application/ports/output/services/message-sender.interface.js'; + +describe('MessagingChatRelay', () => { + let relay: MessagingChatRelay; + let mockSender: { send: ReturnType }; + + beforeEach(() => { + vi.useFakeTimers(); + + mockSender = { + send: vi.fn().mockResolvedValue(undefined), + }; + + relay = new MessagingChatRelay( + mockSender as unknown as IMessageSender, + 100 // short buffer interval for testing + ); + }); + + afterEach(() => { + relay.stop(); + vi.useRealTimers(); + }); + + describe('startRelay', () => { + it('should start a relay and return a confirmation message', () => { + const result = relay.startRelay('feat-123', 'chat-456', 'telegram'); + expect(result).toContain('Chat relay started'); + expect(result).toContain('feat-123'); + expect(relay.hasActiveRelay()).toBe(true); + expect(relay.getActiveFeatureId()).toBe('feat-123'); + }); + }); + + describe('endRelay', () => { + it('should end the relay and return a confirmation message', () => { + relay.startRelay('feat-123', 'chat-456', 'telegram'); + const result = relay.endRelay(); + expect(result).toContain('Chat relay ended'); + expect(result).toContain('feat-123'); + expect(relay.hasActiveRelay()).toBe(false); + }); + + it('should return "no active relay" when there is none', () => { + const result = relay.endRelay(); + expect(result).toContain('No active chat relay'); + }); + }); + + describe('bufferAgentOutput', () => { + it('should buffer output and flush after interval', () => { + relay.startRelay('feat-123', 'chat-456', 'telegram'); + + relay.bufferAgentOutput('Hello '); + relay.bufferAgentOutput('world!'); + + expect(mockSender.send).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + + expect(mockSender.send).toHaveBeenCalledTimes(1); + expect(mockSender.send).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'chat.response', + featureId: 'feat-123', + message: 'Hello world!', + }) + ); + }); + + it('should not send when no active relay', () => { + relay.bufferAgentOutput('test'); + vi.advanceTimersByTime(100); + expect(mockSender.send).not.toHaveBeenCalled(); + }); + }); + + describe('flushBuffer', () => { + it('should flush immediately when called explicitly', () => { + relay.startRelay('feat-123', 'chat-456', 'telegram'); + + relay.bufferAgentOutput('immediate'); + relay.flushBuffer(); + + expect(mockSender.send).toHaveBeenCalledTimes(1); + }); + + it('should not send when buffer is empty', () => { + relay.startRelay('feat-123', 'chat-456', 'telegram'); + relay.flushBuffer(); + expect(mockSender.send).not.toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + it('should flush any remaining buffer and clear the relay', () => { + relay.startRelay('feat-123', 'chat-456', 'telegram'); + relay.bufferAgentOutput('final output'); + relay.stop(); + + expect(mockSender.send).toHaveBeenCalledTimes(1); + expect(relay.hasActiveRelay()).toBe(false); + }); + + it('invokes the unsubscribe callback passed to startRelay', () => { + const unsubscribe = vi.fn(); + relay.startRelay('feat-1', 'chat-1', 'telegram', '/wt/feat-1', unsubscribe); + relay.stop(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + }); + + describe('worktree path and subscription', () => { + it('exposes the active worktree path', () => { + relay.startRelay('feat-42', 'chat-1', 'telegram', '/wt/feat-42'); + expect(relay.getActiveWorktreePath()).toBe('/wt/feat-42'); + }); + + it('returns null worktree path when no active relay', () => { + expect(relay.getActiveWorktreePath()).toBeNull(); + }); + + it('calls the unsubscribe on endRelay', () => { + const unsubscribe = vi.fn(); + relay.startRelay('feat-1', 'chat-1', 'telegram', '/wt', unsubscribe); + relay.endRelay(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it('tears down the previous subscription when startRelay is called again', () => { + const firstUnsub = vi.fn(); + const secondUnsub = vi.fn(); + relay.startRelay('feat-1', 'chat-1', 'telegram', '/wt/1', firstUnsub); + relay.startRelay('feat-2', 'chat-1', 'telegram', '/wt/2', secondUnsub); + expect(firstUnsub).toHaveBeenCalledOnce(); + expect(secondUnsub).not.toHaveBeenCalled(); + expect(relay.getActiveFeatureId()).toBe('feat-2'); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/command-executor.test.ts b/tests/unit/infrastructure/services/messaging/command-executor.test.ts new file mode 100644 index 000000000..3fdfaced6 --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/command-executor.test.ts @@ -0,0 +1,264 @@ +/** + * Messaging Command Executor Unit Tests + * + * Tests for the command executor that maps inbound messaging commands + * to existing Shep use case invocations. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { MessagingCommandExecutor } from '@/infrastructure/services/messaging/command-executor.js'; +import type { MessagingCommand, Feature } from '@/domain/generated/output.js'; +import { + MessagingFrameType, + MessagingPlatform, + MessagingCommandType, +} from '@/domain/generated/output.js'; + +function createCommand(overrides: Partial = {}): MessagingCommand { + return { + type: MessagingFrameType.Command, + command: MessagingCommandType.Help, + chatId: 'chat-123', + platform: MessagingPlatform.Telegram, + ...overrides, + }; +} + +function createMockFeature(overrides: Partial = {}): Feature { + return { + id: 'abcdef12-3456-7890-abcd-ef1234567890', + name: 'Test Feature', + userQuery: 'test query', + slug: 'test-feature', + description: 'Test description', + repositoryPath: '/test/path', + branch: 'feat/test', + lifecycle: 'requirements', + messages: [], + relatedArtifacts: [], + fast: false, + push: false, + openPr: false, + forkAndPr: false, + commitSpecs: true, + ciWatchEnabled: true, + enableEvidence: false, + commitEvidence: false, + approvalGates: { allowPrd: false, allowPlan: false, allowMerge: false }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Feature; +} + +describe('MessagingCommandExecutor', () => { + let executor: MessagingCommandExecutor; + let mockFeatureRepo: { + findById: ReturnType; + findByIdPrefix: ReturnType; + }; + let mockCreateFeature: { execute: ReturnType }; + let mockApproveAgentRun: { execute: ReturnType }; + let mockRejectAgentRun: { execute: ReturnType }; + let mockStopAgentRun: { execute: ReturnType }; + let mockResumeFeature: { execute: ReturnType }; + let mockListFeatures: { execute: ReturnType }; + let mockShowFeature: { execute: ReturnType }; + let mockListRepositories: { execute: ReturnType }; + + beforeEach(() => { + mockFeatureRepo = { + findById: vi.fn().mockResolvedValue(null), + findByIdPrefix: vi.fn().mockResolvedValue(null), + }; + mockCreateFeature = { execute: vi.fn() }; + mockApproveAgentRun = { execute: vi.fn() }; + mockRejectAgentRun = { execute: vi.fn() }; + mockStopAgentRun = { execute: vi.fn() }; + mockResumeFeature = { execute: vi.fn() }; + mockListFeatures = { execute: vi.fn() }; + mockShowFeature = { execute: vi.fn() }; + mockListRepositories = { execute: vi.fn() }; + + executor = new MessagingCommandExecutor( + mockFeatureRepo as any, + mockCreateFeature as any, + mockApproveAgentRun as any, + mockRejectAgentRun as any, + mockStopAgentRun as any, + mockResumeFeature as any, + mockListFeatures as any, + mockShowFeature as any, + mockListRepositories as any + ); + }); + + describe('help command', () => { + it('should return help text', async () => { + const result = await executor.execute(createCommand({ command: MessagingCommandType.Help })); + expect(result).toContain('/new'); + expect(result).toContain('/approve'); + expect(result).toContain('/status'); + }); + }); + + describe('status command', () => { + it('should return "No active features" when list is empty', async () => { + mockListFeatures.execute.mockResolvedValue([]); + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Status }) + ); + expect(result).toBe('No active features.'); + }); + + it('should list features with short IDs', async () => { + mockListFeatures.execute.mockResolvedValue([ + createMockFeature({ + id: 'abcdef12-rest', + name: 'Feature One', + lifecycle: 'implement' as any, + }), + ]); + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Status }) + ); + expect(result).toContain('#abcdef12'); + expect(result).toContain('Feature One'); + expect(result).toContain('implement'); + }); + + it('should show single feature detail when featureId is provided', async () => { + const feature = createMockFeature({ + id: 'abcdef12-rest', + name: 'My Feature', + lifecycle: 'plan' as any, + }); + mockShowFeature.execute.mockResolvedValue(feature); + + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Status, featureId: 'abcdef12' }) + ); + expect(result).toContain('#abcdef12'); + expect(result).toContain('My Feature'); + }); + }); + + describe('approve command', () => { + it('should return usage when no featureId', async () => { + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Approve }) + ); + expect(result).toContain('Usage'); + }); + + it('should return not found when feature does not exist', async () => { + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Approve, featureId: 'abc123' }) + ); + expect(result).toContain('not found'); + }); + + it('should approve when feature has active agent run', async () => { + const feature = createMockFeature({ agentRunId: 'run-456' }); + mockFeatureRepo.findById.mockResolvedValue(feature); + mockApproveAgentRun.execute.mockResolvedValue({ approved: true, reason: '' }); + + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Approve, featureId: feature.id }) + ); + expect(result).toContain('Approved'); + expect(mockApproveAgentRun.execute).toHaveBeenCalledWith('run-456'); + }); + + it('should return error when feature has no agent run', async () => { + const feature = createMockFeature({ agentRunId: undefined }); + mockFeatureRepo.findById.mockResolvedValue(feature); + + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Approve, featureId: feature.id }) + ); + expect(result).toContain('no active agent run'); + }); + }); + + describe('reject command', () => { + it('should reject with feedback', async () => { + const feature = createMockFeature({ agentRunId: 'run-789' }); + mockFeatureRepo.findById.mockResolvedValue(feature); + mockRejectAgentRun.execute.mockResolvedValue({ rejected: true, reason: '' }); + + const result = await executor.execute( + createCommand({ + command: MessagingCommandType.Reject, + featureId: feature.id, + args: 'need error handling', + }) + ); + expect(result).toContain('Rejected'); + expect(result).toContain('with feedback'); + expect(mockRejectAgentRun.execute).toHaveBeenCalledWith('run-789', 'need error handling'); + }); + }); + + describe('stop command', () => { + it('should stop agent run', async () => { + const feature = createMockFeature({ agentRunId: 'run-999' }); + mockFeatureRepo.findById.mockResolvedValue(feature); + mockStopAgentRun.execute.mockResolvedValue({ stopped: true, reason: '' }); + + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Stop, featureId: feature.id }) + ); + expect(result).toContain('Stopped'); + }); + }); + + describe('new command', () => { + it('should return usage when no args', async () => { + const result = await executor.execute(createCommand({ command: MessagingCommandType.New })); + expect(result).toContain('Usage'); + }); + + it('should return error when no repositories configured', async () => { + mockListRepositories.execute.mockResolvedValue([]); + const result = await executor.execute( + createCommand({ command: MessagingCommandType.New, args: 'add healthcheck' }) + ); + expect(result).toContain('No repositories configured'); + }); + + it('should create feature when repository exists', async () => { + mockListRepositories.execute.mockResolvedValue([{ path: '/test/repo' }]); + mockCreateFeature.execute.mockResolvedValue({ + feature: createMockFeature({ id: 'newid123-rest' }), + }); + + const result = await executor.execute( + createCommand({ command: MessagingCommandType.New, args: 'add healthcheck' }) + ); + expect(result).toContain('Started'); + expect(result).toContain('#newid123'); + }); + }); + + describe('resume command', () => { + it('should resume a feature', async () => { + mockResumeFeature.execute.mockResolvedValue({ + feature: createMockFeature(), + newRun: {}, + }); + + const result = await executor.execute( + createCommand({ command: MessagingCommandType.Resume, featureId: 'abc123' }) + ); + expect(result).toContain('Resumed'); + }); + }); + + describe('unknown command', () => { + it('should return unknown command message', async () => { + const result = await executor.execute(createCommand({ command: 'nonexistent' as any })); + expect(result).toContain('Unknown command'); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/content-sanitizer.test.ts b/tests/unit/infrastructure/services/messaging/content-sanitizer.test.ts new file mode 100644 index 000000000..f032bd569 --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/content-sanitizer.test.ts @@ -0,0 +1,62 @@ +/** + * Content Sanitizer Unit Tests + * + * Tests for the sanitization of outbound messaging content + * to prevent leaking sensitive information (paths, env vars, code) + * through third-party messaging platforms. + */ + +import { describe, it, expect } from 'vitest'; +import { sanitizeForMessaging } from '@/infrastructure/services/messaging/content-sanitizer.js'; + +describe('sanitizeForMessaging', () => { + it('should strip Unix absolute file paths', () => { + const result = sanitizeForMessaging('Error at /Users/john/projects/my-app/src/index.ts'); + expect(result).toBe('Error at [path]'); + }); + + it('should strip Windows file paths', () => { + const result = sanitizeForMessaging('Error at C:\\Users\\john\\projects\\app.ts'); + expect(result).toBe('Error at [path]'); + }); + + it('should strip environment variable assignments', () => { + const result = sanitizeForMessaging('Using API_KEY=test-value'); + expect(result).toBe('Using [env]'); + }); + + it('should strip fenced code blocks', () => { + const input = 'Here is the fix:\n```typescript\nconst x = 1;\n```\nDone.'; + const result = sanitizeForMessaging(input); + expect(result).toBe('Here is the fix:\n[code block]\nDone.'); + }); + + it('should strip long inline code', () => { + const longCode = `\`${'a'.repeat(150)}\``; + const result = sanitizeForMessaging(`Check this: ${longCode}`); + expect(result).toBe('Check this: [code]'); + }); + + it('should truncate messages exceeding 4000 characters', () => { + const longMessage = 'a'.repeat(5000); + const result = sanitizeForMessaging(longMessage); + expect(result.length).toBe(4000); + expect(result.endsWith('...')).toBe(true); + }); + + it('should preserve normal text without sensitive content', () => { + const text = 'Feature "add payments" completed successfully. PR #42 ready for review.'; + const result = sanitizeForMessaging(text); + expect(result).toBe(text); + }); + + it('should handle empty strings', () => { + expect(sanitizeForMessaging('')).toBe(''); + }); + + it('should handle messages exactly at the limit', () => { + const text = 'a'.repeat(4000); + const result = sanitizeForMessaging(text); + expect(result).toBe(text); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/http-gateway-client.test.ts b/tests/unit/infrastructure/services/messaging/http-gateway-client.test.ts new file mode 100644 index 000000000..692f02797 --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/http-gateway-client.test.ts @@ -0,0 +1,197 @@ +/** + * HttpGatewayClient Unit Tests + * + * TDD RED: these tests are written before the adapter exists and drive its + * implementation. The adapter is a thin HTTP client over the Commands.com + * Gateway OpenAPI — we mock fetch and assert request shape + response mapping. + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { HttpGatewayClient } from '@/infrastructure/services/messaging/http-gateway.client.js'; + +type FetchMock = ReturnType; + +function jsonResponse(body: unknown, init: { status?: number } = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('HttpGatewayClient', () => { + let fetchMock: FetchMock; + let client: HttpGatewayClient; + + beforeEach(() => { + fetchMock = vi.fn(); + client = new HttpGatewayClient(fetchMock as unknown as typeof fetch); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('fetchAccessToken', () => { + it('POSTs to /oauth/token with client_credentials grant and form body', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + access_token: 'tok-abc', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'rtok-xyz', + }) + ); + + const token = await client.fetchAccessToken({ + gatewayUrl: 'http://localhost:8080', + clientId: 'commands-desktop-public', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:8080/oauth/token'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ + 'content-type': 'application/x-www-form-urlencoded', + }); + expect((init as RequestInit).body as string).toContain('grant_type=client_credentials'); + expect((init as RequestInit).body as string).toContain('client_id=commands-desktop-public'); + + expect(token.accessToken).toBe('tok-abc'); + expect(token.tokenType).toBe('Bearer'); + expect(token.refreshToken).toBe('rtok-xyz'); + expect(token.expiresAt).toBeGreaterThan(Date.now()); + expect(token.expiresAt).toBeLessThanOrEqual(Date.now() + 3_600_000 + 500); + }); + + it('strips trailing slash from gatewayUrl', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ access_token: 't', token_type: 'Bearer', expires_in: 60 }) + ); + + await client.fetchAccessToken({ + gatewayUrl: 'http://localhost:8080/', + clientId: 'cid', + }); + + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:8080/oauth/token'); + }); + + it('throws a descriptive error on non-2xx', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'invalid_client' }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + ); + + await expect( + client.fetchAccessToken({ gatewayUrl: 'http://localhost:8080', clientId: 'cid' }) + ).rejects.toThrow(/gateway.*token.*401/i); + }); + }); + + describe('createIntegrationRoute', () => { + it('POSTs to /gateway/v1/integrations/routes with bearer auth and required body', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + route: { + route_id: 'r-123', + device_id: 'dev-abc', + interface_type: 'telegram', + }, + public_url: 'http://localhost:8080/integrations/r-123/tok-xyz', + route_token: 'tok-xyz', + }, + { status: 201 } + ) + ); + + const route = await client.createIntegrationRoute('http://localhost:8080', 'bearer-value', { + deviceId: 'dev-abc', + interfaceType: 'telegram', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:8080/gateway/v1/integrations/routes'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ + authorization: 'Bearer bearer-value', + 'content-type': 'application/json', + }); + + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toMatchObject({ + device_id: 'dev-abc', + interface_type: 'telegram', + token_auth_mode: 'path', + }); + + expect(route.routeId).toBe('r-123'); + expect(route.routeToken).toBe('tok-xyz'); + expect(route.publicUrl).toBe('http://localhost:8080/integrations/r-123/tok-xyz'); + expect(route.deviceId).toBe('dev-abc'); + expect(route.interfaceType).toBe('telegram'); + }); + + it('passes through optional fields when provided', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + route: { route_id: 'r', device_id: 'd', interface_type: 'telegram' }, + public_url: 'x', + route_token: 't', + }, + { status: 201 } + ) + ); + + await client.createIntegrationRoute('http://localhost:8080', 'bt', { + deviceId: 'd', + interfaceType: 'telegram', + routeToken: 'fixed-token', + tokenMaxAgeDays: 30, + maxBodyBytes: 1024, + deadlineMs: 5000, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body).toMatchObject({ + route_token: 'fixed-token', + token_max_age_days: 30, + max_body_bytes: 1024, + deadline_ms: 5000, + }); + }); + + it('throws on non-2xx with gateway error message', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'device_not_found' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + ); + + await expect( + client.createIntegrationRoute('http://localhost:8080', 'bt', { + deviceId: 'd', + interfaceType: 'telegram', + }) + ).rejects.toThrow(/gateway.*route.*400.*device_not_found/i); + }); + + it('throws if the gateway returns a 2xx without a public_url', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ route: { route_id: 'r' } }, { status: 201 })); + + await expect( + client.createIntegrationRoute('http://localhost:8080', 'bt', { + deviceId: 'd', + interfaceType: 'telegram', + }) + ).rejects.toThrow(/public_url/); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/http-telegram-client.test.ts b/tests/unit/infrastructure/services/messaging/http-telegram-client.test.ts new file mode 100644 index 000000000..33fe6e163 --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/http-telegram-client.test.ts @@ -0,0 +1,74 @@ +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HttpTelegramClient } from '@/infrastructure/services/messaging/http-telegram.client.js'; + +function jsonResponse(body: unknown, init: { status?: number } = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('HttpTelegramClient', () => { + let fetchMock: ReturnType; + let client: HttpTelegramClient; + + beforeEach(() => { + fetchMock = vi.fn(); + client = new HttpTelegramClient(fetchMock as unknown as typeof fetch); + }); + + it('POSTs to api.telegram.org/bot{token}/sendMessage with chat_id and text', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + await client.sendMessage({ + botToken: '123:ABC', + chatId: '@alice', + text: 'hello', + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.telegram.org/bot123:ABC/sendMessage'); + expect((init as RequestInit).method).toBe('POST'); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + chat_id: '@alice', + text: 'hello', + }); + }); + + it('includes parse_mode when specified', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + await client.sendMessage({ + botToken: 't', + chatId: '1', + text: '*bold*', + parseMode: 'MarkdownV2', + }); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.parse_mode).toBe('MarkdownV2'); + }); + + it('throws with gateway description when the API returns non-2xx', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false, description: 'chat not found' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + ); + await expect(client.sendMessage({ botToken: 't', chatId: 'x', text: 'y' })).rejects.toThrow( + /400.*chat not found/ + ); + }); + + it('rejects when botToken is missing', async () => { + await expect(client.sendMessage({ botToken: '', chatId: '1', text: 't' })).rejects.toThrow( + /botToken/ + ); + }); + + it('rejects when chatId is missing', async () => { + await expect(client.sendMessage({ botToken: 't', chatId: '', text: 't' })).rejects.toThrow( + /chatId/ + ); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/messaging-tunnel.adapter.test.ts b/tests/unit/infrastructure/services/messaging/messaging-tunnel.adapter.test.ts new file mode 100644 index 000000000..e14e252ee --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/messaging-tunnel.adapter.test.ts @@ -0,0 +1,309 @@ +/** + * Messaging Tunnel Adapter Unit Tests + * + * Drives the real Commands.com gateway tunnel protocol (tunnel.connected / + * tunnel.activate / tunnel.request / tunnel.response). Uses a fake in-memory + * WebSocket that emits events synchronously and records outbound frames. + */ + +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import WebSocket from 'ws'; +import { MessagingTunnelAdapter } from '@/infrastructure/services/messaging/messaging-tunnel.adapter.js'; +import type { + TunnelActivateFrame, + TunnelConnectedFrame, + TunnelRequestFrame, + TunnelResponseFrame, + TunnelActivateResultFrame, +} from '@/infrastructure/services/messaging/tunnel-protocol.js'; + +/** + * Minimal stand-in for the `ws` library's WebSocket. Supports on/off/once, + * send, close, and exposes a readyState compatible with WebSocket.OPEN. + */ +class FakeWebSocket extends EventEmitter { + public readyState: number = WebSocket.OPEN; + public url: string; + public options: unknown; + public sent: string[] = []; + public closed = false; + + constructor(url: string, options: unknown) { + super(); + this.url = url; + this.options = options; + } + + send(data: string): void { + this.sent.push(data); + } + + ping(): void { + /* no-op */ + } + + close(): void { + this.closed = true; + this.readyState = WebSocket.CLOSED; + this.emit('close'); + } + + // Helpers for tests to simulate server events + emitOpen(): void { + this.emit('open'); + } + + emitFrame(frame: unknown): void { + this.emit('message', Buffer.from(JSON.stringify(frame), 'utf8')); + } + + parseSent(): unknown[] { + return this.sent.map((s) => JSON.parse(s)); + } +} + +function connectedFrame(deviceId = 'dev-1'): TunnelConnectedFrame { + return { type: 'tunnel.connected', device_id: deviceId }; +} + +function activateResultFrame(routeId: string, ok = true): TunnelActivateResultFrame { + return { + type: 'tunnel.activate.result', + results: [ + ok + ? { route_id: routeId, status: 'active' } + : { + route_id: routeId, + status: 'rejected', + error: { code: 'route_not_found', message: 'Route not found' }, + }, + ], + }; +} + +describe('MessagingTunnelAdapter', () => { + let fakeWs: FakeWebSocket; + let adapter: MessagingTunnelAdapter; + + function buildAdapter(routeIds: string[] = ['route-telegram']) { + adapter = new MessagingTunnelAdapter({ + gatewayUrl: 'http://gateway.test', + accessToken: 'tok-abc', + deviceId: 'dev-1', + routeIds, + webSocketFactory: (url, options) => { + fakeWs = new FakeWebSocket(url, options); + return fakeWs as unknown as WebSocket; + }, + }); + } + + beforeEach(() => { + buildAdapter(); + }); + + it('builds the WebSocket URL with ws:// scheme and device_id query, and bearer header', async () => { + const connectPromise = adapter.connect(); + // Resolve the open promise + setImmediate(() => fakeWs.emitOpen()); + await connectPromise; + + expect(fakeWs.url).toBe( + 'ws://gateway.test/gateway/v1/integrations/tunnel/connect?device_id=dev-1' + ); + expect((fakeWs.options as { headers: Record }).headers).toMatchObject({ + authorization: 'Bearer tok-abc', + }); + expect(adapter.isConnected()).toBe(true); + }); + + it('converts https to wss', async () => { + adapter = new MessagingTunnelAdapter({ + gatewayUrl: 'https://gw.example.com', + accessToken: 't', + deviceId: 'd', + routeIds: ['r'], + webSocketFactory: (url, options) => { + fakeWs = new FakeWebSocket(url, options); + return fakeWs as unknown as WebSocket; + }, + }); + const connectPromise = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await connectPromise; + expect(fakeWs.url.startsWith('wss://gw.example.com/')).toBe(true); + }); + + it('sends a single batched tunnel.activate with all routes after tunnel.connected', async () => { + buildAdapter(['route-telegram', 'route-whatsapp']); + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + + fakeWs.emitFrame(connectedFrame()); + + const sent = fakeWs.parseSent() as TunnelActivateFrame[]; + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + type: 'tunnel.activate', + routes: ['route-telegram', 'route-whatsapp'], + }); + }); + + it('marks routes activated after tunnel.activate.result ok', async () => { + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + + fakeWs.emitFrame(connectedFrame()); + fakeWs.emitFrame(activateResultFrame('route-telegram')); + + expect(adapter.isRouteActivated('route-telegram')).toBe(true); + }); + + it('does not mark route activated if the server returns ok:false', async () => { + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + + fakeWs.emitFrame(connectedFrame()); + fakeWs.emitFrame(activateResultFrame('route-telegram', false)); + + expect(adapter.isRouteActivated('route-telegram')).toBe(false); + }); + + it('dispatches tunnel.request to the handler and sends tunnel.response with status + body', async () => { + const handler = vi.fn().mockResolvedValue({ + status: 200, + body: 'ok', + headers: { 'content-type': 'text/plain' }, + }); + adapter.onRequest(handler); + + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + fakeWs.emitFrame(connectedFrame()); + fakeWs.parseSent(); // flush activate + + const request: TunnelRequestFrame = { + type: 'tunnel.request', + request_id: 'req-1', + route_id: 'route-telegram', + method: 'POST', + path: '/hook', + headers: [['content-type', 'application/json']], + body_base64: Buffer.from('{"x":1}', 'utf8').toString('base64'), + }; + + fakeWs.sent.length = 0; + fakeWs.emitFrame(request); + await new Promise((r) => setImmediate(r)); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'req-1', + routeId: 'route-telegram', + method: 'POST', + path: '/hook', + body: '{"x":1}', + headers: { 'content-type': 'application/json' }, + }) + ); + + const responses = fakeWs.parseSent() as TunnelResponseFrame[]; + const resp = responses.find((f) => f.type === 'tunnel.response'); + expect(resp).toBeDefined(); + expect(resp).toMatchObject({ + request_id: 'req-1', + status: 200, + headers: [['content-type', 'text/plain']], + }); + expect(Buffer.from(resp!.body_base64!, 'base64').toString('utf8')).toBe('ok'); + }); + + it('returns 503 if no handler is registered', async () => { + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + fakeWs.emitFrame(connectedFrame()); + fakeWs.sent.length = 0; + + fakeWs.emitFrame({ + type: 'tunnel.request', + request_id: 'r1', + route_id: 'route-telegram', + method: 'POST', + path: '/', + }); + + await new Promise((r) => setImmediate(r)); + const responses = fakeWs.parseSent() as TunnelResponseFrame[]; + expect(responses[0]).toMatchObject({ + type: 'tunnel.response', + request_id: 'r1', + status: 503, + }); + }); + + it('returns 500 if the handler throws', async () => { + adapter.onRequest(async () => { + throw new Error('boom'); + }); + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + fakeWs.emitFrame(connectedFrame()); + fakeWs.sent.length = 0; + + fakeWs.emitFrame({ + type: 'tunnel.request', + request_id: 'r2', + route_id: 'route-telegram', + method: 'GET', + path: '/', + }); + + await new Promise((r) => setImmediate(r)); + const responses = fakeWs.parseSent() as TunnelResponseFrame[]; + expect(responses[0]).toMatchObject({ status: 500, request_id: 'r2' }); + }); + + it('removes a route from activated set on tunnel.route_deactivated', async () => { + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + fakeWs.emitFrame(connectedFrame()); + fakeWs.emitFrame(activateResultFrame('route-telegram')); + expect(adapter.isRouteActivated('route-telegram')).toBe(true); + + fakeWs.emitFrame({ + type: 'tunnel.route_deactivated', + route_id: 'route-telegram', + reason: 'revoked', + }); + expect(adapter.isRouteActivated('route-telegram')).toBe(false); + }); + + it('disconnect() closes the socket and stops reconnecting', async () => { + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + + await adapter.disconnect(); + expect(fakeWs.closed).toBe(true); + expect(adapter.isConnected()).toBe(false); + }); + + it('silently drops malformed frames', async () => { + const p = adapter.connect(); + setImmediate(() => fakeWs.emitOpen()); + await p; + fakeWs.emit('message', Buffer.from('not json', 'utf8')); + fakeWs.emit('message', Buffer.from(JSON.stringify({ type: 'unknown.frame' }), 'utf8')); + // Should not crash; nothing sent back. + expect(fakeWs.sent).toHaveLength(0); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/notification-emitter.test.ts b/tests/unit/infrastructure/services/messaging/notification-emitter.test.ts new file mode 100644 index 000000000..23a830a32 --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/notification-emitter.test.ts @@ -0,0 +1,130 @@ +/** + * Messaging Notification Emitter Unit Tests + * + * Tests for the notification emitter that subscribes to the + * NotificationEventBus and forwards events through the tunnel + * with debouncing. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { MessagingNotificationEmitter } from '@/infrastructure/services/messaging/notification-emitter.js'; +import type { NotificationEvent } from '@/domain/generated/output.js'; +import { NotificationEventType, NotificationSeverity } from '@/domain/generated/output.js'; +import type { IMessageSender } from '@/application/ports/output/services/message-sender.interface.js'; +import type { + NotificationBus, + NotificationEventMap, +} from '@/infrastructure/services/notifications/notification-bus.js'; + +function createTestEvent(overrides: Partial = {}): NotificationEvent { + return { + eventType: NotificationEventType.AgentCompleted, + agentRunId: 'run-123', + featureId: 'feat-456', + featureName: 'Test Feature', + message: 'Agent completed successfully', + severity: NotificationSeverity.Success, + timestamp: new Date().toISOString(), + ...overrides, + }; +} + +describe('MessagingNotificationEmitter', () => { + let emitter: MessagingNotificationEmitter; + let mockSender: { send: ReturnType }; + let bus: NotificationBus; + + beforeEach(() => { + vi.useFakeTimers(); + + mockSender = { + send: vi.fn().mockResolvedValue(undefined), + }; + + bus = new EventEmitter(); + + emitter = new MessagingNotificationEmitter( + mockSender as unknown as IMessageSender, + bus, + 100 // short debounce for testing + ); + }); + + afterEach(() => { + emitter.stop(); + vi.useRealTimers(); + }); + + it('should not forward events before start()', () => { + bus.emit('notification', createTestEvent()); + vi.advanceTimersByTime(200); + expect(mockSender.send).not.toHaveBeenCalled(); + }); + + it('should forward events after start() with debouncing', () => { + emitter.start(); + + bus.emit('notification', createTestEvent()); + expect(mockSender.send).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(mockSender.send).toHaveBeenCalledTimes(1); + }); + + it('should debounce multiple events for the same feature+type', () => { + emitter.start(); + + bus.emit('notification', createTestEvent({ message: 'first' })); + vi.advanceTimersByTime(50); + bus.emit('notification', createTestEvent({ message: 'second' })); + vi.advanceTimersByTime(50); + bus.emit('notification', createTestEvent({ message: 'third' })); + + vi.advanceTimersByTime(100); + expect(mockSender.send).toHaveBeenCalledTimes(1); + expect(mockSender.send).toHaveBeenCalledWith(expect.objectContaining({ message: 'third' })); + }); + + it('should NOT debounce waiting_approval events', () => { + emitter.start(); + + bus.emit('notification', createTestEvent({ eventType: NotificationEventType.WaitingApproval })); + + // Should be sent immediately, no debounce + expect(mockSender.send).toHaveBeenCalledTimes(1); + }); + + it('should not debounce events for different features', () => { + emitter.start(); + + bus.emit('notification', createTestEvent({ featureId: 'feat-1' })); + bus.emit('notification', createTestEvent({ featureId: 'feat-2' })); + + vi.advanceTimersByTime(100); + expect(mockSender.send).toHaveBeenCalledTimes(2); + }); + + it('should stop forwarding after stop()', () => { + emitter.start(); + emitter.stop(); + + bus.emit('notification', createTestEvent()); + vi.advanceTimersByTime(200); + expect(mockSender.send).not.toHaveBeenCalled(); + }); + + it('should sanitize messages before forwarding', () => { + emitter.start(); + + bus.emit( + 'notification', + createTestEvent({ message: 'Error at /Users/john/projects/app/src/index.ts' }) + ); + + vi.advanceTimersByTime(100); + expect(mockSender.send).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Error at [path]' }) + ); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/telegram-webhook-parser.test.ts b/tests/unit/infrastructure/services/messaging/telegram-webhook-parser.test.ts new file mode 100644 index 000000000..38f2c83ce --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/telegram-webhook-parser.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + parseTelegramUpdate, + parsePairCommand, +} from '@/infrastructure/services/messaging/telegram-webhook.parser.js'; + +describe('parseTelegramUpdate', () => { + it('extracts chatId and text from a standard message update', () => { + const raw = JSON.stringify({ + update_id: 1, + message: { + message_id: 10, + chat: { id: 12345, username: 'alice' }, + from: { id: 99, username: 'alice' }, + text: '/status', + }, + }); + const parsed = parseTelegramUpdate(raw); + expect(parsed).toEqual({ + chatId: '12345', + senderId: '99', + senderUsername: 'alice', + text: '/status', + }); + }); + + it('returns null for non-message updates', () => { + expect(parseTelegramUpdate(JSON.stringify({ update_id: 2, edited_message: {} }))).toBeNull(); + expect(parseTelegramUpdate(JSON.stringify({ update_id: 3, callback_query: {} }))).toBeNull(); + }); + + it('returns null when message has no text (e.g. photo-only)', () => { + expect(parseTelegramUpdate(JSON.stringify({ message: { chat: { id: 1 } } }))).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseTelegramUpdate('not json')).toBeNull(); + expect(parseTelegramUpdate('')).toBeNull(); + }); + + it('stringifies numeric chat ids', () => { + const parsed = parseTelegramUpdate( + JSON.stringify({ message: { chat: { id: -100123 }, text: 'hi' } }) + ); + expect(parsed?.chatId).toBe('-100123'); + }); +}); + +describe('parsePairCommand', () => { + it('matches `/pair 123456`', () => { + expect(parsePairCommand('/pair 123456')).toEqual({ code: '123456' }); + }); + + it('matches `/pair@BotName 654321`', () => { + expect(parsePairCommand('/pair@ShepBot 654321')).toEqual({ code: '654321' }); + }); + + it('returns null for non-pair commands', () => { + expect(parsePairCommand('/status')).toBeNull(); + expect(parsePairCommand('hello')).toBeNull(); + }); + + it('requires exactly 6 digits', () => { + expect(parsePairCommand('/pair 12345')).toBeNull(); + expect(parsePairCommand('/pair 1234567')).toBeNull(); + }); + + it('ignores leading/trailing whitespace', () => { + expect(parsePairCommand(' /pair 111111 ')).toEqual({ code: '111111' }); + }); +}); diff --git a/tests/unit/infrastructure/services/messaging/whatsapp-webhook-parser.test.ts b/tests/unit/infrastructure/services/messaging/whatsapp-webhook-parser.test.ts new file mode 100644 index 000000000..8cbfb6208 --- /dev/null +++ b/tests/unit/infrastructure/services/messaging/whatsapp-webhook-parser.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { parseWhatsAppUpdate } from '@/infrastructure/services/messaging/whatsapp-webhook.parser.js'; + +function textUpdate(from: string, body: string): string { + return JSON.stringify({ + object: 'whatsapp_business_account', + entry: [ + { + id: 'biz-1', + changes: [ + { + value: { + messages: [ + { + from, + id: 'wamid.abc', + timestamp: '1700000000', + type: 'text', + text: { body }, + }, + ], + }, + }, + ], + }, + ], + }); +} + +describe('parseWhatsAppUpdate', () => { + it('extracts from + text from a standard text message', () => { + const parsed = parseWhatsAppUpdate(textUpdate('15551234567', 'hello')); + expect(parsed).toEqual({ + chatId: '15551234567', + senderId: '15551234567', + text: 'hello', + }); + }); + + it('returns null for non-text message types', () => { + const raw = JSON.stringify({ + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + value: { + messages: [{ from: '1', type: 'image', image: { id: 'media1' } }], + }, + }, + ], + }, + ], + }); + expect(parseWhatsAppUpdate(raw)).toBeNull(); + }); + + it('returns null for status-only payloads (no messages)', () => { + const raw = JSON.stringify({ + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + value: { + statuses: [{ id: 'wamid.xxx', status: 'delivered' }], + }, + }, + ], + }, + ], + }); + expect(parseWhatsAppUpdate(raw)).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseWhatsAppUpdate('not json')).toBeNull(); + expect(parseWhatsAppUpdate('')).toBeNull(); + }); + + it('returns null when text body is missing', () => { + const raw = JSON.stringify({ + entry: [ + { + changes: [ + { + value: { + messages: [{ from: '1', type: 'text', text: {} }], + }, + }, + ], + }, + ], + }); + expect(parseWhatsAppUpdate(raw)).toBeNull(); + }); +}); diff --git a/tests/unit/presentation/web/components/features/settings/messaging-settings-section.test.tsx b/tests/unit/presentation/web/components/features/settings/messaging-settings-section.test.tsx new file mode 100644 index 000000000..1d9e07787 --- /dev/null +++ b/tests/unit/presentation/web/components/features/settings/messaging-settings-section.test.tsx @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MessagingSettingsSection } from '@/components/features/settings/messaging-settings-section'; +import { MessagingPlatform } from '@shepai/core/domain/generated/output'; + +const mockUpdateSettings = vi.fn(); +const mockBeginPairing = vi.fn(); +const mockConfirmPairing = vi.fn(); +const mockDisconnect = vi.fn(); + +vi.mock('@/app/actions/update-settings', () => ({ + updateSettingsAction: (...args: unknown[]) => mockUpdateSettings(...args), +})); + +vi.mock('@/app/actions/messaging', () => ({ + beginMessagingPairingAction: (...args: unknown[]) => mockBeginPairing(...args), + confirmMessagingPairingAction: (...args: unknown[]) => mockConfirmPairing(...args), + disconnectMessagingAction: (...args: unknown[]) => mockDisconnect(...args), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const disabledConfig = { + enabled: false, + debounceMs: 5000, + chatBufferMs: 3000, +}; + +const enabledUnpairedConfig = { + enabled: true, + gatewayUrl: 'https://gateway.example.com', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { enabled: false, paired: false }, + whatsapp: { enabled: false, paired: false }, +}; + +const telegramPairedConfig = { + enabled: true, + gatewayUrl: 'https://gateway.example.com', + debounceMs: 5000, + chatBufferMs: 3000, + telegram: { enabled: true, paired: true, chatId: '@alice' }, + whatsapp: { enabled: false, paired: false }, +}; + +describe('MessagingSettingsSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateSettings.mockResolvedValue({ success: true }); + mockBeginPairing.mockResolvedValue({ + success: true, + session: { + platform: MessagingPlatform.Telegram, + code: '123456', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + gatewayUrl: 'https://gateway.example.com', + publicUrl: 'https://gateway.example.com/integrations/route-abc/tok-xyz', + routeId: 'route-abc', + }, + }); + mockConfirmPairing.mockResolvedValue({ success: true }); + mockDisconnect.mockResolvedValue({ success: true }); + }); + + it('renders the enable toggle', () => { + render(); + expect(screen.getByTestId('switch-messaging-enabled')).toBeDefined(); + }); + + it('disables gateway input when messaging is off', () => { + render(); + expect(screen.getByTestId('input-gateway-url')).toHaveProperty('disabled', true); + }); + + it('shows pair buttons for unpaired platforms when enabled', () => { + render(); + expect(screen.getByTestId('btn-telegram-pair')).toBeDefined(); + expect(screen.getByTestId('btn-whatsapp-pair')).toBeDefined(); + }); + + it('shows disconnect button for paired platform', () => { + render(); + expect(screen.getByTestId('btn-telegram-disconnect')).toBeDefined(); + expect(screen.queryByTestId('btn-telegram-pair')).toBeNull(); + }); + + it('opens pairing dialog and displays the code + public URL after clicking pair', async () => { + render(); + fireEvent.click(screen.getByTestId('btn-telegram-pair')); + await waitFor(() => expect(mockBeginPairing).toHaveBeenCalledOnce()); + expect(screen.getByTestId('messaging-pairing-dialog')).toBeDefined(); + expect(screen.getByText('123456')).toBeDefined(); + expect( + screen.getByText('https://gateway.example.com/integrations/route-abc/tok-xyz') + ).toBeDefined(); + expect(screen.getByTestId('btn-copy-public-url')).toBeDefined(); + }); + + it('calls confirm pairing with the entered chat id', async () => { + render(); + fireEvent.click(screen.getByTestId('btn-telegram-pair')); + await waitFor(() => expect(screen.getByTestId('messaging-pairing-dialog')).toBeDefined()); + + const input = screen.getByTestId('input-pairing-chat-id') as HTMLInputElement; + fireEvent.change(input, { target: { value: '@alice' } }); + fireEvent.click(screen.getByTestId('btn-confirm-pairing')); + + await waitFor(() => + expect(mockConfirmPairing).toHaveBeenCalledWith({ + platform: MessagingPlatform.Telegram, + chatId: '@alice', + }) + ); + }); + + it('confirm button is disabled until a chat id is entered', async () => { + render(); + fireEvent.click(screen.getByTestId('btn-telegram-pair')); + await waitFor(() => expect(screen.getByTestId('btn-confirm-pairing')).toBeDefined()); + expect(screen.getByTestId('btn-confirm-pairing')).toHaveProperty('disabled', true); + }); + + it('refuses to begin pairing when gateway URL is invalid', async () => { + const invalid = { ...enabledUnpairedConfig, gatewayUrl: 'not a url' }; + render(); + fireEvent.click(screen.getByTestId('btn-telegram-pair')); + await waitFor(() => expect(mockBeginPairing).not.toHaveBeenCalled()); + }); + + it('shows disconnect-all row when at least one platform is paired', () => { + render(); + expect(screen.getByTestId('btn-disconnect-all')).toBeDefined(); + }); + + it('calls disconnect with no platform when disconnect-all is clicked', async () => { + render(); + fireEvent.click(screen.getByTestId('btn-disconnect-all')); + await waitFor(() => expect(mockDisconnect).toHaveBeenCalledWith({ platform: undefined })); + }); +}); diff --git a/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx b/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx index d6ddcf547..2cc7d5457 100644 --- a/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx +++ b/tests/unit/presentation/web/layouts/app-shell-variant.test.tsx @@ -29,6 +29,7 @@ const defaultFlags = { reactFileManager: false, inventory: false, projects: false, + codeReview: false, }; /** diff --git a/translations/ar/web.json b/translations/ar/web.json index 1127837dc..c9328198f 100644 --- a/translations/ar/web.json +++ b/translations/ar/web.json @@ -15,7 +15,8 @@ "flags": "العلامات", "chat": "المحادثة", "layout": "التخطيط", - "database": "قاعدة البيانات" + "database": "قاعدة البيانات", + "messaging": "Messaging" }, "language": { "title": "اللغة", diff --git a/translations/de/web.json b/translations/de/web.json index 7a68d90ef..c5b92cc78 100644 --- a/translations/de/web.json +++ b/translations/de/web.json @@ -15,7 +15,8 @@ "flags": "Flags", "chat": "Chat", "layout": "Layout", - "database": "Datenbank" + "database": "Datenbank", + "messaging": "Messaging" }, "language": { "title": "Sprache", diff --git a/translations/en/web.json b/translations/en/web.json index 7eef675ad..54c158b6a 100644 --- a/translations/en/web.json +++ b/translations/en/web.json @@ -15,7 +15,8 @@ "flags": "Flags", "chat": "Chat", "layout": "Layout", - "database": "Database" + "database": "Database", + "messaging": "Messaging" }, "language": { "title": "Language", @@ -274,7 +275,7 @@ "deleteSubFeatures": "Delete sub-features", "closePullRequest": "Close pull request", "cancel": "Cancel", - "deleting": "Deleting\u2026", + "deleting": "Deleting…", "delete": "Delete" }, "rejectFeedback": { @@ -283,25 +284,25 @@ "ariaLabel": "Rejection feedback", "placeholder": "Describe what needs to change...", "cancel": "Cancel", - "rejecting": "Rejecting\u2026", + "rejecting": "Rejecting…", "confirmReject": "Confirm Reject" }, "emptyState": { "addProject": "Add a project", "addProjectDescription": "Add your project folder to unlock feature creation.", - "addProjectDescriptionLine2": "Describe what you need \u2014 Shep handles the rest.", - "checkingSetup": "Checking setup\u2026", + "addProjectDescriptionLine2": "Describe what you need — Shep handles the rest.", + "checkingSetup": "Checking setup…", "ready": "{{label}} ready", "notInstalled": "{{label}} not installed", "reCheck": "Re-check", "needsAuth": "{{label}} needs authentication", "open": "Open {{label}}", - "checking": "Checking {{label}}\u2026", + "checking": "Checking {{label}}…", "notFound": "{{label}} not found", "docs": "Docs", - "opening": "Opening\u2026", + "opening": "Opening…", "chooseFolder": "Choose a Folder", - "folderHint": "Any folder works \u2014 git will be initialized automatically if needed.", + "folderHint": "Any folder works — git will be initialized automatically if needed.", "orUseCli": "or use the CLI", "copyCommands": "Copy commands", "git": "Git", @@ -310,7 +311,7 @@ "githubCliRequired": "Required for pull requests" }, "welcome": { - "loadingAgents": "Loading agents\u2026", + "loadingAgents": "Loading agents…", "chooseAgent": "Choose your agent", "pickModel": "Pick a model", "selectAgentSubtitle": "Select the AI coding agent you want Shep to use.", @@ -358,11 +359,11 @@ "stopDevServer": "Stop Dev Server", "startDevServer": "Start Dev Server", "retryDevServer": "Retry Dev Server", - "starting": "Starting\u2026", + "starting": "Starting…", "retry": "Retry", "start": "Start", "failed": "Failed", - "deleting": "Deleting\u2026", + "deleting": "Deleting…", "addFeature": "Add feature", "reviewRequirements": "Review Requirements", "reviewTechnicalPlan": "Review Technical Plan", @@ -612,7 +613,7 @@ "addRepository": "Add Repository" }, "modelPicker": { - "searchPlaceholder": "Search or type a model ID\u2026" + "searchPlaceholder": "Search or type a model ID…" }, "skills": { "searchPlaceholder": "Search skills..." diff --git a/translations/es/web.json b/translations/es/web.json index 86cf0c9e2..98724ee87 100644 --- a/translations/es/web.json +++ b/translations/es/web.json @@ -15,7 +15,8 @@ "flags": "Opciones", "chat": "Chat", "layout": "Diseño", - "database": "Base de datos" + "database": "Base de datos", + "messaging": "Messaging" }, "language": { "title": "Idioma", diff --git a/translations/fr/web.json b/translations/fr/web.json index 71e965f2c..830352dbb 100644 --- a/translations/fr/web.json +++ b/translations/fr/web.json @@ -15,7 +15,8 @@ "flags": "Drapeaux", "chat": "Chat", "layout": "Disposition", - "database": "Base de données" + "database": "Base de données", + "messaging": "Messaging" }, "language": { "title": "Langue", diff --git a/translations/he/web.json b/translations/he/web.json index 10851c30b..a9239a4e4 100644 --- a/translations/he/web.json +++ b/translations/he/web.json @@ -15,7 +15,8 @@ "flags": "דגלים", "chat": "צ'אט", "layout": "פריסה", - "database": "מסד נתונים" + "database": "מסד נתונים", + "messaging": "Messaging" }, "language": { "title": "שפה", diff --git a/translations/pt/web.json b/translations/pt/web.json index 59a0a29f1..18484f9bf 100644 --- a/translations/pt/web.json +++ b/translations/pt/web.json @@ -15,7 +15,8 @@ "flags": "Flags", "chat": "Chat", "layout": "Layout", - "database": "Banco de Dados" + "database": "Banco de Dados", + "messaging": "Messaging" }, "language": { "title": "Idioma", diff --git a/translations/ru/web.json b/translations/ru/web.json index 7681f1827..a89bbdc39 100644 --- a/translations/ru/web.json +++ b/translations/ru/web.json @@ -15,7 +15,8 @@ "flags": "Флаги", "chat": "Чат", "layout": "Расположение", - "database": "База данных" + "database": "База данных", + "messaging": "Messaging" }, "language": { "title": "Язык", diff --git a/translations/uk/web.json b/translations/uk/web.json index 0a12e934e..73d1aa012 100644 --- a/translations/uk/web.json +++ b/translations/uk/web.json @@ -15,7 +15,8 @@ "flags": "Прапорці", "chat": "Чат", "layout": "Розташування", - "database": "База даних" + "database": "База даних", + "messaging": "Повідомлення" }, "language": { "title": "Мова", diff --git a/tsp/common/enums/index.tsp b/tsp/common/enums/index.tsp index a5bd5929e..3d8fab0b6 100644 --- a/tsp/common/enums/index.tsp +++ b/tsp/common/enums/index.tsp @@ -17,6 +17,7 @@ import "./language.tsp"; import "./tool.tsp"; import "./notification.tsp"; import "./evidence-type.tsp"; +import "./messaging.tsp"; import "./work-item-enums.tsp"; import "./cloud-deployment.tsp"; import "./interactive-session-event.tsp"; diff --git a/tsp/common/enums/messaging.tsp b/tsp/common/enums/messaging.tsp new file mode 100644 index 000000000..c2ebb1f5b --- /dev/null +++ b/tsp/common/enums/messaging.tsp @@ -0,0 +1,67 @@ +/** + * @module Shep.Common.Enums.Messaging + * + * Defines enums for the external messaging remote control subsystem. + * These enums classify messaging platforms, command types, and + * tunnel frame types used for Telegram/WhatsApp remote control + * of Shep via the Commands.com Gateway. + */ +@doc("Supported external messaging platforms") +enum MessagingPlatform { + @doc("Telegram Bot API") + Telegram: "telegram", + + @doc("WhatsApp Business Cloud API") + WhatsApp: "whatsapp", +} + +@doc("Types of messages exchanged through the messaging tunnel") +enum MessagingFrameType { + @doc("A parsed slash command from the user") + Command: "command", + + @doc("A free-text chat message relayed to an interactive session") + ChatMessage: "chat_message", + + @doc("A chat control command (start/end relay)") + ChatControl: "chat_control", +} + +@doc("Slash commands supported via messaging remote control") +enum MessagingCommandType { + @doc("Create a new feature") + New: "new", + + @doc("Approve a gate on a feature") + Approve: "approve", + + @doc("Reject a gate with feedback") + Reject: "reject", + + @doc("Stop the agent on a feature") + Stop: "stop", + + @doc("Resume a paused feature") + Resume: "resume", + + @doc("Show feature status") + Status: "status", + + @doc("Mute notifications for a feature") + Mute: "mute", + + @doc("Unmute notifications for a feature") + Unmute: "unmute", + + @doc("List all features") + List: "list", + + @doc("Start an interactive chat relay against a feature") + Chat: "chat", + + @doc("End the active interactive chat relay") + End: "end", + + @doc("Show help text") + Help: "help", +} diff --git a/tsp/domain/entities/index.tsp b/tsp/domain/entities/index.tsp index e725f89a9..67f903dce 100644 --- a/tsp/domain/entities/index.tsp +++ b/tsp/domain/entities/index.tsp @@ -26,6 +26,7 @@ import "./feature-status.tsp"; import "./tool.tsp"; import "./notification-event.tsp"; import "./repository.tsp"; +import "./messaging.tsp"; import "./application.tsp"; import "./pm-project.tsp"; import "./work-item.tsp"; diff --git a/tsp/domain/entities/messaging.tsp b/tsp/domain/entities/messaging.tsp new file mode 100644 index 000000000..ba712f6d4 --- /dev/null +++ b/tsp/domain/entities/messaging.tsp @@ -0,0 +1,54 @@ +/** + * @module Shep.Domain.Entities.Messaging + * + * Defines models for the external messaging remote control feature. + * These are value objects (no BaseEntity inheritance) since they represent + * transient protocol messages exchanged between Shep and the Gateway. + * + * ## Message Flow + * + * ``` + * Phone (Telegram/WhatsApp) + * → Gateway (webhook → command parser → tunnel) + * → Shep Daemon (command executor / notification emitter) + * → Gateway (tunnel → platform adapter → send) + * → Phone + * ``` + */ +import "../../common/enums/messaging.tsp"; + +@doc("A parsed command received from a messaging platform via the Gateway tunnel") +model MessagingCommand { + @doc("Type of frame: command, chat_message, or chat_control") + type: MessagingFrameType; + + @doc("The slash command name (new, approve, reject, stop, resume, status)") + command: MessagingCommandType; + + @doc("Target feature ID (short numeric or full UUID)") + featureId?: string; + + @doc("Free-text arguments (feature description, rejection feedback, chat text)") + args?: string; + + @doc("Chat ID for routing responses back to the correct conversation") + chatId: string; + + @doc("Platform for routing responses back (telegram or whatsapp)") + platform: MessagingPlatform; +} + +@doc("A notification or response sent from Shep to a messaging platform via the Gateway tunnel") +model MessagingNotification { + @doc("Event type: feature lifecycle, CI status, gate waiting, command response, chat response") + event: string; + + @doc("ID of the feature this notification relates to") + featureId: string; + + @doc("Human-readable feature name or title") + title: string; + + @doc("Human-readable notification body (sanitized, no code or secrets)") + message: string; +} diff --git a/tsp/domain/entities/settings.tsp b/tsp/domain/entities/settings.tsp index bf8840458..6ab1a3dd3 100644 --- a/tsp/domain/entities/settings.tsp +++ b/tsp/domain/entities/settings.tsp @@ -38,6 +38,7 @@ import "../../common/enums/editor.tsp"; import "../../common/enums/language.tsp"; import "../../common/enums/notification.tsp"; import "../../common/enums/terminal.tsp"; +import "../../common/enums/messaging.tsp"; /** * Model Configuration @@ -684,6 +685,77 @@ model FabLayoutConfig { swapPosition: boolean = false; } +/** + * Messaging Remote Control Configuration + * + * Configuration for controlling Shep remotely via messaging platforms + * (Telegram, WhatsApp) through the Commands.com Gateway. + * + * ## Setup Flow + * + * 1. User deploys the Gateway or connects to an existing one + * 2. `shep settings messaging` wizard configures platform credentials + * 3. Shep registers integration routes on the Gateway + * 4. User pairs their messaging chat via a one-time verification code + * 5. Notifications and commands flow bidirectionally + */ +@doc("Configuration for a single messaging platform connection") +model MessagingPlatformConfig { + @doc("Whether this platform connection is active") + enabled: boolean = false; + + @doc("Platform-specific chat ID for message routing (set during pairing)") + chatId?: string; + + @doc("Whether the chat has been verified via pairing code") + paired: boolean = false; + + @doc("One-time code shown to the user during pairing, cleared once confirmed") + pendingPairingCode?: string; + + @doc("Expiry timestamp for the pending pairing code (ISO-8601)") + pendingPairingExpiresAt?: utcDateTime; + + @doc("Gateway integration route ID allocated during pairing") + routeId?: string; + + @doc("Gateway integration route token (path-auth) allocated during pairing") + routeToken?: string; + + @doc("Public webhook URL that the messaging platform should POST updates to") + publicUrl?: string; + + @doc("Bot API token used by the daemon to send outbound messages (Telegram: 123456:ABC...)") + botToken?: string; +} + +@doc("Messaging remote control configuration") +model MessagingConfig { + @doc("Whether messaging remote control is enabled") + enabled: boolean = false; + + @doc("URL of the Commands.com Gateway instance") + gatewayUrl?: string; + + @doc("Device ID used when registering integration routes and opening the tunnel") + deviceId?: string; + + @doc("OAuth client ID for fetching gateway access tokens (demo mode uses public client)") + gatewayClientId?: string; + + @doc("Telegram platform configuration") + telegram?: MessagingPlatformConfig; + + @doc("WhatsApp platform configuration") + whatsapp?: MessagingPlatformConfig; + + @doc("Debounce window in milliseconds for notification delivery (default: 5000)") + debounceMs: int32 = 5000; + + @doc("Buffer interval in milliseconds for chat relay output batching (default: 3000)") + chatBufferMs: int32 = 3000; +} + /** * Settings Entity * @@ -832,4 +904,13 @@ model Settings extends BaseEntity { */ @doc("FAB layout configuration (optional, defaults applied at runtime)") fabLayout?: FabLayoutConfig; + + /** + * Messaging remote control configuration. + * Controls external messaging integration (Telegram, WhatsApp) via + * the Commands.com Gateway for remote notifications and commands. + * Optional for backward compatibility — defaults applied at runtime. + */ + @doc("Messaging remote control configuration (optional, defaults applied at runtime)") + messaging?: MessagingConfig; }