` 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}
+
+
+
+
+ );
+}
+
+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;
}