diff --git a/.gitignore b/.gitignore index 51fcd53..9828d84 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ Thumbs.db # Pi sessions (persisted locally, not in repo) gateway-sessions/ .codex + +# Claude Code local state (cron lock, transcripts, etc.) +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 272fe79..d1abccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ All notable changes to `@inceptionstack/roundhouse` are documented here. +## [0.6.0] — 2026-05-26 + +> **Migration note for out-of-tree TransportAdapter implementations.** The +> interface gained five new methods (`ownsChatId`, `encodeParentThreadId`, +> `formatNotifySession`, optional `shouldIgnoreMessage`, `stream`) and three +> existing methods changed shape: `enrichPrompt(thread, text)` now takes +> `thread`; `createThread(string | number)` accepts string ids; `notify` +> accepts `(string | number)[]`; `registerCommands()` no longer takes a +> token (each adapter self-sources its env-var creds). External adapters +> need to add the new methods and update those signatures. Adapters that +> only ship `TelegramAdapter` / `SlackAdapter` (the included ones) need no +> action — gateway config files keep working. + +### Added +- **Slack transport** (socket mode, single workspace). `roundhouse setup + --slack` walks you through it: prints the bundled Slack app manifest + (`src/transports/slack/manifest.yaml`), validates `xoxb-`/`xapp-` + tokens via `auth.test`, writes `~/.roundhouse/slack-pairing.json` + with `status="pending"`, and the gateway completes pairing on the + first `message.im` or `assistant_thread_started` event from an + allowed user. +- **Multi-transport composition.** Telegram and Slack can run in the + same gateway instance. New `CompositeTransportAdapter` + (`src/transports/composite.ts`) routes per-thread methods by + `ownsThread`, partitions `notify(chatIds, …)` by `ownsChatId`, and + tracks `pairingComplete` per-transport so paired-then-pair-other + flows work cleanly. +- **Card-model menus.** `RichMenu` now maps to the Chat SDK's + transport-agnostic `CardElement`. The Slack adapter renders to Block + Kit; Telegram renders to inline keyboards. Single converter + (`richMenuToCard` in `src/transports/rich-helpers.ts`) replaces what + would have been per-platform Block Kit translators. +- **Slack streaming** with post-then-edit fallback (rate-limit-aware + throttling, abort-signal honoring, init-failure backoff with hard + cap and final flush). Native AI Assistant streaming deferred to v2. +- **`@chat-adapter/slack@^4.29.0`** added to dependencies. + +### Changed +- **Chat SDK bump** to `^4.29.0` for `chat`, `@chat-adapter/telegram`, + and `@chat-adapter/state-memory`. Backwards-compatible per the SDK's + release notes; no API impact on roundhouse code. +- **`TransportAdapter` interface widened**: new methods `ownsChatId`, + `encodeParentThreadId`, `formatNotifySession`, `shouldIgnoreMessage` + (optional), `stream`. Signature changes: `enrichPrompt(thread, text)`, + `createThread(string | number)`, `notify((string | number)[])`, + `registerCommands()` (adapter self-sources its env-var creds — no + more passing tokens through the gateway). +- **`allowedUserIds` and `notifyChatIds`** widened to + `(string | number)[]` to support Slack's `Uxxx`/`Cxxx` identifiers + alongside Telegram's numerics. `isAllowed` now does dual-lookup + against the heterogeneous union; both forms match against entries + of either type. +- **`ChatThread.post`** widened to `string | { markdown: string } | { card: unknown; fallbackText?: string }`. +- **`gateway/streaming.ts`** dispatches per-turn streaming via + `transport.stream(thread, iter, signal)` — no more hardcoded + `isTelegramThread` branch. +- **Telegram `/start ` filter** moved from the gateway into + `TelegramAdapter.shouldIgnoreMessage` so it can't accidentally drop + Slack messages. +- **`fireBootTurn`** partitions `notifyChatIds` by transport and fires + one boot turn per transport that owns at least one configured chat + id (was a single global `chatIds[0]` which silently favored + whichever transport happened to be listed first). + +### Fixed +- **Number()-coercion sweep.** Several call sites silently dropped + Slack-shaped chat ids by coercing through `Number()` or matching + against `/^-?\d+$/`. Audited and fixed in `gateway.ts:113`, + `gateway.ts:329-333` (cron notifyFn), `gateway.ts:352` (sub-agent + routing), `gateway.ts:978-1001` (notifyStartup session label), + `cli/subagent-command.ts:30-38` (parent thread id encoding), and + `ipc/handler.ts:17-30` (notify session sentinel). + ## [0.5.41] — 2026-05-20 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index de569e3..75ac2e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,14 +23,14 @@ Roundhouse is a chat-gateway between chat platforms (via Vercel Chat SDK) and a **One gateway = one agent target.** Selection happens at startup from `config.agent.type` via `src/agents/registry.ts`. The `AgentRouter` seam (`src/router.ts`) currently always returns that one agent (`SingleAgentRouter`), but the interface is there so per-thread/multi-agent routing can be added without touching `gateway.ts` or the adapters. -**Streaming path vs. fallback path.** `Gateway.handleStreaming` (src/gateway.ts) prefers `agent.promptStream()` if the adapter implements it. It turns pi's event stream into Chat SDK calls: +**Streaming path vs. fallback path.** `Gateway.handleStreaming` (`src/gateway/gateway.ts`) prefers `agent.promptStream()` if the adapter implements it. It turns pi's event stream into Chat SDK calls: - `text_delta` → buffered into a per-turn `AsyncIterable` passed to `thread.handleStream()` (which does post+edit with rate limiting) - `tool_start` → a compact status message via `thread.post()` (separate bubble) - `custom_message` → flush the current streaming turn, post as its own bubble (used by pi extensions like pi-lgtm code review) - `turn_end` / `agent_end` → flush so the next turn starts a fresh message If `promptStream` is missing, `agent.prompt()` is called and the full text is split/posted via `postWithFallback` (markdown-first, plaintext fallback). -**Pi adapter nuance — private event queue drain.** `src/agents/pi.ts` awaits `session._agentEventQueue` (a private field of `@earendil-works/pi-coding-agent`'s `AgentSession`) after every `prompt()` / `agent.continue()`. Without this drain, extension `agent_end` handlers (e.g. pi-lgtm's review) haven't finished and their `followUp` messages or `custom_message` events race against the `unsubscribe()` in the `finally` block, causing lost review bubbles. The field access is wrapped in `if (queue)` — if upstream renames it, behavior silently reverts to the pre-fix race. See the big comment in `drainSessionEvents()` for context before touching it. +**Pi adapter nuance — private event queue drain.** `src/agents/pi/pi-adapter.ts` awaits `session._agentEventQueue` (a private field of `@earendil-works/pi-coding-agent`'s `AgentSession`) after every `prompt()` / `agent.continue()`. Without this drain, extension `agent_end` handlers (e.g. pi-lgtm's review) haven't finished and their `followUp` messages or `custom_message` events race against the `unsubscribe()` in the `finally` block, causing lost review bubbles. The field access is wrapped in `if (queue)` — if upstream renames it, behavior silently reverts to the pre-fix race. See the big comment in `drainSessionEvents()` for context before touching it. **Pi adapter nuance — subscribers must outlive extension-triggered runs.** `runPromptAndFollowUps` loops on *both* `session.isStreaming` (awaits `agent.waitForIdle()`) and `agent.hasQueuedMessages()` (awaits `agent.continue()`). When an extension calls `pi.sendMessage(..., { triggerTurn: true, deliverAs: "followUp" })` inside its `agent_end` handler, pi's `sendCustomMessage` picks one of two branches depending on `isStreaming` at that moment: if still streaming it queues via `agent.followUp()` (caught by `hasQueuedMessages`); if already idle it bypasses the queue and calls `agent.prompt(appMessage)` directly as fire-and-forget, kicking off a new run that is *only* visible via `isStreaming`. The pi CLI is immune because its subscriber stays attached across runs; our per-prompt subscriber must loop on both conditions or we unsubscribe mid-run and Telegram sees the review bubble followed by silence. Don't collapse the loop to a single condition. @@ -44,7 +44,26 @@ If `promptStream` is missing, `agent.prompt()` is called and the full text is sp **Adding a new agent backend:** implement `AgentAdapter` from `src/types.ts` in `src/agents/.ts`, register in `src/agents/registry.ts`, set `"agent": { "type": "" }` in config. If you want streaming, implement `promptStream` yielding `AgentStreamEvent`s; otherwise `prompt` alone is enough and the gateway will fall back. -**Adding a new chat platform:** install the `@chat-adapter/` package, lazy-import it in `buildChatAdapters` inside `src/gateway.ts`, and add a corresponding entry to `GatewayConfig["chat"]["adapters"]` in `src/types.ts`. The unified `handle()` in `gateway.ts` already covers all platforms. +**Adding a new chat platform:** install the `@chat-adapter/` package, register it in `chatAdapterFactories` (`src/transports/chat-adapters.ts`), implement a `TransportAdapter` (`src/transports//-adapter.ts`), add it to `buildTransportDelegates` in `src/gateway/gateway.ts`, and add a corresponding entry to `GatewayConfig["chat"]["adapters"]` in `src/types.ts`. The composite transport routes all per-thread methods automatically by `ownsThread`/`ownsChatId`. + +**Multi-transport composition.** A single gateway runs all configured transports simultaneously through `CompositeTransportAdapter` (`src/transports/composite.ts`). The composite owns a `delegates: TransportAdapter[]` and routes: +- per-thread methods (`postMessage`, `postRich`, `progress`, `stream`, `enrichPrompt`) by the first delegate where `ownsThread(thread)` is true; +- `notify(chatIds, ...)` by partitioning chat ids by `ownsChatId` then fanning out; +- `handlePairing` to the first delegate that returns non-null, decorated with `transport: ` so the gateway tracks `pairingComplete` per-transport (a `Map` — a single boolean would silently block the second transport's pairing once the first paired); +- `registerCommands` and `dispose` to all delegates. +The gateway never branches on platform; everything reads `this.transport.foo()` against the composite. + +**Slack adapter nuances.** +- Thread ids are `slack:CHANNEL:THREAD_TS`. Always use `sdk.encodeThreadId({ channel, threadTs })` and `sdk.decodeThreadId(id)` — never split manually. Top-level posts use `threadTs: ""` (sentinel); the `progress.ts` and `streaming.ts` helpers check for `"" | "main"` and elide `thread_ts` when posting. +- `AdapterPostableMessage` is `string | { raw } | { markdown } | { ast } | { card } | CardElement` (`chat@4.29.0` `chat-D9UYaaNO.d.ts:1549`). **There is no `blocks` field.** Menus go through `{ card, fallbackText }` and the SDK's `cardToBlockKit` does the conversion internally. Same model works for telegram via `extractCard`. The transport-agnostic `richMenuToCard` lives in `src/transports/rich-helpers.ts` and is shared. +- **Streaming + Block Kit can't coexist.** Slack's stream API doesn't take blocks. Decision: streaming turns are agent text only; menu turns are command results that don't stream. If a future feature needs both, finalize the stream first then post a separate menu message. +- **`SlackAdapter.attach(slackSdk)` lifecycle.** Must be called *after* `chat.initialize()` because the Chat SDK Slack adapter populates `_botUserId` and starts socket-mode during initialize. The gateway does this for you (`gateway.ts` post-`chat.initialize()` block); don't call `attach()` earlier or webClient calls will throw `AuthenticationError`. +- **Bot self-loop filtering.** Slack delivers the bot's own messages back through `message.channels` / `message.groups` if those scopes are enabled. The Chat SDK does central isMe filtering; verified that the SDK's `initialize()` already calls `auth.test` and populates `_botUserId` before subscriptions activate (`@chat-adapter/slack@4.29.0` `index.js:868-885`), so the filter is armed by the time events flow. +- **Pairing chicken-and-egg.** Slack's `message.im` only fires for *existing* DM channels. To support users who haven't DM'd the bot yet, the gateway also registers `bot.onAssistantThreadStarted` and synthesizes an IncomingMessage from the event (resolving the user via `slackSdk.getUser(userId)` so the allowlist's userName check has a value to match). Both paths flow through the same `transport.handlePairing` → composite seam. +- **Per-transport boot turn.** `fireBootTurn` partitions `notifyChatIds` by `ownsChatId` and fires one boot turn against the first chatId owned by each transport — not the global `chatIds[0]`. Otherwise multi-transport installs would silently favor whichever transport happened to be listed first. +- `createSlackAdapter` env-var fallback only fires when called with NO config (`zeroConfig = !config`). The factory in `src/transports/chat-adapters.ts` therefore explicitly forwards `process.env.SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `SLACK_SIGNING_SECRET` so they're populated regardless of whether other config keys are set. Verified against `dist/index.js:4233-4243`. Skipping this is a silent `AuthenticationError: No bot token available` at runtime. + +**Type widening for multi-transport.** `allowedUserIds` and `notifyChatIds` are `(string | number)[]` (Telegram numeric, Slack string). `ChatThread.post` accepts `string | { markdown } | { card, fallbackText? }`. `IncomingMessage.chatId` is `string | number`. `isAllowed` does dual lookup against the heterogeneous union by normalizing both sides to `String()`. Several legacy `Number()` coercion sites were caught in Phase 1 (`gateway.ts:113, 329, 352, 978-1001, 1017-1021`, `subagent-command.ts`, `ipc/handler.ts`) — preserve string IDs end-to-end; don't reintroduce `parseInt` or `Number()` casts. ## Debugging diff --git a/README.md b/README.md index e53060f..6c08418 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,53 @@ export ALLOWED_USERS="your_username" roundhouse install # installs as systemd service, starts automatically ``` +## Slack quick start + +Slack is supported in **socket mode** (single workspace, v1). No public URL required — the gateway connects to Slack via WebSocket. + +### 1. Create the Slack app + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From an app manifest**. +2. Pick your workspace. Paste the manifest that `roundhouse setup --slack` prints inline (and writes to `/tmp/roundhouse-slack-manifest.yaml` for easy paste). The same YAML lives in the source tree at `src/transports/slack/manifest.yaml` for reference. +3. **Install to Workspace**, then on the **Basic Information** page: + - Generate an **App-Level Token** with the `connections:write` scope. Copy the `xapp-…` value. + - Open **OAuth & Permissions**, copy the **Bot User OAuth Token** (`xoxb-…`). + +### 2. Run setup + +```bash +roundhouse setup --slack +# (interactive — will prompt for tokens and your Slack username) +``` + +Or non-interactive (e.g. SSM / cloud-init): + +```bash +SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… \ + roundhouse setup --slack --non-interactive --user your_slack_handle +``` + +### 3. Pair + +The setup writes a pending-pairing file (`~/.roundhouse/slack-pairing.json`) and starts the gateway. To complete pairing, **open a new DM with the bot** in Slack (click the bot in your sidebar or search Apps → @your-bot, then send any message). The first message from one of the configured `allowedUsers` completes pairing. + +> ⚠️ Slack only fires `message.im` for *existing* DM channels. If you've never DM'd the bot before, the assistant_thread_started event takes care of it — the bot's manifest enables Slack's Assistants API which fires that event when you click "Message" on the bot's profile. + +### Slack feature support + +| Feature | Supported | +|---|---| +| Plain text | Yes (markdown) | +| Block Kit menus (buttons, actions) | Yes (via the SDK's transport-agnostic Card model) | +| Streaming | Yes (post-then-edit fallback; native AI Assistant streaming is a v2 enhancement) | +| File attachments | Yes (uses Slack's authenticated `url_private` download) | +| Reactions / pins / scheduled messages | No (out of scope for v1) | +| Multi-workspace OAuth | No (single-workspace only in v1) | +| Webhook mode (no socket) | No (socket-only in v1; needs a public URL otherwise) | +| Slash commands as Slack-native commands | No (use roundhouse's `/new`, `/restart`, etc. as plain text) | + +Telegram and Slack can run in the **same gateway instance** — configure both under `chat.adapters` and roundhouse routes per-thread. + ## CLI ``` @@ -184,14 +231,16 @@ Without a config file, defaults are used with env vars (`TELEGRAM_BOT_TOKEN`, `B | `agent.cwd` | Working directory for the agent | | `agent.sessionDir` | Override session storage path | | `chat.botUsername` | Bot display name for Chat SDK | -| `chat.allowedUsers` | Telegram usernames / user IDs allowed (empty = allow all) | -| `chat.notifyChatIds` | Telegram chat IDs to notify on startup (env: `NOTIFY_CHAT_IDS`) | +| `chat.allowedUsers` | Telegram / Slack usernames allowed (empty = allow all) | +| `chat.allowedUserIds` | Immutable user IDs (Telegram numeric, Slack `Uxxx`); paired during setup | +| `chat.notifyChatIds` | Chat IDs to notify on startup (Telegram numeric, Slack `Cxxx`/`Dxxx`) | | `chat.adapters.telegram` | `{ "mode": "polling" \| "webhook" \| "auto" }` | +| `chat.adapters.slack` | `{ "mode": "socket" }` (v1: socket mode only; tokens via `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` env) | | `voice.stt.enabled` | Enable automatic voice transcription (default: off unless configured) | | `voice.stt.chain` | STT provider chain, e.g. `["whisper"]` | | `voice.stt.providers.whisper` | `{ "model": "small", "timeoutMs": 30000 }` | -Secrets stay in env vars: `TELEGRAM_BOT_TOKEN`, `ANTHROPIC_API_KEY`, etc. +Secrets stay in env vars: `TELEGRAM_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `ANTHROPIC_API_KEY`, etc. ## Joining a session from pi CLI @@ -458,27 +507,40 @@ export const createMyAgentAdapter: AgentAdapterFactory = (config) => new MyAgent ## Adding a new chat platform -Add the Chat SDK adapter package and wire it in `gateway.ts`: +Three small wiring points (the gateway code itself never branches on platform): -```typescript -if (config.slack) { - const { createSlackAdapter } = await import("@chat-adapter/slack"); - adapters.slack = createSlackAdapter(); -} -``` +1. **Register the SDK adapter factory** in `src/transports/chat-adapters.ts`: + ```ts + chatAdapterFactories.discord = async () => { + const { createDiscordAdapter } = await import("@chat-adapter/discord"); + return (cfg) => createDiscordAdapter({ /* …forward env vars explicitly… */ }); + }; + ``` +2. **Implement `TransportAdapter`** in `src/transports/discord/discord-adapter.ts`. The contract (`src/transports/types.ts`) covers `postMessage`, `postRich`, `progress`, `stream`, `notify`, `createThread`, `ownsThread`, `ownsChatId`, `encodeParentThreadId`, `formatNotifySession`, plus pairing hooks. The Slack adapter is the cleanest reference impl. +3. **Add the delegate** in `buildTransportDelegates` (top of `src/gateway/gateway.ts`): + ```ts + if (config.discord) delegates.push(new DiscordAdapter()); + ``` -No other changes needed — the gateway's unified handler covers all platforms. +The `CompositeTransportAdapter` automatically routes per-thread methods by `ownsThread` and partitions `notify(chatIds, …)` by `ownsChatId`. No changes needed in the gateway's message handler. ## Files | File | Purpose | |------|---------| | `src/index.ts` | Entry point, config loading, startup | -| `src/gateway.ts` | Owns Chat SDK, wires events → router → agent | +| `src/gateway/gateway.ts` | Owns Chat SDK, wires events → router → agent | | `src/router.ts` | `AgentRouter` interface + `SingleAgentRouter` | | `src/types.ts` | Core interfaces: `AgentAdapter`, `AgentStreamEvent`, `AgentRouter`, `GatewayConfig` | | `src/util.ts` | Pure utilities: `splitMessage`, `isAllowed`, `threadIdToDir`, `startTypingLoop` | +| `src/transports/types.ts` | `TransportAdapter` contract | +| `src/transports/composite.ts` | Multi-transport routing | +| `src/transports/chat-adapters.ts` | Chat SDK adapter factory registry | +| `src/transports/telegram/` | Telegram transport adapter | +| `src/transports/slack/` | Slack transport adapter (socket mode) | +| `src/transports/rich-helpers.ts` | `richMenuToCard`, `stripMarkdownToPlain`, `buildSelectableMenu` | | `src/cli/cli.ts` | CLI: start, run, install, tui, update, logs, etc. | +| `src/cli/setup/` | `setup --telegram` and `setup --slack` flows | | `src/cli/env-file.ts` | Shared env file parsing, serialization, and quoting | | `src/cli/systemd.ts` | Shared systemd service management (unit generation, install, status) | | `src/cli/launchd.ts` | macOS LaunchAgent management (plist generation, install, status) | @@ -487,13 +549,12 @@ No other changes needed — the gateway's unified handler covers all platforms. | `src/cli/doctor/checks/` | Individual health check modules | | `src/cron/` | Cron scheduler, runner, store, schedule, template, format | | `src/cron/helpers.ts` | Shared cron constants and utilities | -| `src/notify/telegram.ts` | Shared Telegram Bot API sender | | `src/agents/pi/pi-adapter.ts` | Pi agent adapter (persistent sessions via pi SDK) | | `src/agents/kiro/kiro-adapter.ts` | Kiro CLI agent adapter (ACP over stdio) | | `src/agents/base-adapter.ts` | Abstract base class — adapter interface contract | | `src/agents/registry.ts` | Agent type → factory registry | | `src/config.ts` | Shared config loading, defaults, env overrides | -| `test/` | Unit + integration tests (vitest, 311 passing) | +| `test/` | Unit + integration tests (vitest, 678 passing) | ## CI/CD diff --git a/architecture.md b/architecture.md index 009e572..c34fd55 100644 --- a/architecture.md +++ b/architecture.md @@ -185,8 +185,8 @@ gateway.config.json ├── notifyChatIds: [...] # Telegram chat IDs for startup notifications └── adapters ├── telegram: { mode: "polling" } - ├── slack: { ... } # (future) - └── discord: { ... } # (future) + ├── slack: { mode: "socket" } # SLACK_BOT_TOKEN/SLACK_APP_TOKEN env + └── discord: { ... } # (future) └── voice # Optional voice features └── stt @@ -228,7 +228,7 @@ Each chat platform thread gets its own agent session: ``` Telegram DM with Alice → threadId = "telegram:123456789" → session A -Slack DM with Alice → threadId = "slack:U12345" → session B +Slack DM with Alice → threadId = "slack:D12345:" → session B Telegram group mention → threadId = "telegram:-100123456" → session C ``` @@ -254,6 +254,45 @@ The `AgentRouter` interface is a seam for future multi-agent routing: The gateway and agent adapters don't change — only the router. +## Transport composition + +A single gateway can run multiple chat platforms concurrently (Telegram + Slack today). The wiring: + +``` + ┌────────────────────────────────────────────┐ + │ CompositeTransportAdapter (this.transport) │ + │ │ + │ delegates: [TelegramAdapter, SlackAdapter] │ + └─────────────┬───────────────┬──────────────┘ + │ │ + ownsThread/ownsChatId routing │ + ▼ ▼ + ┌────────────────────┐ ┌────────────────────┐ + │ TelegramAdapter │ │ SlackAdapter │ + │ │ │ │ + │ ownsThread: │ │ ownsThread: │ + │ adapter.tg- │ │ id startsWith │ + │ Fetch present │ │ "slack:" │ + │ ownsChatId: numeric│ │ ownsChatId: C/D/G/U │ + └────────────────────┘ └────────────────────┘ +``` + +Routing rules implemented in `src/transports/composite.ts`: + +| Method | Routing | +|--------|---------| +| `postMessage`, `postRich`, `progress`, `stream`, `enrichPrompt` | by `ownsThread(thread)` | +| `notify(chatIds, …)` | partition by `ownsChatId`, fan out | +| `createThread(chatId)` | by `ownsChatId` | +| `encodeParentThreadId`, `formatNotifySession` | by `ownsChatId` | +| `registerCommands`, `dispose` | fan out to all delegates | +| `handlePairing` | first delegate that returns non-null; result tagged with delegate name so the gateway tracks `pairingComplete` per-transport | +| `shouldIgnoreMessage` | by `ownsThread` (Telegram drops `/start`, Slack has no equivalent) | + +The gateway code reads `this.transport.foo()` and never branches on platform; adding a third transport is a TransportAdapter implementation + one entry in `chatAdapterFactories` + one entry in `buildTransportDelegates`. + +ID types are heterogeneous union `(string | number)[]` to support both numeric (Telegram) and string (Slack `Uxxx`/`Cxxx`) identifiers in the same allowlist / notify list. + ## Module dependency graph ``` diff --git a/package-lock.json b/package-lock.json index 9e46128..8fc08db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "@inceptionstack/roundhouse", - "version": "0.5.41", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@inceptionstack/roundhouse", - "version": "0.5.41", + "version": "0.6.0", "license": "MIT", "dependencies": { - "@chat-adapter/state-memory": "^4.26.0", - "@chat-adapter/telegram": "^4.26.0", + "@chat-adapter/slack": "=4.29.0", + "@chat-adapter/state-memory": "^4.29.0", + "@chat-adapter/telegram": "^4.29.0", "@earendil-works/pi-coding-agent": "^0.74.0", - "chat": "^4.26.0", + "chat": "^4.29.0", "croner": "^10.0.1", "p-queue": "^9.2.0", "qrcode-terminal": "^0.12.0", @@ -796,31 +797,55 @@ } }, "node_modules/@chat-adapter/shared": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@chat-adapter/shared/-/shared-4.26.0.tgz", - "integrity": "sha512-YD0MGktFXrArUqTBsyPfL5vkdD1WBS58PAWO0oVrMQAMmPxpAXfWGjBtZCkf3y8R8Svb0uVuVXiMZSForaEnMQ==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/shared/-/shared-4.29.0.tgz", + "integrity": "sha512-ARqTDoHJHKN9rpytbFPJbNmqqx3fOg5xwsTZdlingQPAssOSeHDBdqrFJkgDhyCRGbmDtG09cuS0FkVzeoh2qg==", "license": "MIT", "dependencies": { - "chat": "4.26.0" + "chat": "4.29.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@chat-adapter/slack": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/slack/-/slack-4.29.0.tgz", + "integrity": "sha512-s2DXAwkTpmiIKSATXgrO879s1pqFwS70Y0JPd+TRGRzDeh6nfqt5dnKt5Bug0P1zwkB6DoPurhnYS9nqhSmD/w==", + "license": "MIT", + "dependencies": { + "@chat-adapter/shared": "4.29.0", + "@slack/socket-mode": "^2.0.5", + "@slack/web-api": "^7.14.0", + "chat": "4.29.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@chat-adapter/state-memory": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@chat-adapter/state-memory/-/state-memory-4.26.0.tgz", - "integrity": "sha512-FsfyM/A9Bf1yFc1FWmOsK+a4YVwm5FogX25hZxFG6cEvyFb6Cd924SsbtvF06yItY/7J2UFetCsMmBPkdPKshQ==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/state-memory/-/state-memory-4.29.0.tgz", + "integrity": "sha512-+L5LPj9VkyXFXAcfFlTvf2JihuFfQV5U9TfXDlQeG7k6NMEA8irt4a0c6MTxFP7OI5YENknVwRGzmcUubvEWSw==", "license": "MIT", "dependencies": { - "chat": "4.26.0" + "chat": "4.29.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@chat-adapter/telegram": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/@chat-adapter/telegram/-/telegram-4.26.0.tgz", - "integrity": "sha512-PE2HoCQ4648VNKZTuHFanQNoYzM/niNoSbDyYlPq6VOoB5qsoo1ctR8TERyl1EfPBNexWZpSWYrrnQPr15LUfA==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/@chat-adapter/telegram/-/telegram-4.29.0.tgz", + "integrity": "sha512-015tU3HEjFQWw7DgebXWkDeQA6lTdTVEO4btAV3f6U1kEnOfjLVJq98latcsM1WkEvv+3LIFKpsz9pG53aamBw==", "license": "MIT", "dependencies": { - "@chat-adapter/shared": "4.26.0", - "chat": "4.26.0" + "@chat-adapter/shared": "4.29.0", + "chat": "4.29.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@earendil-works/pi-agent-core": { @@ -952,24 +977,26 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1937,6 +1964,29 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz", @@ -1984,6 +2034,105 @@ "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", "license": "Apache-2.0" }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", + "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/web-api/node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@slack/web-api/node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/@slack/web-api/node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@smithy/config-resolver": { "version": "4.4.17", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", @@ -2745,6 +2894,15 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2938,6 +3096,58 @@ "node": ">=4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -3028,6 +3238,19 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3071,9 +3294,9 @@ } }, "node_modules/chat": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/chat/-/chat-4.26.0.tgz", - "integrity": "sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/chat/-/chat-4.29.0.tgz", + "integrity": "sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==", "license": "MIT", "dependencies": { "@workflow/serde": "4.1.0-beta.2", @@ -3083,6 +3306,21 @@ "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "ai": "^6.0.182", + "zod": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + }, + "zod": { + "optional": true + } } }, "node_modules/cli-highlight": { @@ -3172,6 +3410,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3251,6 +3501,15 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3292,6 +3551,20 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3316,6 +3589,24 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -3323,6 +3614,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3592,6 +3910,63 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3618,6 +3993,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -3667,6 +4051,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -3760,6 +4181,18 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3775,6 +4208,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -3860,6 +4332,12 @@ "node": ">= 12" } }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3881,6 +4359,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -4262,6 +4752,15 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -5196,6 +5695,15 @@ } } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-queue": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", diff --git a/package.json b/package.json index fcb765a..056f586 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inceptionstack/roundhouse", - "version": "0.5.41", + "version": "0.6.0", "type": "module", "description": "Multi-platform chat gateway that routes messages through a configured AI agent", "license": "MIT", @@ -39,10 +39,11 @@ "pi/" ], "dependencies": { - "@chat-adapter/state-memory": "^4.26.0", - "@chat-adapter/telegram": "^4.26.0", + "@chat-adapter/slack": "=4.29.0", + "@chat-adapter/state-memory": "^4.29.0", + "@chat-adapter/telegram": "^4.29.0", "@earendil-works/pi-coding-agent": "^0.74.0", - "chat": "^4.26.0", + "chat": "^4.29.0", "croner": "^10.0.1", "p-queue": "^9.2.0", "qrcode-terminal": "^0.12.0", diff --git a/slack-plan.md b/slack-plan.md new file mode 100644 index 0000000..0956ee0 --- /dev/null +++ b/slack-plan.md @@ -0,0 +1,1254 @@ +# Plan: Slack Adapter for Roundhouse (v4) + +> **Revision history:** +> - **v1** reviewed by independent subagent → verdict: minor-to-major revisions. +> - **v2** addressed v1's 17 findings → verdict: minor revisions. +> - **v3** restructured around the Chat SDK `Card` model after verifying the `.d.ts` files → verdict: minor revisions (5 small findings). +> - **v4** closes the v3 findings: +> - `createThread.post` signature dropped the v2-residual `blocks`/`text` shape; now matches the widened `ChatThread.post`. +> - Phase 1 checklist adds `TelegramAdapter.createThread.post` widening (with a fallback gate if the migration proves heavy). +> - `fireBootTurn` partition logic clarified: "first chatId owned by each transport," not the global `chatIds[0]`. +> - `assistant_thread_started` pairing path now does an explicit `slackSdk.getUser(userId)` lookup so allowlist matching has a `userName` to compare; `userId`-literal allowlist entries are also supported as a fallback. +> - `composite.identifyOwner` collapsed into the existing `ownsThread` walk via `composite.delegates.find(...)` — no new method. +> - Verification table cite for `Thread.post` corrected from line 100 (ChannelImpl) to 298 (Thread); `dispatchInteractivePayload` annotated as protected SDK plumbing. +> +> **Earlier v3 changes (preserved):** +> - **`postRich` uses the Chat SDK's transport-agnostic `Card` model** (not a hand-rolled Block Kit converter). The Slack adapter converts `Card → cardToBlockKit` internally; Telegram converts `Card → inline keyboard` via `extractCard`. We map `RichMenu → CardElement` once, transport-agnostic. **This eliminates v2 §2.3 entirely** and unifies with what telegram already does. +> - **`bot.onAssistantThreadStarted` is a real public method on `ChatInstance`** (`chat-D9UYaaNO.d.ts:2913`). The iter-2 reviewer was wrong on this; the protected method they cited is on the Slack adapter, not the public `ChatInstance`. Verified. +> - **`AdapterPostableMessage` is `string | { raw } | { markdown } | { ast } | { card } | CardElement`** — there is no `blocks` field on the Chat SDK's input. v2's `{ blocks, text }` shape was wrong. v3 uses `{ card }`. +> - Streaming polish: abort handling, initial-post failure recovery, throttled overflow. +> - `req.session` numeric-regex parsing in `ipc/handler.ts` is widened to use `ownsChatId`. +> - Cron scheduler/runner notifyFn signature widened to `(string | number)[]`. +> - `pairingComplete` race spelled out: gate is per-transport AND `composite.handlePairing` is only called if any transport reports pending. +> - `ChatThread.post` signature reconciled with what adapters accept (the SDK already accepts the union we want). +> - Section "What's still unverified" removed; everything in v3 is anchored to the version-pinned `.d.ts`. + +> **Memory note:** Chat SDK types may drift between versions. Every type/interface shape in this plan is anchored to `chat@4.29.0` / `@chat-adapter/slack@4.29.0` / `@chat-adapter/telegram@4.29.0`. Re-verify against the same versions before implementation; don't quote shapes from memory. + +## Overview + +Add Slack as a first-class chat transport alongside Telegram, using the existing `TransportAdapter` contract in `src/transports/types.ts`. Two design decisions are fixed up front: + +1. **Socket mode only (v1).** WebSocket connection — no public URL needed. Single Slack workspace per gateway. Best fit for self-hosted deployments. +2. **Multi-transport composition.** One gateway instance can run Telegram and Slack simultaneously. Routing between them is owned by a new `CompositeTransportAdapter`; the gateway code keeps reading like it does today (`this.transport.foo()`). + +The work is split into 5 phases. Each phase is meant to land as its own PR so reviews stay tractable. + +--- + +## Phase 0 — Bump Chat SDK to 4.29 *(separate PR, low-risk)* + +### Why first +- We're 3 minor versions behind (locked at 4.26.0; latest 4.29.0). +- 4.29 release notes call out "previous type names kept as deprecated aliases" — backwards-compatible. +- Unlocks `@chat-adapter/slack@4.29.0` matched to the same release as the rest of the SDK. +- Unlocks `@chat-adapter/tests@4.29.0` (Vitest factories + matchers like `toHavePosted`) for Phase 4 — **but** we have a fallback if it doesn't exist (see Phase 4). + +### Changes +- `package.json`: bump `chat`, `@chat-adapter/telegram`, `@chat-adapter/state-memory` to `^4.29.0`. +- `npm install` to refresh `package-lock.json`. + +### Verification +- `npm test` passes (existing suite). +- **Diff `node_modules/@chat-adapter/telegram/dist/index.d.ts` between 4.26 and 4.29.** Look at the `Adapter` generic, telegramFetch shape, and `processUpdate`. Note any signature drift (the reviewer flagged this as more important than the `video_note` change). +- Verify the bot's own messages don't echo back (Slack and Telegram both do central isMe filtering — see Phase 2 risk #6 for the eager `auth.test` mitigation). +- Smoke test: start gateway with current Telegram setup, exchange a few messages, run `/status`, `/compact`, `/cancel`. Verify streaming still works end-to-end. + +### Risks +- 4.29 changed adapter internals from `private` to `protected`. We don't subclass, so no runtime impact. +- Telegram now handles `video_note` round videos as video attachments — previously dropped silently. Verify `saveAttachments` doesn't choke on the new mediaType. + +--- + +## Phase 1 — Multi-transport refactor *(no Slack code yet)* + +The codebase already has good seams; this phase finishes the job so adding the second transport doesn't require touching `gateway.ts`. + +### 1.1 Widen ID types from `number` to `string | number` + +Slack IDs are strings (`U02XXXX` for users, `C03XXXX` / `D03XXXX` for channels). Telegram IDs are numbers. **The full sweep (verified line by line) is bigger than v1 estimated:** + +| File | Today | Change | +|---|---|---| +| `src/types.ts` `GatewayConfig.chat.allowedUserIds` | `number[]` | `(string \| number)[]` | +| `src/types.ts` `GatewayConfig.chat.notifyChatIds` | `number[]` | `(string \| number)[]` | +| `src/transports/types.ts` `PairingResult` | already `string \| number` ✓ | no change | +| `src/transports/types.ts` `TransportAdapter.createThread(chatId: number)` | numeric | `createThread(chatId: string \| number)` | +| `src/transports/types.ts` `TransportAdapter.notify(chatIds: number[], …)` | numeric | `notify(chatIds: (string \| number)[], …)` | +| `src/util.ts:50-65` `isAllowed(allowedUserIds?: number[])` | `parseInt(author.userId, 10)` then `.includes(numericId)` | accept `(string \| number)[]`; compare via dual lookup: numeric path for tg-style numeric strings, raw-string path for slack-style `Uxxx` | +| `src/util.ts:50` signature | `allowedUserIds?: number[]` | `allowedUserIds?: (string \| number)[]` | +| `src/gateway/gateway.ts:113` | `Number(rawThreadId)` then `Number.isFinite` guard | preserve string when transport says ID is a string; coerce numbers only when both raw values are numeric | +| `src/gateway/gateway.ts:122-141` | numeric persistence in config | persist as-is (JSON handles either) | +| `src/gateway/gateway.ts:329-333` `notifyFn: (chatIds: number[], …)` | numeric | `(string \| number)[]` | +| `src/gateway/gateway.ts:352` `Number(status.routing?.chatId)` | NaN-coerces Slack ID | drop the `Number()` cast; pass through as `string \| number`; route via `transport.notify([id], msg)` (composite partitions by `ownsChatId`) | +| `src/gateway/gateway.ts:978-1001` `notifyStartup` `Number(chatId) < 0` | telegram negative-id semantics | move "group:" detection into `TelegramAdapter.formatNotifySession(id)` (returns `"main" \| "group:N"`); Slack always returns `"main"` | +| `src/gateway/gateway.ts:1017-1021` `fireBootTurn(chatIds[0])` | telegram-only assumption | **partition `notifyChatIds` by `ownsChatId`, fire one boot turn per transport that has a primary chat id**; the gateway iterates the partitioned map | +| `src/cli/subagent-command.ts:30-38` hardcoded `` `telegram:${chatId}:main` `` | telegram-only | resolve transport by `ownsChatId`; encode `parentThreadId` per transport (Slack: `slack:${channel}:${threadTs ?? "main"}`; Telegram: `telegram:${chatId}:main`); the encoding lives behind a `transport.encodeParentThreadId(chatId)` method | +| `src/ipc/handler.ts:17-30` numeric coercions in notify routing | `targetIds: number[]`; `req.session` matched via `/^-?\d+$/` regex | (a) widen `targetIds: (string \| number)[]`; (b) replace numeric regex with `transport.ownsChatId(req.session)` so a Slack `Cxxx` / `Dxxx` session value is treated as a single-target id, not falling through to "send to all"; (c) `transport.notify` already partitions by `ownsChatId` so no further work after this site | +| `src/cron/scheduler.ts:44` `notifyFn?: (chatIds: number[], text: string) => Promise` | numeric | `(string \| number)[]`; same change in `cron/runner.ts` constructor signature | +| `src/cron/runner.ts` `defaultChatIds` | `number[]` | `(string \| number)[]` | +| `src/cron/scheduler.ts` (notifyChatIds plumbing) | numeric | propagate `(string \| number)[]` | +| `src/transports/telegram/notify.ts` `sendTelegramMessage(chatId: string \| number, …)` | already `string \| number` ✓ | no change (already widened) | + +**Tests to update / add:** `config.test.ts`, `gateway-helpers.test.ts`, `setup.test.ts`. Add cases mixing string and number IDs in the same allowlist; add a test that proves `isAllowed` matches a numeric-string-encoded telegram user id (`"12345"`) AND a slack `Uxxx` id without crossing wires. + +### 1.1.5 Reconcile `ChatThread.post` signature with what adapters actually accept + +The current `src/transports/types.ts` declares: +```ts +export interface ChatThread { + id: string; + post(text: string): Promise; + [key: string]: unknown; +} +``` + +But the SDK's real `Thread.post` (per `chat@4.29.0` `chat-D9UYaaNO.d.ts:100, 298`): +```ts +post(message: string | AdapterPostableMessage | AsyncIterable | ChatElement): Promise; +``` + +Our local `ChatThread` interface is a structural lie — narrower than reality. Widen it so transports can pass `{ markdown }` and `{ card, fallbackText }` without `as any` casts: + +```ts +export interface ChatThread { + id: string; + post(message: string | { markdown: string } | { card: unknown; fallbackText?: string }): Promise; + [key: string]: unknown; +} +``` + +Keep `unknown` for the card field (the actual `CardElement` type is `chat@4.29.0`-specific; we don't want to import it into the local interface and tie it to a particular SDK version). Adapters that touch `card` cast at the boundary the same way `TelegramAdapter.postRich` casts to access `adapter.telegramFetch`. + +**`enrichPrompt(thread, text)` call-site inventory (verified before locking the signature change):** the callers are `Gateway.prepareAgentMessage` (gateway.ts:666) and any synthetic-thread path that goes through `prepareAgentMessage`. Boot turn (gateway.ts:1026), sub-agent injection (gateway.ts:1082), and cron notifications (`cron/runner.ts`) all build a thread via `transport.createThread()` first and pass that thread into the agent-turn flow — so they all already have a `thread` to pass. **No additional callers**; the change is mechanical. + +### 1.2 Add `ownsChatId(id)` and `encodeParentThreadId(chatId)` to `TransportAdapter` + +```ts +// src/transports/types.ts +export interface TransportAdapter { + // …existing methods… + + /** + * Pure shape check — return true iff this transport recognizes the given + * chat ID format. No I/O. + * + * Telegram: typeof id === "number" || /^-?\d+$/.test(String(id)) + * Slack: typeof id === "string" && /^[CDGU]/.test(id) + * + * The composite uses this to partition `notifyChatIds` and route notify(). + */ + ownsChatId(id: string | number): boolean; + + /** + * Build a synthetic "parent thread id" string for sub-agent / cron routing + * from a single chat id. Encodes the platform prefix and any extra + * coordinates the transport needs (Slack needs threadTs; "main" sentinel is + * acceptable for top-level channel posts). + * + * Telegram: `telegram:${chatId}:main` + * Slack: `slack:${channelId}:main` (top-level; threadTs filled when known) + */ + encodeParentThreadId(chatId: string | number): string; + + /** + * Format a chat id for human-facing "session: …" labels in startup notifications. + * Replaces the inline `Number(chatId) < 0 ? "group:..." : "main"` logic + * that only made sense for Telegram. + */ + formatNotifySession(chatId: string | number): string; +} +``` + +Update `TelegramAdapter` to implement all three. Slack will implement them in Phase 2. + +### 1.3 Extract `chatAdapterFactories` registry + +`src/gateway/gateway.ts:61-74` has a hardcoded `if (config.telegram)` block. Replace with a registry so adding a transport is one line: + +```ts +// src/transports/chat-adapters.ts (NEW) +type ChatAdapterFactory = (config: Record) => unknown; + +export const chatAdapterFactories: Record Promise> = { + telegram: async () => { + const { createTelegramAdapter } = await import("@chat-adapter/telegram"); + return (cfg) => createTelegramAdapter({ mode: (cfg.mode as any) ?? "auto" }); + }, + slack: async () => { + const { createSlackAdapter } = await import("@chat-adapter/slack"); + return (cfg) => createSlackAdapter({ + mode: (cfg.mode as any) ?? "socket", + // tokens come from env — the SDK auto-reads SLACK_BOT_TOKEN / + // SLACK_APP_TOKEN, so we don't pass them explicitly unless overridden + }); + }, +}; +``` + +`buildChatAdapters` becomes a loop over the configured keys. **Failure mode:** if a configured key has no factory, throw at startup — never silently drop a transport the user expected. + +### 1.4 Composite TransportAdapter + +``` +src/transports/composite.ts (NEW, ~200 LoC — heavier than v1's 150 estimate) +``` + +Implements `TransportAdapter` over `Map`. Routing rules: + +| Method | Routing | +|---|---| +| `enrichPrompt(thread, text)` | by `ownsThread(thread)` (signature changes to add `thread`) | +| `postMessage`, `postRich`, `progress`, `stream` | first delegate where `ownsThread(thread) === true` | +| `createThread(platform, chatId)` | by `platform` arg (caller picks; signature change) | +| `notify(chatIds, text)` | partition `chatIds` by `ownsChatId`, fan out to each delegate | +| `registerCommands()` | call all delegates (each self-sources its creds) | +| `ownsThread(thread)` | true if any delegate claims it | +| `ownsChatId(id)` | true if any delegate claims it | +| `encodeParentThreadId(chatId)` | first delegate where `ownsChatId(chatId)` | +| `formatNotifySession(chatId)` | first delegate where `ownsChatId(chatId)` | +| `isPairingPending()` | `Promise.all` → `.some()` | +| `handlePairing(thread, message)` | first delegate that returns non-null; **does not short-circuit on `pairingComplete`** (see 1.6) | +| `dispose()` | `Promise.all` | +| `shouldIgnoreMessage(text, message, thread)` | first delegate that owns the thread, or `false` if none | + +Failure mode: if no delegate owns a thread for `postMessage`/`postRich`/`stream`/`progress`, log + drop. This matches the existing "best-effort post" model. + +**Note on signature changes:** +- `enrichPrompt(thread, text)` — `thread` arg added. Callers are `prepareAgentMessage` (gateway.ts) and any synthetic-thread caller (boot turn, cron, sub-agent inject). Verify the synthetic threads carry transport-identifying ids (`thread.id` starts with `telegram:` / `slack:`) so `ownsThread()` resolves correctly. **Add a test:** `composite.enrichPrompt(syntheticTelegramThread, text)` routes to TelegramAdapter; same for Slack. +- `createThread(platform, chatId)` — `platform` arg added so the caller picks explicitly (gateway has multiple, doesn't have to guess from `chatId` alone). + +### 1.5 `registerCommands()` self-sources its credentials + +`gateway.ts:937-941` reads `TELEGRAM_BOT_TOKEN` and passes it to `transport.registerCommands(token)` after gating on `if (!this.config.chat.adapters.telegram) return`. Both checks belong in the adapter, not the gateway. + +```ts +// TransportAdapter +registerCommands(): Promise; // signature change: no token arg +``` + +`TelegramAdapter.registerCommands` early-returns when `process.env.TELEGRAM_BOT_TOKEN` is missing. `SlackAdapter.registerCommands` is a no-op (slash commands live in app manifest, not at runtime). + +`Gateway.registerBotCommands` (gateway.ts:937) becomes: + +```ts +private async registerBotCommands() { + await this.transport.registerCommands(); // composite fans out +} +``` + +The telegram-only gate is gone — composite calls all delegates; each delegate owns its own preconditions. + +### 1.6 Per-transport `pairingComplete` (not a single boolean) + +`gateway.ts:83` has `private pairingComplete = false`. With composite, the user could pair Telegram now and Slack later. A single boolean gets stuck `true` after the first transport pairs and the second's `isPairingPending()` is silently ignored. + +**Fix:** +```ts +// Gateway +private pairingComplete = new Map(); // keyed by transport name +``` + +The gateway's incoming-message hook (`gateway.ts:245`) currently reads: + +```ts +if (!this.pairingComplete && await this.transport.isPairingPending()) { … } +``` + +In v3, this becomes: + +```ts +const ownerName = this.transport.delegates.find(d => d.ownsThread(thread))?.name; +if (ownerName && !this.pairingComplete.get(ownerName)) { + if (await this.transport.isPairingPending()) { + const handled = await this.handlePendingPairing(message, thread); + if (handled) return; + } +} +``` + +Two pieces this requires: + +1. **No new method** — the composite already walks delegates; the gateway can find the owning transport via: + ```ts + const owner = composite.delegates.find(d => d.ownsThread(thread))?.name; + ``` + Expose `delegates: ReadonlyArray` on the composite (or a `forEach`/`find` helper if direct exposure feels too leaky). v3 prefers this over introducing `identifyOwner` — fewer methods to test, same semantics. +2. `CompositeTransportAdapter.handlePairing(thread, message): Promise<(PairingResult & { transport: string }) | null>` — returns the delegate name alongside the result. After success, `gateway.handlePendingPairing` sets `this.pairingComplete.set(result.transport, true)`. + +**Race scenario the iter-2 reviewer asked about:** both transports pending, Telegram event arrives first. +- `identifyOwner(telegramThread) → "telegram"`. Gate is open (`!pairingComplete.get("telegram")`). +- `transport.isPairingPending()` returns true (Telegram's pairing file still pending). +- `handlePairing(telegramThread, message)` walks delegates in order. Telegram delegate matches; Slack delegate sees a thread it doesn't own and short-circuits via `ownsThread === false` → returns null. Composite returns `{ transport: "telegram", … }`. +- Gateway sets `pairingComplete.set("telegram", true)`. Slack is still pending, untouched. +- Later, Slack event arrives. `identifyOwner → "slack"`. `pairingComplete.get("slack")` is unset → falsy → gate is open. Pairing proceeds. + +Resolved cleanly. Add a test in `composite-transport.test.ts` that exercises this exact sequence. + +This is a semi-breaking change to the `handlePairing` return type. Old `PairingResult` callers (only the gateway) are updated. + +### 1.7 Move `/start` filter into transport via `shouldIgnoreMessage` + +`gateway.ts:255` short-circuits `if (_isCmd(userText, "/start", _botUsername))` — Telegram-specific (`/start ` is the BotFather pairing handshake). Slack has no `/start` semantics. + +```ts +// TransportAdapter +/** Pre-handler hook: return true to drop the message before any other gateway logic. */ +shouldIgnoreMessage?(text: string, message: IncomingMessage, thread: ChatThread): boolean; +``` + +Telegram implements it (returns true for `/start`). Slack omits it. Composite routes by `ownsThread(thread)`. + +### 1.8 Constructor wiring + +```ts +// src/gateway/gateway.ts (constructor) +constructor(router: AgentRouter, config: GatewayConfig) { + this.router = router; + this.config = config; + this.transport = buildCompositeTransport(config.chat.adapters); + _botUsername = config.chat.botUsername || ""; +} +``` + +`buildCompositeTransport` looks at the config keys and instantiates one `TransportAdapter` per configured platform, then wraps in `CompositeTransportAdapter`. **Always wrap**, even with one transport — the test harness expects a uniform interface, and the composite overhead is negligible (one map lookup per call). + +### Phase 1 checklist +- [ ] Widen `allowedUserIds` / `notifyChatIds` types in `src/types.ts` +- [ ] Update `isAllowed` (`src/util.ts`): dual lookup (numeric path + raw-string path); preserve telegram numeric-id matching +- [ ] Add `ownsChatId()`, `encodeParentThreadId()`, `formatNotifySession()` to `TransportAdapter` interface +- [ ] Implement those three on `TelegramAdapter` +- [ ] Add optional `shouldIgnoreMessage()` hook +- [ ] Move `/start` filter into `TelegramAdapter.shouldIgnoreMessage` +- [ ] Change `enrichPrompt(thread, text)` signature +- [ ] Change `createThread(platform, chatId)` signature +- [ ] Change `registerCommands()` signature (no token arg); move `TELEGRAM_BOT_TOKEN` gate into `TelegramAdapter.registerCommands` +- [ ] Change `handlePairing` return type to include `transport: string` +- [ ] Replace `pairingComplete: boolean` with `Map` in Gateway +- [ ] Fix `gateway.ts:113` (preserve string IDs) +- [ ] Fix `gateway.ts:329-333` (notifyFn signature) +- [ ] Fix `gateway.ts:352` (drop `Number()` cast on sub-agent chatId) +- [ ] Fix `gateway.ts:978-1001` (move negative-id detection into `formatNotifySession`) +- [ ] Fix `gateway.ts:1017-1021` (partition boot-turn chatIds by transport) +- [ ] Fix `cli/subagent-command.ts:30-38` (use `transport.encodeParentThreadId`) +- [ ] Fix `ipc/handler.ts:17-30` (accept and partition mixed-type ids; replace `/^-?\d+$/` regex with `transport.ownsChatId(req.session)` so Slack `Cxxx` / `Dxxx` sessions route correctly) +- [ ] Update `cron/scheduler.ts:44` and `cron/runner.ts` `notifyFn` signature to `(string \| number)[]` +- [ ] **Widen `TelegramAdapter.createThread.post`** (`src/transports/telegram/telegram-adapter.ts:196`) to match the new `ChatThread.post` shape: `string \| { markdown: string } \| { card: unknown; fallbackText?: string }`. Route `{ card }` through the SDK's `postMessage` (which already handles `PostableCard` via `extractCard`) rather than the existing direct `telegramFetch("sendMessage", …)` path. If migration would balloon Phase 1, gate `{ card }` to throw "telegram createThread.post does not yet accept card" so type checks pass and tests catch the gap; the Phase 2 "bonus refactor" then completes the migration. Pick the simpler of the two during implementation. +- [ ] Tighten `fireBootTurn` partition logic: for each transport that owns at least one chatId in `notifyChatIds`, fire one boot turn against the *first* chatId owned by that transport (not the global `chatIds[0]`). Document this as "primary chatId per transport." +- [ ] Create `src/transports/composite.ts` +- [ ] Create `src/transports/chat-adapters.ts` factory registry +- [ ] Update `Gateway` constructor + `buildChatAdapters` +- [ ] Update tests: `config.test.ts`, `gateway-helpers.test.ts`, `unit.test.ts`, `setup.test.ts` +- [ ] Add `composite-transport.test.ts` covering: routing by `ownsThread`, partitioned `notify`, dispose fan-out, `handlePairing` per-transport completion +- [ ] Smoke test: telegram still works end-to-end + +### Phase 1 risks +- The signature changes ripple through tests and the gateway. **Mitigation:** do them in one commit so the type system catches every call site. +- `isAllowed` is in the auth hot path — one regression here breaks auth. **Mitigation:** add property-based tests with mixed-type allowlists. Specific case: telegram user id `12345` (number) + slack user id `"U02ABC"` (string) in the same `allowedUserIds`, both must match their respective platforms. +- Migration of old `telegram-pairing.json` files: those store `userId: number` and `chatId: number`. The widened union accepts those untouched — no migration needed at the file level. But the in-memory `allowedUserIds` array now mixes types; numeric comparisons must use a helper, not raw `===`. + +--- + +## Phase 2 — Slack adapter + +Single workspace, socket mode. Implements the same `TransportAdapter` contract as `TelegramAdapter`. Mirrors the telegram directory layout for parity and DRY. + +### Files + +``` +src/transports/slack/ +├── slack-adapter.ts (~300 LoC) — TransportAdapter impl +├── format.ts (~100 LoC) — markdown helpers, mention rewriting, mrkdwn fallback for menus +├── pairing.ts (~140 LoC) — first-DM pairing state with assistant_thread_started fallback +├── notify.ts (~80 LoC) — chat.postMessage wrapper (matches SDK defaults) +├── progress.ts (~100 LoC) — chat.postMessage + chat.update for editable progress +├── rich-ui.ts (~80 LoC) — RichMenu → Block Kit blocks +├── streaming.ts (~150 LoC) — streaming with post-then-edit fallback (see 2.7) +└── manifest.yaml (static) — Slack app manifest for setup CLI to print +``` + +**Updated effort estimate:** ~950 LoC (was 700; the streaming fallback alone is ~150 LoC). + +### 2.1 `slack-adapter.ts` + +Three facts anchored to `@chat-adapter/slack@4.29.0` and `chat@4.29.0`: + +**A. Slack thread ID is `slack:CHANNEL:THREAD_TS`.** Per `@chat-adapter/slack@4.29.0` `index.d.ts:866-892`: +```ts +encodeThreadId(platformData: { channel: string; threadTs: string }): string; +decodeThreadId(threadId: string): { channel: string; threadTs: string }; +isDM(threadId: string): boolean; // checks if channel starts with 'D' +channelIdFromThreadId(threadId: string): string; +``` + +We MUST use `adapter.encodeThreadId()` to construct synthetic threads. We also MUST use `adapter.channelIdFromThreadId(thread.id)` rather than parsing `thread.id.split(":")[1]` manually. This is the same pattern telegram uses (`thread.adapter.telegramFetch` is opaque-by-design). + +**B. There is no `blocks` field on `AdapterPostableMessage`.** The shape per `chat@4.29.0` `chat-D9UYaaNO.d.ts:1549`: + +```ts +type AdapterPostableMessage = + | string + | PostableRaw // { raw: string, attachments?, files? } + | PostableMarkdown // { markdown: string, attachments?, files? } + | PostableAst // { ast: Root, attachments?, files? } + | PostableCard // { card: CardElement, fallbackText?, files? } + | CardElement; +``` + +**Use `{ card }` for menus.** The Slack adapter's `cardToBlockKit` (`index.d.ts:73`) converts a `CardElement` into Block Kit blocks internally; the Telegram adapter does the same conversion to inline keyboards via `extractCard`. **Do not write a custom Block Kit converter.** + +**C. `bot.onAssistantThreadStarted(handler)` is a public method on `ChatInstance`** (`chat-D9UYaaNO.d.ts:2913`): + +```ts +onAssistantThreadStarted(handler: AssistantThreadStartedHandler): void; +onAssistantContextChanged(handler: AssistantContextChangedHandler): void; +onAppHomeOpened(handler: AppHomeOpenedHandler): void; +onMemberJoinedChannel(handler: MemberJoinedChannelHandler): void; +``` + +The iter-2 reviewer flagged this as missing because they were looking at the Slack adapter's `protected handleAssistantThreadStarted` (which is internal). The public registration path lives on the core `Chat` class. + +Sketch: + +```ts +export class SlackAdapter implements TransportAdapter { + readonly name = "slack"; + + // Holds the @chat-adapter/slack instance after Chat SDK initialize(). + // Populated by an attach() method the gateway calls after chat.initialize(). + private slackSdk: import("@chat-adapter/slack").SlackAdapter | null = null; + + attach(slackSdk: import("@chat-adapter/slack").SlackAdapter): void { + this.slackSdk = slackSdk; + } + + enrichPrompt(thread: ChatThread, text: string): string { + return `${text}\n\n${SLACK_FORMAT_HINT}`; + } + + async postMessage(thread: ChatThread, text: string): Promise { + // Chat SDK adapter accepts { markdown } natively; emits Slack markdown_text. + await thread.post({ markdown: text }); + } + + async postRich(thread: ChatThread, response: RichResponse): Promise { + if (!response.menu) { + await this.safePostText(thread, response.text); + return; + } + try { + const body = response.menuCaption ?? response.text; + // Build a transport-agnostic Card. The Slack adapter's cardToBlockKit + // converts this to Block Kit; Telegram's extractCard does the same to + // inline keyboards. We never touch Block Kit JSON ourselves. + const card = richMenuToCard(response.menu, body); + await thread.post({ card, fallbackText: stripMarkdownToPlain(body) }); + } catch (err) { + console.warn("[roundhouse] slack postRich failed:", err); + await this.safePostText(thread, response.text); + } + } + + progress(thread: ChatThread, initialText: string): Promise { + return createSlackProgress(this.requireSdk(), thread, initialText); + } + + async stream(thread: ChatThread, iter: AsyncIterable): Promise { + return handleSlackStream(this.requireSdk(), thread, iter); // see 2.7 + } + + async registerCommands(): Promise { + // No-op — Slack slash commands live in the app manifest, not at runtime. + } + + ownsThread(thread: ChatThread): boolean { + return typeof thread?.id === "string" && thread.id.startsWith("slack:"); + } + + ownsChatId(id: string | number): boolean { + return typeof id === "string" && /^[CDGU]/.test(id); + } + + encodeParentThreadId(chatId: string | number): string { + // Top-level posts: use "main" as a sentinel threadTs; postChannelMessage + // is used at send time so the threadTs is irrelevant for outbound. + return `slack:${chatId}:main`; + } + + formatNotifySession(chatId: string | number): string { + // Slack channel ids start with C (public), G (private), D (DM), U (user/IM). + // Map to a consistent "main" / "channel:Cxxx" / "dm:Dxxx" label. + const id = String(chatId); + if (id.startsWith("D")) return "main"; + if (id.startsWith("C") || id.startsWith("G")) return `channel:${id}`; + return "main"; + } + + createThread(chatId: string | number): ChatThread { + // Synthetic thread for boot/cron/sub-agent paths. + // We use postChannelMessage at send-time (no thread_ts), so the + // synthetic thread carries an empty-string threadTs sentinel. + const sdk = this.requireSdk(); + const channelId = String(chatId); + const threadId = sdk.encodeThreadId({ channel: channelId, threadTs: "" }); + return { + id: threadId, + // Expose the SDK adapter under a slack-specific key for postRich / + // progress / stream to narrow on (mirrors telegram's `adapter.telegramFetch`). + adapter: { slack: sdk }, + // Signature matches the widened ChatThread.post (see §1.1.5): + // string | { markdown } | { card, fallbackText? }. No blocks/text shapes + // — those are not in AdapterPostableMessage and would be silently + // dropped or rejected by the adapter. + post: async (content: string | { markdown: string } | { card: unknown; fallbackText?: string }) => { + // postChannelMessage = top-level post (no thread_ts). For replying + // inside a Slack thread we'd use postMessage with a real threadId. + await sdk.postChannelMessage(channelId, content as AdapterPostableMessage); + }, + startTyping: async () => { + // Generic "Typing..." indicator — no scope requirement. + try { await sdk.startTyping(threadId); } catch {} + }, + }; + } + + async notify(chatIds: (string | number)[], text: string): Promise { + const slackIds = chatIds.filter(id => this.ownsChatId(id)); + if (slackIds.length === 0) return; + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + console.warn("[roundhouse] SLACK_BOT_TOKEN not set — skipping slack notification"); + return; + } + for (const id of slackIds) { + await postSlackMessage(token, String(id), text); + } + } + + async isPairingPending(): Promise { /* read slack-pairing.json */ } + async handlePairing(thread, message) { /* see 2.4 */ } + shouldIgnoreMessage() { return false; } // no /start equivalent + + private requireSdk() { + if (!this.slackSdk) throw new Error("SlackAdapter not attached to Chat SDK yet"); + return this.slackSdk; + } + + private async safePostText(thread: ChatThread, text: string): Promise { + try { await this.postMessage(thread, text); return; } catch {} + try { await thread.post(text); } catch (err) { + console.error("[roundhouse] slack safePostText: all paths failed:", err); + } + } +} +``` + +Note: `attach(slackSdk)` is called by the gateway after `chat.initialize()` — Chat SDK exposes adapter instances via `bot.getAdapter("slack")`. The gateway runs once: `if (this.transport instanceof CompositeTransportAdapter) compositeAttachSlack(this.transport, this.chat.getAdapter("slack"))`. + +### 2.2 Format strategy + +**Outgoing plain messages (no menu):** `{ markdown: text }` — Chat SDK adapter renders to Slack `markdown_text` natively. **No converter needed.** + +**Outgoing menus:** `{ card }` — the Slack adapter renders to Block Kit internally. Card prose is markdown via `Text({ style: "muted"|"bold"|"plain" })` and `Section` elements. Adapter handles platform-specific escaping. No mrkdwn converter on our side. + +**Outgoing length limit:** +- Plain `postMessage`: `markdown_text` is capped at 12,000 chars. Chunk at 12,000 with newline-preferred split (mirror `splitMessage` in `src/util.ts`). +- Cards: Slack's section block is 3,000 chars per block; the adapter chunks long text into multiple sections. Verify with a test that posts a 50,000-char body inside a card. +- Menus: `actions` block holds at most 5 elements; the adapter chunks buttons across multiple actions blocks. Verify the SDK actually does this (one quick spike); if not, our `richMenuToCard` chunks button groups itself. + +**Incoming:** Chat SDK adapter parses Slack mrkdwn into AST. We don't see raw mrkdwn. + +**`format.ts` content** (~40 LoC, much smaller than v2 estimated): +- `richMenuToCard(menu, prose): CardElement` — see §2.3. +- `stripMarkdownToPlain(md): string` — used as `fallbackText` for `PostableCard` (one-line summary for clients that can't render cards). Reuse `markdownToPlainText` from `chat@4.29.0` (`chat-D9UYaaNO.d.ts:714` exports it). + +### 2.3 Map `RichMenu` to the SDK's transport-agnostic `Card` model (`rich-ui.ts`) + +This subsection in v2 was a Slack-specific Block Kit converter. v3 deletes it: the Chat SDK already provides a transport-agnostic Card model that both the Slack and Telegram adapters convert internally. + +`CardElement` shape, anchored to `chat@4.29.0` `jsx-runtime-CFq1K_Ve.d.ts:150-243`: + +```ts +interface CardElement { + type: "card"; + title?: string; + subtitle?: string; + imageUrl?: string; + children: CardChild[]; // TextElement | ActionsElement | SectionElement | ... +} + +interface ButtonElement { + type: "button"; + id: string; // ← maps to action_id (Slack) / callback_data (Telegram) + label: string; + value?: string; // payload sent to onAction handler + style?: ButtonStyle; + actionType?: "action" | "modal"; + callbackUrl?: string; + disabled?: boolean; +} + +interface ActionsElement { + type: "actions"; + children: (ButtonElement | LinkButtonElement | SelectElement | RadioSelectElement)[]; +} +``` + +The mapping is then trivial: + +```ts +// src/transports/rich-helpers.ts (NEW or extend existing) +import { Card, Section, Text, Actions, Button } from "chat"; +import type { CardElement } from "chat"; +import type { RichMenu, RichButton } from "./types"; + +export function richMenuToCard(menu: RichMenu, headerProse?: string): CardElement { + const children: CardChild[] = []; + if (headerProse) { + children.push(Section([Text(headerProse)])); // adapter renders markdown + } + for (const section of menu.sections) { + const sectionChildren: CardChild[] = []; + if (section.title) sectionChildren.push(Text(section.title, { style: "bold" })); + sectionChildren.push(Actions(section.buttons.map(richButtonToButton))); + children.push(Section(sectionChildren)); + } + return Card({ children }); +} + +function richButtonToButton(btn: RichButton): ButtonElement { + return Button({ + id: btn.actionId, // ← Slack maps to action_id; Telegram maps to callback_data + label: btn.label, + value: btn.value, + ...(btn.selected ? { style: "primary" } : {}), + }); +} +``` + +This single helper now serves **both** transports. Telegram's existing `rich-ui.ts:toTelegramInlineKeyboard` (currently called from `telegram-adapter.ts:postRich`) becomes redundant — instead, telegram's `postRich` also goes through `thread.post({ card })` and the `@chat-adapter/telegram` adapter's `extractCard` does the conversion. **This unifies the menu-rendering path across transports.** + +> **Bonus refactor opportunity for Phase 1:** convert `TelegramAdapter.postRich` to use `{ card }` first, then extend the same path to Slack in Phase 2. Defer to Phase 1 only if it stays a contained 1-day diff; otherwise leave telegram on its current `toTelegramInlineKeyboard` path and let Slack be the first user of the unified card path. Either way, Slack uses `{ card }` from day one. + +Slack `block_actions` events arrive via the WebSocket; the Chat SDK fires them as `chat.onAction(actionId, handler)` events — same API the telegram inline keyboard already uses (`gateway.ts:311`). **Zero changes to the action-dispatch layer in the gateway.** Verified: `@chat-adapter/slack@4.29.0` `index.d.ts:625` `dispatchInteractivePayload` is the SDK plumbing that emits these. + +**Action-id collision question (raised in iter-2 review):** when both Telegram and Slack run in the same gateway, are `chat.onAction("model")` callbacks fired correctly? The action handler receives `event.thread` (a `Thread` instance). The thread's `id` carries the platform prefix (`telegram:…` / `slack:…`) so the handler can route by `transport.ownsThread(event.thread)` if it ever needs to differentiate. Today's handlers don't need to — they only call `transport.postRich(event.thread, …)`, which composite already routes correctly. **Add a test** in `composite-transport.test.ts` that registers an `onAction("test")` handler, fires it from both a fake-telegram thread and a fake-slack thread, and verifies the handler sees the correct `event.thread.id` in each case. + +### 2.4 Pairing (`pairing.ts`) + +The "first DM from allowed user" model has a chicken-and-egg gap: Slack `message.im` events only fire for *existing* DM channels. Until the user opens a DM with the bot, we can never see a message. + +**Three-pronged approach:** + +1. **Setup output makes it explicit.** The CLI prints: + > To complete pairing, open a new DM with @BOT_NAME in your Slack workspace. (Click the bot name in your sidebar, or search for the bot, then send any message.) The first message from `@your-username` completes pairing. + +2. **Listen to `assistant_thread_started`** — fires when a user opens an Assistant thread DM with the bot (`@chat-adapter/slack@4.29.0` `index.d.ts:288-303` for the event shape; `chat@4.29.0` `chat-D9UYaaNO.d.ts:2913` for the public `bot.onAssistantThreadStarted(handler)` registration). For workspaces with the Assistants API enabled (which we enable in the manifest), this fires *before* the user types anything. We capture `user_id` and `channel_id` from the event payload and complete pairing immediately. Requires in the manifest: + - `oauth_config.scopes.bot += assistant:write` + - `event_subscriptions.bot_events += assistant_thread_started` + - `features.assistant_view` block (NOT a top-level `apps.assistant.enabled` flag — see §3 manifest; the iter-2 reviewer flagged this). + - In the gateway: register `bot.onAssistantThreadStarted(async (event) => transport.handleAssistantThreadStarted(event))` after `chat.initialize()`. The composite routes the call to the Slack delegate (Telegram doesn't implement it). + +3. **Fallback: first message in the DM channel.** If the user posts before opening the assistant thread (or if assistant API isn't enabled in the workspace), `message.im` still works. + +The pairing module covers both: + +```ts +interface PendingSlackPairing { + version: 1; + workspaceTeamId?: string; + botUserId?: string; + allowedUsers: string[]; // Slack usernames (display names normalized) + createdAt: string; + status: "pending" | "paired"; + pairedAt?: string; + channelId?: string; // Dxxx DM channel + userId?: string; // Uxxx user + username?: string; +} + +const PAIRING_PATH = resolve(ROUNDHOUSE_DIR, "slack-pairing.json"); +``` + +`handlePairing` in the adapter is called for both `message.im` and `assistant_thread_started`-derived events. The gateway converts the latter into an `IncomingMessage`-shaped envelope before calling `handlePairing`. The conversion is non-trivial because `AssistantThreadStartedEvent` carries `userId`, `channelId`, `threadTs`, `context.teamId` (`chat-D9UYaaNO.d.ts:2120`) but **not `text`** and **not a populated `author.userName`** — so we must look the user up explicitly: + +```ts +// In gateway start, after attaching adapter +bot.onAssistantThreadStarted(async (event) => { + const slackSdk = bot.getAdapter("slack"); + // Slack adapter exposes lookupUser via getUser() — verified at slack + // index.d.ts:612 (getUser(userId): Promise). + const userInfo = await slackSdk.getUser(event.userId).catch(() => null); + const synthetic: IncomingMessage = { + text: "", + author: { + userId: event.userId, + userName: userInfo?.userName, // populated for allowlist match + name: userInfo?.displayName, + }, + chatId: event.channelId, + raw: event, + }; + // Run through the same composite.handlePairing path as message.im + await this.handlePendingPairing(synthetic, this.transport.createThread("slack", event.channelId)); +}); +``` + +On match (either path): +1. Verify `message.author.userName` is in `allowedUsers` (case-insensitive, strip leading `@`). If `userName` is not yet populated (assistant_thread_started before user lookup completes — handled by the `await` above), allowlist match falls back to `userId` if any user in `allowedUsers` is a `Uxxx` literal. +2. Capture `channelId` and `userId`. +3. Persist `slack-pairing.json` with `status: "paired"` (include `username` if resolved). +4. Return `{ threadId: channelId, userId, username, transport: "slack" }`. + +The gateway's `handlePendingPairing` (gateway.ts:102) widens to handle string IDs (Phase 1.6 already covered this). + +### 2.5 Notify and channel-post (`notify.ts`) + +Mirror telegram's `notify.ts`. The reviewer flagged that we should match the SDK's defaults so notify and gateway-emitted messages render consistently: + +```ts +export async function postSlackMessage( + token: string, + channelId: string, + text: string, + options?: { unfurlLinks?: boolean; mrkdwn?: boolean }, +): Promise { + try { + const res = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + channel: channelId, + markdown_text: text, + unfurl_links: options?.unfurlLinks ?? false, // match SDK default (no unfurl in chat ops) + mrkdwn: options?.mrkdwn ?? true, // match SDK default + }), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + console.warn(`[slack] postMessage to ${channelId} failed (${res.status}): ${errBody.slice(0, 200)}`); + } + return res.ok; + } catch (err) { + console.warn(`[slack] postMessage to ${channelId} failed:`, (err as Error).message); + return false; + } +} + +export async function postSlackToMany( + chatIds: (string | number)[], + text: string, +): Promise { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) return; + const slackIds = chatIds.filter(id => typeof id === "string" && /^[CDGU]/.test(id)); + for (const id of slackIds) await postSlackMessage(token, String(id), text); +} +``` + +### 2.6 Progress messages (`progress.ts`) + +Slack's `chat.update` supports in-place edits. Mirror telegram's `createProgressMessage`: + +```ts +export async function createSlackProgress( + sdk: SlackAdapter, + thread: ChatThread, + initialText: string, +): Promise { + const { channel, threadTs } = sdk.decodeThreadId(thread.id); + const initial = await sdk.webClient.chat.postMessage({ + channel, + markdown_text: initialText, + ...(threadTs && threadTs !== "main" && threadTs !== "" ? { thread_ts: threadTs } : {}), + }); + const ts = initial.ts as string; + + return { + update: async (text: string) => { + try { + await sdk.webClient.chat.update({ + channel, + ts, + markdown_text: text, + }); + } catch { + // ProgressMessage contract: never throw. Telegram's progress.ts + // does the same swallow. + } + }, + }; +} +``` + +### 2.7 Streaming integration + +**Reviewer caught a v1 mistake:** the SDK's native `stream(threadId, textStream, options)` (`index.d.ts:837`) requires `recipientUserId` and `recipientTeamId` in options, AND requires the Slack AI Assistant feature enabled in the manifest. We can't rely on this for v1. + +**Realistic plan: post-then-edit fallback first; native streaming second.** + +The iter-2 review flagged three holes in v2's sketch: (a) `handleOverflow` doesn't honor edit throttling so back-to-back overflows can hit Slack rate limits; (b) no abort signal handling; (c) failed initial post leaves `messageTs=null` forever and every subsequent chunk re-attempts `sendInitial`. v3 closes all three: + +```ts +// src/transports/slack/streaming.ts + +const STREAM_EDIT_INTERVAL_MS = 800; // Slack rate limit ~1 edit/sec; 800ms is safe +const SLACK_TEXT_LIMIT = 12_000; // markdown_text limit +const SLACK_MIN_PUBLIC_LIMIT = 4000; // chunk threshold to ensure clean breaks +const INIT_FAIL_BACKOFF_MS = 1500; // pause before retrying initial send +const MAX_INIT_RETRIES = 3; + +export async function handleSlackStream( + sdk: SlackAdapter, + thread: ChatThread, + stream: AsyncIterable, + signal?: AbortSignal, +): Promise { + const { channel, threadTs } = sdk.decodeThreadId(thread.id); + const replyOpts = (threadTs && threadTs !== "main" && threadTs !== "") + ? { thread_ts: threadTs } + : {}; + + let accumulated = ""; + let messageTs: string | null = null; + let lastEditAt = 0; + let lastSentText = ""; + let committedLength = 0; + let initFailures = 0; + let lastInitAttemptAt = 0; + + const sleepRemaining = async () => { + const wait = STREAM_EDIT_INTERVAL_MS - (Date.now() - lastEditAt); + if (wait > 0) await new Promise(r => setTimeout(r, wait)); + }; + + const sendInitial = async (body: string) => { + if (initFailures >= MAX_INIT_RETRIES) return; // give up; let final flush try + if (Date.now() - lastInitAttemptAt < INIT_FAIL_BACKOFF_MS) return; // backoff + lastInitAttemptAt = Date.now(); + try { + const result = await sdk.webClient.chat.postMessage({ + channel, + markdown_text: body, + ...replyOpts, + }); + messageTs = result.ts as string; + lastSentText = body; + lastEditAt = Date.now(); + initFailures = 0; + } catch (err) { + initFailures++; + console.warn(`[slack/stream] initial post failed (${initFailures}/${MAX_INIT_RETRIES}):`, err); + } + }; + + const editMessage = async (body: string) => { + if (!messageTs || body === lastSentText) return; + try { + await sdk.webClient.chat.update({ channel, ts: messageTs, markdown_text: body }); + lastSentText = body; + lastEditAt = Date.now(); + } catch { + // Slack rejects empty/invalid edits silently — keep streaming. + } + }; + + const handleOverflow = async () => { + const current = accumulated.slice(committedLength); + if (current.length <= SLACK_TEXT_LIMIT) return; + + // Finalize current message at a clean boundary (newline if possible). + // Throttle BEFORE the edit so back-to-back overflows don't hit rate limits. + await sleepRemaining(); + const cutAt = Math.max( + current.lastIndexOf("\n", SLACK_TEXT_LIMIT - 100), + SLACK_MIN_PUBLIC_LIMIT, + ); + const final = current.slice(0, cutAt); + await editMessage(final); + committedLength += cutAt; + messageTs = null; + lastSentText = ""; + }; + + for await (const chunk of stream) { + if (signal?.aborted) break; + accumulated += chunk; + + if (!messageTs) { + const body = accumulated.slice(committedLength); + if (body.trim()) await sendInitial(body); + // If sendInitial failed but we haven't exhausted retries, keep + // accumulating; next chunk's iteration will retry after backoff. + continue; + } + + await handleOverflow(); + if (signal?.aborted) break; + if (Date.now() - lastEditAt >= STREAM_EDIT_INTERVAL_MS) { + await editMessage(accumulated.slice(committedLength)); + } + } + + // Final flush — runs even if signal aborted, so the user sees the last + // partial buffer rather than silent truncation. + const remaining = accumulated.slice(committedLength); + if (!remaining.trim()) return; + + if (messageTs) { + await editMessage(remaining); + } else if (initFailures < MAX_INIT_RETRIES) { + // We never got an initial message id — try one final post unconditionally + // (no backoff gate) so the user isn't left with nothing. + try { + await sdk.webClient.chat.postMessage({ + channel, + markdown_text: remaining, + ...replyOpts, + }); + } catch (err) { + console.error("[slack/stream] final post failed:", err); + } + } +} +``` + +Three changes vs v2: +1. **Throttled overflow:** `await sleepRemaining()` before the overflow edit so a burst of large chunks can't fire two edits in 800ms. +2. **Abort handling:** check `signal?.aborted` at chunk boundaries and after overflow handling. The gateway already wires `AbortController` for `/cancel` (`gateway.ts:476-484`); pass it through. +3. **Initial-post failure recovery:** `initFailures` counter with `INIT_FAIL_BACKOFF_MS` between attempts; cap at `MAX_INIT_RETRIES`. After the limit, stop retrying inside the loop but keep buffering. The final flush gets one more attempt as a last resort. + +The `gateway/streaming.ts:13,134-148` block currently does: +```ts +const useTelegramHtml = isTelegramThread(thread); +// ... if (useTelegramHtml) handleTelegramHtmlStream(thread, ts.iterable) +``` + +Refactor to a `transport.stream(thread, iter, signal)` dispatch instead: +```ts +// streaming.ts (post-refactor) +const streamPromise = transport.stream + ? transport.stream(thread, ts.iterable, signal).catch((err: Error) => { /* … */ }) + : fallbackChunkedPost(thread, ts.iterable); +``` + +`TransportAdapter` gets `stream(thread, iter, signal?): Promise` as a required method. Telegram impl wraps `handleTelegramHtmlStream` (existing helper, will need a small abort-signal threading change too); Slack impl wraps `handleSlackStream`. + +The `signal` is already available in the gateway: `Gateway.handleAgentTurn` creates one per turn at `gateway.ts:476-484` and passes it to `handleStreaming`. Plumb it through `transport.stream`. + +**Native streaming (deferred to v2):** `assistant.threads.streaming` requires AI Assistant features. Add a v2 task to detect when the workspace has them enabled and switch to native, but ship v1 with post-then-edit which works on every Slack workspace. + +### 2.8 Files in attachments + +Slack files require auth on download (`url_private` + bot token in `Authorization`). The Chat SDK's `Attachment.fetchData()` handles this (`index.d.ts:771` `rehydrateAttachment`). `src/gateway/attachments.ts` already calls `attachment.fetchData()` if available — **add a test that exercises this with a mock Slack attachment**, don't refactor blindly. + +### Phase 2 checklist +- [ ] Add `@chat-adapter/slack@^4.29.0` to `package.json` +- [ ] Create `src/transports/slack/` directory with files listed above +- [ ] Implement `SlackAdapter` class with `attach(slackSdk)` integration +- [ ] Implement `richMenuToCard` in `rich-helpers.ts` (transport-agnostic; uses Chat SDK Card model — no per-platform Block Kit converter) +- [ ] Implement `stripMarkdownToPlain` (or reuse `markdownToPlainText` from `chat`) for card `fallbackText` +- [ ] Implement `createSlackProgress` (`progress.ts`) +- [ ] Implement `handleSlackStream` post-then-edit (`streaming.ts`) +- [ ] Implement Slack pairing with both `message.im` and `assistant_thread_started` paths +- [ ] Add `stream()` to `TransportAdapter` interface + telegram + slack impls +- [ ] Refactor `gateway/streaming.ts` to call `transport.stream()` +- [ ] Register `bot.onAssistantThreadStarted` in gateway and route to pairing +- [ ] Register `slack` in `chatAdapterFactories` +- [ ] Wire `SlackAdapter.attach()` after `chat.initialize()` in gateway start +- [ ] Add Slack manifest YAML +- [ ] Eagerly call Slack `auth.test` on gateway start so `botUserId` is populated before subscriptions activate (mitigates self-loop window) +- [ ] Verify attachments work end-to-end (test a real upload) + +### Phase 2 risks +1. **Bot self-loop.** Slack delivers bot messages back through `message.channels` / `message.groups`. SDK central isMe filter relies on `botUserId` being populated, fetched lazily. **Mitigation:** call `slackSdk.webClient.auth.test()` in `gateway.start()` before any subscriptions, and store the result so the filter is always armed before the first event. +2. **AdapterPostableMessage shape** — v3 uses `{ card }` (real shape per `chat@4.29.0` `chat-D9UYaaNO.d.ts:1549`). The v2 `{ blocks, text }` shape was wrong and is not used in v3. +3. **Streaming + cards can't coexist.** Stream API doesn't take cards/blocks. Decision: streaming turns are agent text only; menu turns are command results that don't stream. Document this constraint in CLAUDE.md. If a future feature needs both, finalize the stream then post a separate menu message — that's fine because menus are typically command responses, not mid-conversation. +4. **DM channel discovery.** Pairing assumes the user opened a DM first. The setup CLI tells them to. Assistant-thread fallback covers most cases. **Mitigation:** add a CLI doctor command `roundhouse doctor --slack` that shows current pairing status and a deep link to open the DM. +5. **Chat SDK 4.29 breaking changes.** Re-verify after Phase 0: `Adapter` generic shape, `webClient` getter behavior in single-workspace mode. The Slack adapter d.ts at `index.d.ts:533` says `webClient` throws `AuthenticationError` outside any context in multi-workspace mode — ours is single-workspace, so the static `botToken` path applies and `webClient` works without a context. +6. **Slack manifest schema evolves.** The plan's manifest uses `features.assistant_view`, which is the current field for enabling Assistants API features. Verify against `https://api.slack.com/reference/manifests` before shipping; the v2 plan incorrectly listed a non-existent `apps.assistant.enabled: true` flag. + +--- + +## Phase 3 — `roundhouse setup --slack` CLI + +Mirror `setup --telegram` flow. Files: + +``` +src/cli/setup/slack.ts — Slack-specific helpers (token validation, redaction) +src/cli/setup/flows.ts — add runInteractiveSlackSetup, runNonInteractiveSlackSetup +src/cli/setup/args.ts — accept --slack flag and --slack-bot-token / --slack-app-token +src/cli/setup/steps.ts — reusable steps already platform-agnostic (no change needed) +src/cli/setup/types.ts — extend SetupOptions for slack tokens +``` + +### Flow (interactive) + +``` +roundhouse setup --slack + +① Preflight (shared) — node version, disk, agent install detection +② Print Slack app setup guide: + 1. Go to api.slack.com/apps → "From an app manifest" + 2. Paste the manifest (printed inline; also written to /tmp/roundhouse-slack-manifest.yaml) + 3. Install to your workspace + 4. Enable Socket Mode and generate App-Level Token (xapp-…) + 5. Copy Bot User OAuth Token (xoxb-…) from OAuth & Permissions +③ Masked prompt for SLACK_BOT_TOKEN (xoxb-…); regex-validate prefix before proceeding +④ Masked prompt for SLACK_APP_TOKEN (xapp-…); regex-validate prefix +⑤ Validate tokens via auth.test → returns bot user id, team name, team id +⑥ Prompt for Slack username (the user — for allowlist) +⑦ Install packages (shared with telegram path) +⑧ Write ~/.roundhouse/.env: SLACK_BOT_TOKEN, SLACK_APP_TOKEN +⑨ Write config.chat.adapters.slack = { mode: "socket" } +⑩ Write pending-pairing file with allowedUsers +⑪ Install + start systemd / launchd service (shared) +⑫ Print explicit pairing instructions: + "To complete pairing, open a new DM with @ in your Slack workspace + (click bot in sidebar or search), then send ANY message. + Pairing completes when the first message from @ arrives. + slack://app?team=&id= opens the bot directly." +``` + +### Flow (non-interactive) + +``` +roundhouse setup --slack \ + --slack-bot-token "$SLACK_BOT_TOKEN" \ + --slack-app-token "$SLACK_APP_TOKEN" \ + --user my-slack-username \ + --non-interactive +``` + +Same as telegram — JSON logger, exit codes, diagnostics on failure. + +### Manifest (Phase 3 — corrected from v1) + +`src/transports/slack/manifest.yaml` literal. Trimmed scopes (dropped `users:read.email`; the reviewer flagged it as a privacy red flag and unnecessary): + +```yaml +display_information: + name: Roundhouse + description: Roundhouse chat-gateway bot + +features: + bot_user: + display_name: Roundhouse + always_online: true + assistant_view: + assistant_description: Roundhouse AI agent + suggested_prompts: [] + +oauth_config: + scopes: + bot: + - app_mentions:read + - assistant:write # for assistant_thread_started + setStatus + - channels:history + - chat:write + - groups:history + - im:history + - im:read + - im:write + - users:read # for username matching during pairing + # users:read.email INTENTIONALLY OMITTED — not needed for matching + +settings: + event_subscriptions: + bot_events: + - app_mention + - message.im + - message.channels + - message.groups + - assistant_thread_started # for first-DM pairing fallback + - assistant_thread_context_changed + interactivity: + is_enabled: true + socket_mode_enabled: true +``` + +### `slack.ts` helpers + +```ts +export interface SlackBotInfo { + botUserId: string; // Uxxx — the bot's own user id (for self-loop filter) + botName: string; // bot display name + teamId: string; // Txxx + teamName: string; +} + +export async function validateSlackTokens(botToken: string, appToken: string): Promise { + // 1. Validate bot token via auth.test (HTTP POST with Bearer botToken) + // 2. Validate app token shape (xapp-prefix); we can't auth.test it but + // a malformed xapp- token will fail at socket connect time. + // Throws on invalid; redacts token in error messages. +} + +export function redactSlackToken(token: string): string { + // xoxb-XXX...XXX (preserve prefix so user can tell which token is bad) + if (token.length < 12) return "***"; + return token.slice(0, 8) + "..." + token.slice(-4); +} +``` + +### Phase 3 checklist +- [ ] `runInteractiveSlackSetup` in `flows.ts` +- [ ] `runNonInteractiveSlackSetup` in `flows.ts` +- [ ] `slack.ts` helpers (token validation, redaction) +- [ ] Regex prefix-check for `xoxb-` and `xapp-` at prompt time +- [ ] `--slack`, `--slack-bot-token`, `--slack-app-token` args +- [ ] Slack manifest YAML in `src/transports/slack/manifest.yaml` +- [ ] Pairing file write (mirror telegram) +- [ ] Update `setup/index.ts` exports +- [ ] Print "open new DM with bot" instructions explicitly (not just "DM @bot") +- [ ] Smoke test the full flow against a real Slack workspace +- [ ] Add `roundhouse doctor --slack` to surface pairing status (sees pending file, tells user where to click) + +### Phase 3 risks +- Slack tokens come in two flavors (xoxb, xapp). Easy to mix up. **Mitigation:** regex-validate the prefix at prompt-time, before calling `auth.test`. +- Setup must not log tokens. **Mitigation:** redaction helper + no `console.log(opts)` anywhere. +- Manifest schema may evolve. **Mitigation:** the YAML is a starting point; reference the SDK's published example (Phase 0 verification step) when shipping. + +--- + +## Phase 4 — Tests + +### New test files + +| File | What it covers | +|---|---| +| `test/slack-format.test.ts` | `markdownToSlackMrkdwn` round-trips, escape rules, `<@U>` / `` preservation, length truncation | +| `test/slack-postrich.test.ts` | Block Kit shape for menus, action_id mapping, prose-as-section-block (NOT markdown_text+blocks) | +| `test/slack-pairing.test.ts` | First-DM pairing, `assistant_thread_started` pairing, allowlist matching | +| `test/composite-transport.test.ts` | Routing by `ownsThread`, partitioned `notify`, `dispose` fan-out, per-transport `pairingComplete` Map race (telegram-paired-then-slack and the reverse), `enrichPrompt(thread, text)` routing for synthetic threads, `chat.onAction` callback routing across both transports | +| `test/ipc-handler-partition.test.ts` | `req.session = "Cxxx"` triggers Slack-only target; `req.session = "12345"` triggers telegram-only; missing transport returns useful error | +| `test/cron-notify-partition.test.ts` | `notifyFn` receives mixed `(string \| number)[]` and partitions correctly by `ownsChatId` | +| `test/setup-slack.test.ts` | Token validation, env file, manifest write, prefix regex | +| `test/slack-adapter.test.ts` | Smoke: postMessage, postRich, createThread, notify, ownsChatId, encodeParentThreadId, formatNotifySession | +| `test/slack-streaming.test.ts` | Post-then-edit fallback: edit interval throttling, overflow chunking, final flush | +| `test/slack-attachment.test.ts` | Mock `Attachment.fetchData()` round-trip | + +### Updated test files + +- `test/config.test.ts` — string + number IDs in `allowedUserIds` +- `test/gateway-helpers.test.ts` — composite transport in helpers +- `test/setup.test.ts` — `--slack` arg parsing +- `test/adapter-interface.test.ts` — `ownsChatId`, `stream`, `encodeParentThreadId`, `formatNotifySession` methods on the contract +- `test/telegram-format.test.ts` — verify `enrichPrompt(thread, text)` signature change didn't break cases +- `test/typing.test.ts` — Slack typing indicator path +- `test/old-bugs.test.ts` — add cases for new bugs we'll catch (notify partitioning by `ownsChatId`) + +### `@chat-adapter/tests@4.29.0` integration + +The reviewer flagged that this package may not exist. Verify before adopting: +```bash +npm view @chat-adapter/tests version +``` +If it exists, use `createMockAdapter`, `toHavePosted`, `toHaveDispatched` for the new Slack tests. If not, hand-roll mocks (matches the existing telegram test style — see `test/telegram-postrich.test.ts`). Don't block Phase 4 on availability. + +### Phase 4 checklist +- [ ] Verify `@chat-adapter/tests@^4.29.0` exists; add to devDeps if so +- [ ] If using SDK helpers: wire vitest setup file +- [ ] If hand-rolling: copy mock-thread pattern from `test/telegram-postrich.test.ts` +- [ ] Write new test files listed above +- [ ] Update existing test files +- [ ] Confirm `npm test` is green +- [ ] Add a "two transports, one gateway" integration test that exercises both telegram and slack notify in the same run + +--- + +## Phase 5 — Documentation + +### Updated files +- `README.md` — Slack section: setup, manifest, supported features, limitations +- `architecture.md` — section on `CompositeTransportAdapter` + transport routing; updated diagram +- `CLAUDE.md` — Slack adapter nuances (mirror the telegram nuance section's depth) +- `CHANGELOG.md` — entries for each phase release + +### CLAUDE.md additions +- **"Bot self-loop filtering"** — explain how Chat SDK central filtering keeps bot messages from echoing, and that we eagerly populate `botUserId` via `auth.test` at startup to close the lazy-fetch race window +- **"Streaming + Block Kit can't coexist"** — document the constraint and the menus-don't-stream rule +- **"Slack thread-id encoding"** — `slack:CHANNEL:THREAD_TS` format; always use `adapter.encodeThreadId()` / `decodeThreadId()`, never split manually +- **"`markdown_text` is mutually exclusive with `text` and `blocks`"** — section blocks for menu prose, not markdown_text +- **"Multi-transport composition"** — how `CompositeTransportAdapter` routes calls; per-transport `pairingComplete` map +- **"Per-transport boot turn"** — `fireBootTurn` partitions chatIds by `ownsChatId` and runs once per transport +- **"Slack pairing chicken-and-egg"** — first DM doesn't exist yet; `assistant_thread_started` is the primary capture; fallback `message.im` for non-assistant workspaces + +--- + +## Out of scope (explicit non-goals for v1) + +- Multi-workspace OAuth (single workspace only) +- Webhook mode (socket mode only — no public URL needed) +- Slack Connect / external shared channels +- Token rotation / encryption at rest +- Slash commands as Slack-native commands (users type `/new` as text; gateway parses it the same way today) +- Modals (Block Kit views) +- Reactions, pins, scheduled messages (those are openclaw-style "tools", not chat-gateway features) +- Threading inside a Slack channel — **clarification:** v1 always replies at channel/DM root (uses `postChannelMessage` for top-level posts; never sets `thread_ts`). The SDK's thread-id format still includes `threadTs`, but we use a `"main"` sentinel and ignore inbound `thread_ts` for routing. +- Enterprise Grid org-wide installs +- App Home tab +- Native streaming via Slack's `assistant.threads.streaming` API (post-then-edit fallback ships in v1; native streaming is a v2 enhancement once we know the workspace has AI Assistant features) +- `@username` mention rewriting in outgoing menu prose (v2; needs user lookup) + +--- + +## Effort estimate (revised after iter-2 review and SDK type verification) + +| Phase | Files touched | New code | Risk | +|---|---|---|---| +| 0 — chat SDK bump | 2 | ~10 LoC | low | +| 1 — refactor | ~16 | ~450 LoC (sweep is comprehensive: includes IPC, cron, ChatThread.post widening) | medium | +| 2 — slack adapter | 8 new + 6 modified | ~800 LoC (was 950; v3 deletes the Block Kit converter — `richMenuToCard` is ~30 LoC, not 80; the SDK does the rest) | medium | +| 3 — setup CLI | 2 new + 4 modified | ~350 LoC | low | +| 4 — tests | 10 new + 7 modified | ~800 LoC | low | +| 5 — docs | 4 modified | ~200 LoC | trivial | + +**Total:** ~2,600 LoC across 5 PRs. Phase 2 dropped ~150 LoC vs v2 by adopting the Chat SDK Card model; that LoC was added back in Phase 4 for additional tests (composite race, IPC partition, cron partition, action-id routing). + +--- + +## Open questions resolved during review + +1. ~~Bot self-loop filtering~~ → **eager `auth.test` at gateway start** populates `botUserId` before subscriptions activate (Phase 2 risk #1). +2. ~~Slack typing indicators~~ → **generic `startTyping(threadId)` for v1**, no `assistant:write` scope needed for the basic indicator (per `index.d.ts:823`). `setAssistantStatus` with custom text is a v2 enhancement. +3. ~~Mention triggering~~ → **DM-only for v1.** Channel mentions deferred. `app_mention` event is in the manifest in case we want it later, but the gateway only acts on `onDirectMessage` and `onAssistantThreadStarted` paths. +4. ~~Channel join behavior~~ → DM-only sidesteps it. If the bot is added to a channel, all `message.channels` events are filtered out by the gateway's `ownsThread` + DM-only routing. + +--- + +## Migration / rollback + +- Phase 0 (SDK bump): rollback = `git revert`, `npm install`. Lockfile-only change. +- Phase 1 (refactor): existing telegram users keep working. Config files: numeric `allowedUserIds`/`notifyChatIds` remain valid (union widening). Old `telegram-pairing.json` files unchanged. +- Phase 2 (Slack adapter): purely additive. Telegram users aren't affected unless `chat.adapters.slack` is configured. +- Phase 3+ (setup, tests, docs): additive. + +No data migration. Pairing files are platform-specific (`telegram-pairing.json` vs `slack-pairing.json`) so coexist by name. + +--- + +## Verified-against-source claims + +The following claims are anchored to the version-pinned `.d.ts` files. Re-verify the SAME versions before implementation; the [chat-sdk-types-may-drift-between-versions](#) memory notes that types can shift between versions even within a minor. + +| Claim | Source | +|---|---| +| Slack thread id format `slack:CHANNEL:THREAD_TS` (encode/decode/isDM/channelIdFromThreadId) | `@chat-adapter/slack@4.29.0` `index.d.ts:866 (encode), 871 (isDM), 881 (decode), 892 (channelIdFromThreadId)` | +| `AdapterPostableMessage = string \| { raw } \| { markdown } \| { ast } \| { card } \| CardElement` | `chat@4.29.0` `chat-D9UYaaNO.d.ts:1549` | +| `Thread.post(message: string \| AdapterPostableMessage \| AsyncIterable \| ChatElement)` | `chat@4.29.0` `chat-D9UYaaNO.d.ts:298` (the ChannelImpl variant at :100 has the same signature) | +| `bot.onAssistantThreadStarted(handler)` is a public method on `ChatInstance` | `chat@4.29.0` `chat-D9UYaaNO.d.ts:2913` | +| Telegram and Slack adapters both consume `AdapterPostableMessage` and convert `card` internally | telegram `index.d.ts:361 (postMessage), 418 (resolveParseMode reads card)`; slack `index.d.ts:73 (cardToBlockKit)` | +| `webClient` works in single-workspace mode without explicit context | slack `index.d.ts:533` | +| `postChannelMessage(channelId, message: AdapterPostableMessage)` posts at channel root, no thread_ts | slack `index.d.ts:911`, chat `chat-D9UYaaNO.d.ts:710` | +| `dispatchInteractivePayload` fires `chat.onAction` events for block_actions | slack `index.d.ts:625` (note: this is `protected` SDK plumbing — we don't call it; we rely on its effect of emitting `onAction` callbacks) | +| `Card / Section / Actions / Button / Text` factory functions and shapes | chat `jsx-runtime-CFq1K_Ve.d.ts:52 (Button), 100 (Actions), 150 (CardElement), 181 (Card factory), 226 (Section), 241 (Actions factory), 268 (Button factory)` | +| `AssistantThreadStartedEvent` payload shape (channelId, userId, threadTs, context.teamId) | chat `chat-D9UYaaNO.d.ts:2120` | + +## What still requires a small spike during early Phase 2 + +1. **Chat SDK 4.29 telegram d.ts diff vs 4.26** — Phase 0 verification. Spend 10 minutes on a structured diff before bumping (we already have the 4.29 d.ts cached at `/tmp/tg-inspect/package/dist/index.d.ts`; need to compare against whatever the project locks today). +2. **`@chat-adapter/tests` package availability** — `npm view @chat-adapter/tests version` before Phase 4 commits. If absent, fall back to hand-rolled mocks (matches existing telegram test style). +3. **Slack adapter actions block button cap** — verify whether the SDK's `cardToBlockKit` chunks button groups itself when an `ActionsElement` has more than 5 children. If not, `richMenuToCard` chunks at our layer. 5-line spike. +4. **Slack manifest `features.assistant_view` schema** — confirm the exact shape against current Slack manifest docs. The manifest in §3 is a starting point. diff --git a/src/cli/setup.ts b/src/cli/setup.ts index e6b719f..f08945b 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -43,6 +43,7 @@ import { } from "./setup/steps"; import { resolveAgentForSetup, textLog, textStepLog } from "./setup/runtime"; import { runInteractiveTelegramSetup, runNonInteractiveTelegramSetup } from "./setup/flows"; +import { runInteractiveSlackSetup, runNonInteractiveSlackSetup } from "./setup/slack-flows"; // ── Orchestrator ───────────────────────────────────── @@ -71,6 +72,16 @@ export async function cmdSetup(argv: string[]): Promise { return; } + // Route to --slack flows + if (opts.slack) { + if (opts.nonInteractive) { + await runNonInteractiveSlackSetup(opts); + } else { + await runInteractiveSlackSetup(opts); + } + return; + } + // Legacy flow (no --telegram flag) const logger = textStepLog; const agent = resolveAgentForSetup(opts, logger); @@ -195,7 +206,11 @@ export async function cmdPair(argv: string[]): Promise { const config = JSON.parse(await readFile(CONFIG_PATH, "utf8")); if (!config.chat) config.chat = {}; const existingUserIds: number[] = config.chat.allowedUserIds ?? []; - const existingNotifyIds: number[] = (config.chat.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n)); + // Preserve both numeric (Telegram) and string (Slack: D.../C...) IDs + const existingNotifyIds: (number | string)[] = (config.chat.notifyChatIds ?? []).map((id) => { + const num = Number(id); + return !isNaN(num) ? num : id; // Keep non-numeric IDs as-is + }); if (!existingUserIds.includes(result.userId)) existingUserIds.push(result.userId); if (!existingNotifyIds.includes(result.chatId)) existingNotifyIds.push(result.chatId); @@ -216,10 +231,11 @@ export async function cmdPair(argv: string[]): Promise { function printDryRun(opts: SetupOptions): void { const agent = getAgentDefinition(opts.agent); - textLog("\n🔧 Roundhouse Setup (DRY RUN)"); + const transport = opts.slack ? "Slack" : "Telegram"; + textLog(`\n🔧 Roundhouse Setup (DRY RUN — ${transport})`); textLog("━━━━━━━━━━━━━━━━━━━\n"); textLog(`Agent: ${agent.name} (${agent.type})`); - textLog("Would validate Telegram token"); + textLog(`Would validate ${transport} ${opts.slack ? "tokens (xoxb + xapp)" : "token"}`); textLog("Would stop existing gateway (if running)"); textLog(`Would install: npm install -g @inceptionstack/roundhouse`); for (const pkg of agent.packages) { @@ -233,11 +249,18 @@ function printDryRun(opts: SetupOptions): void { textLog(`Would install: pi-psst extension`); } for (const ext of opts.extensions) textLog(`Would install extension: ${ext}`); - if (!opts.nonInteractive && opts.notifyChatIds.length === 0) { + if (opts.slack) { + textLog(`Would write pending pairing file ~/.roundhouse/slack-pairing.json (status=pending)`); + textLog(`Would save Slack app manifest to /tmp/roundhouse-slack-manifest.yaml`); + } else if (!opts.nonInteractive && opts.notifyChatIds.length === 0) { textLog(`Would pair via Telegram (interactive)`); } if (opts.psst) { - textLog(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`); + if (opts.slack) { + textLog(`Would store SLACK_BOT_TOKEN, SLACK_APP_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`); + } else { + textLog(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`); + } } if (agent.configDirs?.length) { textLog(`Would configure: agent-specific settings`); @@ -245,9 +268,9 @@ function printDryRun(opts: SetupOptions): void { } textLog(` Set defaultProvider: ${opts.provider}`); textLog(` Set defaultModel: ${opts.model}`); - textLog(`Would write: ~/.roundhouse/gateway.config.json`); + textLog(`Would write: ~/.roundhouse/gateway.config.json (adapters.${opts.slack ? "slack" : "telegram"} configured)`); textLog(`Would write: ~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`); - textLog(`Would register ${BOT_COMMANDS.length} bot commands`); + if (!opts.slack) textLog(`Would register ${BOT_COMMANDS.length} bot commands`); if (opts.systemd) textLog(`Would install systemd service`); textLog("\nNo changes made.\n"); } @@ -257,22 +280,31 @@ function printDryRun(opts: SetupOptions): void { function printSetupHelp(): void { console.log(` Usage: - roundhouse setup --telegram Interactive wizard (recommended) - TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --telegram --non-interactive --user USERNAME Non-interactive automation (SSM/cloud-init) - TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --user USERNAME Legacy (non-wizard) setup + roundhouse setup --telegram Interactive Telegram wizard + roundhouse setup --slack Interactive Slack wizard (socket mode) + TELEGRAM_BOT_TOKEN=... roundhouse setup \\\n --telegram --non-interactive --user USERNAME Non-interactive Telegram automation + SLACK_BOT_TOKEN=... SLACK_APP_TOKEN=... \\\n roundhouse setup --slack --non-interactive \\\n --user USERNAME Non-interactive Slack automation -Modes: - --telegram Telegram-focused setup (wizard or non-interactive) - --non-interactive Suppress all prompts (for automation/SSM/cloud-init) - Requires TELEGRAM_BOT_TOKEN env var and --user +Transport (mutually exclusive): + --telegram Telegram setup (wizard or non-interactive) + --slack Slack setup, socket mode (wizard or non-interactive) + --non-interactive Suppress all prompts (for automation/SSM/cloud-init) -Required (or prompted in interactive --telegram): - --user Telegram username (repeatable, strips @) +Required (or prompted in interactive mode): + --user Bot owner's username on the platform (repeatable, strips @) -Token: +Telegram credentials: TELEGRAM_BOT_TOKEN env Preferred — not in shell history --bot-token Accepted in interactive mode only +Slack credentials (env preferred — never via flags in --non-interactive): + SLACK_BOT_TOKEN env Bot token (xoxb-…) + SLACK_APP_TOKEN env App-level token (xapp-…) for socket mode + SLACK_SIGNING_SECRET env Optional, for webhook mode (v1 ships socket-only) + --slack-bot-token Interactive only + --slack-app-token Interactive only + --slack-signing-secret Interactive only + Agent: --agent Agent type (default: pi; available: ${listAvailableAgentTypes().join(", ")}) --provider AI provider (default: amazon-bedrock) @@ -281,7 +313,7 @@ Agent: --cwd Agent working directory (default: ~) Channel: - --notify-chat Telegram chat ID (repeatable, skips pairing) + --notify-chat Chat ID to notify (Telegram numeric, repeatable) Service: --no-systemd Skip systemd install @@ -289,7 +321,7 @@ Service: --with-psst Use psst vault for secrets (default: .env file) Display: - --qr Force QR code display + --qr Force QR code display (Telegram only) --no-qr Disable QR code display Behavior: diff --git a/src/cli/setup/args.ts b/src/cli/setup/args.ts index 2e594b0..61281d2 100644 --- a/src/cli/setup/args.ts +++ b/src/cli/setup/args.ts @@ -21,6 +21,10 @@ export function parseSetupArgs(argv: string[]): SetupOptions { force: false, dryRun: false, telegram: false, + slack: false, + slackBotToken: "", + slackAppToken: "", + slackSigningSecret: "", nonInteractive: false, qr: "auto", agent: "pi", @@ -47,6 +51,10 @@ export function parseSetupArgs(argv: string[]): SetupOptions { case "--non-interactive": opts.nonInteractive = true; break; case "--headless": opts.nonInteractive = true; break; // alias case "--telegram": opts.telegram = true; break; + case "--slack": opts.slack = true; break; + case "--slack-bot-token": opts.slackBotToken = next(); break; + case "--slack-app-token": opts.slackAppToken = next(); break; + case "--slack-signing-secret": opts.slackSigningSecret = next(); break; case "--agent": opts.agent = next().toLowerCase(); opts._agentExplicit = true; break; case "--qr": opts.qr = "always"; break; case "--no-qr": opts.qr = "never"; break; @@ -62,14 +70,33 @@ export function parseSetupArgs(argv: string[]): SetupOptions { if (!opts.botToken) { opts.botToken = process.env.TELEGRAM_BOT_TOKEN ?? ""; } + if (!opts.slackBotToken) opts.slackBotToken = process.env.SLACK_BOT_TOKEN ?? ""; + if (!opts.slackAppToken) opts.slackAppToken = process.env.SLACK_APP_TOKEN ?? ""; + if (!opts.slackSigningSecret) opts.slackSigningSecret = process.env.SLACK_SIGNING_SECRET ?? ""; - // Non-interactive: warn about --bot-token (argv visible in process listings) + // Non-interactive: warn about secrets in argv (visible in process listings) if (opts.nonInteractive && argv.some((a) => a === "--bot-token")) { throw new Error( "--bot-token is not accepted in --non-interactive mode (argv visible in process listings).\n" + "Use: TELEGRAM_BOT_TOKEN=... roundhouse setup --telegram --non-interactive --user USERNAME", ); } + if ( + opts.nonInteractive && + (argv.includes("--slack-bot-token") || + argv.includes("--slack-app-token") || + argv.includes("--slack-signing-secret")) + ) { + throw new Error( + "--slack-bot-token / --slack-app-token / --slack-signing-secret are not accepted in --non-interactive mode (argv visible in process listings).\n" + + "Use: SLACK_BOT_TOKEN=... SLACK_APP_TOKEN=... roundhouse setup --slack --non-interactive --user USERNAME", + ); + } + + // Mutually-exclusive transport flags (a single setup invocation targets one) + if (opts.telegram && opts.slack) { + throw new Error("--telegram and --slack are mutually exclusive in a single setup invocation. Run setup twice if you want both."); + } // Validate agent type try { @@ -78,29 +105,56 @@ export function parseSetupArgs(argv: string[]): SetupOptions { throw new Error(err.message); } - // Interactive --telegram defers token/user prompting to the wizard + // Interactive flows defer token/user prompting to the wizard const isInteractiveTelegram = opts.telegram && !opts.nonInteractive && process.stdin.isTTY; + const isInteractiveSlack = opts.slack && !opts.nonInteractive && process.stdin.isTTY; - // Validate - if (!opts.botToken && !opts.dryRun && !isInteractiveTelegram) { - throw new Error( - "Bot token required. Provide via:\n" + - " TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" + - " roundhouse setup --bot-token TOKEN --user USERNAME", - ); - } - if (opts.users.length === 0 && !isInteractiveTelegram) { - throw new Error( - "At least one --user USERNAME is required.\n" + - "This is your Telegram username (without @).", - ); + // Validate (Slack-vs-Telegram-aware) + if (opts.slack) { + if (!opts.slackBotToken && !opts.dryRun && !isInteractiveSlack) { + throw new Error( + "Slack bot token required. Provide via:\n" + + " SLACK_BOT_TOKEN=xoxb-… SLACK_APP_TOKEN=xapp-… roundhouse setup --slack --user USERNAME", + ); + } + if (!opts.slackAppToken && !opts.dryRun && !isInteractiveSlack) { + throw new Error( + "Slack app token (xapp-…) required for socket mode. Provide via SLACK_APP_TOKEN env var.", + ); + } + if (opts.slackBotToken && !/^xoxb-/.test(opts.slackBotToken)) { + throw new Error("--slack-bot-token must start with `xoxb-` (bot token)."); + } + if (opts.slackAppToken && !/^xapp-/.test(opts.slackAppToken)) { + throw new Error("--slack-app-token must start with `xapp-` (app-level token)."); + } + if (opts.users.length === 0 && !isInteractiveSlack) { + throw new Error( + "At least one --user USERNAME is required.\n" + + "This is your Slack username (display name, without @).", + ); + } + } else { + if (!opts.botToken && !opts.dryRun && !isInteractiveTelegram) { + throw new Error( + "Bot token required. Provide via:\n" + + " TELEGRAM_BOT_TOKEN=... roundhouse setup --user USERNAME\n" + + " roundhouse setup --bot-token TOKEN --user USERNAME", + ); + } + if (opts.users.length === 0 && !isInteractiveTelegram) { + throw new Error( + "At least one --user USERNAME is required.\n" + + "This is your Telegram username (without @).", + ); + } } for (const ext of opts.extensions) { if (!EXTENSION_NAME_RE.test(ext)) { throw new Error(`Invalid extension name: ${ext}`); } } - if (opts.notifyChatIds.some(isNaN)) { + if (opts.notifyChatIds.some((id) => typeof id === "number" && isNaN(id))) { throw new Error("--notify-chat must be a number"); } diff --git a/src/cli/setup/slack-flows.ts b/src/cli/setup/slack-flows.ts new file mode 100644 index 0000000..aa8ca75 --- /dev/null +++ b/src/cli/setup/slack-flows.ts @@ -0,0 +1,491 @@ +/** + * cli/setup/slack-flows.ts — `roundhouse setup --slack` interactive + + * non-interactive flows. + * + * Mirrors `flows.ts` (telegram) but is dedicated to socket-mode Slack: + * - bot token + app token + (optional) signing secret + * - manifest printed inline + saved to /tmp for paste convenience + * - first-DM pairing (write pending file; the gateway completes it on + * first message.im or assistant_thread_started from an allowed user) + * + * We deliberately DO NOT reuse the telegram stepConfigure / stepStoreSecrets + * because both encode telegram-specific secret names and adapter defaults. + * Phase 5 documentation will note this — when a third transport lands we + * can refactor a shared platform-agnostic configure step. + */ + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { tmpdir, platform } from "node:os"; +import { execFileSync } from "node:child_process"; +import { + stepPreflight, + stepStopGateway, + stepInstallPackages, + stepInstallBundle, + stepInstallSystemd, + stepPostflight, +} from "./steps"; +import { atomicWriteJson, atomicWriteText } from "./helpers"; +import { type SetupOptions } from "./types"; +import { envQuote, parseEnvFile } from "../env-file"; +import { promptText, promptMasked } from "./prompts"; +import { resolveAgentForSetup, textLog, textStepLog, createStepLog } from "./runtime"; +import { createJsonLogger, type SetupDiagnostics, printDiagnosticError } from "./logger"; +import { + validateSlackBotToken, + validateSlackAppTokenShape, + redactSlackToken, + readBundledManifest, + type SlackBotInfo, +} from "./slack"; +import { + writePendingSlackPairing, + readPendingSlackPairing, + type PendingSlackPairing, +} from "../../transports/slack/pairing"; +import { + ROUNDHOUSE_DIR, + CONFIG_PATH, + ENV_FILE_PATH as ENV_PATH, + fileExists, +} from "../../config"; + +const SLACK_MANIFEST_TMP = resolve(tmpdir(), "roundhouse-slack-manifest.yaml"); + +// ── Slack-specific helpers ─────────────────────────── + +async function stepValidateSlackTokens(logger: ReturnType, opts: SetupOptions): Promise { + logger.step("②", "Validating Slack tokens..."); + validateSlackAppTokenShape(opts.slackAppToken); + const info = await validateSlackBotToken(opts.slackBotToken); + logger.ok(`Bot: @${info.botName} (id: ${info.botUserId})`); + logger.ok(`Workspace: ${info.teamName} (id: ${info.teamId})`); + return info; +} + +async function stepWriteSlackEnv( + logger: ReturnType, + opts: SetupOptions, + info: SlackBotInfo, +): Promise { + logger.step("⑧", "Writing ~/.roundhouse/.env..."); + await mkdir(ROUNDHOUSE_DIR, { recursive: true }); + + // Merge with existing env so unrelated keys (AWS_*, etc.) survive. + let existing = new Map(); + try { existing = parseEnvFile(await readFile(ENV_PATH, "utf8")); } catch {} + + // Use envQuote so values with `"`, `$`, backtick, or backslash round-trip + // through parseEnvFile and don't trigger shell expansion in systemd's + // EnvironmentFile parser. + if (!opts.psst) { + existing.set("SLACK_BOT_TOKEN", envQuote(opts.slackBotToken)); + existing.set("SLACK_APP_TOKEN", envQuote(opts.slackAppToken)); + if (opts.slackSigningSecret) existing.set("SLACK_SIGNING_SECRET", envQuote(opts.slackSigningSecret)); + // Only set BOT_USERNAME if not already present (preserve Telegram value in mixed installs) + if (!existing.has("BOT_USERNAME")) existing.set("BOT_USERNAME", envQuote(info.botName)); + // Merge with existing ALLOWED_USERS (don't replace, which would drop prior users) + // Note: parseEnvFile returns quoted values (e.g., "alice,bob"), unquote before splitting + const rawUsers = existing.get("ALLOWED_USERS") ?? ""; + const unquoted = rawUsers.startsWith('"') && rawUsers.endsWith('"') ? rawUsers.slice(1, -1) : rawUsers; + const existingUsers = unquoted.split(",").filter(Boolean) ?? []; + const allUsers = Array.from(new Set([...existingUsers, ...opts.users])); + existing.set("ALLOWED_USERS", envQuote(allUsers.join(","))); + } else { + // psst path: still write non-secret config so systemd EnvironmentFile + // has BOT_USERNAME / ALLOWED_USERS for the gateway warning logic. + // Only set BOT_USERNAME if not already present (preserve Telegram value in mixed installs) + if (!existing.has("BOT_USERNAME")) existing.set("BOT_USERNAME", envQuote(info.botName)); + // Merge with existing ALLOWED_USERS (don't replace, which would drop prior users) + // Note: parseEnvFile returns quoted values (e.g., "alice,bob"), unquote before splitting + const rawUsers = existing.get("ALLOWED_USERS") ?? ""; + const unquoted = rawUsers.startsWith('"') && rawUsers.endsWith('"') ? rawUsers.slice(1, -1) : rawUsers; + const existingUsers = unquoted.split(",").filter(Boolean) ?? []; + const allUsers = Array.from(new Set([...existingUsers, ...opts.users])); + existing.set("ALLOWED_USERS", envQuote(allUsers.join(","))); + } + + // Bedrock defaults if needed (mirror telegram step) + const getExisting = (key: string) => existing.get(key); + if (opts.provider === "amazon-bedrock") { + if (!existing.has("AWS_PROFILE")) existing.set("AWS_PROFILE", getExisting("AWS_PROFILE") ?? envQuote("default")); + if (!existing.has("AWS_DEFAULT_REGION")) existing.set("AWS_DEFAULT_REGION", getExisting("AWS_DEFAULT_REGION") ?? envQuote("us-east-1")); + if (!existing.has("AWS_REGION")) existing.set("AWS_REGION", getExisting("AWS_REGION") ?? getExisting("AWS_DEFAULT_REGION") ?? envQuote("us-east-1")); + } + + const lines: string[] = []; + for (const [k, v] of existing.entries()) lines.push(`${k}=${v}`); + await atomicWriteText(ENV_PATH, lines.join("\n") + "\n"); + logger.ok(`~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`); +} + +async function stepWriteSlackConfig( + logger: ReturnType, + opts: SetupOptions, + info: SlackBotInfo, + agent: import("../../agents/registry").AgentDefinition, +): Promise { + logger.step("⑨", "Configuring agent + writing gateway.config.json..."); + + await mkdir(ROUNDHOUSE_DIR, { recursive: true }); + + // Run the agent's own configurator (writes ~/.pi/agent/settings.json + // for pi, etc.). Telegram's stepConfigure does the same. + if (agent.configure) { + await agent.configure({ + provider: opts.provider, + model: opts.model, + cwd: opts.cwd, + force: opts.force, + psst: opts.psst, + extensions: opts.extensions, + }); + } + + let gatewayConfig: Record = {}; + if (!opts.force) { + try { gatewayConfig = JSON.parse(await readFile(CONFIG_PATH, "utf8")); } catch {} + } + + const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? []; + const existingUserIds: (string | number)[] = gatewayConfig.chat?.allowedUserIds ?? []; + const existingNotifyIds: (string | number)[] = gatewayConfig.chat?.notifyChatIds ?? []; + + const mergedUsers = [...new Set([...existingUsers, ...opts.users])]; + // Phase 3 doesn't pre-populate allowedUserIds — pairing fills it via the gateway hook. + const mergedUserIds = [...existingUserIds]; + const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])]; + + // Preserve telegram adapter config if already set (multi-transport coexistence). + const existingAdapters = gatewayConfig.chat?.adapters ?? {}; + + gatewayConfig = { + ...gatewayConfig, + _version: 1, + agent: { + ...gatewayConfig.agent, + ...agent.configDefaults, + type: agent.type, + cwd: opts.cwd, + }, + chat: { + ...gatewayConfig.chat, + allowedUsers: mergedUsers, + allowedUserIds: mergedUserIds, + notifyChatIds: mergedNotifyIds, + adapters: { ...existingAdapters, slack: { mode: "socket", botUsername: info.botName } }, + }, + ...(opts.voice === false ? { voice: { stt: { enabled: false } } } : {}), + }; + + await atomicWriteJson(CONFIG_PATH, gatewayConfig); + logger.ok(`~/.roundhouse/gateway.config.json (slack adapter configured)`); +} + +async function stepStoreSlackSecrets( + logger: ReturnType, + opts: SetupOptions, + info: SlackBotInfo, +): Promise { + if (!opts.psst) { + logger.step("⑦", "Storing secrets..."); + logger.ok("Skipped (default — use --with-psst to enable)"); + return; + } + logger.step("⑦", "Storing secrets in psst..."); + + // Preserve existing vault values for BOT_USERNAME and ALLOWED_USERS + // Try to read existing values (may fail if not set yet) + let existingBotUsername = info.botName; + let existingAllowedUsers = opts.users; + + try { + const botUsernameOutput = execFileSync("psst", ["get", "BOT_USERNAME"], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5_000, + }).trim(); + if (botUsernameOutput && !botUsernameOutput.includes("not found")) { + existingBotUsername = botUsernameOutput; // Preserve existing + } + } catch { + // Key doesn't exist yet, use new value + } + + try { + const allowedUsersOutput = execFileSync("psst", ["get", "ALLOWED_USERS"], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5_000, + }).trim(); + if (allowedUsersOutput && !allowedUsersOutput.includes("not found")) { + const existing = allowedUsersOutput.split(",").filter(Boolean); + existingAllowedUsers = Array.from(new Set([...existing, ...opts.users])); + } + } catch { + // Key doesn't exist yet, use new value + } + + const secrets: [string, string][] = [ + ["SLACK_BOT_TOKEN", opts.slackBotToken], + ["SLACK_APP_TOKEN", opts.slackAppToken], + ["BOT_USERNAME", existingBotUsername], + ["ALLOWED_USERS", existingAllowedUsers.join(",")], + ]; + + if (opts.slackSigningSecret) secrets.push(["SLACK_SIGNING_SECRET", opts.slackSigningSecret]); + + for (const [name, value] of secrets) { + try { + execFileSync("psst", ["set", name, "--stdin"], { + input: value, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 10_000, + }); + logger.ok(`${name} → psst vault`); + } catch { + try { + execFileSync("psst", ["set", name, "--stdin"], { + input: value, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 10_000, + env: { ...process.env, PSST_FORCE: "1" }, + }); + logger.ok(`${name} → psst vault (updated)`); + } catch (err: any) { + logger.warn(`Failed to store ${name} in psst: ${err.message}`); + } + } + } +} + +async function stepWriteSlackPairing( + logger: ReturnType, + opts: SetupOptions, + info: SlackBotInfo, +): Promise { + logger.step("⑩", "Writing slack-pairing.json (status: pending)..."); + const existing = await readPendingSlackPairing(); + const pending: PendingSlackPairing = { + version: 1, + workspaceTeamId: info.teamId, + botUserId: info.botUserId, + allowedUsers: opts.users, + createdAt: existing?.createdAt ?? new Date().toISOString(), + status: "pending", + }; + await writePendingSlackPairing(pending); + logger.ok(`~/.roundhouse/slack-pairing.json`); + return pending; +} + +async function stepDumpManifest(logger: ReturnType): Promise { + const manifest = await readBundledManifest(); + await writeFile(SLACK_MANIFEST_TMP, manifest, { mode: 0o600 }); + logger.ok(`Slack app manifest copied to ${SLACK_MANIFEST_TMP}`); + return manifest; +} + +function printSlackAppGuide(): void { + textLog(""); + textLog(" 📱 Create / update the Slack app"); + textLog(" ────────────────────────────────"); + textLog(" 1. Open https://api.slack.com/apps → 'Create New App' → 'From an app manifest'"); + textLog(" 2. Pick the workspace, paste the manifest from below (also saved to /tmp)"); + textLog(" 3. Click 'Create' → 'Install to Workspace' → review scopes → 'Allow'"); + textLog(" 4. Open 'Basic Information' → scroll to 'App-Level Tokens' → 'Generate Token and Scopes'"); + textLog(" Add scope: connections:write"); + textLog(" Copy the xapp-… token"); + textLog(" 5. Open 'OAuth & Permissions' → copy the 'Bot User OAuth Token' (xoxb-…)"); + textLog(""); +} + +function printSlackPairingHint(info: SlackBotInfo, opts: SetupOptions): void { + const allowedDisplay = opts.users.length + ? opts.users.map((u) => `@${u}`).join(", ") + : "@your-slack-username"; + textLog(""); + textLog(" 🤝 Pairing"); + textLog(" ─────────"); + textLog(` In Slack, open a NEW DM with @${info.botName} and send any message.`); + textLog(` (Click the bot in your sidebar, or search 'Apps' → @${info.botName}.)`); + textLog(` The first message from one of: ${allowedDisplay} will complete pairing.`); + textLog(""); + textLog(` Open the bot in Slack: slack://app?team=${info.teamId}&id=${info.botUserId}`); + textLog(""); +} + +// ── Interactive flow ───────────────────────────────── + +export async function runInteractiveSlackSetup(opts: SetupOptions): Promise { + const logger = textStepLog; + const agent = resolveAgentForSetup(opts, logger); + textLog("\n🔧 Roundhouse Slack Setup"); + textLog("━━━━━━━━━━━━━━━━━━━━━━━━━"); + + try { + await stepPreflight(logger, opts, agent); + await stepDumpManifest(logger); + printSlackAppGuide(); + + const manifest = await readBundledManifest(); + textLog(" ── Slack app manifest ──"); + for (const line of manifest.split("\n")) textLog(` ${line}`); + textLog(" ────────────────────────"); + textLog(""); + + if (!opts.slackBotToken) { + opts.slackBotToken = await promptMasked(" Paste your Slack bot token (xoxb-…)"); + if (!opts.slackBotToken) { logger.fail("No bot token provided"); process.exit(2); } + } + if (!opts.slackAppToken) { + opts.slackAppToken = await promptMasked(" Paste your Slack app-level token (xapp-…)"); + if (!opts.slackAppToken) { logger.fail("No app token provided"); process.exit(2); } + } + + const info = await stepValidateSlackTokens(logger, opts); + + if (opts.users.length === 0) { + logger.step("③", "Slack username..."); + const username = await promptText(" Your Slack username (without @)"); + if (!username) { logger.fail("Username required"); process.exit(2); } + opts.users.push(username.replace(/^@/, "")); + logger.ok(`Allowed: ${opts.users.map((u) => `@${u}`).join(", ")}`); + } + + await stepStopGateway(logger); + await stepInstallPackages(logger, opts, agent); + await stepInstallBundle(logger, opts); + + await stepStoreSlackSecrets(logger, opts, info); + await stepWriteSlackEnv(logger, opts, info); + await stepWriteSlackConfig(logger, opts, info, agent); + await stepWriteSlackPairing(logger, opts, info); + + await stepInstallSystemd(logger, opts); + await stepPostflight(logger); + + printSlackPairingHint(info, opts); + + textLog("\n━━━━━━━━━━━━━━━━━━━━━━━━━"); + textLog("✅ Roundhouse is ready!"); + textLog(` Bot: @${info.botName} in ${info.teamName}`); + textLog(` Tokens stored in ~/.roundhouse/.env (slack: ${redactSlackToken(opts.slackBotToken)} / ${redactSlackToken(opts.slackAppToken)}).`); + textLog(` DM @${info.botName} in Slack to complete pairing.`); + textLog(""); + } catch (err: any) { + textLog("\n━━━━━━━━━━━━━━━━━━━━━━━━━"); + textLog(`❌ Setup failed: ${err.message}`); + textLog(" Re-run: roundhouse setup --slack\n"); + process.exit(1); + } +} + +// ── Non-interactive flow ───────────────────────────── + +export async function runNonInteractiveSlackSetup(opts: SetupOptions): Promise { + const logger = createJsonLogger(); + const stepLogger = createStepLog(logger); + const agent = resolveAgentForSetup(opts, stepLogger); + + try { + if (!opts.slackBotToken) { + logger.error("validation.failed", "SLACK_BOT_TOKEN env var required for --non-interactive"); + process.exit(2); + } + if (!opts.slackAppToken) { + logger.error("validation.failed", "SLACK_APP_TOKEN env var required for --non-interactive"); + process.exit(2); + } + if (opts.users.length === 0) { + logger.error("validation.failed", "--user is required for --non-interactive"); + process.exit(2); + } + + logger.step(1, 9, "preflight.start", "Running preflight checks"); + await stepPreflight(stepLogger, opts, agent); + logger.ok("Preflight passed"); + + logger.step(2, 9, "slack.validate", "Validating Slack tokens"); + const info = await stepValidateSlackTokens(stepLogger, opts); + logger.ok(`Bot: @${info.botName} workspace=${info.teamName}`); + + logger.step(3, 9, "gateway.stop", "Checking for running gateway"); + await stepStopGateway(stepLogger); + + logger.step(4, 9, "packages.install", "Installing packages"); + await stepInstallPackages(stepLogger, opts, agent); + logger.ok("Packages installed"); + + await stepInstallBundle(stepLogger, opts); + + logger.step(5, 10, "slack.secrets.store", "Storing secrets"); + await stepStoreSlackSecrets(stepLogger, opts, info); + + logger.step(6, 10, "slack.env.write", "Writing env"); + await stepWriteSlackEnv(stepLogger, opts, info); + + logger.step(7, 10, "slack.config.write", "Writing config"); + await stepWriteSlackConfig(stepLogger, opts, info, agent); + + logger.step(8, 10, "slack.pairing.write", "Writing pending-pairing"); + await stepWriteSlackPairing(stepLogger, opts, info); + + logger.step(9, 10, "slack.manifest", "Saving manifest to /tmp"); + await stepDumpManifest(stepLogger); + + let serviceInstalled = false; + logger.step(10, 10, "service.install", "Installing service"); + if (!opts.systemd && platform() !== "darwin") { + logger.warn("service.skip", "--no-systemd: service not installed. Start manually: roundhouse start"); + } else { + await stepInstallSystemd(stepLogger, opts); + try { + const state = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim(); + if (state === "active") { + logger.ok("Service is active"); + serviceInstalled = true; + } else { + logger.warn("service.state", `Service state: ${state}`); + } + } catch { + logger.warn("service.state", "Could not verify service state"); + } + } + + logger.info("setup.complete", "Non-interactive Slack setup complete", { + botName: info.botName, + botUserId: info.botUserId, + teamId: info.teamId, + pairingStatus: "pending", + serviceInstalled, + }); + stepLogger.log(""); + stepLogger.log("━━━━━━━━━━━━━━━━━━━━━━━━━"); + stepLogger.log("✅ Roundhouse Slack installed!"); + stepLogger.log(""); + stepLogger.log(` DM @${info.botName} in Slack to complete pairing.`); + } catch (err: any) { + const diag: SetupDiagnostics = { + node: process.version, + platform: platform(), + arch: process.arch, + cwd: process.cwd(), + roundhouseDir: ROUNDHOUSE_DIR, + configExists: await fileExists(CONFIG_PATH).catch(() => false), + envExists: await fileExists(ENV_PATH).catch(() => false), + pairingStatus: (await readPendingSlackPairing())?.status ?? "not found", + serviceState: "unknown", + error: { name: err.name, message: err.message, stack: err.stack }, + }; + try { + diag.serviceState = execFileSync("systemctl", ["is-active", "roundhouse"], { encoding: "utf8" }).trim(); + } catch {} + printDiagnosticError(diag, true); + process.exit(1); + } +} + diff --git a/src/cli/setup/slack.ts b/src/cli/setup/slack.ts new file mode 100644 index 0000000..cdc5aa2 --- /dev/null +++ b/src/cli/setup/slack.ts @@ -0,0 +1,113 @@ +/** + * cli/setup/slack.ts — Slack API helpers for `roundhouse setup --slack`. + * + * Token validation only — the gateway's @chat-adapter/slack instance + * does the heavy lifting at runtime. We use auth.test here as a quick + * "is this xoxb- token valid" check during setup so we fail fast + * before writing config / starting the service. + */ + +import { readFile } from "node:fs/promises"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +export interface SlackBotInfo { + /** Slack bot user ID (Uxxx) — used for self-loop filtering and pairing fallback. */ + botUserId: string; + /** Bot display name. */ + botName: string; + /** Workspace team ID (Txxx). */ + teamId: string; + /** Workspace name (human-readable). */ + teamName: string; +} + +const TOKEN_REDACT_PREFIX = 8; + +export function redactSlackToken(token: string): string { + if (token.length < 12) return "***"; + return token.slice(0, TOKEN_REDACT_PREFIX) + "..." + token.slice(-4); +} + +/** + * Validate a Slack bot token via auth.test. + * + * Throws on any failure with the token redacted in the message — never + * leak xoxb- secrets to logs or error displays. + */ +export async function validateSlackBotToken(botToken: string): Promise { + if (!/^xoxb-/.test(botToken)) { + throw new Error(`Bot token must start with 'xoxb-' (got: ${redactSlackToken(botToken)})`); + } + const res = await fetch("https://slack.com/api/auth.test", { + method: "POST", + headers: { Authorization: `Bearer ${botToken}` }, + // 15s ceiling so a hung Slack endpoint doesn't block setup forever. + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) { + throw new Error(`Slack auth.test HTTP ${res.status} (token: ${redactSlackToken(botToken)})`); + } + const data = await res.json() as { + ok?: boolean; + error?: string; + user_id?: string; + user?: string; + team_id?: string; + team?: string; + }; + if (!data.ok) { + throw new Error(`Slack auth.test failed: ${data.error ?? "unknown"} (token: ${redactSlackToken(botToken)})`); + } + if (!data.user_id || !data.team_id) { + throw new Error(`Slack auth.test returned without user_id/team_id (token: ${redactSlackToken(botToken)})`); + } + return { + botUserId: data.user_id, + botName: data.user ?? "roundhouse", + teamId: data.team_id, + teamName: data.team ?? data.team_id, + }; +} + +/** + * Validate the shape of a Slack app-level token (xapp-…). We can't auth.test + * an app token directly (it's only valid against the socket-mode endpoint), + * but we can shape-check it so the user catches paste errors during setup. + */ +export function validateSlackAppTokenShape(appToken: string): void { + if (!/^xapp-\d-[A-Z0-9]+-\d+-[a-f0-9]+$/.test(appToken)) { + throw new Error( + `App token shape looks wrong (expected xapp-N-AXXXX-NNN-hex, got: ${redactSlackToken(appToken)}).\n` + + `Generate one at api.slack.com/apps → Basic Information → App-Level Tokens with the connections:write scope.` + ); + } +} + +/** + * Read the bundled Slack app manifest YAML. + * + * Resolves relative to this module. We probe two candidate paths so we + * work for both `tsx src/...` (dev: this file at src/cli/setup/slack.ts) + * and `node src/dist/...` (built: this file at src/dist/index.js after + * a single-file bundle). The manifest itself is shipped at + * src/transports/slack/manifest.yaml in the package. + */ +export async function readBundledManifest(): Promise { + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + // Dev (tsx): setup/slack.ts → ../../transports/slack/manifest.yaml + resolve(here, "..", "..", "transports", "slack", "manifest.yaml"), + // Built (src/dist/): src/dist/index.js → ../transports/slack/manifest.yaml + resolve(here, "..", "transports", "slack", "manifest.yaml"), + ]; + for (const candidate of candidates) { + try { + return await readFile(candidate, "utf8"); + } catch { /* try next */ } + } + throw new Error( + `Could not find Slack manifest. Searched: ${candidates.join(", ")}. ` + + `Make sure the package's src/transports/slack/manifest.yaml is shipped.`, + ); +} diff --git a/src/cli/setup/steps.ts b/src/cli/setup/steps.ts index a53a411..cda19e9 100644 --- a/src/cli/setup/steps.ts +++ b/src/cli/setup/steps.ts @@ -395,11 +395,19 @@ export async function stepConfigure( const existingUsers: string[] = gatewayConfig.chat?.allowedUsers ?? []; const existingUserIds: number[] = gatewayConfig.chat?.allowedUserIds ?? []; - const existingNotifyIds: number[] = (gatewayConfig.chat?.notifyChatIds ?? []).map(Number).filter((n) => !isNaN(n)); + // Preserve both numeric IDs (Telegram) and string IDs (Slack: D.../C...) + const existingNotifyIds: (number | string)[] = (gatewayConfig.chat?.notifyChatIds ?? []).map((id) => { + const num = Number(id); + return !isNaN(num) ? num : id; + }); const mergedUsers = [...new Set([...existingUsers, ...opts.users])]; const mergedUserIds = [...existingUserIds]; - const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds])]; + // Merge notify IDs: preserve existing (both numeric + Slack string IDs), add new ones + const mergedNotifyIds = [...new Set([...existingNotifyIds, ...opts.notifyChatIds.map((id) => { + const num = Number(id); + return !isNaN(num) ? num : id; + })])]; if (pairResult) { if (!mergedUserIds.includes(pairResult.userId)) { @@ -428,6 +436,12 @@ export async function stepConfigure( await atomicWriteJson(CONFIG_PATH, gatewayConfig); logger.ok(`~/.roundhouse/gateway.config.json`); + // Read existing .env to preserve unrelated keys (Slack tokens, custom vars, etc.) + let existingEnv = new Map(); + try { + existingEnv = parseEnvFile(await readFile(ENV_PATH, "utf8")); + } catch {} + const envLines: string[] = []; if (!opts.psst) { @@ -445,10 +459,6 @@ export async function stepConfigure( } if (opts.provider === "amazon-bedrock") { - let existingEnv = new Map(); - try { - existingEnv = parseEnvFile(await readFile(ENV_PATH, "utf8")); - } catch {} const getExisting = (key: string) => existingEnv.get(key); if (!envLines.some((l) => l.startsWith("AWS_PROFILE="))) { @@ -462,6 +472,13 @@ export async function stepConfigure( } } + // Preserve existing Slack and other adapter credentials + for (const [key, value] of existingEnv) { + if (key.startsWith("SLACK_") && !envLines.some((l) => l.startsWith(key + "="))) { + envLines.push(`${key}=${envQuote(value)}`); + } + } + await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n"); logger.ok(`~/.roundhouse/.env${opts.psst ? " (non-secret config only)" : ""}`); } diff --git a/src/cli/setup/types.ts b/src/cli/setup/types.ts index f6ae4ca..7bb5d54 100644 --- a/src/cli/setup/types.ts +++ b/src/cli/setup/types.ts @@ -14,7 +14,7 @@ export interface SetupOptions { model: string; extensions: string[]; cwd: string; - notifyChatIds: number[]; + notifyChatIds: (string | number)[]; systemd: boolean; voice: boolean; psst: boolean; @@ -22,6 +22,14 @@ export interface SetupOptions { dryRun: boolean; /** Telegram-focused setup flow */ telegram: boolean; + /** Slack-focused setup flow */ + slack: boolean; + /** Slack bot token (xoxb-…) — only used when slack === true */ + slackBotToken: string; + /** Slack app token (xapp-…) — only used when slack === true */ + slackAppToken: string; + /** Slack signing secret (only relevant for webhook mode; v1 ships socket-only) */ + slackSigningSecret: string; /** Non-interactive mode (no TTY prompts) */ nonInteractive: boolean; /** QR code display mode */ diff --git a/src/cli/subagent-command.ts b/src/cli/subagent-command.ts index d6417ad..6652efc 100644 --- a/src/cli/subagent-command.ts +++ b/src/cli/subagent-command.ts @@ -15,7 +15,7 @@ import type { SpawnSpec, SubAgentRole, RoutingInfo } from "../subagents/types"; const ROUNDHOUSE_DIR = join(homedir(), ".roundhouse"); -function loadGatewayConfig(): { notifyChatIds: number[] } { +function loadGatewayConfig(): { notifyChatIds: (string | number)[] } { try { const raw = readFileSync(join(ROUNDHOUSE_DIR, "gateway.config.json"), "utf8"); const cfg = JSON.parse(raw); @@ -25,17 +25,34 @@ function loadGatewayConfig(): { notifyChatIds: number[] } { } } +/** + * Detect a transport name from a chat id shape. Mirrors the rules in each + * `TransportAdapter.ownsChatId`. Kept as a CLI-only duplicate because this + * code path runs without instantiating the gateway transport composite. + */ +function detectTransportFromChatId(chatId: string): "telegram" | "slack" | null { + if (/^-?\d+$/.test(chatId)) return "telegram"; + if (/^[CDGU]/.test(chatId)) return "slack"; + return null; +} + function buildRouting(): RoutingInfo { const cfg = loadGatewayConfig(); - const chatId = String(cfg.notifyChatIds[0] ?? ""); + const rawId = cfg.notifyChatIds[0]; + const chatId = rawId == null ? "" : String(rawId); if (!chatId) { - console.error("Error: no Telegram chat configured. Run 'roundhouse setup' first."); + console.error("Error: no chat configured. Run 'roundhouse setup' first."); + process.exit(1); + } + const transport = detectTransportFromChatId(chatId); + if (!transport) { + console.error(`Error: cannot determine transport from chatId "${chatId}". Expected telegram numeric or slack C/D/G/U prefix.`); process.exit(1); } return { - transport: "telegram", + transport, chatId, - parentThreadId: `telegram:${chatId}:main`, + parentThreadId: `${transport}:${chatId}:main`, }; } diff --git a/src/cron/runner.ts b/src/cron/runner.ts index 92fea54..c12b05d 100644 --- a/src/cron/runner.ts +++ b/src/cron/runner.ts @@ -19,8 +19,8 @@ export class CronRunner { constructor( private store: CronStore, private agentConfig?: GatewayConfig["agent"], - private defaultChatIds?: number[], - private notifyFn?: (chatIds: number[], text: string) => Promise, + private defaultChatIds?: (string | number)[], + private notifyFn?: (chatIds: (string | number)[], text: string) => Promise, ) {} async runJob( diff --git a/src/cron/scheduler.ts b/src/cron/scheduler.ts index 9fe5691..c1fc277 100644 --- a/src/cron/scheduler.ts +++ b/src/cron/scheduler.ts @@ -41,7 +41,7 @@ export class CronSchedulerService { private lastHeartbeatAt = 0; // 0 = fires on first tick after startup (intentional catch-up) private tickMs: number; - constructor(private opts?: { tickMs?: number; agentConfig?: GatewayConfig["agent"]; notifyChatIds?: number[]; notifyFn?: (chatIds: number[], text: string) => Promise }) { + constructor(private opts?: { tickMs?: number; agentConfig?: GatewayConfig["agent"]; notifyChatIds?: (string | number)[]; notifyFn?: (chatIds: (string | number)[], text: string) => Promise }) { this.store = new CronStore(); this.runner = new CronRunner(this.store, this.opts?.agentConfig, this.opts?.notifyChatIds, this.opts?.notifyFn); this.queue = new PQueue({ concurrency: 1 }); diff --git a/src/gateway/bot-username-resolver.ts b/src/gateway/bot-username-resolver.ts new file mode 100644 index 0000000..a051a0c --- /dev/null +++ b/src/gateway/bot-username-resolver.ts @@ -0,0 +1,45 @@ +/** + * Resolve the correct bot username for a given thread, respecting per-adapter overrides. + * + * Pattern: Store optional override in adapter config (e.g., `chat.adapters.slack.botUsername`), + * then resolve at dispatch time with fallback to global `chat.botUsername`. + * + * Responsibility: single concern — map thread → transport → override ← global fallback. + */ + +export interface BotUsernameResolverConfig { + globalBotUsername: string; + adapterOverrides: Record; // e.g., { slack: "slackbot", telegram: "telegrambot" } +} + +export class BotUsernameResolver { + constructor(private config: BotUsernameResolverConfig) {} + + /** + * Resolve the bot username for a thread. + * + * Strategy: + * 1. Infer transport name from thread ID prefix (e.g., "slack:C01:1712" → "slack"). + * 2. Check if adapter has an override (e.g., `adapterOverrides.slack`). + * 3. Fall back to global username. + * + * Returns empty string if no username is configured (caller must handle). + */ + resolve(thread: any): string { + const transportName = this.inferTransportFromThread(thread); + + if (transportName && transportName in this.config.adapterOverrides) { + return this.config.adapterOverrides[transportName]; + } + + return this.config.globalBotUsername; + } + + /** Infer transport name from thread.id prefix (e.g., "slack:...", "telegram:...") */ + private inferTransportFromThread(thread: any): string | null { + if (!thread?.id || typeof thread.id !== "string") return null; + + const prefix = thread.id.split(":")[0]; + return prefix && prefix.match(/^[a-z]+$/) ? prefix : null; + } +} diff --git a/src/gateway/commands.ts b/src/gateway/commands.ts index fd37716..c620ea8 100644 --- a/src/gateway/commands.ts +++ b/src/gateway/commands.ts @@ -22,7 +22,7 @@ export interface CommandContext { agent: AgentAdapter; config: GatewayConfig; allowedUsers: string[]; - allowedUserIds: number[]; + allowedUserIds: (string | number)[]; verboseThreads: Set; threadLocks: Map>; postWithFallback: (thread: any, text: string) => Promise; diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 587d405..b9c7961 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -8,7 +8,7 @@ import { Chat } from "chat"; import { createMemoryState } from "@chat-adapter/state-memory"; import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig } from "../types"; -import { splitMessage, isAllowed, startTypingLoop } from "../util"; +import { splitMessage, isAllowed, startTypingLoop, sameId } from "../util"; import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "../voice/stt-service"; import { runDoctor, formatDoctorTelegram, createDoctorContext } from "../cli/doctor/runner"; import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "../config"; @@ -34,10 +34,11 @@ import { isPreTurn, matchesDescriptor, } from "./command-registry"; -import { TelegramAdapter } from "../transports"; +import { TelegramAdapter, SlackAdapter, CompositeTransportAdapter, buildCompositeTransport, buildChatSdkAdapters } from "../transports"; import type { TransportAdapter } from "../transports"; import { SubAgentOrchestratorImpl, SubAgentWatcher } from "../subagents"; import type { RunStatus, RoutingInfo } from "../subagents"; +import { BotUsernameResolver } from "./bot-username-resolver"; import { hostname } from "node:os"; import { join } from "node:path"; import { readFile } from "node:fs/promises"; @@ -52,25 +53,32 @@ export type { TurnSource }; const MAX_SUBAGENT_STDOUT_CHARS = 3000; const MAX_MESSAGE_CHUNK = 4000; -/** Bot username for command suffix validation (set during gateway init) */ -let _botUsername = ""; - -// ── Chat SDK adapter factories ─────────────────────── -// Lazy-imported so we don't crash if an adapter package isn't installed. - -async function buildChatAdapters( - config: GatewayConfig["chat"]["adapters"] -): Promise> { - const adapters: Record = {}; - - if (config.telegram) { - const { createTelegramAdapter } = await import("@chat-adapter/telegram"); - adapters.telegram = createTelegramAdapter({ - mode: (config.telegram.mode as "auto" | "polling" | "webhook") ?? "auto", - }); - } +/** + * Build the list of `TransportAdapter` delegates from the configured + * chat-adapter keys. The Slack delegate is loaded lazily because + * @chat-adapter/slack may not be installed in older deployments — + * if Slack is configured, it MUST be installed; we fail loudly there. + */ +function buildTransportDelegates( + config: GatewayConfig["chat"]["adapters"], +): TransportAdapter[] { + const delegates: TransportAdapter[] = []; + if (config.telegram) delegates.push(new TelegramAdapter()); + if (config.slack) delegates.push(new SlackAdapter()); + // Caller (Gateway.start) validates `chatAdapters` is non-empty before + // subscribing to events. We don't throw here on empty so test harnesses + // that inject a fake transport after construction still work. + return delegates; +} - return adapters; +// ── Matchers ────────────────────────────────────────── +// Build command matchers for a given botUsername. +// Shared by pre-turn and in-turn dispatch to avoid duplication. +function buildMatchers(botUsername: string) { + return { + isCommand: (t: string, c: string) => _isCmd(t, c, botUsername), + isCommandWithArgs: (t: string, c: string) => _isCmdArgs(t, c, botUsername), + }; } // ── Gateway ────────────────────────────────────────── @@ -79,8 +87,14 @@ export class Gateway { private chat!: Chat; private router: AgentRouter; private config: GatewayConfig; - private transport: TransportAdapter; - private pairingComplete = false; + private transport: CompositeTransportAdapter; + private botUsernameResolver: BotUsernameResolver; + /** + * Per-transport "pairing complete" flag. Keyed by transport delegate name. + * Replaces a single boolean that would silently block a second transport's + * pairing once the first one paired. + */ + private pairingComplete = new Map(); private sttService: SttService | null = null; private cronScheduler: CronSchedulerService | null = null; private ipcServer: IpcServer | null = null; @@ -94,8 +108,28 @@ export class Gateway { constructor(router: AgentRouter, config: GatewayConfig) { this.router = router; this.config = config; - this.transport = new TelegramAdapter(); - _botUsername = config.chat.botUsername || ""; + // Initialize bot username resolver with per-adapter overrides + const adapterOverrides: Record = {}; + for (const [adapterName, adapterConfig] of Object.entries(config.chat.adapters)) { + if (adapterConfig && typeof adapterConfig === 'object' && 'botUsername' in adapterConfig) { + const botUsername = (adapterConfig as any).botUsername; + // Only accept string overrides; ignore non-strings to prevent crashes in helpers + if (typeof botUsername === 'string' && botUsername.length > 0) { + adapterOverrides[adapterName] = botUsername; + } + } + } + this.botUsernameResolver = new BotUsernameResolver({ + globalBotUsername: config.chat.botUsername || "", + adapterOverrides, + }); + const delegates = buildTransportDelegates(config.chat.adapters); + // Empty config (e.g. test harnesses that override `transport` post-construction) + // gets a single-delegate composite around a stub Telegram adapter so the + // composite's "at least one delegate" invariant holds. Real deployments + // always have at least one configured. + this.transport = buildCompositeTransport(delegates.length > 0 ? delegates : [new TelegramAdapter()]); + // BotUsernameResolver handles bot identity per transport; no global fallback needed } /** Handle pending pairing via transport adapter. Returns true if handled. */ @@ -107,24 +141,22 @@ export class Gateway { const result = await this.transport.handlePairing(thread, message); if (!result) return false; - const { threadId: rawThreadId, userId: rawUserId, username } = result; - // Config arrays are currently number[] — coerce with guard. - // When a string-ID transport (Slack/Discord) arrives, widen config types too. - const threadId = typeof rawThreadId === "string" ? Number(rawThreadId) : rawThreadId; - const userId = typeof rawUserId === "string" ? Number(rawUserId) : rawUserId; + // String IDs (Slack `Uxxx`/`Cxxx`) and numeric IDs (Telegram) coexist + // in the widened config arrays. Persist as-is — JSON handles either. + const { threadId, userId, username, transport: transportName } = result; - if (!Number.isFinite(threadId) || !Number.isFinite(userId)) { - console.error(`[roundhouse] Pairing returned non-numeric IDs: threadId=${rawThreadId} userId=${rawUserId}`); + if (threadId == null || userId == null) { + console.error(`[roundhouse] Pairing returned null IDs: threadId=${threadId} userId=${userId}`); return false; } - // Update in-memory config + // Update in-memory config (heterogeneous union list) if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = []; - if (!this.config.chat.allowedUserIds.includes(userId)) { + if (!this.config.chat.allowedUserIds.some(id => sameId(id, userId))) { this.config.chat.allowedUserIds.push(userId); } if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = []; - if (!this.config.chat.notifyChatIds.includes(threadId)) { + if (!this.config.chat.notifyChatIds.some(id => sameId(id, threadId))) { this.config.chat.notifyChatIds.push(threadId); } @@ -136,9 +168,13 @@ export class Gateway { const configRaw = JSON.parse(await rf(cfgPath, "utf8")); if (!configRaw.chat) configRaw.chat = {}; if (!configRaw.chat.allowedUserIds) configRaw.chat.allowedUserIds = []; - if (!configRaw.chat.allowedUserIds.includes(userId)) configRaw.chat.allowedUserIds.push(userId); + if (!configRaw.chat.allowedUserIds.some((id: string | number) => sameId(id, userId))) { + configRaw.chat.allowedUserIds.push(userId); + } if (!configRaw.chat.notifyChatIds) configRaw.chat.notifyChatIds = []; - if (!configRaw.chat.notifyChatIds.includes(threadId)) configRaw.chat.notifyChatIds.push(threadId); + if (!configRaw.chat.notifyChatIds.some((id: string | number) => sameId(id, threadId))) { + configRaw.chat.notifyChatIds.push(threadId); + } const tmp = `${cfgPath}.tmp.${rb(4).toString("hex")}`; await wf(tmp, JSON.stringify(configRaw, null, 2) + "\n"); await mvf(tmp, cfgPath).catch(async (e) => { try { await ulf(tmp); } catch {} throw e; }); @@ -146,8 +182,10 @@ export class Gateway { console.error("[roundhouse] failed to update config after pairing:", cfgErr); } - console.log(`[roundhouse] Pairing complete: @${username} threadId=${threadId} userId=${userId}`); - this.pairingComplete = true; + console.log(`[roundhouse] Pairing complete (${transportName ?? "?"}): @${username} threadId=${threadId} userId=${userId}`); + if (transportName) { + this.pairingComplete.set(transportName, true); + } await thread.post("✅ Roundhouse paired successfully!\n\nSend /status to verify everything is working."); return true; } catch (err) { @@ -157,7 +195,7 @@ export class Gateway { } async start() { - const chatAdapters = await buildChatAdapters(this.config.chat.adapters); + const chatAdapters = await buildChatSdkAdapters(this.config.chat.adapters); // Initialize STT service (enabled by default, can be disabled via config) const rawSttConfig = this.config.voice?.stt; @@ -194,10 +232,11 @@ export class Gateway { const allowedUsers = (this.config.chat.allowedUsers ?? []).map((u) => u.toLowerCase() ); - // Ensure arrays exist on config so pairing hook mutations are visible to isAllowed + // Ensure arrays exist on config so pairing hook mutations are visible to isAllowed. + // Heterogeneous (string | number)[] — Telegram IDs are numeric, Slack are strings. if (!this.config.chat.allowedUserIds) this.config.chat.allowedUserIds = []; if (!this.config.chat.notifyChatIds) this.config.chat.notifyChatIds = []; - const allowedUserIds = this.config.chat.allowedUserIds; + const allowedUserIds: (string | number)[] = this.config.chat.allowedUserIds; // SECURITY: Warn (loudly) when no auth allowlist is configured if (allowedUsers.length === 0 && allowedUserIds.length === 0) { @@ -225,10 +264,8 @@ export class Gateway { }); const preTurnCommands = allDescriptors.filter(isPreTurn); const inTurnCommands = allDescriptors.filter(d => !isPreTurn(d)); - const matchers = { - isCommand: (t: string, c: string) => _isCmd(t, c, _botUsername), - isCommandWithArgs: (t: string, c: string) => _isCmdArgs(t, c, _botUsername), - }; + + // ── Unified handler ────────────────────────────── const handle = async (thread: any, message: any) => { @@ -241,8 +278,12 @@ export class Gateway { `[roundhouse] ${thread.id} -> ${agentThreadId} @${authorName}: "${userText.slice(0, 120)}"${rawAttachments.length ? ` +${rawAttachments.length} attachment(s)` : ""}` ); - // Check for pending pairing via transport adapter - if (!this.pairingComplete && await this.transport.isPairingPending()) { + // Check for pending pairing via the transport that owns this thread. + // Per-transport `pairingComplete` flag prevents one paired transport + // from short-circuiting another that's still pending. + const owningTransport = this.transport.ownerOf(thread); + const ownerName = owningTransport?.name; + if (ownerName && !this.pairingComplete.get(ownerName) && await this.transport.isPairingPending()) { const handled = await this.handlePendingPairing(message, thread); if (handled) return; } @@ -252,12 +293,15 @@ export class Gateway { return; } - if (_isCmd(userText, "/start", _botUsername)) return; + // Transport-specific message filtering (e.g. Telegram /start handshake). + if (this.transport.shouldIgnoreMessage?.(userText, message, thread)) return; if (!userText.trim() && !rawAttachments.length) return; // ── Command dispatch (in-turn stage) ─── const trimmed = userText.trim(); - if (await this.dispatchInTurnCommand(inTurnCommands, matchers, thread, message, trimmed, agentThreadId)) { + // Resolve bot username for this thread's transport, then dispatch + const botUsername = this.botUsernameResolver.resolve(thread); + if (await this.dispatchInTurnCommand(inTurnCommands, thread, botUsername, message, trimmed, agentThreadId)) { return; } @@ -273,6 +317,9 @@ export class Gateway { // Pre-turn commands fire before the main handler (and before the // session-pressure gate), so /cancel etc. still interrupt a mid-run // agent. Allowlist is enforced here for all pre-turn handlers. + // Resolve bot username for this thread's transport for @botname matching + const botUsername = this.botUsernameResolver.resolve(thread); + const matchers = buildMatchers(botUsername); for (const desc of preTurnCommands) { if (matchesDescriptor(desc, text, matchers)) { if (!isAllowed(message, allowedUsers, allowedUserIds)) return; @@ -316,6 +363,30 @@ export class Gateway { await this.chat.initialize(); + // Slack-specific post-initialize wiring (no-op when slack isn't configured): + // 1. Attach the Chat SDK Slack adapter instance to our SlackAdapter + // delegate so postRich/progress/stream can call its webClient. + // The SDK's own initialize() already called auth.test and populated + // botUserId, so the bot self-loop filter is armed by the time events + // start flowing — no extra eager call needed here (verified against + // @chat-adapter/slack@4.29.0 dist/index.js:868-885). + // 2. Register onAssistantThreadStarted to drive first-DM pairing + // before the user has typed anything. + const slackDelegate = this.transport.delegates.find((d): d is SlackAdapter => d.name === "slack") as SlackAdapter | undefined; + if (slackDelegate) { + try { + const slackSdk = (this.chat as unknown as { getAdapter(name: string): unknown }).getAdapter("slack") as Parameters[0]; + if (slackSdk) { + slackDelegate.attach(slackSdk); + this.registerAssistantThreadStartedHandler(); + } else { + console.warn("[roundhouse] slack adapter not exposed via chat.getAdapter('slack') — pairing/streaming may not work"); + } + } catch (err) { + console.error("[roundhouse] slack post-initialize wiring failed:", (err as Error).message); + } + } + const platforms = Object.keys(this.config.chat.adapters).join(", "); console.log(`[roundhouse] gateway ready (platforms: ${platforms})`); @@ -326,7 +397,7 @@ export class Gateway { this.cronScheduler = new CronSchedulerService({ agentConfig: this.config.agent, notifyChatIds: this.config.chat.notifyChatIds, - notifyFn: async (chatIds: number[], text: string) => { + notifyFn: async (chatIds: (string | number)[], text: string) => { if (chatIds.length && this.transport) { await this.transport.notify(chatIds, text); } @@ -349,7 +420,10 @@ export class Gateway { // Start sub-agent orchestrator + watcher this.subagentOrchestrator = new SubAgentOrchestratorImpl(); this.subagentOrchestrator.onSpawn(async (status) => { - const chatId = Number(status.routing?.chatId); + // Sub-agent routing carries the chatId as a string; preserve as-is so + // both telegram (numeric-as-string) and slack (`Cxxx`) work. Composite + // partitions by ownsChatId before fanning out. + const chatId = status.routing?.chatId; if (chatId) { const msg = `🔬 **Sub-agent launched** (${status.role})\nrun: \`${status.runId.slice(0, 8)}\``; try { await this.transport.notify([chatId], msg); } catch {} @@ -440,7 +514,25 @@ export class Gateway { console.error(`[roundhouse] memory prepare error:`, (err as Error).message); } - stopTyping = startTypingLoop(thread); + // Inject a Slack-specific stopTyping hook when the thread belongs + // to Slack — the SDK's default clear path is broken in 4.29.0 + // (sends `loading_messages: [""]` which Slack rejects). For + // non-Slack threads slackStopTyping is null and the typing loop + // falls back to thread.startTyping("") (Telegram's sendChatAction + // auto-expires anyway so the call is harmless there). + // + // Wrapper object delegates startTyping back to the original thread + // so we keep its `this`-binding. Don't spread the class instance — + // that would lose its prototype. + const slackDelegate = this.transport.delegates.find((d) => d.name === "slack") as SlackAdapter | undefined; + const slackStopTyping = slackDelegate?.stopTypingFor?.(thread) ?? null; + const typingThread = slackStopTyping + ? { + startTyping: (status?: string) => thread.startTyping(status), + stopTyping: slackStopTyping, + } + : thread; + stopTyping = startTypingLoop(typingThread); // Pre-turn recovery: if a prior turn failed to compact (state has // pendingCompact === "emergency"), the live session is almost certainly @@ -661,9 +753,9 @@ export class Gateway { return null; } - // Enrich prompt via transport adapter + // Enrich prompt via transport adapter (composite routes by ownsThread). if (agentMessage.text) { - agentMessage.text = this.transport.enrichPrompt(agentMessage.text); + agentMessage.text = this.transport.enrichPrompt(thread, agentMessage.text); } return agentMessage; @@ -721,7 +813,7 @@ export class Gateway { private buildCommandContext( thread: any, message: any, agentThreadId: string, authorName: string, - allowedUsers: string[], allowedUserIds: number[], + allowedUsers: string[], allowedUserIds: (string | number)[], verboseThreads: Set, threadLocks: Map>, ): CommandContext { return { @@ -759,7 +851,7 @@ export class Gateway { */ private buildCommandDescriptors(deps: { allowedUsers: string[]; - allowedUserIds: number[]; + allowedUserIds: (string | number)[]; verboseThreads: Set; threadLocks: Map>; abortControllers: Map; @@ -855,6 +947,7 @@ export class Gateway { verbose, signal, postWithFallback: (t, text) => this.postWithFallback(t, text), + transport: this.transport, }); } @@ -871,9 +964,13 @@ export class Gateway { */ private async dispatchInTurnCommand( inTurnCommands: readonly CommandDescriptor[], - matchers: { isCommand: (t: string, c: string) => boolean; isCommandWithArgs: (t: string, c: string) => boolean }, - thread: any, message: unknown, trimmed: string, agentThreadId: string, + thread: any, + botUsername: string, + message: unknown, + trimmed: string, + agentThreadId: string, ): Promise { + const matchers = buildMatchers(botUsername); // Reuse shared factory const inv: CommandInvocation = { thread, message: message as { text?: string; [key: string]: unknown }, text: trimmed, agentThreadId }; for (const desc of inTurnCommands) { if (matchesDescriptor(desc, trimmed, matchers)) { @@ -930,20 +1027,78 @@ export class Gateway { } /** - * Register bot commands with Telegram so they appear in the / menu. - * Runs on every startup to keep commands in sync with the code. + * Register bot commands with each configured platform so they appear in + * its / menu (Telegram) or app config (Slack — no-op at runtime). + * Each delegate self-sources its own creds and no-ops if they're missing, + * so the gateway calls unconditionally. */ private async registerBotCommands() { - if (!this.config.chat.adapters.telegram) return; - const token = process.env.TELEGRAM_BOT_TOKEN; - if (!token) return; - await this.transport.registerCommands(token); + await this.transport.registerCommands(); + } + + /** + * Wire `bot.onAssistantThreadStarted` → composite.handlePairing. + * + * Slack fires this when the user opens an assistant DM with the bot, + * BEFORE the user types anything. Without this hook, the first-DM + * pairing path can deadlock: `message.im` only fires for *existing* DMs, + * so a user who just clicked "Message" on the bot's profile has no DM + * channel yet and never produces an event. + * + * We synthesize an IncomingMessage with the user's display name resolved + * via `slackSdk.getUser(userId)` so the pending pairing's allowedUsers + * check (which compares lowercased userName) succeeds. + */ + private registerAssistantThreadStartedHandler(): void { + const slackDelegate = this.transport.delegates.find((d) => d.name === "slack") as SlackAdapter | undefined; + if (!slackDelegate) return; + + // chat.onAssistantThreadStarted is the public registration on ChatInstance + // (chat@4.29.0 chat-D9UYaaNO.d.ts:2913). Cast here is just to match the + // local Chat type that elides the assistant API. + const bot = this.chat as unknown as { + onAssistantThreadStarted?: (handler: (event: any) => Promise | void) => void; + getAdapter(name: string): { getUser?(userId: string): Promise<{ userName?: string; fullName?: string } | null> } | undefined; + }; + + if (!bot.onAssistantThreadStarted) { + console.warn("[roundhouse] chat instance doesn't expose onAssistantThreadStarted; skipping"); + return; + } + + bot.onAssistantThreadStarted(async (event: { userId: string; channelId: string; threadTs: string }) => { + try { + const slackSdk = bot.getAdapter("slack"); + const userInfo = slackSdk?.getUser ? await slackSdk.getUser(event.userId).catch(() => null) : null; + // Synthesize a thread that ownsThread('slack:…') and a message that + // matches the IncomingMessage shape the existing pairing pipeline expects. + const syntheticThread = this.transport.createThread(event.channelId); + const syntheticMessage = { + text: "", + author: { + userId: event.userId, + userName: userInfo?.userName, + name: userInfo?.fullName, + }, + chatId: event.channelId, + raw: event, + }; + const handled = await this.handlePendingPairing(syntheticMessage, syntheticThread); + if (handled) { + console.log(`[roundhouse] slack pairing completed via assistant_thread_started for ${event.userId}`); + } + } catch (err) { + console.error("[roundhouse] assistant_thread_started handler failed:", (err as Error).message); + } + }); } /** * Send a startup notification to configured chat IDs. - * Currently Telegram-only — when Slack/Discord adapters are added, - * extend this to use their respective APIs or a Chat SDK broadcast API. + * Multi-transport: each chatId is routed by `ownsChatId`; the + * "Session: …" label is delegated to `transport.formatNotifySession` + * so platform-specific semantics (Telegram negative-id = group, Slack + * `Dxxx` = DM) live in the adapter, not here. */ private async notifyStartup(platforms: string) { const chatIds = this.config.chat.notifyChatIds; @@ -976,7 +1131,7 @@ export class Gateway { const whatsNew = checkVersionChange(); for (const chatId of chatIds) { - const sessionId = Number(chatId) < 0 ? `group:${chatId}` : "main"; + const sessionId = this.transport.formatNotifySession(chatId); const perChatText = [ `\u2705 Roundhouse is online`, ``, @@ -1004,6 +1159,12 @@ export class Gateway { /** * Fire a boot turn — send a prompt to the agent so it greets in-character. * Seeds the session on startup so context is never empty. + * + * Multi-transport: fire one boot turn per transport that has at least one + * configured chat id, against the FIRST chat id owned by that transport. + * This ensures both Telegram and Slack get a "hello" on the same gateway + * boot, instead of a global `chatIds[0]` which would silently favor + * whichever transport happened to be listed first. */ private async fireBootTurn( verboseThreads: Set, @@ -1013,19 +1174,28 @@ export class Gateway { const chatIds = this.config.chat.notifyChatIds; if (!chatIds?.length) return; - // Only fire for the primary (first) chat - const primaryChatId = chatIds[0]; - const agentThreadId = "main"; - - // Create a thread via the transport adapter — no transport-specific logic in gateway - const syntheticThread = this.transport.createThread(primaryChatId); - const bootPrompt = "You just came online after a restart. Say a brief hello in-character (1–2 sentences max). Check your workspace for any pending tasks."; - try { - await this.handleAgentTurn(syntheticThread, agentThreadId, bootPrompt, [], verboseThreads, threadLocks, abortControllers, "boot"); - } catch (err) { - console.error("[roundhouse] boot turn failed:", (err as Error).message); + // Pick the first chatId owned by each delegate (deduplicated by transport name). + const seenTransports = new Set(); + const primaryPerTransport: Array<{ transport: string; chatId: string | number }> = []; + for (const chatId of chatIds) { + const owner = this.transport.ownerOfChatId(chatId); + if (!owner || seenTransports.has(owner.name)) continue; + seenTransports.add(owner.name); + primaryPerTransport.push({ transport: owner.name, chatId }); + } + + for (const { transport, chatId } of primaryPerTransport) { + try { + // Derive per-transport boot session id (not global "main") + // This ensures each transport seeds its own session, not cross-contaminated + const agentThreadId = this.transport.encodeParentThreadId(chatId); + const syntheticThread = this.transport.createThread(chatId); + await this.handleAgentTurn(syntheticThread, agentThreadId, bootPrompt, [], verboseThreads, threadLocks, abortControllers, "boot"); + } catch (err) { + console.error(`[roundhouse] boot turn (${transport}) failed:`, (err as Error).message); + } } } @@ -1046,7 +1216,7 @@ export class Gateway { /** Handle sub-agent completion — notify user AND inject result into agent session */ private async handleSubagentCompletion(status: RunStatus, routing: RoutingInfo): Promise { - const chatId = Number(routing.chatId); + const chatId = routing.chatId; if (!chatId) return; await this.notifySubagentResult(status, chatId); @@ -1054,7 +1224,7 @@ export class Gateway { } /** Notify user of sub-agent completion via transport */ - private async notifySubagentResult(status: RunStatus, chatId: number): Promise { + private async notifySubagentResult(status: RunStatus, chatId: string | number): Promise { const emoji = status.status === "complete" ? "✅" : status.status === "timeout" ? "⏰" : "❌"; const duration = status.completedAt && status.startedAt ? Math.round((Date.parse(status.completedAt) - Date.parse(status.startedAt)) / 1000) @@ -1068,7 +1238,7 @@ export class Gateway { } /** Inject sub-agent output into agent session as synthetic turn */ - private async injectSubagentResult(status: RunStatus, chatId: number): Promise { + private async injectSubagentResult(status: RunStatus, chatId: string | number): Promise { try { const runDir = join(process.env.HOME || "/home/ec2-user", ".roundhouse", "subagents", status.runId); let stdout = ""; diff --git a/src/gateway/streaming.ts b/src/gateway/streaming.ts index 5d3b935..9e5302b 100644 --- a/src/gateway/streaming.ts +++ b/src/gateway/streaming.ts @@ -9,8 +9,8 @@ */ import type { AgentStreamEvent } from "../types"; +import type { TransportAdapter } from "../transports"; import { READ_ONLY_TOOLS } from "../memory/types"; -import { isTelegramThread, handleTelegramHtmlStream } from "../transports/telegram/html"; import { DEBUG_STREAM } from "../util"; import { toolIcon } from "./helpers"; import { isContextOverflowError } from "../agents/shared/error-classifiers"; @@ -92,6 +92,14 @@ export interface StreamContext { verbose: boolean; signal?: AbortSignal; postWithFallback: (thread: any, text: string) => Promise; + /** + * Transport that owns the thread. The dispatch routes the per-turn + * AsyncIterable through `transport.stream(thread, iter, signal)`. + * Telegram's impl wraps the existing handleTelegramHtmlStream; Slack's + * impl wraps a post-then-edit fallback. Optional only because tests can + * fall back to `thread.handleStream` directly. + */ + transport?: TransportAdapter; } export interface StreamResult { @@ -112,7 +120,7 @@ export async function handleStreaming( stream: AsyncIterable, ctx: StreamContext, ): Promise { - const { thread, verbose, signal, postWithFallback } = ctx; + const { thread, verbose, signal, postWithFallback, transport } = ctx; let activeTools = new Map(); let usedFileModifyingTools = false; @@ -131,16 +139,17 @@ export async function handleStreaming( currentPromise = null; }; - const useTelegramHtml = isTelegramThread(thread); - + // Per-transport streaming: prefer transport.stream(thread, iter, signal) + // when we have a transport; fall back to thread.handleStream() (used by + // tests that don't pass a transport). const ensureStream = () => { if (!currentPromise) { const ts = createTextStream(); currentPush = ts.push; currentFinish = ts.finish; - currentPromise = useTelegramHtml - ? handleTelegramHtmlStream(thread, ts.iterable).catch((err: Error) => { - console.warn(`[roundhouse] telegram html stream error:`, err.message); + currentPromise = transport + ? transport.stream(thread, ts.iterable, signal).catch((err: Error) => { + console.warn(`[roundhouse] transport stream error:`, err.message); }) : thread.handleStream(ts.iterable).catch((err: Error) => { console.warn(`[roundhouse] handleStream error:`, err.message); diff --git a/src/ipc/handler.ts b/src/ipc/handler.ts index 18f1b7b..4c654ce 100644 --- a/src/ipc/handler.ts +++ b/src/ipc/handler.ts @@ -17,12 +17,16 @@ export function createIpcHandler( const allChatIds = getConfig().chat.notifyChatIds ?? []; if (allChatIds.length === 0) return { ok: false, error: "No notifyChatIds configured" }; - let targetIds: number[]; + let targetIds: (string | number)[]; if (req.session === "main") { targetIds = [allChatIds[0]]; - } else if (req.session && /^-?\d+$/.test(req.session)) { - targetIds = [Number(req.session)]; + } else if (req.session && transport.ownsChatId(req.session)) { + // session value is a recognized chat id (telegram numeric-as-string + // OR slack `Cxxx`/`Dxxx`/`Uxxx`/`Gxxx`). Single-target route. + targetIds = [req.session]; } else { + // No session, or session that doesn't match any transport's id shape: + // fan out to all configured ids. targetIds = allChatIds; } diff --git a/src/transports/chat-adapters.ts b/src/transports/chat-adapters.ts new file mode 100644 index 0000000..d2d99bb --- /dev/null +++ b/src/transports/chat-adapters.ts @@ -0,0 +1,76 @@ +/** + * transports/chat-adapters.ts — Chat SDK adapter factory registry + * + * Maps a transport name (key in `config.chat.adapters`) to a lazy factory + * that returns a configured `@chat-adapter/` instance for the + * Vercel Chat SDK. Lazy because each adapter package is an optional + * dependency — we don't crash if a non-configured one is uninstalled. + * + * Adding a new chat platform = one entry here + a `TransportAdapter` + * implementation under `src/transports//`. + */ + +/** + * A factory that builds a chat-SDK adapter instance from its config block. + * The return type is `unknown` because the chat SDK's `Adapter` interface + * is generic over the platform's raw event/thread types — different per + * platform, and we don't unify them here. + */ +type ChatAdapterFactory = (config: Record) => unknown; + +/** Lazy factory: imports the adapter package only when it's needed. */ +type LazyChatAdapterFactory = () => Promise; + +export const chatAdapterFactories: Record = { + telegram: async () => { + const { createTelegramAdapter } = await import("@chat-adapter/telegram"); + return (cfg) => createTelegramAdapter({ + mode: (cfg.mode as "auto" | "polling" | "webhook" | undefined) ?? "auto", + }); + }, + slack: async () => { + const mod = await import("@chat-adapter/slack").catch(() => null); + if (!mod) { + throw new Error( + "Slack transport configured but @chat-adapter/slack is not installed. " + + "Run: npm install @chat-adapter/slack", + ); + } + const { createSlackAdapter } = mod as typeof import("@chat-adapter/slack"); + return (cfg) => createSlackAdapter({ + mode: (cfg.mode as "socket" | "webhook" | undefined) ?? "socket", + // CRITICAL: createSlackAdapter only env-falls-back env vars when ZERO + // config is passed (zeroConfig = !config). Because we pass an object, + // we MUST forward the env vars explicitly — otherwise webClient calls + // throw `AuthenticationError: No bot token available …`. + // Verified against @chat-adapter/slack@4.29.0 dist/index.js:4233-4243. + botToken: (cfg.botToken as string | undefined) ?? process.env.SLACK_BOT_TOKEN, + appToken: (cfg.appToken as string | undefined) ?? process.env.SLACK_APP_TOKEN, + signingSecret: (cfg.signingSecret as string | undefined) ?? process.env.SLACK_SIGNING_SECRET, + }); + }, +}; + +/** + * Build the `Chat` adapters map from a roundhouse `config.chat.adapters` + * block. Throws if a configured key has no factory (so a typo at config + * time fails at startup, not silently). + */ +export async function buildChatAdapters( + config: Record | undefined>, +): Promise> { + const out: Record = {}; + for (const [name, cfg] of Object.entries(config)) { + if (!cfg) continue; + const factory = chatAdapterFactories[name]; + if (!factory) { + throw new Error( + `Unknown chat adapter "${name}" in config.chat.adapters. ` + + `Known adapters: ${Object.keys(chatAdapterFactories).join(", ")}`, + ); + } + const create = await factory(); + out[name] = create(cfg); + } + return out; +} diff --git a/src/transports/composite.ts b/src/transports/composite.ts new file mode 100644 index 0000000..b073163 --- /dev/null +++ b/src/transports/composite.ts @@ -0,0 +1,197 @@ +/** + * transports/composite.ts — Composite transport adapter + * + * Routes calls across multiple `TransportAdapter` delegates. Lets the + * gateway run multiple chat platforms (Telegram + Slack) under a single + * `this.transport` field, with no per-call branching. + * + * Routing rules: + * - Per-thread methods (postMessage/postRich/progress/stream/enrichPrompt): + * dispatch to the first delegate where `ownsThread(thread)` is true. + * - `notify(chatIds, …)`: partition chatIds by `ownsChatId`, fan out. + * - `createThread(chatId)`: route by `ownsChatId`. + * - `encodeParentThreadId` / `formatNotifySession`: route by `ownsChatId`. + * - `registerCommands` / `dispose` / `isPairingPending`: fan out to all. + * - `handlePairing`: walk delegates, return first non-null. Decorates + * the result with the delegate's `name` so the gateway can mark + * `pairingComplete` per-transport. + * - `shouldIgnoreMessage`: routed by `ownsThread`. + * + * If no delegate owns a thread / chat id, per-thread/per-chatId methods + * log + drop. This matches the existing best-effort post model. + */ + +import type { + TransportAdapter, + ChatThread, + IncomingMessage, + PairingResult, + RichResponse, + ProgressMessage, +} from "./types"; + +/** No-op progress message — used as a fallback when no delegate owns the thread. */ +const NOOP_PROGRESS: ProgressMessage = { update: async () => {} }; + +export class CompositeTransportAdapter implements TransportAdapter { + readonly name = "composite"; + readonly delegates: ReadonlyArray; + + constructor(delegates: TransportAdapter[]) { + if (delegates.length === 0) { + throw new Error("CompositeTransportAdapter requires at least one delegate"); + } + this.delegates = delegates; + } + + /** Find the delegate (if any) that owns `thread`. Public so gateway can map thread → transport name. */ + ownerOf(thread: ChatThread): TransportAdapter | null { + return this.delegates.find(d => d.ownsThread(thread)) ?? null; + } + + /** Find the delegate (if any) that recognizes `chatId`. */ + ownerOfChatId(id: string | number): TransportAdapter | null { + return this.delegates.find(d => d.ownsChatId(id)) ?? null; + } + + enrichPrompt(thread: ChatThread, text: string): string { + const owner = this.ownerOf(thread); + return owner ? owner.enrichPrompt(thread, text) : text; + } + + async postMessage(thread: ChatThread, text: string): Promise { + const owner = this.ownerOf(thread); + if (!owner) { + console.warn(`[composite] postMessage: no delegate owns thread ${thread.id}; dropping`); + return; + } + await owner.postMessage(thread, text); + } + + async postRich(thread: ChatThread, response: RichResponse): Promise { + const owner = this.ownerOf(thread); + if (!owner) { + console.warn(`[composite] postRich: no delegate owns thread ${thread.id}; dropping`); + return; + } + await owner.postRich(thread, response); + } + + async progress(thread: ChatThread, initialText: string): Promise { + const owner = this.ownerOf(thread); + if (!owner) { + console.warn(`[composite] progress: no delegate owns thread ${thread.id}; returning no-op`); + return NOOP_PROGRESS; + } + return owner.progress(thread, initialText); + } + + async stream(thread: ChatThread, iter: AsyncIterable, signal?: AbortSignal): Promise { + const owner = this.ownerOf(thread); + if (!owner) { + console.warn(`[composite] stream: no delegate owns thread ${thread.id}; dropping`); + return; + } + await owner.stream(thread, iter, signal); + } + + async registerCommands(): Promise { + // Fan out — each delegate self-sources its own creds and no-ops if missing. + await Promise.all(this.delegates.map(d => d.registerCommands().catch(err => { + console.warn(`[composite] ${d.name}.registerCommands failed:`, (err as Error).message); + }))); + } + + ownsThread(thread: ChatThread): boolean { + return this.delegates.some(d => d.ownsThread(thread)); + } + + ownsChatId(id: string | number): boolean { + return this.delegates.some(d => d.ownsChatId(id)); + } + + encodeParentThreadId(chatId: string | number): string { + const owner = this.ownerOfChatId(chatId); + if (!owner) { + throw new Error(`No transport recognizes chat id ${chatId}`); + } + return owner.encodeParentThreadId(chatId); + } + + formatNotifySession(chatId: string | number): string { + const owner = this.ownerOfChatId(chatId); + return owner ? owner.formatNotifySession(chatId) : "main"; + } + + async notify(chatIds: (string | number)[], text: string): Promise { + // Partition by delegate, then fan out. Delegates also filter internally, + // so this is partly defensive — but it lets us avoid calling notify on + // delegates that have nothing to do, which keeps logs quiet. + const buckets = new Map(); + for (const id of chatIds) { + const owner = this.ownerOfChatId(id); + if (!owner) { + console.warn(`[composite] notify: no delegate owns chat id ${id}; skipping`); + continue; + } + const list = buckets.get(owner) ?? []; + list.push(id); + buckets.set(owner, list); + } + await Promise.all( + [...buckets.entries()].map(([d, ids]) => + d.notify(ids, text).catch(err => { + console.warn(`[composite] ${d.name}.notify failed:`, (err as Error).message); + }), + ), + ); + } + + createThread(chatId: string | number): ChatThread { + const owner = this.ownerOfChatId(chatId); + if (!owner) { + throw new Error(`No transport recognizes chat id ${chatId} for createThread`); + } + return owner.createThread(chatId); + } + + async isPairingPending(): Promise { + const flags = await Promise.all(this.delegates.map(d => d.isPairingPending().catch(() => false))); + return flags.some(Boolean); + } + + async handlePairing(thread: ChatThread, message: IncomingMessage): Promise { + // Walk delegates in order. The first that returns non-null wins. We + // attach `transport: d.name` so the gateway can mark `pairingComplete` + // per-transport. + // + // CRITICAL: Only dispatch to delegates that own the thread. This prevents + // cross-transport pairing side effects: e.g., if Telegram sends a message + // with a username matching pending Slack pairing, Slack's handlePairing + // must not be invoked (it would match on username alone without checking + // thread ownership, corrupting notifyChatIds with a Telegram chat ID). + for (const d of this.delegates) { + if (!d.ownsThread(thread)) continue; // Skip non-owning adapters + const result = await d.handlePairing(thread, message); + if (result) { + return { ...result, transport: result.transport ?? d.name }; + } + } + return null; + } + + shouldIgnoreMessage(text: string, message: IncomingMessage, thread: ChatThread): boolean { + const owner = this.ownerOf(thread); + if (!owner?.shouldIgnoreMessage) return false; + return owner.shouldIgnoreMessage(text, message, thread); + } +} + +/** + * Convenience: build a composite from the configured chat-adapter map keys. + * Always wraps in a composite even for a single delegate so the gateway + * has a uniform interface. + */ +export function buildCompositeTransport(delegates: TransportAdapter[]): CompositeTransportAdapter { + return new CompositeTransportAdapter(delegates); +} diff --git a/src/transports/index.ts b/src/transports/index.ts index f6eed09..d765377 100644 --- a/src/transports/index.ts +++ b/src/transports/index.ts @@ -5,6 +5,7 @@ export type { TransportAdapter, ChatThread, + ChatThreadPost, IncomingMessage, PairingResult, RichButton, @@ -15,5 +16,8 @@ export type { MinimalThread, } from "./types"; export { TelegramAdapter } from "./telegram/telegram-adapter"; -export { buildSelectableMenu } from "./rich-helpers"; +export { SlackAdapter } from "./slack/slack-adapter"; +export { CompositeTransportAdapter, buildCompositeTransport } from "./composite"; +export { chatAdapterFactories, buildChatAdapters as buildChatSdkAdapters } from "./chat-adapters"; +export { buildSelectableMenu, richMenuToCard, stripMarkdownToPlain } from "./rich-helpers"; export type { SelectableOption, SelectableMenuOpts } from "./rich-helpers"; diff --git a/src/transports/rich-helpers.ts b/src/transports/rich-helpers.ts index 63e883e..cc4ade8 100644 --- a/src/transports/rich-helpers.ts +++ b/src/transports/rich-helpers.ts @@ -9,7 +9,15 @@ * Transport-neutral: this module never imports from transports/telegram. */ -import type { RichButton, RichResponse } from "./types"; +import { Card, Section, Actions, Button } from "chat"; +import type { CardElement } from "chat"; +import type { RichButton, RichMenu, RichResponse } from "./types"; + +// `Text` from "chat" resolves to mdast's TYPE re-export, not the JSX +// factory; the factory lives at chat/jsx-runtime. Constructing TextElement +// objects directly keeps the dep boundary small. +const text = (content: string, style?: "plain" | "bold" | "muted") => + ({ type: "text" as const, content, ...(style ? { style } : {}) }); /** A single picker option. `key` is the callback value; `label` is what the user sees. */ export interface SelectableOption { @@ -116,3 +124,75 @@ export function buildSelectableMenu(opts: SelectableMenuOpts): RichResponse { }, }; } + +/** + * Convert a RichMenu to the Chat SDK's transport-agnostic Card model. + * + * The Slack adapter renders this to Block Kit via cardToBlockKit; + * Telegram's adapter renders it to inline keyboards via extractCard. + * One conversion → many platforms — this is the v3 plan's reason for + * dropping per-transport menu converters. + * + * `headerProse` is optional rendering for the prose that would otherwise + * be the menuCaption. Cards put it inside a Section so the markdown + * actually renders (Slack's mrkdwn is the accepted text dialect inside + * Block Kit sections). + */ +export function richMenuToCard(menu: RichMenu, headerProse?: string): CardElement { + const children: ReturnType[] = []; + if (headerProse) { + children.push(Section([text(headerProse) as any])); + } + for (const section of menu.sections) { + const sectionChildren: any[] = []; + if (section.title) sectionChildren.push(text(section.title, "bold")); + // Slack's actions block holds at most 5 elements; chunk if needed so + // the SDK doesn't have to. + for (const chunk of chunkArray(section.buttons, 5)) { + sectionChildren.push(Actions(chunk.map(richButtonToButton))); + } + children.push(Section(sectionChildren)); + } + return Card({ children }); +} + +function richButtonToButton(btn: RichButton) { + return Button({ + id: btn.actionId, // Slack: action_id; Telegram: callback_data + label: btn.label, + value: btn.value, + ...(btn.selected ? { style: "primary" as const } : {}), + }); +} + +function chunkArray(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +/** + * Strip markdown to a plain-text approximation for card `fallbackText`. + * Used by transports that fall back when cards can't render — Slack's + * notifications/mobile-previews show this string. + * + * Best-effort; not a proper markdown parser. Removes: + * `**bold**` / `*bold*` / `_italic_` / `~~strike~~` / `` `code` `` + * markdown links → text only + * leading bullet markers + */ +export function stripMarkdownToPlain(md: string): string { + return md + .replace(/```[\s\S]*?```/g, (m) => m.slice(3, -3).replace(/^\w*\n?/, "")) // fenced code + .replace(/\*\*\*(.+?)\*\*\*/g, "$1") + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/__(.+?)__/g, "$1") + .replace(/(? { + try { + const res = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + channel: channelId, + markdown_text: text, + // Match the SDK's defaults so links don't unfurl unexpectedly in + // notify-emitted messages while gateway-emitted ones don't. + unfurl_links: options?.unfurlLinks ?? false, + mrkdwn: options?.mrkdwn ?? true, + }), + signal: AbortSignal.timeout(options?.timeoutMs ?? DEFAULT_TIMEOUT), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + console.warn(`[slack] postMessage to ${channelId} failed (${res.status}): ${errBody.slice(0, 200)}`); + return false; + } + // Slack returns ok:false in JSON for app-level errors even on HTTP 200. + const json = await res.json().catch(() => null) as { ok?: boolean; error?: string } | null; + if (json && json.ok === false) { + console.warn(`[slack] postMessage to ${channelId} api error: ${json.error ?? "unknown"}`); + return false; + } + return true; + } catch (err) { + console.warn(`[slack] postMessage to ${channelId} failed:`, (err as Error).message); + return false; + } +} + +/** Filter chatIds to slack-shaped strings and post to each. */ +export async function postSlackToMany( + chatIds: (string | number)[], + text: string, +): Promise { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + console.warn("[slack] SLACK_BOT_TOKEN not set — skipping notification"); + return; + } + const slackIds = chatIds.filter(isSlackChatId) as string[]; + for (const id of slackIds) { + await postSlackMessage(token, id, text); + } +} diff --git a/src/transports/slack/pairing.ts b/src/transports/slack/pairing.ts new file mode 100644 index 0000000..eca55cd --- /dev/null +++ b/src/transports/slack/pairing.ts @@ -0,0 +1,119 @@ +/** + * transports/slack/pairing.ts — Persistent pending-pairing state for Slack. + * + * Used by: + * - `roundhouse setup --slack`: writes pending pairing before starting + * the gateway. + * - gateway: detects pending pairing and completes on the first + * message.im event from an allowed user (or assistant_thread_started + * when the workspace has Assistants API enabled — Slack fires that + * immediately when the user opens an assistant DM thread, before the + * user types anything). + * + * The first-DM-from-allowed-user model has a chicken-and-egg gap: Slack + * `message.im` events only fire for *existing* DM channels. If the user + * hasn't opened a DM with the bot first, we never see a message — hence + * the assistant_thread_started fallback in §2.4 of slack-plan.md. + */ + +import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { randomBytes } from "node:crypto"; +import { ROUNDHOUSE_DIR } from "../../config"; + +export interface PendingSlackPairing { + version: 1; + /** Slack workspace team ID (Txxx). Captured at first event. */ + workspaceTeamId?: string; + /** Slack bot user id (Uxxx). Captured at gateway start via auth.test. */ + botUserId?: string; + /** Lowercase usernames that may complete the pairing. */ + allowedUsers: string[]; + /** Optionally allow Slack user IDs (`Uxxx`) directly — for assistant_thread_started where userName isn't known yet. */ + allowedUserIds?: string[]; + createdAt: string; + status: "pending" | "paired"; + pairedAt?: string; + /** DM channel ID (Dxxx) once paired. */ + channelId?: string; + /** Slack user ID (Uxxx) once paired. */ + userId?: string; + /** Slack username once resolved. */ + username?: string; +} + +export const SLACK_PAIRING_PATH = resolve(ROUNDHOUSE_DIR, "slack-pairing.json"); + +/** Read the pending Slack pairing file. Returns null if missing or invalid. */ +export async function readPendingSlackPairing(): Promise { + try { + const raw = await readFile(SLACK_PAIRING_PATH, "utf8"); + const data = JSON.parse(raw); + if (data?.version === 1 && Array.isArray(data?.allowedUsers) && data?.status) { + return data as PendingSlackPairing; + } + return null; + } catch { + return null; + } +} + +/** Atomic 0600 write. */ +export async function writePendingSlackPairing(state: PendingSlackPairing): Promise { + await mkdir(dirname(SLACK_PAIRING_PATH), { recursive: true }); + const tmp = `${SLACK_PAIRING_PATH}.tmp.${randomBytes(4).toString("hex")}`; + try { + await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + await rename(tmp, SLACK_PAIRING_PATH); + } catch (err) { + try { await unlink(tmp); } catch {} + throw err; + } +} + +export async function completePendingSlackPairing(result: { + channelId: string; + userId: string; + username?: string; +}): Promise { + const existing = await readPendingSlackPairing(); + if (!existing || existing.status !== "pending") return null; + const completed: PendingSlackPairing = { + ...existing, + status: "paired", + pairedAt: new Date().toISOString(), + channelId: result.channelId, + userId: result.userId, + username: result.username ?? existing.username, + }; + await writePendingSlackPairing(completed); + return completed; +} + +export async function clearPendingSlackPairing(): Promise { + try { await unlink(SLACK_PAIRING_PATH); } catch {} +} + +/** + * Check whether `messageOrEvent` matches the pending Slack pairing. + * + * Returns the matched pairing details if so, null otherwise. Uses both: + * - `author.userName` (lowercased, leading-@ stripped) compared to + * `pending.allowedUsers`. + * - `author.userId` (raw) compared to `pending.allowedUserIds` — covers + * the `assistant_thread_started` path where userName isn't populated + * yet (the gateway pre-resolves it via getUser before calling here, + * but we still accept Uxxx-literal allowlist entries as a fallback). + */ +export function matchPendingPairing( + pending: PendingSlackPairing, + authorUserName: string | undefined, + authorUserId: string | undefined, +): boolean { + if (pending.status !== "pending") return false; + const normalizedName = (authorUserName ?? "").trim().replace(/^@/, "").toLowerCase(); + const allowed = pending.allowedUsers.map((u) => u.replace(/^@/, "").toLowerCase()); + if (normalizedName && allowed.includes(normalizedName)) return true; + if (authorUserId && pending.allowedUserIds?.includes(authorUserId)) return true; + return false; +} diff --git a/src/transports/slack/progress.ts b/src/transports/slack/progress.ts new file mode 100644 index 0000000..ca03837 --- /dev/null +++ b/src/transports/slack/progress.ts @@ -0,0 +1,68 @@ +/** + * transports/slack/progress.ts — Editable progress messages for Slack. + * + * Mirrors src/transports/telegram/progress.ts: post once, then edit in + * place via chat.update. The `update()` callback never throws — long- + * running commands rely on best-effort progress display, not delivery + * guarantees. + */ + +import type { SlackAdapter } from "@chat-adapter/slack"; +import type { ChatThread, ProgressMessage } from "../types"; + +interface SlackThreadShape { + id?: string; + adapter?: { slack?: SlackAdapter }; +} + +/** No-op fallback so we always return a usable ProgressMessage. */ +const NOOP_PROGRESS: ProgressMessage = { update: async () => {} }; + +export async function createSlackProgress( + sdk: SlackAdapter, + thread: ChatThread, + initialText: string, +): Promise { + const shape = thread as unknown as SlackThreadShape; + const threadId = typeof shape.id === "string" ? shape.id : null; + if (!threadId || !threadId.startsWith("slack:")) { + return NOOP_PROGRESS; + } + + const { channel, threadTs } = sdk.decodeThreadId(threadId); + // Slack's encodeThreadId requires a non-empty threadTs; we use "" as + // a sentinel for top-level posts. A real threadTs (e.g. "1712023032.1234") + // means we're inside a Slack reply thread. + const replyOpts = (threadTs && threadTs !== "" && threadTs !== "main") + ? { thread_ts: threadTs } + : {}; + + let initial: { ts?: string; channel?: string } | undefined; + try { + initial = await sdk.webClient.chat.postMessage({ + channel, + markdown_text: initialText, + ...replyOpts, + }) as any; + } catch (err) { + console.warn("[slack/progress] initial post failed:", (err as Error).message); + return NOOP_PROGRESS; + } + + const ts = initial?.ts; + if (!ts) return NOOP_PROGRESS; + + return { + update: async (text: string) => { + try { + await sdk.webClient.chat.update({ + channel, + ts, + markdown_text: text, + }); + } catch { + // ProgressMessage.update is documented to never throw. + } + }, + }; +} diff --git a/src/transports/slack/slack-adapter.ts b/src/transports/slack/slack-adapter.ts new file mode 100644 index 0000000..dc17fff --- /dev/null +++ b/src/transports/slack/slack-adapter.ts @@ -0,0 +1,371 @@ +/** + * transports/slack/slack-adapter.ts — Slack TransportAdapter + * + * Single workspace, socket mode (v1). Implements the same TransportAdapter + * contract as TelegramAdapter so the gateway never branches on platform. + * + * Key facts the design relies on (all anchored to chat@4.29.0 / + * @chat-adapter/slack@4.29.0 — see slack-plan.md "Verified-against-source claims"): + * - Slack thread ids are `slack:CHANNEL:THREAD_TS`. + * - AdapterPostableMessage is `string | { raw } | { markdown } | { ast } | { card } | CardElement` + * — there is no `blocks` field. Menus go through `{ card }`. + * - `bot.onAssistantThreadStarted(handler)` is the public registration + * for opening assistant DMs (lets us pair before the user types). + * - Single-workspace `webClient` works without an explicit context. + * - `postChannelMessage` posts at channel root, no thread_ts. + */ + +import type { SlackAdapter as SlackSdkAdapter } from "@chat-adapter/slack"; +import type { + TransportAdapter, + ChatThread, + ChatThreadPost, + IncomingMessage, + PairingResult, + RichResponse, + ProgressMessage, +} from "../types"; +import { richMenuToCard, stripMarkdownToPlain } from "../rich-helpers"; +import { + readPendingSlackPairing, + completePendingSlackPairing, + matchPendingPairing, +} from "./pairing"; +import { isSlackChatId, SLACK_MARKDOWN_TEXT_LIMIT } from "./format"; +import { postSlackToMany, postSlackMessage } from "./notify"; +import { createSlackProgress } from "./progress"; +import { handleSlackStream } from "./streaming"; + +const SLACK_FORMAT_HINT = "[Format your final answer for Slack: prefer concise plain text and standard markdown. Avoid Telegram-only HTML.]"; + +interface SlackThreadShape { + id?: string; + adapter?: { slack?: SlackSdkAdapter }; +} + +export class SlackAdapter implements TransportAdapter { + readonly name = "slack"; + + private slackSdk: SlackSdkAdapter | null = null; + + /** + * Wire the @chat-adapter/slack instance after `chat.initialize()` has run. + * Until then, methods that need the SDK fail with a clear error so a + * misordered startup is loud, not silently broken. + */ + attach(slackSdk: SlackSdkAdapter): void { + this.slackSdk = slackSdk; + } + + enrichPrompt(_thread: ChatThread, text: string): string { + return `${text}\n\n${SLACK_FORMAT_HINT}`; + } + + async postMessage(thread: ChatThread, text: string): Promise { + // Plain markdown_text — adapter handles the conversion. + // Long messages are split here so we don't trip Slack's 12k cap. + if (text.length <= SLACK_MARKDOWN_TEXT_LIMIT) { + await thread.post({ markdown: text }); + return; + } + for (const chunk of splitForSlack(text)) { + await thread.post({ markdown: chunk }); + } + } + + /** + * Render a RichResponse: + * - no menu → markdown post via thread.post. + * - with menu → `{ card }` payload. The Slack adapter's cardToBlockKit + * turns the Card into Block Kit blocks; markdown_text is unused + * (and would conflict with blocks anyway). + */ + async postRich(thread: ChatThread, response: RichResponse): Promise { + if (!response.menu) { + await this.safePostText(thread, response.text); + return; + } + try { + const body = response.menuCaption ?? response.text; + const card = richMenuToCard(response.menu, body); + await thread.post({ card, fallbackText: stripMarkdownToPlain(body) }); + } catch (err) { + console.warn("[slack] postRich failed, falling back to text:", (err as Error).message); + await this.safePostText(thread, response.text); + } + } + + progress(thread: ChatThread, initialText: string): Promise { + return createSlackProgress(this.requireSdk(), thread, initialText); + } + + async stream(thread: ChatThread, iter: AsyncIterable, signal?: AbortSignal): Promise { + return handleSlackStream(this.requireSdk(), thread, iter, signal); + } + + async registerCommands(): Promise { + // Slack slash commands live in the app manifest, not at runtime. + } + + ownsThread(thread: ChatThread): boolean { + return typeof thread?.id === "string" && thread.id.startsWith("slack:"); + } + + ownsChatId(id: string | number): boolean { + return isSlackChatId(id); + } + + encodeParentThreadId(chatId: string | number): string { + // "main" sentinel for top-level posts — postChannelMessage ignores threadTs. + return `slack:${chatId}:main`; + } + + formatNotifySession(chatId: string | number): string { + const id = String(chatId); + if (id.startsWith("D")) return "main"; + if (id.startsWith("C") || id.startsWith("G")) return `channel:${id}`; + return "main"; + } + + createThread(chatId: string | number): ChatThread { + const sdk = this.requireSdk(); + const channelId = String(chatId); + // Slack's encodeThreadId requires a non-empty threadTs at the type + // level; "" is the agreed sentinel for top-level posts. The SDK's + // decodeThreadId round-trips it. + const threadId = sdk.encodeThreadId({ channel: channelId, threadTs: "" }); + // postChannelMessage at runtime expects "slack:CHANNEL" (it splits on ":" + // and grabs index [1]) — passing a bare channel id throws ValidationError. + // The published .d.ts only documented the param as `channelId: string`, + // not the required prefix. Anchor the format here. + const sdkChannelId = `slack:${channelId}`; + + const thread: ChatThread = { + id: threadId, + adapter: { slack: sdk } as { slack: SlackSdkAdapter }, + post: async (content: ChatThreadPost) => { + // postChannelMessage posts at the channel root (no thread_ts). + // For replies inside an existing Slack thread we'd use + // postMessage with a real threadId — gateway internals don't yet + // need that path. + if (typeof content === "string") { + await sdk.postChannelMessage(sdkChannelId, { markdown: content }); + return; + } + if ("markdown" in content) { + await sdk.postChannelMessage(sdkChannelId, { markdown: content.markdown }); + return; + } + if ("card" in content) { + await sdk.postChannelMessage(sdkChannelId, { + card: content.card as Parameters[1] extends infer M + ? M extends { card: infer C } ? C : never + : never, + fallbackText: content.fallbackText, + } as Parameters[1]); + } + }, + startTyping: async () => { + // `startTyping(threadId)` without a status uses Slack's default + // "Typing…" indicator and does NOT require the assistant:write + // scope (per index.d.ts:823). + try { await sdk.startTyping(threadId); } catch {} + }, + // No `stopTyping` on synthetic threads: synthetic threads use a + // channel-root threadTs ("") which the SDK's startTyping early- + // returns from, so there's nothing to clear. The real stop-path + // for incoming-message Slack threads is `stopTypingFor(thread)`, + // attached at the gateway boundary. + }; + return thread; + } + + async notify(chatIds: (string | number)[], text: string): Promise { + // Filter to slack-shaped ids only (composite already partitions; this + // is defensive for direct callers). + const slackIds = chatIds.filter(isSlackChatId) as string[]; + if (slackIds.length === 0) return; + + // Prefer the SDK's webClient (auto-handles workspace token); fall back + // to the env-token REST helper when no SDK is attached (e.g. CLI ops + // outside the gateway). + const sdk = this.slackSdk; + if (sdk) { + for (const id of slackIds) { + try { + await sdk.webClient.chat.postMessage({ + channel: id, + markdown_text: text, + unfurl_links: false, + mrkdwn: true, + }); + } catch (err) { + console.warn(`[slack] notify(${id}) failed:`, (err as Error).message); + // Best-effort fall-through to REST helper. + const token = process.env.SLACK_BOT_TOKEN; + if (token) await postSlackMessage(token, id, text); + } + } + return; + } + await postSlackToMany(slackIds, text); + } + + /** + * Build a transport-specific `stopTyping` callback for an incoming Slack + * thread, suitable for attaching to the `startTypingLoop` cleanup path. + * + * Why we need a custom one: `@chat-adapter/slack@4.29.0` `startTyping` + * unconditionally forwards `loading_messages: [status ?? "Typing..."]` + * to `assistant.threads.setStatus`. With `status === ""` (the documented + * "clear" value), the array becomes `[""]` which Slack rejects with + * `loading_messages/0 must be more than 0 characters`. Per + * https://docs.slack.dev/reference/methods/assistant.threads.setStatus + * `loading_messages` is optional, so we call the API directly without + * it. Returns null when the thread isn't a Slack thread (composite + * routing fallback) so the typing-loop can use its default path. + */ + stopTypingFor(thread: ChatThread): (() => Promise) | null { + if (!this.ownsThread(thread)) return null; + const sdk = this.slackSdk; + if (!sdk) return null; + const threadId = (thread as unknown as SlackThreadShape).id; + if (!threadId) return null; + let channel: string; + let threadTs: string; + try { + const decoded = sdk.decodeThreadId(threadId); + channel = decoded.channel; + threadTs = decoded.threadTs; + } catch { + return null; + } + if (!threadTs || threadTs === "" || threadTs === "main") { + // Synthetic thread (channel root). The SDK's startTyping early- + // returns when threadTs is empty, so nothing to clear. + return null; + } + return async () => { + try { + await sdk.webClient.assistant.threads.setStatus({ + channel_id: channel, + thread_ts: threadTs, + status: "", + // NB: deliberately NOT sending loading_messages — that's the + // SDK helper bug we're working around. + }); + } catch (err) { + // Best-effort; never throw from the cleanup path. The 2-minute + // auto-timeout will eventually clear the indicator regardless. + console.warn("[slack] stopTypingFor failed (auto-timeout will clear):", (err as Error).message); + } + }; + } + + async isPairingPending(): Promise { + const pending = await readPendingSlackPairing(); + return pending?.status === "pending"; + } + + /** + * Match against pending Slack pairing. Returns PairingResult on match. + * + * Two paths fire `handlePairing`: + * 1. message.im event from a user. `message.author` carries + * userName/userId already populated by the SDK. + * 2. assistant_thread_started — the gateway adapts that event into a + * synthetic IncomingMessage *after* resolving the user via + * `slackSdk.getUser(userId)` so userName is populated. + */ + async handlePairing(thread: ChatThread, message: IncomingMessage): Promise { + // Early guard: only process threads this adapter owns (defensive; Composite also filters) + if (!this.ownsThread(thread)) return null; + + const pending = await readPendingSlackPairing(); + if (!pending || pending.status !== "pending") return null; + + const author = (message.author ?? {}) as { userName?: string; userId?: string | number }; + const userIdRaw = author.userId == null ? "" : String(author.userId); + const userName = author.userName; + + if (!matchPendingPairing(pending, userName, userIdRaw)) { + // Quietly skip non-matching messages; not an error. + return null; + } + + // Extract Slack channel id from the thread (slack:CHANNEL:THREAD_TS) — + // prefer the SDK's parser over manual splits so future encoding changes + // don't silently break us. + const sdk = this.slackSdk; + const threadId = (thread as unknown as SlackThreadShape).id ?? ""; + let channelId: string | undefined; + if (sdk && threadId.startsWith("slack:")) { + try { channelId = sdk.decodeThreadId(threadId).channel; } catch { /* fall through */ } + } + if (!channelId) { + // Fall back to chatId on the message envelope (set by gateway when + // it builds the synthetic IncomingMessage from assistant events). + const raw = (message as { chatId?: unknown }).chatId; + if (typeof raw === "string") channelId = raw; + } + if (!channelId || !userIdRaw) { + console.error(`[slack] pairing matched but missing channelId or userId (channel=${channelId} user=${userIdRaw})`); + return null; + } + + await completePendingSlackPairing({ + channelId, + userId: userIdRaw, + username: userName, + }); + + return { + threadId: channelId, + userId: userIdRaw, + username: userName ?? userIdRaw, + transport: this.name, + }; + } + + // ── private helpers ────────────────────────────────── + + private requireSdk(): SlackSdkAdapter { + if (!this.slackSdk) { + throw new Error("SlackAdapter not attached to Chat SDK yet — call attach(slackSdk) after chat.initialize()"); + } + return this.slackSdk; + } + + /** Best-effort text post that never throws. Used by postRich's degradation paths. */ + private async safePostText(thread: ChatThread, text: string): Promise { + try { + await this.postMessage(thread, text); + return; + } catch (err) { + console.warn("[slack] safePostText.postMessage failed, trying thread.post:", (err as Error).message); + } + try { + await thread.post(text); + } catch (err) { + console.error("[slack] safePostText: all paths failed:", (err as Error).message); + } + } +} + +/** Split a long markdown body at newline boundaries into Slack-sized chunks. */ +function splitForSlack(text: string): string[] { + if (text.length <= SLACK_MARKDOWN_TEXT_LIMIT) return [text]; + const out: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= SLACK_MARKDOWN_TEXT_LIMIT) { + out.push(remaining); + break; + } + let cut = remaining.lastIndexOf("\n", SLACK_MARKDOWN_TEXT_LIMIT); + if (cut < SLACK_MARKDOWN_TEXT_LIMIT / 2) cut = SLACK_MARKDOWN_TEXT_LIMIT; + out.push(remaining.slice(0, cut)); + remaining = remaining[cut] === "\n" ? remaining.slice(cut + 1) : remaining.slice(cut); + } + return out; +} diff --git a/src/transports/slack/streaming.ts b/src/transports/slack/streaming.ts new file mode 100644 index 0000000..b93d139 --- /dev/null +++ b/src/transports/slack/streaming.ts @@ -0,0 +1,203 @@ +/** + * transports/slack/streaming.ts — Post-then-edit streaming for Slack. + * + * The Chat SDK's `stream()` API requires `recipientUserId` and + * `recipientTeamId` in options and is gated on the workspace having + * Slack's AI Assistant feature enabled. Until we can detect that, + * we ship this fallback: send an initial message, then edit it in + * place every ~800 ms with the accumulated text. + * + * v3 plan polish: + * - throttle BEFORE overflow edits (back-to-back overflows can't burst Slack rate limits) + * - check `signal?.aborted` at chunk boundaries + * - retry initial post with backoff and a hard cap, then attempt one + * final post during flush (so the user never sees silence) + */ + +import type { SlackAdapter } from "@chat-adapter/slack"; +import type { ChatThread } from "../types"; + +const STREAM_EDIT_INTERVAL_MS = 800; +const SLACK_TEXT_LIMIT = 12_000; +const SLACK_MIN_PUBLIC_LIMIT = 4_000; +const INIT_FAIL_BACKOFF_MS = 1_500; +const MAX_INIT_RETRIES = 3; + +interface SlackThreadShape { + id?: string; +} + +export async function handleSlackStream( + sdk: SlackAdapter, + thread: ChatThread, + stream: AsyncIterable, + signal?: AbortSignal, +): Promise { + const threadId = (thread as unknown as SlackThreadShape).id ?? ""; + if (!threadId.startsWith("slack:")) { + console.warn("[slack/stream] called with non-slack thread id:", threadId); + return; + } + const { channel, threadTs } = sdk.decodeThreadId(threadId); + const replyOpts = (threadTs && threadTs !== "" && threadTs !== "main") + ? { thread_ts: threadTs } + : {}; + + let accumulated = ""; + let messageTs: string | null = null; + let lastEditAt = 0; + let lastSentText = ""; + let committedLength = 0; + let initFailures = 0; + let lastInitAttemptAt = 0; + + const sleepRemaining = async () => { + const wait = STREAM_EDIT_INTERVAL_MS - (Date.now() - lastEditAt); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + }; + + const sendInitial = async (body: string) => { + if (initFailures >= MAX_INIT_RETRIES) return; + // Only enforce backoff when we're actually retrying after a failure; + // otherwise re-inits after handleOverflow get blocked when a healthy + // first message happened to land within the backoff window. + if (initFailures > 0 && Date.now() - lastInitAttemptAt < INIT_FAIL_BACKOFF_MS) return; + lastInitAttemptAt = Date.now(); + try { + const result = await sdk.webClient.chat.postMessage({ + channel, + markdown_text: body, + ...replyOpts, + }) as { ts?: string }; + if (!result.ts) { + // Treat missing ts as a soft failure so subsequent chunks retry once + // the backoff window passes. + initFailures++; + return; + } + messageTs = result.ts; + lastSentText = body; + lastEditAt = Date.now(); + initFailures = 0; + } catch (err) { + initFailures++; + console.warn( + `[slack/stream] initial post failed (${initFailures}/${MAX_INIT_RETRIES}):`, + (err as Error).message, + ); + } + }; + + const editMessage = async (body: string) => { + if (!messageTs || body === lastSentText) return; + try { + await sdk.webClient.chat.update({ + channel, + ts: messageTs, + markdown_text: body, + }); + lastSentText = body; + lastEditAt = Date.now(); + } catch { + // Slack rejects empty/invalid edits silently — keep streaming. + } + }; + + const handleOverflow = async () => { + const current = accumulated.slice(committedLength); + if (current.length <= SLACK_TEXT_LIMIT) return; + + // Throttle before the overflow edit so a burst of large chunks can't + // fire two edits within the rate-limit window. + await sleepRemaining(); + + // Finalize the current message at a clean boundary (newline if possible). + const newlineIdx = current.lastIndexOf("\n", SLACK_TEXT_LIMIT - 100); + const cutAt = newlineIdx > SLACK_MIN_PUBLIC_LIMIT + ? newlineIdx + : Math.max(SLACK_MIN_PUBLIC_LIMIT, SLACK_TEXT_LIMIT - 100); + const final = current.slice(0, cutAt); + await editMessage(final); + committedLength += cutAt; + messageTs = null; + lastSentText = ""; + }; + + /** + * If the uncommitted buffer is over the limit, slice off a clean + * sub-12k prefix and post it directly via chat.postMessage (no edit + * cycle). Returns true if the chunk loop should `continue` because + * we already drained part of the buffer without an active message. + */ + const sendOverflowChunkDirect = async (): Promise => { + const current = accumulated.slice(committedLength); + if (current.length <= SLACK_TEXT_LIMIT) return false; + const newlineIdx = current.lastIndexOf("\n", SLACK_TEXT_LIMIT - 100); + const cutAt = newlineIdx > SLACK_MIN_PUBLIC_LIMIT + ? newlineIdx + : Math.max(SLACK_MIN_PUBLIC_LIMIT, SLACK_TEXT_LIMIT - 100); + const slice = current.slice(0, cutAt); + try { + await sdk.webClient.chat.postMessage({ + channel, + markdown_text: slice, + ...replyOpts, + }); + committedLength += cutAt; + // Force a fresh init for whatever's left. + messageTs = null; + lastSentText = ""; + return true; + } catch (err) { + console.warn("[slack/stream] overflow direct post failed:", (err as Error).message); + return false; + } + }; + + for await (const chunk of stream) { + if (signal?.aborted) break; + accumulated += chunk; + + if (!messageTs) { + // If a single chunk pushed us past Slack's per-message cap before + // we even sent the initial message, slice and post the prefix + // directly. The remainder will trigger a fresh sendInitial below. + if (await sendOverflowChunkDirect()) { + // Re-evaluate with the now-trimmed accumulated buffer. + const body = accumulated.slice(committedLength); + if (body.trim()) await sendInitial(body); + continue; + } + const body = accumulated.slice(committedLength); + if (body.trim()) await sendInitial(body); + continue; + } + + await handleOverflow(); + if (signal?.aborted) break; + if (Date.now() - lastEditAt >= STREAM_EDIT_INTERVAL_MS) { + await editMessage(accumulated.slice(committedLength)); + } + } + + // Final flush — runs even on abort so the user sees the partial buffer + // rather than silent truncation. + const remaining = accumulated.slice(committedLength); + if (!remaining.trim()) return; + + if (messageTs) { + await editMessage(remaining); + } else if (initFailures < MAX_INIT_RETRIES) { + // We never got an initial message id during the stream — try one more + // unconditional post so the user isn't left with nothing. + try { + await sdk.webClient.chat.postMessage({ + channel, + markdown_text: remaining, + ...replyOpts, + }); + } catch (err) { + console.error("[slack/stream] final post failed:", (err as Error).message); + } + } +} diff --git a/src/transports/telegram/telegram-adapter.ts b/src/transports/telegram/telegram-adapter.ts index e236cf3..ac14801 100644 --- a/src/transports/telegram/telegram-adapter.ts +++ b/src/transports/telegram/telegram-adapter.ts @@ -8,12 +8,13 @@ import type { TransportAdapter, ChatThread, + ChatThreadPost, IncomingMessage, PairingResult, RichResponse, ProgressMessage, } from "../types"; -import { isTelegramThread, postTelegramHtml } from "./html"; +import { isTelegramThread, postTelegramHtml, handleTelegramHtmlStream } from "./html"; import { markdownToTelegramHtml } from "./format"; import { sendTelegramToMany } from "./notify"; import { BOT_COMMANDS } from "./bot-commands"; @@ -21,6 +22,9 @@ import { readPendingPairing, completePendingPairing, clearPendingPairing, isStar import { toTelegramInlineKeyboard } from "./rich-ui"; import { createProgressMessage } from "./progress"; +/** Bot-command suffix sentinels we recognize as Telegram-specific to ignore. */ +const TELEGRAM_START_PATTERN = /^\/start(\s|@|$)/i; + /** Extract the numeric Telegram chat id from a thread's id string. */ function extractTelegramChatId(thread: { id?: string; platformThreadId?: string }): string | undefined { return thread.platformThreadId?.split(":")?.[1] ?? thread.id?.split(":")?.[1]; @@ -31,7 +35,7 @@ const TELEGRAM_FORMAT_HINT = "[Format your final answer to be telegram-friendly. export class TelegramAdapter implements TransportAdapter { readonly name = "telegram"; - enrichPrompt(text: string): string { + enrichPrompt(_thread: ChatThread, text: string): string { return `${text}\n\n${TELEGRAM_FORMAT_HINT}`; } @@ -152,8 +156,9 @@ export class TelegramAdapter implements TransportAdapter { return createProgressMessage(thread, initialText); } - async registerCommands(token: string): Promise { - if (!token) return; + async registerCommands(): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) return; // Adapter self-sources; no-op when token absent try { const res = await fetch(`https://api.telegram.org/bot${token}/setMyCommands`, { method: "POST", @@ -175,7 +180,32 @@ export class TelegramAdapter implements TransportAdapter { return isTelegramThread(thread as any); } - createThread(chatId: number): ChatThread { + ownsChatId(id: string | number): boolean { + if (typeof id === "number") return Number.isFinite(id); + return typeof id === "string" && /^-?\d+$/.test(id); + } + + encodeParentThreadId(chatId: string | number): string { + // For Telegram groups (negative IDs), encode as 'group:' to match + // inbound routing in resolveAgentThreadId(). This ensures boot turns seed + // the same session as live inbound messages. + const n = typeof chatId === "number" ? chatId : Number(chatId); + if (Number.isFinite(n) && n < 0) return `group:${chatId}`; + return "main"; + } + + formatNotifySession(chatId: string | number): string { + // Telegram negative IDs identify groups; positive IDs identify direct chats. + const n = typeof chatId === "number" ? chatId : Number(chatId); + if (Number.isFinite(n) && n < 0) return `group:${chatId}`; + return "main"; + } + + shouldIgnoreMessage(text: string): boolean { + return TELEGRAM_START_PATTERN.test(text.trim()); + } + + createThread(chatId: string | number): ChatThread { const token = process.env.TELEGRAM_BOT_TOKEN; const threadId = `telegram:${chatId}`; const telegramFetch = async (method: string, payload: Record) => { @@ -193,23 +223,49 @@ export class TelegramAdapter implements TransportAdapter { const thread: ChatThread = { id: threadId, adapter: { telegramFetch }, - post: async (content: string | { markdown: string }) => { - const text = typeof content === "string" ? content : content.markdown; - await postTelegramHtml(thread as any, text); + post: async (content: ChatThreadPost) => { + if (typeof content === "string") { + await postTelegramHtml(thread as any, content); + return; + } + if ("markdown" in content) { + await postTelegramHtml(thread as any, content.markdown); + return; + } + // `{ card }` path: the card-based menu rendering is a Phase 2 unification. + // Until then, telegram synthetic threads only use markdown/text from + // gateway internals (boot turn, cron, sub-agent injections), so falling + // back to the card's fallbackText is sufficient. + if ("card" in content) { + const fallback = content.fallbackText ?? "(card)"; + await postTelegramHtml(thread as any, fallback); + } }, startTyping: async () => {}, }; return thread; } - async notify(chatIds: number[], text: string): Promise { + async notify(chatIds: (string | number)[], text: string): Promise { if (!process.env.TELEGRAM_BOT_TOKEN) { console.warn("[roundhouse] TELEGRAM_BOT_TOKEN not set — skipping notification"); return; } + // Filter to telegram-shaped IDs only (composite already partitions, but + // defend against direct callers passing a heterogeneous list). + const tgIds = chatIds.filter(id => this.ownsChatId(id)); + if (tgIds.length === 0) return; // Convert lightweight markdown to Telegram HTML const html = markdownToTelegramHtml(text); - await sendTelegramToMany(chatIds, html, { parseMode: "HTML" }); + await sendTelegramToMany(tgIds, html, { parseMode: "HTML" }); + } + + async stream(thread: ChatThread, iter: AsyncIterable, _signal?: AbortSignal): Promise { + // The existing streaming helper does not yet thread an abort signal; the + // gateway's stream loop already aborts at the agent layer when /cancel + // fires, so chunks stop arriving. Wiring the signal end-to-end is a + // small follow-up. + await handleTelegramHtmlStream(thread as any, iter); } async isPairingPending(): Promise { @@ -218,6 +274,9 @@ export class TelegramAdapter implements TransportAdapter { } async handlePairing(thread: ChatThread, message: IncomingMessage): Promise { + // Early guard: only process threads this adapter owns (defensive; Composite also filters) + if (!this.ownsThread(thread)) return null; + const text = (message.text ?? "").trim(); if (!text) return null; @@ -260,6 +319,6 @@ export class TelegramAdapter implements TransportAdapter { // Mark pairing complete in transport state await completePendingPairing({ chatId, userId, username: originalName }); - return { threadId: chatId, userId, username: originalName }; + return { threadId: chatId, userId, username: originalName, transport: this.name }; } } diff --git a/src/transports/types.ts b/src/transports/types.ts index 6172e12..1720c52 100644 --- a/src/transports/types.ts +++ b/src/transports/types.ts @@ -5,10 +5,23 @@ * The gateway uses this interface to remain transport-agnostic. */ +/** + * Postable shapes accepted by `ChatThread.post`. + * + * Mirrors the subset of Chat SDK `AdapterPostableMessage` we actually use: + * plain string, `{ markdown }`, and `{ card, fallbackText? }`. Card payload + * is `unknown` to avoid coupling this interface to a specific Chat SDK + * version's `CardElement` type — adapters narrow at the boundary. + */ +export type ChatThreadPost = + | string + | { markdown: string } + | { card: unknown; fallbackText?: string }; + /** Minimal thread interface (subset of Chat SDK thread) */ export interface ChatThread { id: string; - post(text: string): Promise; + post(message: ChatThreadPost): Promise; [key: string]: unknown; } @@ -16,7 +29,7 @@ export interface ChatThread { export interface IncomingMessage { text?: string; author?: { userName?: string; name?: string; userId?: string | number; id?: string }; - chatId?: number; + chatId?: string | number; raw?: { from?: { id?: number } }; [key: string]: unknown; } @@ -128,6 +141,12 @@ export interface PairingResult { userId: string | number; /** Display name */ username: string; + /** + * Transport name that produced this result. The composite transport + * fills this in when fanning out `handlePairing`. Individual delegates + * may omit it (the composite assigns its own `.name` after the call). + */ + transport?: string; } /** @@ -140,8 +159,14 @@ export interface TransportAdapter { /** Transport name (e.g. "telegram") */ readonly name: string; - /** Enrich prompt text before sending to agent (e.g. formatting hints) */ - enrichPrompt(text: string): string; + /** + * Enrich prompt text before sending to agent (e.g. formatting hints). + * + * `thread` is provided so the composite transport can route to the + * delegate that owns the thread. Single-transport adapters may ignore + * the argument. + */ + enrichPrompt(thread: ChatThread, text: string): string; /** Post a message using platform-native formatting */ postMessage(thread: ChatThread, text: string): Promise; @@ -173,14 +198,46 @@ export interface TransportAdapter { */ progress(thread: ChatThread, initialText: string): Promise; - /** Register bot commands with the platform */ - registerCommands(token: string): Promise; + /** + * Register bot commands with the platform. + * + * Adapter is responsible for sourcing its own credentials from env. + * No-op when prerequisites are missing (so callers can fire + * unconditionally without checking transport-specific env vars). + */ + registerCommands(): Promise; /** Check if a thread belongs to this transport */ ownsThread(thread: ChatThread): boolean; + /** + * Pure shape check — return true iff this transport recognizes the given + * chat ID format. No I/O. Used by the composite transport to partition + * `notifyChatIds` and route notify() calls. + * + * Telegram: typeof id === "number" || /^-?\d+$/.test(String(id)) + * Slack: typeof id === "string" && /^[CDGU]/.test(id) + */ + ownsChatId(id: string | number): boolean; + + /** + * Build a synthetic "parent thread id" for sub-agent / cron routing + * from a single chat id. Encodes the platform prefix and any + * coordinates the transport needs. + * + * Telegram: `telegram:${chatId}:main` + * Slack: `slack:${channelId}:main` + */ + encodeParentThreadId(chatId: string | number): string; + + /** + * Format a chat id for human-facing "Session: …" labels in startup + * notifications. Replaces inline platform-specific logic in the gateway. + */ + formatNotifySession(chatId: string | number): string; + /** Send notifications to configured recipients */ - notify(chatIds: number[], text: string): Promise; + notify(chatIds: (string | number)[], text: string): Promise; /** * Create a thread object for a given chat ID. @@ -188,7 +245,7 @@ export interface TransportAdapter { * where no incoming message triggered the interaction. * Returns a thread compatible with the streaming system. */ - createThread(chatId: number): ChatThread; + createThread(chatId: string | number): ChatThread; /** * Check if a pairing flow is pending. @@ -202,4 +259,24 @@ export interface TransportAdapter { * Transport manages its own state (nonce files, OAuth tokens, etc.) */ handlePairing(thread: ChatThread, message: IncomingMessage): Promise; + + /** + * Optional pre-handler hook: return true to drop the message before any + * other gateway logic runs. Lets transports filter platform-specific + * sentinel messages (e.g. Telegram's `/start `). + * + * Composite routes to the delegate that owns the thread; if no delegate + * owns it, the message is not dropped. + */ + shouldIgnoreMessage?(text: string, message: IncomingMessage, thread: ChatThread): boolean; + + /** + * Stream agent text into a single thread message with progressive edits. + * + * Adapters that lack streaming MUST still satisfy this contract by + * collecting the iterable into chunks and calling `postMessage` for each. + * `signal` is honored at chunk boundaries — when aborted, the adapter + * SHOULD finalize the current message rather than truncate silently. + */ + stream(thread: ChatThread, iter: AsyncIterable, signal?: AbortSignal): Promise; } diff --git a/src/types.ts b/src/types.ts index 47e3ec3..b02e8ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -187,10 +187,20 @@ export interface GatewayConfig { chat: { botUsername: string; allowedUsers?: string[]; - /** Immutable Telegram user IDs (paired during setup) */ - allowedUserIds?: number[]; - /** Telegram chat IDs to notify on startup */ - notifyChatIds?: number[]; + /** + * Immutable per-platform user IDs (paired during setup). + * Telegram IDs are numeric (e.g. 123456789); Slack IDs are strings + * (e.g. "U02XXXXX"). The allowlist is a heterogeneous union so a single + * gateway running both transports can authenticate users from either. + */ + allowedUserIds?: (string | number)[]; + /** + * Per-platform chat IDs to notify on startup. + * Telegram chat IDs are numeric (negative for groups); Slack channel IDs + * are strings starting with C/D/G. The composite transport partitions + * this list by `ownsChatId` before fanning out notifications. + */ + notifyChatIds?: (string | number)[]; adapters: { telegram?: Record; slack?: Record; diff --git a/src/util.ts b/src/util.ts index df8f24a..7c2a9ed 100644 --- a/src/util.ts +++ b/src/util.ts @@ -39,28 +39,48 @@ export function splitMessage(text: string, maxLen: number): string[] { return chunks; } +/** + * Compare two ids (chat or user) loosely. Treats `12345` and `"12345"` as + * equal so a heterogeneous allowlist (telegram numeric + slack string) can + * still detect duplicates without coercion. + */ +export function sameId(a: string | number, b: string | number): boolean { + return String(a) === String(b); +} + /** * Check if a Chat SDK message author is in the allowlist. - * Only matches on userName (unique handle) and userId (numeric ID). + * Only matches on userName (unique handle) and userId (immutable platform ID). * Does NOT match on fullName (user-controlled display name). + * + * Dual lookup against `allowedUserIds`: Telegram IDs are numeric (123456789); + * Slack IDs are strings ("U02XXXXX"). Both forms match against entries of + * either type, so a heterogeneous allowlist authenticates users from either + * platform. */ export function isAllowed( - message: { author?: { userName?: string; userId?: string; fullName?: string } }, + message: { author?: { userName?: string; userId?: string | number; fullName?: string } }, allowedUsers: string[], - allowedUserIds?: number[], + allowedUserIds?: (string | number)[], ): boolean { if (allowedUsers.length === 0 && (!allowedUserIds || allowedUserIds.length === 0)) return true; const author = message.author ?? {}; - // Check immutable numeric user ID first - if (allowedUserIds?.length && author.userId) { - const numericId = parseInt(author.userId, 10); - if (!isNaN(numericId) && allowedUserIds.includes(numericId)) return true; + // Check immutable platform user ID first. + // Normalize both sides to string before comparing — IncomingMessage.author.userId + // can arrive as string or number depending on the platform; allowedUserIds can + // hold either too. `sameId`-style equality avoids treating "12345" and 12345 as + // different. + if (allowedUserIds?.length && author.userId != null) { + const rawId = String(author.userId); + for (const entry of allowedUserIds) { + if (String(entry) === rawId) return true; + } } // Fall back to username check const candidates = [author.userName, author.userId] - .filter(Boolean) + .filter((v) => v != null && v !== "") .map((s) => String(s).toLowerCase()); return candidates.some((c) => allowedUsers.includes(c)); } @@ -69,17 +89,33 @@ export function isAllowed( * Start a periodic typing indicator loop. * Calls thread.startTyping() immediately and then every intervalMs. * Returns a stop function. + * + * On stop, also calls thread.stopTyping() if the thread exposes one. + * Telegram's `sendChatAction` auto-expires after ~5 s so its + * createThread doesn't bother. Slack's `assistant.threads.setStatus` + * persists until explicitly cleared OR a message lands in the same + * `thread_ts` — and our streaming posts to the channel root, not the + * incoming thread, so the status sticks indefinitely without an + * explicit clear. */ export function startTypingLoop( - thread: { startTyping: (status?: string) => Promise }, + thread: { startTyping: (status?: string) => Promise; stopTyping?: () => Promise | void }, intervalMs: number = 4000 ): () => void { let stopped = false; let timer: ReturnType | undefined; + /** + * Tracks the most recent in-flight startTyping() promise. The cleanup + * path AWAITS this before sending its clear ("") so a tick that started + * just before stop() can't land *after* the clear and silently re-set + * the indicator. The Slack assistant_thread status doesn't auto-expire, + * so this race translates directly into a stuck "Typing…" pill. + */ + let inFlight: Promise | undefined; const send = () => { if (stopped) return; - thread.startTyping().catch(() => {}); + inFlight = thread.startTyping().catch(() => {}); }; send(); // fire immediately @@ -89,9 +125,37 @@ export function startTypingLoop( return () => { stopped = true; if (timer) clearInterval(timer); + // Best-effort cleanup — never throw, never block the caller. We + // dispatch asynchronously so the caller's `try/finally` returns + // immediately; the actual clear lands on the next tick after any + // in-flight startTyping() has resolved. + void (async () => { + try { if (inFlight) await inFlight; } catch {} + // Prefer a transport-supplied stopTyping (Slack injects one that + // bypasses the SDK's setStatus-with-bad-loading_messages bug). + // Fall back to the standard thread.startTyping("") for transports + // (Telegram) that auto-expire or accept the empty arg cleanly. + const cleared = await tryStopTypingHook(thread); + if (!cleared) { + try { await thread.startTyping(""); } catch {} + } + })(); }; } +/** + * Try the transport-supplied `stopTyping` hook. Returns true if the hook + * was called (regardless of whether it threw — best-effort), false if the + * thread doesn't expose one. + */ +async function tryStopTypingHook( + thread: { stopTyping?: () => Promise | void }, +): Promise { + if (typeof thread.stopTyping !== "function") return false; + try { await thread.stopTyping(); } catch {} + return true; +} + /** * Convert a threadId to a safe directory name. * Uses a scheme that avoids collisions between different separators. diff --git a/test/composite-transport.test.ts b/test/composite-transport.test.ts new file mode 100644 index 0000000..dbadf18 --- /dev/null +++ b/test/composite-transport.test.ts @@ -0,0 +1,199 @@ +/** + * test/composite-transport.test.ts — Tests for CompositeTransportAdapter. + * + * Verifies multi-transport routing semantics: + * - per-thread methods dispatch to the delegate that owns the thread + * - notify() partitions chat ids by ownsChatId and fans out + * - handlePairing returns the first non-null result and tags it with the + * delegate name + * - shouldIgnoreMessage routes by ownsThread + * - chat.onAction routing still works because event.thread carries the + * platform prefix (verified by the structural test below) + */ + +import { describe, it, expect, vi } from "vitest"; +import { CompositeTransportAdapter } from "../src/transports/composite"; +import type { TransportAdapter, ChatThread } from "../src/transports/types"; + +function fakeDelegate(name: string, prefix: string, idCheck: (id: string | number) => boolean): TransportAdapter { + return { + name, + enrichPrompt: vi.fn((_t, text) => `${text} [${name}]`), + postMessage: vi.fn(async () => {}), + postRich: vi.fn(async () => {}), + progress: vi.fn(async () => ({ update: vi.fn(async () => {}) })), + stream: vi.fn(async () => {}), + registerCommands: vi.fn(async () => {}), + ownsThread: (t) => typeof t.id === "string" && t.id.startsWith(prefix), + ownsChatId: idCheck, + encodeParentThreadId: (id) => `${name}:${id}:main`, + formatNotifySession: () => `session-${name}`, + notify: vi.fn(async () => {}), + createThread: (id) => ({ id: `${prefix}${id}`, post: async () => {} } as ChatThread), + isPairingPending: vi.fn(async () => false), + handlePairing: vi.fn(async () => null), + shouldIgnoreMessage: vi.fn(() => false), + }; +} + +describe("CompositeTransportAdapter", () => { + it("requires at least one delegate", () => { + expect(() => new CompositeTransportAdapter([])).toThrow(/at least one delegate/); + }); + + it("routes per-thread methods to the owning delegate", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + const composite = new CompositeTransportAdapter([tg, sl]); + + const tThread: ChatThread = { id: "telegram:123", post: async () => {} }; + const sThread: ChatThread = { id: "slack:C0", post: async () => {} }; + + await composite.postMessage(tThread, "hi"); + expect(tg.postMessage).toHaveBeenCalledOnce(); + expect(sl.postMessage).not.toHaveBeenCalled(); + + await composite.postMessage(sThread, "hi"); + expect(sl.postMessage).toHaveBeenCalledOnce(); + + expect(composite.enrichPrompt(tThread, "x")).toBe("x [telegram]"); + expect(composite.enrichPrompt(sThread, "x")).toBe("x [slack]"); + }); + + it("partitions notify() by ownsChatId", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + const composite = new CompositeTransportAdapter([tg, sl]); + + await composite.notify([123, "C01ABC", -456, "U02XYZ"], "hello"); + + expect(tg.notify).toHaveBeenCalledWith([123, -456], "hello"); + expect(sl.notify).toHaveBeenCalledWith(["C01ABC", "U02XYZ"], "hello"); + }); + + it("notify drops chat ids that no delegate recognizes", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const composite = new CompositeTransportAdapter([tg]); + + await composite.notify(["C01ABC"], "hello"); // not telegram-shaped + expect(tg.notify).not.toHaveBeenCalled(); + }); + + it("handlePairing returns the first non-null result, tagged with delegate name", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + (tg.handlePairing as ReturnType).mockResolvedValue(null); + (sl.handlePairing as ReturnType).mockResolvedValue({ + threadId: "C01", userId: "U01", username: "alice", + }); + + const composite = new CompositeTransportAdapter([tg, sl]); + const result = await composite.handlePairing( + { id: "slack:C01", post: async () => {} } as ChatThread, + { text: "hi" } as any, + ); + + expect(result).toEqual({ + threadId: "C01", userId: "U01", username: "alice", transport: "slack", + }); + }); + + it("preserves a delegate-provided transport tag (does not overwrite)", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + (tg.handlePairing as ReturnType).mockResolvedValue({ + threadId: 123, userId: 456, username: "bob", transport: "telegram", + }); + + const composite = new CompositeTransportAdapter([tg]); + const result = await composite.handlePairing( + { id: "telegram:123", post: async () => {} } as ChatThread, + { text: "hi" } as any, + ); + + expect(result?.transport).toBe("telegram"); + }); + + it("shouldIgnoreMessage routes by ownsThread; returns false when no owner", () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + (tg.shouldIgnoreMessage as ReturnType).mockReturnValue(true); + + const composite = new CompositeTransportAdapter([tg]); + + expect(composite.shouldIgnoreMessage("/start", {} as any, { id: "telegram:1", post: async () => {} } as ChatThread)).toBe(true); + expect(composite.shouldIgnoreMessage("/start", {} as any, { id: "slack:C0", post: async () => {} } as ChatThread)).toBe(false); + }); + + it("registerCommands fans out to all delegates", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + const composite = new CompositeTransportAdapter([tg, sl]); + + await composite.registerCommands(); + + expect(tg.registerCommands).toHaveBeenCalledOnce(); + expect(sl.registerCommands).toHaveBeenCalledOnce(); + }); + + it("isPairingPending returns true if any delegate has pending", async () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + (sl.isPairingPending as ReturnType).mockResolvedValue(true); + + const composite = new CompositeTransportAdapter([tg, sl]); + expect(await composite.isPairingPending()).toBe(true); + }); + + it("ownerOf and ownerOfChatId expose the matching delegate (gateway uses these to gate per-transport pairingComplete)", () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + const composite = new CompositeTransportAdapter([tg, sl]); + + expect(composite.ownerOf({ id: "telegram:1", post: async () => {} } as ChatThread)?.name).toBe("telegram"); + expect(composite.ownerOf({ id: "slack:C0", post: async () => {} } as ChatThread)?.name).toBe("slack"); + expect(composite.ownerOf({ id: "discord:1", post: async () => {} } as ChatThread)).toBeNull(); + + expect(composite.ownerOfChatId(123)?.name).toBe("telegram"); + expect(composite.ownerOfChatId("C01")?.name).toBe("slack"); + expect(composite.ownerOfChatId("xx")).toBeNull(); + }); + + it("encodeParentThreadId throws when no delegate owns the chat id (sub-agent routing must not silently mis-route)", () => { + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const composite = new CompositeTransportAdapter([tg]); + + expect(() => composite.encodeParentThreadId("C01")).toThrow(/No transport recognizes chat id/); + }); + + it("per-transport pairingComplete: paired-then-pending race walkthrough", async () => { + // Walkthrough from slack-plan.md §1.6: + // 1. Both transports have pending pairings. + // 2. Telegram event arrives first; ownerOf returns telegram delegate. + // 3. Telegram pairing completes; gateway sets pairingComplete["telegram"] = true. + // 4. Slack event arrives; ownerOf returns slack delegate. Slack still pending. + // 5. Slack pairing completes. + const tg = fakeDelegate("telegram", "telegram:", (id) => /^-?\d+$/.test(String(id))); + const sl = fakeDelegate("slack", "slack:", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + (tg.isPairingPending as ReturnType).mockResolvedValue(true); + (sl.isPairingPending as ReturnType).mockResolvedValue(true); + (tg.handlePairing as ReturnType).mockResolvedValue({ threadId: 1, userId: 1, username: "tg" }); + (sl.handlePairing as ReturnType).mockResolvedValue({ threadId: "C0", userId: "U0", username: "sl" }); + + const composite = new CompositeTransportAdapter([tg, sl]); + + // Step 1: telegram event + const tThread: ChatThread = { id: "telegram:1", post: async () => {} }; + const tResult = await composite.handlePairing(tThread, { text: "/start abc" } as any); + expect(tResult?.transport).toBe("telegram"); + + // Step 2: slack event still routes correctly (the composite has no internal + // 'paired' flag — the gateway tracks that. Composite always tries delegates + // in order; telegram doesn't own slack thread so it returns null per the + // ownsThread filter inside its real handlePairing implementation.) + const sThread: ChatThread = { id: "slack:C0", post: async () => {} }; + // Mock telegram's handlePairing to return null when the thread isn't its — + // matches the real adapter's pre-check. + (tg.handlePairing as ReturnType).mockResolvedValueOnce(null); + const sResult = await composite.handlePairing(sThread, { text: "hi" } as any); + expect(sResult?.transport).toBe("slack"); + }); +}); diff --git a/test/cron-notify-partition.test.ts b/test/cron-notify-partition.test.ts new file mode 100644 index 0000000..69dd894 --- /dev/null +++ b/test/cron-notify-partition.test.ts @@ -0,0 +1,62 @@ +/** + * test/cron-notify-partition.test.ts — Cron notifyFn signature widened in + * Phase 1 to accept (string | number)[]. Verify a heterogeneous list of + * chat ids passes through unchanged when the gateway's notifyFn forwards + * to transport.notify. + * + * The gateway wires notifyFn at gateway.ts:357. We exercise that wire by + * constructing the same lambda shape and asserting it doesn't drop or + * coerce. + */ + +import { describe, it, expect, vi } from "vitest"; +import { CompositeTransportAdapter } from "../src/transports/composite"; +import type { TransportAdapter } from "../src/transports/types"; + +function fake(name: string, ownsId: (id: string | number) => boolean): TransportAdapter { + return { + name, + enrichPrompt: (_t, t) => t, + postMessage: vi.fn(async () => {}), + postRich: vi.fn(async () => {}), + progress: vi.fn(async () => ({ update: vi.fn(async () => {}) })), + stream: vi.fn(async () => {}), + registerCommands: vi.fn(async () => {}), + ownsThread: () => false, + ownsChatId: ownsId, + encodeParentThreadId: (id) => `${name}:${id}:main`, + formatNotifySession: () => "main", + notify: vi.fn(async () => {}), + createThread: (id) => ({ id: `${name}:${id}`, post: async () => {} }), + isPairingPending: vi.fn(async () => false), + handlePairing: vi.fn(async () => null), + }; +} + +describe("cron notifyFn → composite.notify partition", () => { + it("forwards a heterogeneous (string | number)[] without dropping ids", async () => { + const tg = fake("telegram", (id) => /^-?\d+$/.test(String(id))); + const sl = fake("slack", (id) => typeof id === "string" && /^[CDGU]/.test(id)); + const composite = new CompositeTransportAdapter([tg, sl]); + + // Mirror the lambda the gateway constructs (gateway.ts:349). + const notifyFn = async (chatIds: (string | number)[], text: string) => { + if (chatIds.length) await composite.notify(chatIds, text); + }; + + await notifyFn([12345, "C01ABC", -100, "U02XYZ"], "cron fired"); + + expect(tg.notify).toHaveBeenCalledWith([12345, -100], "cron fired"); + expect(sl.notify).toHaveBeenCalledWith(["C01ABC", "U02XYZ"], "cron fired"); + }); + + it("no-ops when chatIds is empty (matches gateway guard)", async () => { + const tg = fake("telegram", (id) => /^-?\d+$/.test(String(id))); + const composite = new CompositeTransportAdapter([tg]); + const notifyFn = async (chatIds: (string | number)[], text: string) => { + if (chatIds.length) await composite.notify(chatIds, text); + }; + await notifyFn([], "nope"); + expect(tg.notify).not.toHaveBeenCalled(); + }); +}); diff --git a/test/gateway-multi-transport.test.ts b/test/gateway-multi-transport.test.ts new file mode 100644 index 0000000..d05cb87 --- /dev/null +++ b/test/gateway-multi-transport.test.ts @@ -0,0 +1,115 @@ +/** + * test/gateway-multi-transport.test.ts — Two transports, one Gateway. + * + * Covers the wiring around `buildTransportDelegates` (gateway.ts:71-83) + * + `buildCompositeTransport`. We never call `Gateway.start()` (it'd + * try to open a real Slack websocket / Telegram polling); we just + * construct the gateway, then walk the composite via the same paths + * `start()` uses internally. + * + * Risks closed: + * - Configuring both adapters doesn't crash the constructor. + * - The composite owns BOTH delegates and routes by ownsThread/ownsChatId. + * - notify partitions across both transports. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Gateway } from "../src/gateway/gateway"; +import type { AgentRouter, GatewayConfig } from "../src/types"; +import type { CompositeTransportAdapter } from "../src/transports/composite"; + +function makeRouter(): AgentRouter { + return { + resolve: () => ({ name: "noop" } as any), + dispose: async () => {}, + }; +} + +function makeBothConfig(): GatewayConfig { + return { + agent: { type: "noop" }, + chat: { + botUsername: "test", + adapters: { + telegram: { mode: "polling" }, + slack: { mode: "socket" }, + }, + }, + } as GatewayConfig; +} + +interface InternalGateway { + transport: CompositeTransportAdapter; +} + +describe("Gateway with both telegram + slack configured", () => { + it("constructs without throwing and exposes both delegates on the composite", () => { + const gw = new Gateway(makeRouter(), makeBothConfig()); + const composite = (gw as unknown as InternalGateway).transport; + const names = composite.delegates.map((d) => d.name).sort(); + expect(names).toEqual(["slack", "telegram"]); + }); + + it("composite.ownsChatId routes telegram numeric vs slack Cxxx correctly", () => { + const gw = new Gateway(makeRouter(), makeBothConfig()); + const composite = (gw as unknown as InternalGateway).transport; + + expect(composite.ownerOfChatId(12345)?.name).toBe("telegram"); + expect(composite.ownerOfChatId(-100123)?.name).toBe("telegram"); + expect(composite.ownerOfChatId("12345")?.name).toBe("telegram"); + expect(composite.ownerOfChatId("C01ABC")?.name).toBe("slack"); + expect(composite.ownerOfChatId("D02DEF")?.name).toBe("slack"); + expect(composite.ownerOfChatId("U03USER")?.name).toBe("slack"); + // Garbage shape — neither delegate claims it. + expect(composite.ownerOfChatId("garbage")).toBeNull(); + }); + + it("composite.ownsThread routes by platform prefix (with platform-decorated threads)", () => { + const gw = new Gateway(makeRouter(), makeBothConfig()); + const composite = (gw as unknown as InternalGateway).transport; + + // Telegram's ownsThread requires adapter.telegramFetch (defended at the + // boundary — the SDK decorates real threads with this). Slack's ownsThread + // is just the id prefix. + const tgThread = { + id: "telegram:42", + adapter: { telegramFetch: async () => null }, + post: async () => {}, + } as any; + const slThread = { id: "slack:C01:1712", post: async () => {} } as any; + + expect(composite.ownerOf(tgThread)?.name).toBe("telegram"); + expect(composite.ownerOf(slThread)?.name).toBe("slack"); + }); + + it("notify partitions a heterogeneous chat-id list across both delegates", async () => { + const gw = new Gateway(makeRouter(), makeBothConfig()); + const composite = (gw as unknown as InternalGateway).transport; + + // Spy on each delegate's notify so we can assert the partition without + // hitting the network. The composite calls them directly. + const tg = composite.delegates.find((d) => d.name === "telegram")!; + const sl = composite.delegates.find((d) => d.name === "slack")!; + const tgSpy = vi.spyOn(tg, "notify").mockResolvedValue(undefined); + const slSpy = vi.spyOn(sl, "notify").mockResolvedValue(undefined); + + await composite.notify([12345, "C01ABC", -100, "U02XYZ", "garbage"], "hello"); + + expect(tgSpy).toHaveBeenCalledWith([12345, -100], "hello"); + expect(slSpy).toHaveBeenCalledWith(["C01ABC", "U02XYZ"], "hello"); + // "garbage" was dropped (no owner) — neither delegate sees it. + + tgSpy.mockRestore(); + slSpy.mockRestore(); + }); + + it("formatNotifySession routes labels through the correct transport", () => { + const gw = new Gateway(makeRouter(), makeBothConfig()); + const composite = (gw as unknown as InternalGateway).transport; + + expect(composite.formatNotifySession(-100456)).toBe("group:-100456"); // telegram negative-id + expect(composite.formatNotifySession(789)).toBe("main"); // telegram positive + expect(composite.formatNotifySession("C01ABC")).toBe("channel:C01ABC"); // slack channel + expect(composite.formatNotifySession("D01DM")).toBe("main"); // slack DM + }); +}); diff --git a/test/ipc-handler-partition.test.ts b/test/ipc-handler-partition.test.ts new file mode 100644 index 0000000..6687fa7 --- /dev/null +++ b/test/ipc-handler-partition.test.ts @@ -0,0 +1,100 @@ +/** + * test/ipc-handler-partition.test.ts — IPC handler routing for mixed-transport chat ids. + * + * Verifies the regression flagged in slack-plan.md iter-2: + * - req.session = "Cxxx" (slack channel) routes single-target instead of + * falling through to "send to all" (which the old `/^-?\d+$/` regex did). + * - req.session = "12345" (numeric-as-string telegram) routes single-target. + * - Missing/unknown session fans out to all configured ids. + */ + +import { describe, it, expect, vi } from "vitest"; +import { createIpcHandler } from "../src/ipc/handler"; +import type { TransportAdapter } from "../src/transports/types"; +import type { GatewayConfig } from "../src/types"; + +function makeTransport(): { transport: TransportAdapter; notifyMock: ReturnType } { + const notifyMock = vi.fn(async () => {}); + const transport: TransportAdapter = { + name: "composite-stub", + enrichPrompt: (_t, x) => x, + postMessage: async () => {}, + postRich: async () => {}, + progress: async () => ({ update: async () => {} }), + stream: async () => {}, + registerCommands: async () => {}, + ownsThread: () => true, + ownsChatId: (id) => { + const s = String(id); + return /^-?\d+$/.test(s) || /^[CDGU]/.test(s); + }, + encodeParentThreadId: (id) => `${id}:main`, + formatNotifySession: () => "main", + notify: notifyMock, + createThread: (id) => ({ id: String(id), post: async () => {} }), + isPairingPending: async () => false, + handlePairing: async () => null, + }; + return { transport, notifyMock }; +} + +function makeConfig(notifyChatIds: (string | number)[]): GatewayConfig { + return { + agent: { type: "noop" }, + chat: { botUsername: "test", adapters: { telegram: {} }, notifyChatIds }, + } as GatewayConfig; +} + +describe("IPC handler — multi-transport notify partition", () => { + it("routes a slack-shaped session string to a single target", async () => { + const { transport, notifyMock } = makeTransport(); + const handler = createIpcHandler(transport, () => makeConfig([12345, "C01ABC", -678])); + + const res = await handler({ type: "notify", session: "C01ABC", text: "hi" }); + expect(res).toEqual({ ok: true }); + expect(notifyMock).toHaveBeenCalledWith(["C01ABC"], "hi"); + }); + + it("routes a numeric-string session to a single target (telegram path)", async () => { + const { transport, notifyMock } = makeTransport(); + const handler = createIpcHandler(transport, () => makeConfig([12345, "C01ABC"])); + + const res = await handler({ type: "notify", session: "12345", text: "hi" }); + expect(res).toEqual({ ok: true }); + expect(notifyMock).toHaveBeenCalledWith(["12345"], "hi"); + }); + + it("falls back to all chat ids when session is missing", async () => { + const { transport, notifyMock } = makeTransport(); + const all: (string | number)[] = [12345, "C01ABC", -678]; + const handler = createIpcHandler(transport, () => makeConfig(all)); + + await handler({ type: "notify", text: "hi" }); + expect(notifyMock).toHaveBeenCalledWith(all, "hi"); + }); + + it("'main' session targets the first configured chat id", async () => { + const { transport, notifyMock } = makeTransport(); + const handler = createIpcHandler(transport, () => makeConfig([12345, "C01ABC"])); + + await handler({ type: "notify", session: "main", text: "hi" }); + expect(notifyMock).toHaveBeenCalledWith([12345], "hi"); + }); + + it("falls back to all when session is an unrecognized id shape", async () => { + const { transport, notifyMock } = makeTransport(); + const all: (string | number)[] = [12345, "C01ABC"]; + const handler = createIpcHandler(transport, () => makeConfig(all)); + + await handler({ type: "notify", session: "garbage-shape", text: "hi" }); + expect(notifyMock).toHaveBeenCalledWith(all, "hi"); + }); + + it("returns error when no notifyChatIds are configured", async () => { + const { transport } = makeTransport(); + const handler = createIpcHandler(transport, () => makeConfig([])); + + const res = await handler({ type: "notify", text: "hi" }); + expect(res).toEqual({ ok: false, error: "No notifyChatIds configured" }); + }); +}); diff --git a/test/prepare-agent-message.test.ts b/test/prepare-agent-message.test.ts new file mode 100644 index 0000000..185dccf --- /dev/null +++ b/test/prepare-agent-message.test.ts @@ -0,0 +1,74 @@ +/** + * test/prepare-agent-message.test.ts — Pin Gateway.prepareAgentMessage's + * enrichPrompt call shape. Regression test for a real bug introduced when + * the TransportAdapter.enrichPrompt signature widened from (text) to + * (thread, text): the gateway's call site was originally missed and would + * silently set agentMessage.text = undefined for every turn. + * + * We exercise prepareAgentMessage via a TS-cast into the private method, + * matching the post-command-result.test.ts pattern. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Gateway } from "../src/gateway/gateway"; +import type { AgentRouter, GatewayConfig } from "../src/types"; + +function makeGateway(transport: any): Gateway { + const router: AgentRouter = { + resolve: () => ({ name: "noop" } as any), + dispose: async () => {}, + }; + const config: GatewayConfig = { + agent: { type: "noop" }, + chat: { botUsername: "test", adapters: {} }, + } as GatewayConfig; + const gw = new Gateway(router, config); + (gw as unknown as { transport: any }).transport = transport; + return gw; +} + +interface InternalGateway { + prepareAgentMessage: (thread: any, agentThreadId: string, userText: string, rawAttachments: any[]) => Promise<{ text: string; attachments?: unknown[] } | null>; +} + +describe("Gateway.prepareAgentMessage — enrichPrompt arity", () => { + it("calls transport.enrichPrompt(thread, text), not (text)", async () => { + const enrichPrompt = vi.fn((_t: any, text: string) => `${text} [hint]`); + const transport = { + enrichPrompt, + // The composite-stub doesn't need ownsThread/etc here — gateway's + // prepareAgentMessage only invokes enrichPrompt. + }; + const gw = makeGateway(transport); + const thread = { id: "telegram:42", post: async () => {} }; + + const result = await (gw as unknown as InternalGateway).prepareAgentMessage( + thread, + "telegram:42", + "hello", + [], + ); + + expect(enrichPrompt).toHaveBeenCalledWith(thread, "hello"); + expect(result?.text).toBe("hello [hint]"); + }); + + it("does not enrich when there's no text (attachment-only message)", async () => { + const enrichPrompt = vi.fn((_t: any, text: string) => `${text} [hint]`); + const transport = { enrichPrompt }; + const gw = makeGateway(transport); + const thread = { id: "telegram:42", post: async () => {} }; + + // No text + no attachments → returns null. We're more interested in + // confirming enrichPrompt isn't invoked with undefined. + const result = await (gw as unknown as InternalGateway).prepareAgentMessage( + thread, + "telegram:42", + "", + [], + ); + + expect(result).toBeNull(); + expect(enrichPrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/test/setup-slack-validate-token.test.ts b/test/setup-slack-validate-token.test.ts new file mode 100644 index 0000000..a1806ab --- /dev/null +++ b/test/setup-slack-validate-token.test.ts @@ -0,0 +1,96 @@ +/** + * test/setup-slack-validate-token.test.ts — validateSlackBotToken with mocked fetch. + * + * Bar: the function must (a) call auth.test against Slack with the bot + * token in Authorization, (b) parse user_id/team_id on success, (c) NEVER + * leak the raw token in any error message. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { validateSlackBotToken } from "../src/cli/setup/slack"; + +const FAKE_TOKEN = "xoxb-99999-99999-loaderXyzAbcDefGhi"; + +describe("validateSlackBotToken", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("rejects token with wrong prefix without hitting the network", async () => { + const fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as any; + + await expect(validateSlackBotToken("xoxa-not-a-bot")).rejects.toThrow(/must start with 'xoxb-'/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns SlackBotInfo on a successful auth.test", async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + ok: true, + user_id: "U02BOT", + user: "roundhouse", + team_id: "T01TEAM", + team: "Acme", + }), + } as any)) as any; + + const info = await validateSlackBotToken(FAKE_TOKEN); + expect(info).toEqual({ + botUserId: "U02BOT", + botName: "roundhouse", + teamId: "T01TEAM", + teamName: "Acme", + }); + }); + + it("on Slack-side failure (ok:false), throws with the redacted token", async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: false, error: "invalid_auth" }), + } as any)) as any; + + try { + await validateSlackBotToken(FAKE_TOKEN); + expect.fail("should have thrown"); + } catch (err) { + const msg = (err as Error).message; + expect(msg).toContain("invalid_auth"); + // Redacted form: "xoxb-999...XyzAbc" (prefix 8 + last 4) + expect(msg).toContain("xoxb-999"); + // The full token must NEVER appear in the error + expect(msg).not.toContain(FAKE_TOKEN); + } + }); + + it("on HTTP failure throws with the redacted token", async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: false, + status: 500, + } as any)) as any; + + try { + await validateSlackBotToken(FAKE_TOKEN); + expect.fail("should have thrown"); + } catch (err) { + const msg = (err as Error).message; + expect(msg).toMatch(/HTTP 500/); + expect(msg).not.toContain(FAKE_TOKEN); + } + }); + + it("when auth.test returns OK but missing user_id/team_id, throws (defensive)", async () => { + globalThis.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: true /* missing user_id/team_id */ }), + } as any)) as any; + + await expect(validateSlackBotToken(FAKE_TOKEN)).rejects.toThrow(/without user_id\/team_id/); + }); +}); diff --git a/test/setup-slack.test.ts b/test/setup-slack.test.ts new file mode 100644 index 0000000..c8ee4fd --- /dev/null +++ b/test/setup-slack.test.ts @@ -0,0 +1,120 @@ +/** + * test/setup-slack.test.ts — `roundhouse setup --slack` argument parsing + * and Slack-helper unit tests. + */ + +import { describe, it, expect } from "vitest"; +import { parseSetupArgs } from "../src/cli/setup/args"; +import { + redactSlackToken, + validateSlackAppTokenShape, + readBundledManifest, +} from "../src/cli/setup/slack"; + +describe("parseSetupArgs --slack", () => { + it("accepts --slack with required tokens via flags", () => { + const opts = parseSetupArgs([ + "--slack", + "--slack-bot-token", "xoxb-test-placeholder", + "--slack-app-token", "xapp-1-A0123456789-1234567890-abcd1234ef567890", + "--user", "alice", + ]); + expect(opts.slack).toBe(true); + expect(opts.slackBotToken).toMatch(/^xoxb-/); + expect(opts.slackAppToken).toMatch(/^xapp-/); + expect(opts.users).toEqual(["alice"]); + }); + + it("rejects --telegram and --slack together", () => { + expect(() => parseSetupArgs(["--telegram", "--slack", "--user", "alice"])) + .toThrow(/mutually exclusive/); + }); + + it("rejects bot token with wrong prefix", () => { + expect(() => parseSetupArgs([ + "--slack", + "--slack-bot-token", "xoxa-malformed", + "--slack-app-token", "xapp-1-A1-1-abc", + "--user", "alice", + ])).toThrow(/must start with `xoxb-`/); + }); + + it("rejects app token with wrong prefix", () => { + expect(() => parseSetupArgs([ + "--slack", + "--slack-bot-token", "xoxb-ok", + "--slack-app-token", "xoxb-wrong", + "--user", "alice", + ])).toThrow(/must start with `xapp-`/); + }); + + it("rejects --slack-bot-token in --non-interactive mode (argv leakage)", () => { + expect(() => parseSetupArgs([ + "--slack", "--non-interactive", + "--slack-bot-token", "xoxb-1234", + "--user", "alice", + ])).toThrow(/argv visible in process listings/); + }); + + it("rejects --slack-signing-secret in --non-interactive mode (argv leakage)", () => { + expect(() => parseSetupArgs([ + "--slack", "--non-interactive", + "--slack-signing-secret", "secret-value", + "--user", "alice", + ])).toThrow(/argv visible in process listings/); + }); + + it("falls back to env vars when flags omitted", () => { + process.env.SLACK_BOT_TOKEN = "xoxb-from-env"; + process.env.SLACK_APP_TOKEN = "xapp-from-env"; + try { + const opts = parseSetupArgs(["--slack", "--user", "alice"]); + expect(opts.slackBotToken).toBe("xoxb-from-env"); + expect(opts.slackAppToken).toBe("xapp-from-env"); + } finally { + delete process.env.SLACK_BOT_TOKEN; + delete process.env.SLACK_APP_TOKEN; + } + }); +}); + +describe("redactSlackToken", () => { + it("preserves prefix + last 4 chars so users can identify which token is broken", () => { + expect(redactSlackToken("xoxb-12345678-90ABCDEF-rest1234")).toBe("xoxb-123...1234"); + }); + + it("returns *** for too-short input (avoid leaking partial)", () => { + expect(redactSlackToken("short")).toBe("***"); + }); +}); + +describe("validateSlackAppTokenShape", () => { + it("accepts well-formed xapp tokens", () => { + expect(() => + validateSlackAppTokenShape("xapp-1-A0123456789-1234567890-abcd1234ef567890ab1234cd5678ef9012"), + ).not.toThrow(); + }); + + it("rejects malformed app tokens with a redacted error", () => { + try { + validateSlackAppTokenShape("xapp-not-real"); + expect.fail("should have thrown"); + } catch (err) { + expect((err as Error).message).toMatch(/App token shape/); + // The error redacts; "xapp-not-" should not appear in full + expect((err as Error).message).not.toContain("xapp-not-real"); + } + }); +}); + +describe("readBundledManifest", () => { + it("loads the YAML manifest including required scopes", async () => { + const manifest = await readBundledManifest(); + expect(manifest).toContain("display_information:"); + expect(manifest).toContain("assistant:write"); + expect(manifest).toContain("socket_mode_enabled: true"); + expect(manifest).toContain("assistant_thread_started"); + // Privacy: ensure we don't request users:read.email — flagged in plan + expect(manifest).not.toContain("users:read.email"); + }); +}); diff --git a/test/slack-adapter.test.ts b/test/slack-adapter.test.ts new file mode 100644 index 0000000..af3769e --- /dev/null +++ b/test/slack-adapter.test.ts @@ -0,0 +1,160 @@ +/** + * test/slack-adapter.test.ts — TransportAdapter contract checks for SlackAdapter. + * + * Verifies the surface that Phase 1's CompositeTransportAdapter relies on: + * - ownsThread, ownsChatId, encodeParentThreadId, formatNotifySession + * - shape of postRich (uses { card, fallbackText }, NOT { blocks }) + * - createThread builds a slack:CHANNEL:THREAD_TS id via the SDK encoder + * - postChannelMessage path receives the right payload shape + */ + +import { describe, it, expect, vi } from "vitest"; +import { SlackAdapter } from "../src/transports/slack/slack-adapter"; +import type { ChatThread } from "../src/transports/types"; + +function fakeSdk() { + const calls: Record = { + postChannelMessage: [], + encodeThreadId: [], + decodeThreadId: [], + startTyping: [], + chat_postMessage: [], + chat_update: [], + }; + const sdk = { + encodeThreadId: vi.fn((data: { channel: string; threadTs: string }) => { + calls.encodeThreadId.push(data); + return `slack:${data.channel}:${data.threadTs || "main"}`; + }), + decodeThreadId: vi.fn((id: string) => { + calls.decodeThreadId.push(id); + const [, channel, threadTs = ""] = id.split(":"); + return { channel, threadTs }; + }), + isDM: (id: string) => /^slack:D/.test(id), + postChannelMessage: vi.fn(async (channelId: string, message: any) => { + calls.postChannelMessage.push({ channelId, message }); + return { id: "raw-msg" }; + }), + startTyping: vi.fn(async (id: string) => { calls.startTyping.push(id); }), + webClient: { + chat: { + postMessage: vi.fn(async (args: any) => { + calls.chat_postMessage.push(args); + return { ts: "1712023032.0001", channel: args.channel }; + }), + update: vi.fn(async (args: any) => { calls.chat_update.push(args); return {}; }), + }, + auth: { test: vi.fn(async () => ({ ok: true })) }, + }, + getUser: vi.fn(async (id: string) => ({ userId: id, userName: "alice", fullName: "Alice" })), + }; + return { sdk: sdk as any, calls }; +} + +describe("SlackAdapter", () => { + it("ownsThread / ownsChatId / encodeParentThreadId / formatNotifySession", () => { + const a = new SlackAdapter(); + expect(a.ownsThread({ id: "slack:C01:main", post: async () => {} })).toBe(true); + expect(a.ownsThread({ id: "telegram:1", post: async () => {} })).toBe(false); + + expect(a.ownsChatId("C01ABC")).toBe(true); + expect(a.ownsChatId("D01XYZ")).toBe(true); + expect(a.ownsChatId("U02USER")).toBe(true); + expect(a.ownsChatId("12345")).toBe(false); + expect(a.ownsChatId(123)).toBe(false); + + expect(a.encodeParentThreadId("C01")).toBe("slack:C01:main"); + + expect(a.formatNotifySession("D01")).toBe("main"); + expect(a.formatNotifySession("C01ABC")).toBe("channel:C01ABC"); + expect(a.formatNotifySession("Gxxxxx")).toBe("channel:Gxxxxx"); + }); + + it("requires SDK attach() before SDK-dependent methods", () => { + const a = new SlackAdapter(); + expect(() => a.createThread("C01")).toThrow(/not attached/); + }); + + it("createThread builds a slack:CHANNEL:THREAD_TS id and a working post()", async () => { + const { sdk, calls } = fakeSdk(); + const a = new SlackAdapter(); + a.attach(sdk); + const thread = a.createThread("C01"); + + expect(thread.id).toBe("slack:C01:main"); + expect(calls.encodeThreadId).toEqual([{ channel: "C01", threadTs: "" }]); + + await thread.post("hello"); + // SDK's postChannelMessage requires the "slack:" prefix on the channel id — + // it splits on ":" and grabs index [1]. Passing a bare "C01" throws. + expect(calls.postChannelMessage).toEqual([{ channelId: "slack:C01", message: { markdown: "hello" } }]); + + await thread.post({ markdown: "**bold**" }); + expect(calls.postChannelMessage[1].message).toEqual({ markdown: "**bold**" }); + }); + + it("postRich uses { card, fallbackText }, never { blocks }", async () => { + const { sdk, calls } = fakeSdk(); + const a = new SlackAdapter(); + a.attach(sdk); + + const post = vi.fn(async () => {}); + const thread: ChatThread = { id: "slack:C01:main", post }; + + await a.postRich(thread, { + text: "fallback prose", + menuCaption: "Pick one:", + menu: { sections: [{ buttons: [{ label: "OK", actionId: "ok", value: "ok" }] }] }, + }); + + expect(post).toHaveBeenCalledOnce(); + const [arg] = post.mock.calls[0]; + expect(arg).not.toHaveProperty("blocks"); + expect(arg).toHaveProperty("card"); + expect(arg).toHaveProperty("fallbackText"); + // confirm there's no smuggled markdown_text either + expect(arg).not.toHaveProperty("markdown_text"); + + // The card itself is the SDK's CardElement shape. + const card = (arg as { card: any }).card; + expect(card.type).toBe("card"); + expect(Array.isArray(card.children)).toBe(true); + }); + + it("postRich falls back to text when there's no menu", async () => { + const a = new SlackAdapter(); + const post = vi.fn(async () => {}); + const thread: ChatThread = { id: "slack:C01:main", post }; + + await a.postRich(thread, { text: "just text" }); + + expect(post).toHaveBeenCalledOnce(); + const [arg] = post.mock.calls[0]; + expect(arg).toEqual({ markdown: "just text" }); + }); + + it("notify routes through webClient.chat.postMessage when SDK is attached", async () => { + const { sdk, calls } = fakeSdk(); + const a = new SlackAdapter(); + a.attach(sdk); + + await a.notify(["C01", 12345, "U02"], "hi"); + + expect(calls.chat_postMessage).toHaveLength(2); // dropped 12345 (telegram-shaped) + expect(calls.chat_postMessage[0]).toMatchObject({ + channel: "C01", + markdown_text: "hi", + unfurl_links: false, + mrkdwn: true, + }); + expect(calls.chat_postMessage[1]).toMatchObject({ channel: "U02" }); + }); + + it("enrichPrompt appends a slack-friendly hint", () => { + const a = new SlackAdapter(); + const out = a.enrichPrompt({ id: "slack:C01:main", post: async () => {} }, "user input"); + expect(out.startsWith("user input\n\n")).toBe(true); + expect(out).toContain("Format your final answer for Slack"); + }); +}); diff --git a/test/slack-format.test.ts b/test/slack-format.test.ts new file mode 100644 index 0000000..578059c --- /dev/null +++ b/test/slack-format.test.ts @@ -0,0 +1,95 @@ +/** + * test/slack-format.test.ts — slack format helpers + richMenuToCard + */ + +import { describe, it, expect } from "vitest"; +import { isSlackChatId, SLACK_MARKDOWN_TEXT_LIMIT } from "../src/transports/slack/format"; +import { richMenuToCard, stripMarkdownToPlain } from "../src/transports/rich-helpers"; + +describe("isSlackChatId", () => { + it.each([ + ["C01ABC", true], + ["D01XYZ", true], + ["G01PRIV", true], + ["U02USER", true], + ["12345", false], + ["-100123", false], + ["c01abc", false], // lowercase → not a Slack id + [12345, false], + ["", false], + ])("isSlackChatId(%j) === %s", (id, expected) => { + expect(isSlackChatId(id as any)).toBe(expected); + }); + + it("SLACK_MARKDOWN_TEXT_LIMIT is the documented 12k cap", () => { + expect(SLACK_MARKDOWN_TEXT_LIMIT).toBe(12_000); + }); +}); + +describe("richMenuToCard", () => { + it("emits a CardElement with action blocks (NOT raw Block Kit)", () => { + const card = richMenuToCard( + { + sections: [ + { + title: "Pick:", + buttons: [ + { label: "Yes", actionId: "decide", value: "yes" }, + { label: "No", actionId: "decide", value: "no", selected: true }, + ], + }, + ], + }, + "Header text" + ); + + expect(card.type).toBe("card"); + expect(Array.isArray(card.children)).toBe(true); + // First section is the optional header prose. + const headerSection = (card.children[0] as any); + expect(headerSection.type).toBe("section"); + // Second section has the title + actions. + const buttonSection = (card.children[1] as any); + const actions = buttonSection.children.find((c: any) => c.type === "actions"); + expect(actions).toBeDefined(); + expect(actions.children.length).toBe(2); + expect(actions.children[0].id).toBe("decide"); + expect(actions.children[0].value).toBe("yes"); + expect(actions.children[1].style).toBe("primary"); // selected: true + }); + + it("chunks button groups at 5 (Slack actions block max)", () => { + const buttons = Array.from({ length: 7 }, (_, i) => ({ + label: `b${i}`, + actionId: "x", + value: `${i}`, + })); + const card = richMenuToCard({ sections: [{ buttons }] }); + const section = card.children[0] as any; + const actionBlocks = section.children.filter((c: any) => c.type === "actions"); + expect(actionBlocks.length).toBe(2); // 5 + 2 + expect(actionBlocks[0].children.length).toBe(5); + expect(actionBlocks[1].children.length).toBe(2); + }); +}); + +describe("stripMarkdownToPlain", () => { + it("removes inline emphasis and code markers", () => { + expect(stripMarkdownToPlain("**bold** and *italic* and `code`")).toBe( + "bold and italic and code" + ); + }); + + it("strips fenced code fences but keeps content", () => { + const input = "```js\nconsole.log(1)\n```"; + expect(stripMarkdownToPlain(input)).toContain("console.log(1)"); + }); + + it("turns markdown links into plain link text", () => { + expect(stripMarkdownToPlain("see [docs](https://x.com)")).toBe("see docs"); + }); + + it("keeps emoji and bullets readable", () => { + expect(stripMarkdownToPlain("- one\n- two")).toBe("• one\n• two"); + }); +}); diff --git a/test/slack-pairing.test.ts b/test/slack-pairing.test.ts new file mode 100644 index 0000000..f8eda0a --- /dev/null +++ b/test/slack-pairing.test.ts @@ -0,0 +1,55 @@ +/** + * test/slack-pairing.test.ts — pending Slack pairing matching. + * + * Covers `matchPendingPairing`, the pure helper that the SlackAdapter uses + * to decide whether an inbound event corresponds to the pending pairing. + * The first-DM (message.im) path populates `userName`; the + * assistant_thread_started path may only have `userId` until the gateway + * resolves the user via getUser — so we accept Uxxx-literal allowlist + * entries as a fallback. + */ + +import { describe, it, expect } from "vitest"; +import { matchPendingPairing, type PendingSlackPairing } from "../src/transports/slack/pairing"; + +const base: PendingSlackPairing = { + version: 1, + allowedUsers: ["alice"], + createdAt: "2026-05-26T00:00:00Z", + status: "pending", +}; + +describe("matchPendingPairing", () => { + it("matches by lowercased userName", () => { + expect(matchPendingPairing(base, "Alice", "U02ABC")).toBe(true); + expect(matchPendingPairing(base, "ALICE", "U02ABC")).toBe(true); + }); + + it("strips a leading @ when matching userName", () => { + expect(matchPendingPairing(base, "@alice", "U02ABC")).toBe(true); + }); + + it("rejects a userName not in the allowlist", () => { + expect(matchPendingPairing(base, "bob", "U02BOB")).toBe(false); + }); + + it("matches by Slack userId when allowedUserIds is set (assistant_thread_started fallback)", () => { + const pending: PendingSlackPairing = { + ...base, + allowedUsers: ["alice"], + allowedUserIds: ["U02ABC"], + }; + // userName missing (assistant event before getUser resolution) + expect(matchPendingPairing(pending, undefined, "U02ABC")).toBe(true); + }); + + it("rejects when neither name nor id matches", () => { + const pending: PendingSlackPairing = { ...base, allowedUserIds: ["U02ABC"] }; + expect(matchPendingPairing(pending, "bob", "U99XYZ")).toBe(false); + }); + + it("rejects already-paired states", () => { + const paired: PendingSlackPairing = { ...base, status: "paired" }; + expect(matchPendingPairing(paired, "alice", "U02ABC")).toBe(false); + }); +}); diff --git a/test/slack-streaming.test.ts b/test/slack-streaming.test.ts new file mode 100644 index 0000000..37ec867 --- /dev/null +++ b/test/slack-streaming.test.ts @@ -0,0 +1,126 @@ +/** + * test/slack-streaming.test.ts — post-then-edit streaming for Slack. + * + * Covers the v3 polish from slack-plan.md §2.7: + * - first chunk → chat.postMessage; subsequent chunks → chat.update. + * - Final flush edits even when no individual chunk crossed the throttle. + * - Aborted signal stops chunk-loop iteration but still flushes the + * accumulated buffer. + * - Initial-post failure with backoff cap so the loop doesn't hammer Slack. + */ + +import { describe, it, expect, vi } from "vitest"; +import { handleSlackStream } from "../src/transports/slack/streaming"; +import type { ChatThread } from "../src/transports/types"; + +function fakeSdk(opts: { initialFails?: number } = {}) { + let initialPostCalls = 0; + const calls: { post: any[]; update: any[] } = { post: [], update: [] }; + const sdk = { + decodeThreadId: (id: string) => { + const [, channel, threadTs = ""] = id.split(":"); + return { channel, threadTs }; + }, + webClient: { + chat: { + postMessage: vi.fn(async (args: any) => { + calls.post.push(args); + initialPostCalls++; + if (initialPostCalls <= (opts.initialFails ?? 0)) { + throw new Error("simulated post failure"); + } + return { ts: `ts-${initialPostCalls}` }; + }), + update: vi.fn(async (args: any) => { calls.update.push(args); return {}; }), + }, + }, + }; + return { sdk: sdk as any, calls }; +} + +async function* gen(...chunks: string[]) { + for (const c of chunks) yield c; +} + +const thread: ChatThread = { id: "slack:C01:main", post: async () => {} }; + +describe("handleSlackStream", () => { + it("posts initial then edits with each subsequent chunk's accumulated buffer (final flush)", async () => { + const { sdk, calls } = fakeSdk(); + await handleSlackStream(sdk, thread, gen("hello ", "world")); + expect(calls.post).toHaveLength(1); + expect(calls.post[0]).toMatchObject({ channel: "C01", markdown_text: "hello " }); + // Throttling means we may not get a per-chunk edit — but the final flush + // MUST update with the full accumulated body. + const lastUpdate = calls.update[calls.update.length - 1]; + expect(lastUpdate).toMatchObject({ channel: "C01", ts: "ts-1", markdown_text: "hello world" }); + }); + + it("retries initial post after backoff and then sends final flush as a last-resort post", async () => { + const { sdk, calls } = fakeSdk({ initialFails: 1 }); + await handleSlackStream(sdk, thread, gen("part1", "part2")); + // First initial post throws; the loop's backoff prevents an immediate + // retry on chunk 2. The final flush retries unconditionally and + // succeeds with the full body. + expect(calls.post.length).toBeGreaterThanOrEqual(2); + const successful = calls.post.find((p: any) => p.markdown_text.includes("part1part2")); + expect(successful).toBeDefined(); + }); + + it("respects an aborted signal: stops iterating but still flushes what we have", async () => { + const { sdk, calls } = fakeSdk(); + const ac = new AbortController(); + async function* abortAfterFirst() { + yield "alpha"; + ac.abort(); + yield "beta"; // should be skipped due to abort check + } + await handleSlackStream(sdk, thread, abortAfterFirst(), ac.signal); + // Initial post happened with "alpha"; final flush MAY edit but only with + // already-accumulated text (which depends on iteration order). Either + // way, we should never have posted "beta" content. + const allText = [...calls.post, ...calls.update] + .map((c: any) => c.markdown_text || c.text) + .join("|"); + expect(allText).toContain("alpha"); + expect(allText).not.toContain("beta"); + }); + + it("no-ops on empty stream (no posts, no updates)", async () => { + const { sdk, calls } = fakeSdk(); + await handleSlackStream(sdk, thread, gen()); + expect(calls.post).toHaveLength(0); + expect(calls.update).toHaveLength(0); + }); + + it("warns and exits early on a non-slack thread id", async () => { + const { sdk, calls } = fakeSdk(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + await handleSlackStream(sdk, { id: "telegram:42", post: async () => {} }, gen("nope")); + expect(calls.post).toHaveLength(0); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("overflow: starts a fresh message after crossing the 12k cap", async () => { + // Build a single chunk that exceeds the 12k limit. We expect: + // 1. initial post (with the first 12k slice — clean cut) + // 2. handleOverflow finalizes the first message and nulls messageTs + // 3. continued chunks initialize a NEW message + const { sdk, calls } = fakeSdk(); + const big = "a".repeat(13_000); + await handleSlackStream(sdk, thread, gen(big)); + + // Two posts (first chunk, then overflow re-init via final flush) + expect(calls.post.length).toBeGreaterThanOrEqual(2); + // Both should target the same channel and be valid markdown_text payloads + for (const p of calls.post) { + expect(p.channel).toBe("C01"); + expect(typeof p.markdown_text).toBe("string"); + expect(p.markdown_text.length).toBeLessThanOrEqual(12_000); + } + // The combined posted text should equal the original input + const combined = calls.post.map((p: any) => p.markdown_text).join(""); + expect(combined.length).toBe(big.length); + }); +}); diff --git a/test/telegram-setup.test.ts b/test/telegram-setup.test.ts index 56b2253..7c7f70e 100644 --- a/test/telegram-setup.test.ts +++ b/test/telegram-setup.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { resolve } from "node:path"; import { mkdtemp, rm, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -285,6 +285,7 @@ describe("TelegramAdapter.handlePairing", () => { let adapter: any; beforeEach(async () => { + vi.resetModules(); // Clear module cache so ROUNDHOUSE_DIR is re-evaluated tempDir = await mkdtemp(resolve(tmpdir(), "roundhouse-adapter-test-")); process.env.ROUNDHOUSE_DIR = tempDir; const { TelegramAdapter } = await import("../src/transports/telegram/telegram-adapter"); @@ -356,7 +357,11 @@ describe("TelegramAdapter.handlePairing", () => { status: "pending", }); - const thread = { id: "telegram:123456", post: async () => {} }; + const thread = { + id: "telegram:123456", + post: async () => {}, + adapter: { telegramFetch: async () => {} }, // Required for isTelegramThread check + }; const message = { text: `/start ${nonce}`, author: { name: "alice", userId: 789 }, @@ -382,7 +387,11 @@ describe("TelegramAdapter.handlePairing", () => { status: "pending", }); - const thread = { id: "telegram:123", post: async () => {} }; + const thread = { + id: "telegram:123", + post: async () => {}, + adapter: { telegramFetch: async () => {} }, // Required for isTelegramThread check + }; const message = { text: `/start ${nonce}`, author: { name: "Alice", userId: 456 }, diff --git a/test/topic-dispatch-regression.test.ts b/test/topic-dispatch-regression.test.ts index aebd7e1..805bbe5 100644 --- a/test/topic-dispatch-regression.test.ts +++ b/test/topic-dispatch-regression.test.ts @@ -19,6 +19,7 @@ import { describe, it, expect, vi } from "vitest"; import { Gateway } from "../src/gateway/gateway"; +import { BotUsernameResolver } from "../src/gateway/bot-username-resolver"; import { applyTopicOverride, setActiveTopic, TOPIC_ACTION_ID } from "../src/gateway/topic-command"; import type { AgentRouter, GatewayConfig } from "../src/types"; import type { CommandDescriptor } from "../src/gateway/command-registry"; @@ -49,23 +50,22 @@ interface GatewayInternals { /** Live in-turn dispatcher that handle() uses. Calling this directly is the closest we can get to driving the real handler without a Chat SDK. */ dispatchInTurnCommand: ( inTurnCommands: readonly CommandDescriptor[], - matchers: { isCommand: (t: string, c: string) => boolean; isCommandWithArgs: (t: string, c: string) => boolean }, - thread: any, message: any, trimmed: string, agentThreadId: string, + thread: any, + botUsername: string, + message: any, + trimmed: string, + agentThreadId: string, ) => Promise; } -function buildInTurn(gw: Gateway): { inTurn: CommandDescriptor[]; matchers: any } { +function buildInTurn(gw: Gateway): { inTurn: CommandDescriptor[] } { const internals = gw as unknown as GatewayInternals; const all = internals.buildCommandDescriptors({ allowedUsers: [], allowedUserIds: [], verboseThreads: new Set(), threadLocks: new Map(), abortControllers: new Map(), }); const inTurn = all.filter(d => d.stage !== "pre-turn"); - const matchers = { - isCommand: (t: string, c: string) => isCommand(t, c, "test"), - isCommandWithArgs: (t: string, c: string) => isCommandWithArgs(t, c, "test"), - }; - return { inTurn, matchers }; + return { inTurn }; } describe("gateway dispatch \u2014 topic-session adapter preservation (regression)", () => { @@ -105,8 +105,12 @@ describe("gateway dispatch \u2014 topic-session adapter preservation (regression // Drive the live dispatcher \u2014 same code path handle() uses. const handled = await internals.dispatchInTurnCommand( - inTurn, matchers, - transportThread, { text: "/topic" }, "/topic", agentThreadId, + inTurn, + transportThread, + "test_bot", + { text: "/topic" }, + "/topic", + agentThreadId, ); expect(handled).toBe(true); @@ -141,14 +145,48 @@ describe("gateway dispatch \u2014 topic-session adapter preservation (regression it("dispatcher returns false for unrecognized commands", async () => { const transport = { postRich: vi.fn(), progress: vi.fn() }; const gw = makeGateway(transport); - const { inTurn, matchers } = buildInTurn(gw); + const { inTurn } = buildInTurn(gw); const internals = gw as unknown as GatewayInternals; const transportThread = { id: "telegram:99", post: vi.fn() }; const handled = await internals.dispatchInTurnCommand( - inTurn, matchers, transportThread, { text: "hi" }, "hi", "main", + inTurn, + transportThread, + "test_bot", + { text: "hi" }, + "hi", + "main", ); expect(handled).toBe(false); expect(transport.postRich).not.toHaveBeenCalled(); }); }); + +describe("BotUsernameResolver", () => { + it("resolves Slack override when available", () => { + const resolver = new BotUsernameResolver({ + globalBotUsername: "telegram_bot", + adapterOverrides: { slack: "slack_bot" }, + }); + const slackThread = { id: "slack:C01:main" }; + const telegramThread = { id: "telegram:42" }; + expect(resolver.resolve(slackThread)).toBe("slack_bot"); + expect(resolver.resolve(telegramThread)).toBe("telegram_bot"); + }); + + it("falls back to global when no adapter override", () => { + const resolver = new BotUsernameResolver({ + globalBotUsername: "default_bot", + adapterOverrides: {}, + }); + expect(resolver.resolve({ id: "slack:C01:main" })).toBe("default_bot"); + }); + + it("returns empty string when no global or override configured", () => { + const resolver = new BotUsernameResolver({ + globalBotUsername: "", + adapterOverrides: {}, + }); + expect(resolver.resolve({ id: "telegram:42" })).toBe(""); + }); +}); diff --git a/test/transport-stream-dispatch.test.ts b/test/transport-stream-dispatch.test.ts new file mode 100644 index 0000000..0148cbf --- /dev/null +++ b/test/transport-stream-dispatch.test.ts @@ -0,0 +1,91 @@ +/** + * test/transport-stream-dispatch.test.ts — pin the streaming refactor. + * + * Phase 2 changed gateway/streaming.ts to dispatch per-turn streaming + * through `transport.stream(thread, iter, signal)` instead of the + * hardcoded telegram-html / thread.handleStream branch. This test + * covers the seam: + * - When `transport` is provided, transport.stream is called with the + * thread, an AsyncIterable, and the abort signal. + * - When `transport` is omitted (test harness), the loop falls back to + * `thread.handleStream` (existing behavior). + */ + +import { describe, it, expect, vi } from "vitest"; +import { handleStreaming, type StreamContext } from "../src/gateway/streaming"; +import type { AgentStreamEvent } from "../src/types"; + +async function* events(...evts: AgentStreamEvent[]) { + for (const e of evts) yield e; +} + +function fakeThread() { + return { + id: "telegram:1", + handleStream: vi.fn(async () => {}), + post: vi.fn(async () => {}), + }; +} + +describe("handleStreaming dispatch", () => { + it("routes the per-turn iterable through transport.stream when a transport is provided", async () => { + const thread = fakeThread(); + const ac = new AbortController(); + const streamMock = vi.fn(async () => {}); + const ctx: StreamContext = { + thread, + verbose: false, + signal: ac.signal, + postWithFallback: async () => {}, + transport: { + name: "fake", + enrichPrompt: (_t, t) => t, + postMessage: async () => {}, + postRich: async () => {}, + progress: async () => ({ update: async () => {} }), + stream: streamMock as any, + registerCommands: async () => {}, + ownsThread: () => true, + ownsChatId: () => true, + encodeParentThreadId: (id) => `fake:${id}`, + formatNotifySession: () => "main", + notify: async () => {}, + createThread: () => ({ id: "fake:0", post: async () => {} }), + isPairingPending: async () => false, + handlePairing: async () => null, + }, + }; + + await handleStreaming( + events( + { type: "text_delta", text: "hello " }, + { type: "text_delta", text: "world" }, + { type: "agent_end" }, + ), + ctx, + ); + + expect(streamMock).toHaveBeenCalledOnce(); + const [calledThread, asyncIter, calledSignal] = streamMock.mock.calls[0]; + expect(calledThread).toBe(thread); + expect(typeof (asyncIter as any)[Symbol.asyncIterator]).toBe("function"); + expect(calledSignal).toBe(ac.signal); + expect(thread.handleStream).not.toHaveBeenCalled(); + }); + + it("falls back to thread.handleStream when no transport is provided", async () => { + const thread = fakeThread(); + const ctx: StreamContext = { + thread, + verbose: false, + postWithFallback: async () => {}, + }; + + await handleStreaming( + events({ type: "text_delta", text: "hi" }, { type: "agent_end" }), + ctx, + ); + + expect(thread.handleStream).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/typing.test.ts b/test/typing.test.ts index 5a296ed..5bc0c00 100644 --- a/test/typing.test.ts +++ b/test/typing.test.ts @@ -23,12 +23,51 @@ describe("startTypingLoop", () => { expect(startTyping).toHaveBeenCalledTimes(3); stop(); + // stop() runs cleanup asynchronously so it can await any in-flight + // tick before sending the clear (race-fix). Drain the microtask + // queue so the deferred startTyping("") lands. + await vi.runAllTimersAsync(); + expect(startTyping).toHaveBeenCalledTimes(4); + expect(startTyping).toHaveBeenLastCalledWith(""); + vi.advanceTimersByTime(500); - expect(startTyping).toHaveBeenCalledTimes(3); // no more after stop + expect(startTyping).toHaveBeenCalledTimes(4); // no further interval ticks vi.useRealTimers(); }); + it("calls thread.stopTyping on stop when the thread provides one", async () => { + const startTyping = vi.fn().mockResolvedValue(undefined); + const stopTyping = vi.fn().mockResolvedValue(undefined); + const stop = startTypingLoop({ startTyping, stopTyping }, 100); + stop(); + // Cleanup is async — flush microtasks. + await new Promise((r) => setImmediate(r)); + expect(stopTyping).toHaveBeenCalledOnce(); + }); + + it("waits for an in-flight startTyping() before sending the clear", async () => { + // Race regression: a tick that started just before stop() must NOT + // land after the clear and silently re-set Slack's persistent status. + let resolveInFlight: () => void = () => {}; + const inFlightPromise = new Promise((r) => { resolveInFlight = r; }); + const startTyping = vi.fn() + .mockImplementationOnce(() => inFlightPromise) // first (immediate) tick stays in flight + .mockResolvedValue(undefined); // subsequent calls (incl. clear) resolve sync + + const stop = startTypingLoop({ startTyping }, 100); + stop(); + // Cleanup is awaiting the in-flight promise; the clear hasn't fired yet. + await new Promise((r) => setImmediate(r)); + expect(startTyping).toHaveBeenCalledTimes(1); // only the immediate, clear deferred + + resolveInFlight(); + await new Promise((r) => setImmediate(r)); + // Now the clear has landed. + expect(startTyping).toHaveBeenCalledTimes(2); + expect(startTyping).toHaveBeenLastCalledWith(""); + }); + it("does not throw if startTyping rejects", async () => { const startTyping = vi.fn().mockRejectedValue(new Error("network")); const stop = startTypingLoop({ startTyping }, 100); diff --git a/test/unit.test.ts b/test/unit.test.ts index e1c8911..819e5dc 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -118,6 +118,82 @@ describe("isAllowed", () => { it("blocks messages with empty author", () => { expect(isAllowed({ author: {} }, ["alice"])).toBe(false); }); + + // ── Multi-transport allowlist (Phase 1: widened to (string | number)[]) ── + + it("matches a numeric telegram id from a heterogeneous allowlist", () => { + expect( + isAllowed( + { author: { userId: "12345" } }, + [], + [12345, "U02ABC"], + ), + ).toBe(true); + }); + + it("matches a slack string id from a heterogeneous allowlist", () => { + expect( + isAllowed( + { author: { userId: "U02ABC" } }, + [], + [12345, "U02ABC"], + ), + ).toBe(true); + }); + + it("does NOT match a slack id when only numeric entries are allowlisted", () => { + // parseInt("U02ABC", 10) is NaN — must not silently match a numeric entry. + expect( + isAllowed( + { author: { userId: "U02ABC" } }, + [], + [12345, 67890], + ), + ).toBe(false); + }); + + it("does NOT match a telegram numeric id when only string entries are allowlisted", () => { + expect( + isAllowed( + { author: { userId: "12345" } }, + [], + ["U02ABC"], + ), + ).toBe(false); + }); + + it("rejects a numeric-shaped string entry that doesn't fully match the userId", () => { + // Defensive: parseInt("12345abc", 10) === 12345, but the raw string isn't + // "12345" so it must NOT match a numeric entry of 12345. + expect( + isAllowed( + { author: { userId: "12345abc" } }, + [], + [12345], + ), + ).toBe(false); + }); + + it("matches when message.author.userId is a raw number (not string)", () => { + // Some platforms hand us userId as a number directly. The dual-lookup + // must coerce both sides to string before comparing — otherwise a + // numeric allowlist entry [12345] silently misses message.userId === 12345. + expect( + isAllowed( + { author: { userId: 12345 as unknown as string } }, + [], + [12345], + ), + ).toBe(true); + + expect( + isAllowed( + { author: { userId: 12345 as unknown as string } }, + [], + ["12345"], + ), + ).toBe(true); + }); }); // ─────────────────────────────────────────────────────