From 8d568e5eda1618c5c55d7e8174d46b08e35c06d3 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 6 Aug 2026 09:50:47 +0000 Subject: [PATCH 1/2] feat(channel): add Slack Socket Mode connector --- ...6-08-06-feature-slack-channel-connector.md | 193 +++++++++++ ...6-08-06-feature-slack-channel-connector.md | 123 +++++++ ...6-08-06-feature-slack-channel-connector.md | 67 ++++ ...6-08-06-feature-slack-channel-connector.md | 92 ++++++ ...6-08-06-feature-slack-channel-connector.md | 115 +++++++ package-lock.json | 153 ++++++++- packages/channel-connector/README.md | 5 +- packages/channel-connector/package.json | 2 + packages/channel-connector/src/ConfigStore.ts | 1 + .../src/__tests__/ChannelManager.test.ts | 2 +- .../src/__tests__/ConfigStore.test.ts | 35 +- .../__tests__/adapters/SlackAdapter.test.ts | 255 +++++++++++++++ .../utils/SlackDeliveryQueue.test.ts | 71 ++++ .../utils/SlackPairingSession.test.ts | 26 ++ .../src/__tests__/utils/slackMarkdown.test.ts | 53 +++ .../src/adapters/ChannelAdapter.ts | 22 +- .../src/adapters/SlackAdapter.ts | 308 ++++++++++++++++++ packages/channel-connector/src/index.ts | 26 +- packages/channel-connector/src/types.ts | 76 ++++- .../src/utils/SlackDeliveryQueue.ts | 107 ++++++ .../src/utils/SlackPairingSession.ts | 33 ++ .../src/utils/slackMarkdown.ts | 165 ++++++++++ .../src/__tests__/commands/channel.test.ts | 32 +- .../services/channel/slack-question.test.ts | 83 +++++ packages/cli/src/commands/channel.ts | 75 ++++- .../src/services/channel/channel-runner.ts | 113 ++++--- .../src/services/channel/slack-question.ts | 78 +++++ web/content/docs/12-channel.md | 72 +++- 28 files changed, 2311 insertions(+), 72 deletions(-) create mode 100644 docs/ai/design/2026-08-06-feature-slack-channel-connector.md create mode 100644 docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md create mode 100644 docs/ai/planning/2026-08-06-feature-slack-channel-connector.md create mode 100644 docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md create mode 100644 docs/ai/testing/2026-08-06-feature-slack-channel-connector.md create mode 100644 packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts create mode 100644 packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts create mode 100644 packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts create mode 100644 packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts create mode 100644 packages/channel-connector/src/adapters/SlackAdapter.ts create mode 100644 packages/channel-connector/src/utils/SlackDeliveryQueue.ts create mode 100644 packages/channel-connector/src/utils/SlackPairingSession.ts create mode 100644 packages/channel-connector/src/utils/slackMarkdown.ts create mode 100644 packages/cli/src/__tests__/services/channel/slack-question.test.ts create mode 100644 packages/cli/src/services/channel/slack-question.ts diff --git a/docs/ai/design/2026-08-06-feature-slack-channel-connector.md b/docs/ai/design/2026-08-06-feature-slack-channel-connector.md new file mode 100644 index 00000000..ecbc3fea --- /dev/null +++ b/docs/ai/design/2026-08-06-feature-slack-channel-connector.md @@ -0,0 +1,193 @@ +--- +phase: design +title: Slack Channel Connector Design +description: Provider-neutral bridge architecture with a local Slack Socket Mode adapter +--- + +# Slack Channel Connector Design + +## Architecture Overview + +```mermaid +graph LR + U[Paired Slack user] -->|DM message.im / block action| SM[Slack Socket Mode] + SM --> SA[SlackAdapter] + SA -->|normalized message/action| BR[Provider-neutral ChannelBridge] + BR -->|TtyWriter| AG[Bound local agent] + AG -->|conversation + request store| OP[Output poller] + OP --> BR + BR --> R[Slack renderer/chunker] + R --> Q[Per-conversation delivery queue] + Q -->|chat.postMessage| WA[Slack Web API] + CS[(channels.json 0600)] --> BR + ID[(bounded event IDs)] --> SA +``` + +`channel-connector` remains unaware of agents. It owns provider adapters, normalized transport types, rendering, SDK integration, and local channel configuration. The CLI owns agent discovery, terminal writes, conversation/request polling, authorization policy coordination, and bridge lifecycle. + +## Technology Choices + +- `@slack/socket-mode`: official Socket Mode lifecycle and envelope acknowledgment. +- `@slack/web-api`: official credential validation and `chat.postMessage` calls, including platform errors/rate-limit metadata. +- Existing `marked` lexer: semantic Markdown tokenization shared conceptually with Telegram, with a dedicated Slack renderer. +- Vitest SDK mocks/fixtures: no network or credentials in automated tests. + +## Data Models + +```ts +interface BaseChannelEntry { + enabled: boolean; + createdAt: string; +} + +type ChannelEntry = + | BaseChannelEntry & { type: 'telegram'; config: TelegramConfig } + | BaseChannelEntry & { type: 'slack'; config: SlackConfig }; + +interface SlackConfig { + appToken: string; + botToken: string; + appId: string; + botUserId: string; + workspaceId: string; + workspaceName?: string; + authorizedUserId?: string; + authorizedConversationId?: string; + transport: 'socket-mode'; + audience: 'dm'; +} +``` + +Normalized events gain optional stable identity/thread fields while remaining source-compatible: + +```ts +interface IncomingMessage { + channelType: string; + chatId: string; + userId: string; + text: string; + timestamp: Date; + messageId?: string; + threadId?: string; + workspaceId?: string; + metadata?: Record; +} + +interface IncomingInteraction { + channelType: string; + chatId: string; + userId: string; + interactionId: string; + messageId: string; + actionId: string; + value: string; + workspaceId?: string; + timestamp: Date; +} +``` + +## Internal API Design + +```ts +interface ChannelAdapter { + readonly type: string; + start(): Promise; + stop(): Promise; + sendMessage(chatId: string, text: string, options?: SendMessageOptions): Promise; + onMessage(handler: MessageHandler): void; + isHealthy(): Promise; +} + +interface InteractiveChannelAdapter extends ChannelAdapter { + onInteraction(handler: InteractionHandler): void; + sendQuestion(chatId: string, question: ChannelQuestion): Promise; + finalizeInteraction(chatId: string, messageId: string): Promise; +} +``` + +The optional send return/options are backward-compatible at runtime; Telegram can ignore threading and return its message ID. The CLI uses a type guard rather than importing provider-specific methods. + +## Slack Adapter Responsibilities + +- Construct/inject official SDK clients. +- Validate `auth.test` identity during setup through a separate factory/service. +- Start/stop Socket Mode and expose connection health. +- Acknowledge every recognized envelope before awaiting agent work. +- Normalize only plain-text `message.im` events. +- Reject wrong team, bots/self, subtypes, missing IDs, shared-channel contexts, and unauthorized identities. +- Maintain a bounded bridge-lifetime event-ID set and mark IDs before handler dispatch. +- Normalize `block_actions`, acknowledge immediately, and pass authorized action values to the CLI. +- Render and enqueue outbound messages. + +## Pairing and Authorization + +1. Setup validates tokens and stores verified app/workspace/bot identity, but no Slack user. +2. Starting an unpaired bridge generates a CSPRNG pairing code with a ten-minute TTL and prints it only to the local terminal. +3. The adapter accepts only DM events for the configured workspace. A message matching the active code atomically stores `authorizedUserId` and `authorizedConversationId`; the code is invalidated. +4. All subsequent messages and interactions must match workspace, user, and conversation. Authorization is rechecked immediately before terminal writes to prevent workflow bypass. +5. Pairing messages are consumed by the bridge and never sent to the agent. + +## Rendering, Chunking, and Delivery + +- Parse CommonMark into semantic tokens before rendering each chunk. +- Translate headings/bold/italic/strike/code/links/lists into conservative Slack `mrkdwn` and escape `&`, `<`, and `>` unless deliberately producing a link. +- Do not enable name parsing; plain `@channel`, `@here`, and `@everyone` remain inert text. +- Split at token, paragraph, line, word, then Unicode code-point boundaries. Re-open fenced code per chunk. +- Keep each top-level `text` payload at or below 4,000 JavaScript characters. +- Send the first chunk normally, record its `ts`, and send remaining chunks with `thread_ts` equal to that parent. +- Use a bounded FIFO queue per conversation. A single worker preserves ordering, waits on rate-limit retry metadata, applies bounded exponential backoff to transient failures, and drops/reports overflow rather than consuming unbounded memory. + +## Questions and Prompt Semantics + +- Move question parsing/specification and terminal-key mapping out of the Telegram-specific service. +- Provider renderers implement question presentation; Slack uses Block Kit section/actions with stable action IDs and short opaque values. +- Active question state is keyed by an opaque request ID and bound to workspace, conversation, user, agent session, and expiry. +- The adapter acknowledges the Slack action before the CLI writes the digit/Escape key. +- Replays, stale actions, and mismatched identities are acknowledged and ignored. +- Non-question agent requests remain notifications. Generic Slack text is delivered as normal terminal input and is never reclassified as approval by message content. + +## CLI and Setup Integration + +- `channel connect --name` dispatches to a provider setup strategy. +- Slack setup prompts secretly for app and bot tokens, validates with official SDKs, and persists the verified entry. +- `channel start` resolves a named entry regardless of type; omission retains the legacy exactly-one-Telegram behavior unless exactly one total channel exists. +- The runner uses an adapter factory and provider-neutral authorization/interaction helpers. +- List/status use provider display metadata rather than Telegram casts. +- Daemon arguments include only channel and agent names; tokens remain in `channels.json`. + +## Security Boundaries + +- Trust boundaries: Slack network → official SDK event → adapter validation → CLI authorization → local TTY; agent output → renderer → external Slack API. +- Tokens are password inputs, stored only in mode-`0600` config, never interpolated into shell commands or logs. +- IDs are exact-match allowlisted and treated as opaque Slack identifiers. +- Pairing codes use `crypto.randomBytes`, expire, are single-use, and use timing-safe comparison. +- External text is data. It is not executed, used as a path/URL, or automatically converted into privileged Slack mentions. +- Queue, text, block actions, event IDs, and question sessions have explicit bounds. +- SDK TLS verification stays enabled. + +## Alternatives and Decisions + +- Socket Mode is selected over a public Events API because the daemon is local-first and behind NAT/firewalls. +- A user-owned, undistributed app is selected over OAuth because the MVP is single-workspace and Marketplace distribution is incompatible with Socket Mode/remote-terminal policy. +- DM-only is selected over `app_mention` to minimize accidental exposure and scopes. +- Provider capabilities are selected over a single Telegram-shaped interface; providers can support interactions and threading without leaking SDK types into the CLI. +- The existing JSON secret store is retained for compatibility; a keychain abstraction is deferred. + +## Non-Functional Requirements + +- Incoming envelope acknowledgment begins synchronously and completes within Slack's three-second expectation. +- Normal online round trip remains within one existing two-second agent poll plus Slack API latency. +- Queue defaults are bounded (100 outbound jobs per conversation; 1,000 recent event IDs; ten-minute interaction/pairing TTL). +- Reconnects are delegated to the official Socket Mode client; `isHealthy` reflects connection lifecycle. +- All new code is mockable through injected SDK-shaped clients and clocks/sleep functions. +- No public API removal; Telegram remains fully supported. + +## Official Platform References + +- Socket Mode: https://docs.slack.dev/apis/events-api/using-socket-mode/ +- Events/retries: https://docs.slack.dev/apis/events-api/ +- Web API rate limits: https://docs.slack.dev/apis/web-api/rate-limits/ +- Message formatting: https://docs.slack.dev/messaging/formatting-message-text/ +- `chat.postMessage`: https://docs.slack.dev/reference/methods/chat.postmessage +- Interactivity: https://docs.slack.dev/interactivity/handling-user-interaction/ +- App manifests: https://docs.slack.dev/app-manifests/ diff --git a/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md b/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md new file mode 100644 index 00000000..b4a6a59b --- /dev/null +++ b/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md @@ -0,0 +1,123 @@ +--- +phase: implementation +title: Slack Channel Connector Implementation Guide +description: Living implementation record for the Slack Socket Mode connector +--- + +# Slack Channel Connector Implementation Guide + +## Development Setup + +- Active worktree: `.worktrees/feature-slack-channel-connector` +- Branch: `feature-slack-channel-connector` +- Base: latest `origin/main` at workspace creation +- Dependencies: deterministic `npm ci`; official Slack SDK packages are added through the lockfile. +- Automated tests use injected SDK clients and synthetic fixtures; no real credentials are required. + +## Code Structure + +- `packages/channel-connector/src/types.ts`: discriminated config and normalized provider-neutral events. +- `packages/channel-connector/src/adapters/`: Telegram and Slack transport implementations plus capability contracts. +- `packages/channel-connector/src/utils/`: provider-specific Markdown rendering/chunking and bounded delivery helpers. +- `packages/cli/src/services/channel/`: provider setup/factory, generic bridge runner, authorization, and structured questions. +- `packages/cli/src/commands/channel.ts`: provider-neutral connect/list/start/status UX. +- `web/content/docs/12-channel.md`: user setup, manifest, security, and manual validation. + +## Implementation Notes + +### Task 1.1 — Provider contracts + +- Changed `types.ts`, `ChannelAdapter.ts`, public exports, and ConfigStore/manager tests. +- Red: ConfigStore test failed because `isSlackEntry` did not exist. +- Green/refactor: introduced a discriminated config union, Slack config, stable message/thread metadata, generic send results/options, question/interaction models, and an interactive adapter type guard. +- Evidence: 14 targeted tests and package typecheck pass. +- Design deviation: `sendMessage` permits `void` so the published Telegram implementation remains source-compatible; new providers return `SentMessage`. + +### Task 1.2 — Slack renderer and chunker + +- Added `slackMarkdown.ts` and public exports with `marked` token rendering, Slack control-character escaping, conservative formatting, semantic code splitting, and Unicode-safe hard splitting. +- Red: renderer test suite failed because the Slack utility did not exist. +- Green/refactor: four formatting/chunking tests and package typecheck pass. +- Edge cases: broad mentions remain literal, code chunks are independently fenced, and rendered chunks stay at or below 4,000 characters. + +### Task 1.3 — Slack delivery queue + +- Added official Slack SDK dependencies and `SlackDeliveryQueue`. +- Red: queue tests failed because the module did not exist. +- Green/refactor: per-conversation FIFO state, parent/thread sends, one explicit rate-limit retry, injected sleep, and queue bounds pass three tests plus typecheck. +- Security/performance: Web API payloads disable unfurls and queue state is removed when drained. + +### Task 2.1 — Slack adapter + +- Added `SlackAdapter` backed by official `SocketModeClient` and `WebClient`, with injectable SDK-shaped clients. +- Red: adapter suite failed because the module did not exist. +- Green/refactor: nine tests cover lifecycle/health, prompt acknowledgment order, normalization, idempotency, and identity/message filtering; typecheck passes. +- Trust boundary: stable event IDs are recorded before consumer dispatch and listener failures cannot reject the SDK event loop. + +### Task 2.2 — Explicit pairing + +- Added `SlackPairingSession` and unpaired adapter flow with persistence callback. +- Red: pairing utility/adapter tests failed on missing behavior. +- Green/refactor: CSPRNG code generation, timing-safe comparison, whitespace normalization, strict case, ten-minute expiry, single use, exact workspace/DM constraints, and consumed pairing input pass 13 tests plus typecheck. + +### Tasks 2.3 and 3.1-3.3 — Questions, CLI, runtime, and docs + +- Added Slack Block Kit question rendering and `SlackQuestionService`; valid option/Skip actions finalize once and write one digit/Escape. +- Added `channel connect slack` with hidden prompts plus official `apps.connections.open` and `auth.test` validation. +- Generalized runner input/output, provider construction, pairing persistence, bridge type metadata, list/status identity, and daemon launch without credential arguments. +- Added the exact minimal Slack manifest, pairing/security/troubleshooting guidance, and optional sandbox validation to channel docs. +- Red/green evidence: missing question service, setup behavior, app-token validation, and discriminated runner compilation each failed before implementation; 16 adapter tests, 22 targeted CLI tests, connector typecheck, and both package builds pass. +- Design deviation: the runner branches at its provider composition root rather than introducing a separate factory file; provider SDK details remain inside `channel-connector` and the branch is exhaustive over implemented providers. + +## Integration Points + +- Slack Socket Mode events enter `channel-connector`; agent discovery and TTY writes stay in the CLI. +- Agent conversation/request polling emits through a generic adapter interface. +- Slack credentials remain in `ConfigStore`; bridge metadata and daemon arguments contain names/IDs only. +- Telegram remains an implementation of the same contracts. + +## Error Handling + +- Reject malformed/unauthorized inbound events without terminal side effects. +- Acknowledge Slack envelopes/actions before asynchronous processing. +- Retry only rate-limit/transient outbound failures with explicit bounds. +- Preserve plain-text delivery fallback when rendering fails. +- Surface safe health/setup errors without tokens or raw SDK credential payloads. + +## Performance Considerations + +- Bounded recent-event and interaction maps. +- Bounded per-conversation queues with serialized workers. +- Semantic chunking before API calls; no conversation-history reads from Slack. +- Existing two-second agent output polling remains unchanged. + +## Security Notes + +- Exact workspace/user/conversation allowlist plus expiring CSPRNG pairing. +- No first-message authorization. +- No automatic mention parsing or generic approval inference. +- Mode-`0600` config/registry/log files and credential-free daemon argv. +- Official Slack SDK networking with normal TLS verification. + +## Formal Final Security Review + +The installed `ai-devkit:security-review` checklist was applied to the complete `origin/main...HEAD` diff on 2026-08-06. Result: no unresolved critical or high feature-specific findings. + +- **Credentials and process exposure:** app/bot tokens enter through hidden prompts, are passed only to official SDK constructors, and are absent from daemon argv, bridge registry, status/list output, debug statements, and user-facing errors. Slack setup deliberately replaces SDK errors with a credential-safe message. +- **Storage and migration:** `channels.json` persists secrets in the existing local store and now forces `0600` after every write, including overwriting a permissive existing file. Telegram entries retain their prior shape. Missing/corrupt/unknown channel configurations do not construct a Slack adapter and therefore fail closed. +- **Inbound authorization:** exact workspace, paired user, and DM conversation IDs are required. Pairing uses 48 random bits encoded as 12 hex characters, timing-safe comparison, ten-minute expiry, single use, and persistence before runtime authorization. Persistence failure leaves the adapter unauthorized. Bot/self/subtype/non-DM/Slack Connect events are acknowledged and rejected. +- **Replay and acknowledgment:** Socket Mode event/action envelopes are acknowledged before consumer work. A bounded 1,000-ID bridge-lifetime set suppresses retries and reconnect duplicates; question state additionally binds conversation/message/value, expires after ten minutes, and is consumed before terminal input. +- **Approval boundary and terminal input:** ordinary authorized DM text uses the existing message-to-bound-TTY path and is never interpreted as approval. Only a current `AskUserQuestion` Block Kit action can call `sendKey`, and accepted values are exactly one generated option digit or Escape for Skip. +- **Outbound safety and availability:** Slack control characters are escaped in rendered content and question fallback text, so ordinary Markdown cannot create mentions. Output is capped at 4,000 characters per call, unfurls are disabled, queues are bounded to 100 jobs per conversation, retry occurs once, and an excessive `Retry-After` is capped at 60 seconds. +- **Dependencies:** the lockfile resolves official `@slack/socket-mode@3.0.0` and `@slack/web-api@8.0.0`. `npm audit --audit-level=critical --omit=dev` exits 0 with no critical advisory. Its 22 high, 7 moderate, and 2 low reports are pre-existing transitive dependency findings outside the introduced Slack SDK path; broad upgrades are outside this feature and should be handled separately. +- **Compatibility:** the discriminated provider seam preserves Telegram configuration and behavior; the full repository lint/build/test gate exercises existing Telegram suites. + +Review-driven red/green fixes covered permissive existing config permissions, pairing-persistence fail-closed behavior and listener rejection isolation, question fallback mention escaping, excessive rate-limit delay, duplicate SDK acknowledgment, and expired interactive actions. + +## Deviations and Follow-ups + +- Event idempotency is bounded to the 1,000 most recent IDs for the bridge lifetime rather than time-expiring; this keeps memory bounded and covers Socket Mode retry/reconnect duplication without persistence. +- Structured questions are single-select in the MVP and expire after ten minutes. Multi-select continues through the existing terminal interaction rather than guessing Slack approval semantics. +- Security review findings and their TDD remediations are recorded in the formal review above. No blocking feature-specific findings remain. +- `npm audit --audit-level=critical --omit=dev` reports no critical advisories; existing lower-severity transitive advisories remain outside this scoped feature. +- Real Slack credential validation was intentionally not performed; the documented sandbox exercise remains optional manual validation. diff --git a/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md b/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md new file mode 100644 index 00000000..2815de34 --- /dev/null +++ b/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md @@ -0,0 +1,67 @@ +--- +phase: planning +title: Slack Channel Connector Plan +description: Ordered strict-TDD plan for the local Slack Socket Mode connector +--- + +# Slack Channel Connector Plan + +## Milestones + +- [x] Milestone 1: Provider-neutral contracts and Slack-safe delivery foundation +- [x] Milestone 2: Secure Slack Socket Mode transport, pairing, and interaction support +- [x] Milestone 3: CLI/daemon integration, documentation, regression coverage, and release readiness + +## Task Breakdown + +### Phase 1: Contracts and rendering + +- [x] **Task 1.1 — Discriminated channel configuration and provider capabilities.** Outcome: Slack and Telegram configs are type-safe and the CLI can consume generic send/interaction contracts. Dependencies: none. Evidence: config/contract unit tests and typecheck. Scenarios: configuration/adapter seam. +- [x] **Task 1.2 — Slack Markdown renderer and semantic chunker.** Outcome: safe independently valid chunks at or below 4,000 characters with code preservation and plain fallback. Dependency: 1.1. Evidence: renderer coverage. Scenarios: renderer/chunker matrix. +- [x] **Task 1.3 — Rate-limit-aware threaded delivery queue.** Outcome: bounded ordered per-conversation sends with parent/thread continuity and retry metadata. Dependencies: 1.1-1.2. Evidence: fake-timer Web API tests. Scenarios: delivery queue and burst limits. + +### Phase 2: Transport, pairing, and questions + +- [x] **Task 2.1 — Slack adapter on official SDKs.** Outcome: injectable Socket Mode/Web API clients, event normalization, prompt acknowledgment, filtering, idempotency, health, and lifecycle. Dependencies: Phase 1. Evidence: SDK-mocked adapter tests. Scenarios: adapter events/health. +- [x] **Task 2.2 — Explicit pairing and allowlist persistence.** Outcome: expiring single-use CSPRNG pairing with exact team/user/DM authorization and no first-speaker takeover. Dependency: 2.1. Evidence: pairing/security unit and integration tests. Scenarios: pairing/authorization. +- [x] **Task 2.3 — Provider-neutral structured questions.** Outcome: shared question parsing/state with Telegram compatibility and Slack Block Kit option/Skip actions. Dependencies: 1.1, 2.1-2.2. Evidence: interaction and terminal-key tests. Scenarios: questions and replays. + +### Phase 3: CLI and product integration + +- [x] **Task 3.1 — Slack setup and adapter factory.** Outcome: `channel connect slack`, official SDK identity validation, named provider resolution, and secret-safe config. Dependencies: Phase 2. Evidence: command/service tests. Scenarios: CLI setup. +- [x] **Task 3.2 — Generic bridge runner, status, and daemon lifecycle.** Outcome: Slack/Telegram runtime dispatch, generic authorization/output delivery, accurate bridge type, provider-neutral display, and unchanged Telegram behavior. Dependencies: 3.1. Evidence: runner/command/daemon integration tests. Scenarios: cross-component and regressions. +- [x] **Task 3.3 — User documentation and app manifest.** Outcome: installable manifest, setup/run/security/troubleshooting guide, scope explanation, and optional manual sandbox validation. Dependencies: 3.1-3.2. Evidence: docs lint and content review. +- [x] **Task 3.4 — Full verification and security remediation.** Outcome: coverage gaps closed, lint/typecheck/build/tests green, trust boundaries reviewed, docs finalized. Dependencies: all tasks. Evidence: fresh verification commands and final review. + +## Dependencies and Sequencing + +- Every production behavior follows red → green → refactor; each task begins with a targeted failing test. +- Phase 1 creates the stable API used by transport and CLI work. +- Pairing precedes accepting any agent input. +- Interaction handling depends on stable authorization and idempotency. +- CLI integration follows adapter behavior so command tests mock a real contract rather than inventing one. +- Phase 6 planning reconciliation occurs after every completed task. + +## Risks & Mitigation + +| Risk | Mitigation | +|---|---| +| Provider abstraction grows beyond MVP | Add only capabilities required by Telegram and Slack tests; keep Slack SDK types inside adapter modules. | +| Slack retry shapes differ across SDK versions | Test public SDK error fields and prefer SDK retry behavior where documented; keep injected sleeper/clients. | +| First-user takeover | Never auto-authorize; require expiring local pairing code and exact identity tuple. | +| Duplicate terminal input | Acknowledge promptly, mark stable IDs before dispatch, bound/persist enough state for bridge lifetime. | +| Long output hits rate limits | Serialize per conversation, thread chunks, honor retry delay, bound the queue. | +| Telegram regressions | Preserve defaults and run existing package/CLI suites after each integration task. | +| Secret leakage | Password prompts, redaction tests, no credentials in argv/registry/log/status. | +| Slack policy limits future distribution | Document custom single-workspace app scope; keep OAuth/Marketplace out of implementation. | + +## Resources Needed + +- Official Slack Socket Mode, Events API, Web API, formatting, interactivity, manifest, scope, and rate-limit documentation. +- Official npm packages `@slack/socket-mode` and `@slack/web-api`. +- Existing Telegram adapter, question service, channel runner, configuration, daemon, CLI, and tests. +- No Slack credentials for automated implementation; optional manual sandbox credentials after code review. + +## Progress Summary + +All tasks are complete under TDD: provider contracts, rendering/chunking, queued delivery, official SDK transport, explicit pairing, expiring Slack interactions, dual-token setup validation, generic runner/daemon/status integration, user documentation, and security review. Task tracing is unavailable because `npx ai-devkit@latest task list --name slack-channel-connector --json` returns `unknown command 'task'`. Fresh lint, build, full tests, targeted coverage, and diff checks pass. diff --git a/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md b/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md new file mode 100644 index 00000000..696fa2d1 --- /dev/null +++ b/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md @@ -0,0 +1,92 @@ +--- +phase: requirements +title: Slack Channel Connector Requirements +description: Local-first single-workspace Slack Socket Mode bridge for supervised AI DevKit agents +--- + +# Slack Channel Connector Requirements + +## Problem Statement + +AI DevKit can bridge a running local agent to Telegram, but teams already supervising engineering work in Slack cannot receive agent assurance signals or answer bounded agent questions there. The current connector package is nominally provider-neutral while its configuration, runner, authorization, status, rendering, and interaction paths are Telegram-specific. + +The target user is an individual developer or small team operator running AI DevKit locally who wants a private Slack DM control surface for one explicitly selected agent. Today they must use Telegram or remain at the local terminal. + +## Goals & Objectives + +### Goals + +- Add a bidirectional Slack connector using official `@slack/socket-mode` and `@slack/web-api` SDKs. +- Preserve the local-first daemon model: outbound WebSocket/API connections only, with no public endpoint. +- Support one custom Slack app, workspace, paired Slack user, DM, and local agent per channel instance. +- Generalize the channel runner/configuration/interaction seams without changing Telegram behavior. +- Deliver safe Slack `mrkdwn`, semantic long-message chunks, threaded continuations, structured single-question interactions, prompt notifications, event idempotency, paced delivery, and reconnect-aware health. +- Make authorization fail closed and keep credentials out of process arguments, bridge registries, status, and logs. +- Position Slack as an assurance and orchestration supervision surface: completions, verification evidence, failures, blockers, reviews, and bounded decisions. + +### Non-goals + +- Public channels, `app_mention`, all-channel listening, slash commands, or Slack Connect. +- OAuth, distribution, Marketplace listing, multi-workspace installations, or hosted Events API endpoints. +- Multiple Slack users controlling one bridge. +- File upload or ingestion. +- Starting/killing arbitrary agents, arbitrary terminal selection, or generic remote-shell controls from Slack. +- Durable cloud delivery while the local daemon is offline. +- Reading or backfilling Slack conversation history. + +## User Stories & Use Cases + +- As a local developer, I can configure a Slack custom app with app and bot tokens and verify its identity without exposing either token. +- As a developer, I pair by sending a short-lived code in a DM so the first unrelated workspace user cannot claim my agent. +- As the paired user, I can DM an instruction to an explicitly bound running agent and receive new assistant/system output in the same DM. +- As the paired user, I receive long Markdown and fenced code as readable Slack-safe messages, with continuation chunks kept in a thread. +- As the paired user, I can answer a supported single-select agent question or skip it using Slack buttons. +- As the paired user, I receive other tool/approval prompts as notifications and can respond through the existing terminal input path; ordinary messages are never silently interpreted as an approval action. +- As an operator, I can start, stop, list, and inspect Slack bridges using existing named-channel and daemon commands. +- As an operator, I can see degraded connector health without tokens or sensitive prompt bodies being logged. + +### Edge cases + +- Events from another workspace, user, DM, bot, edited message, message subtype, Slack Connect context, or the connector itself are ignored or rejected. +- Duplicate Socket Mode envelopes/events and repeated button actions do not reach the agent twice. +- A stale or wrong-user button is acknowledged but cannot write to the terminal. +- HTTP 429 responses pause only the affected conversation queue according to `Retry-After`. +- WebSocket disconnect/reconnect does not create duplicate listeners or lose persisted pairing. +- Queue growth and idempotency state are bounded. +- Markdown rendering failure falls back to escaped plain text. + +## Success Criteria + +1. `channel connect slack --name ` validates official SDK credentials and persists a discriminated Slack config in the existing `0600` channel store. +2. Pairing requires a cryptographically random, expiring code delivered by the intended user in a Slack DM; stored allowlists include team, user, and conversation IDs. +3. Only allowlisted `message.im` text events reach `TtyWriter`; bot/self/subtype/duplicate/wrong-identity events never do. +4. New assistant/system output and agent request notifications are delivered through a provider-neutral runner without a Telegram regression. +5. Slack output escapes platform control characters, suppresses unintended mentions, preserves code, targets at most 4,000 characters per message, and sends continuation chunks with the first message's `thread_ts`. +6. A per-conversation sender serializes delivery, honors SDK rate-limit retry metadata, and enforces a bounded queue. +7. Supported single-select questions render as Slack buttons; valid actions acknowledge promptly and write exactly one digit or Escape key to the bound terminal. +8. App/bot tokens never appear in CLI status/list output, bridge metadata, daemon arguments, or debug logs. +9. Existing Telegram config, renderer, question buttons, foreground/daemon lifecycle, and unnamed single-Telegram resolution continue to pass existing tests. +10. New/changed code reaches the repository's practical coverage target, with 100% targeted coverage pursued and any tooling-generated exceptions documented. +11. Automated tests require no real Slack credentials; an optional sandbox-workspace manual procedure is documented. + +## Constraints & Assumptions + +- Node.js remains the runtime and package APIs remain ESM. +- Use official `@slack/socket-mode` and `@slack/web-api`; do not implement Slack signing, WebSocket, or Web API protocols directly. +- Required Slack configuration is Socket Mode, interactivity, bot user/App Home messages, bot scopes `chat:write` and `im:history`, app-level scope `connections:write`, and event `message.im`. +- Slack `mrkdwn` differs from CommonMark; rendering is provider-specific. +- Slack recommends short messages; the implementation uses a conservative 4,000-character ceiling and Slack threads rather than file uploads. +- `channels.json` remains the compatibility store and is protected with mode `0600`. OS keychain integration is a follow-up. +- Pairing is completed while the bridge is running and the generated code is held in memory; only the resulting allowlist is persisted. +- Socket Mode is not Marketplace-compatible, and Slack Marketplace policy is not part of this local custom-app MVP. +- Existing agent conversation polling limitations remain unless a provider-neutral change is necessary for Slack correctness. + +## Alternatives Considered + +- **Incoming webhook notifier:** fastest assurance-only validation but cannot support pairing, inbound commands, or questions. +- **Public Events API:** supports hosted scale and OAuth but violates the MVP's local-first/no-public-endpoint constraint. +- **Socket Mode DM-only:** chosen because it matches Telegram's outbound daemon model while supporting Events API and interactivity. + +## Questions & Open Items + +No material open items. Public-channel support, distributable OAuth, file uploads, and OS-keychain storage are explicitly deferred product decisions. diff --git a/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md b/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md new file mode 100644 index 00000000..f2074f44 --- /dev/null +++ b/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md @@ -0,0 +1,115 @@ +--- +phase: testing +title: Slack Channel Connector Testing Strategy +description: Credential-free SDK-mocked validation for Slack transport, security, delivery, and Telegram compatibility +--- + +# Slack Channel Connector Testing Strategy + +## Test Coverage Goals + +- Pursue 100% statements/branches/functions/lines for new Slack-specific modules; document unreachable SDK defensive branches. +- Cover all trust-boundary rejection paths and retry/idempotency behavior. +- Exercise CLI-to-adapter-to-terminal and output-to-threaded-delivery integration with mocks. +- Keep all automated tests credential-free and network-free. + +## Unit Tests + +### Configuration and adapter seam + +- [x] Discriminated Telegram and Slack entries round-trip through `ConfigStore` with mode `0600`. +- [x] Saving over a permissive existing `channels.json` repairs its mode to `0600`. +- [x] Existing Telegram entries parse and behave unchanged. +- [x] Runtime composition selects Telegram or Slack through the discriminated config without leaking config. + +### Slack renderer and chunker + +- [x] CommonMark emphasis, links, lists, inline code, and fenced code become safe Slack `mrkdwn`. +- [x] `&`, `<`, and `>` are escaped and plain broad mentions are not activated. +- [x] Long paragraphs, Unicode, lists, and fenced code split into independently valid chunks of at most 4,000 characters. +- [ ] Renderer failure falls back to bounded escaped plain text. + +### Slack delivery queue + +- [x] Jobs for one conversation remain ordered and first/continuation chunks use parent/thread timestamps. +- [x] Separate conversations use independent queue state. +- [x] Rate-limit errors honor retry metadata using an injected sleeper. +- [x] Rate limits retry once with bounded delay; permanent errors propagate. +- [x] Excessive `Retry-After` values are capped to prevent an unbounded worker stall. +- [x] Queue overflow is rejected without unbounded growth. + +### Slack adapter events and health + +- [x] Socket Mode start/stop updates health and registers listeners once. +- [x] Valid paired `message.im` normalizes all stable IDs and reaches the handler once. +- [x] Envelope acknowledgment occurs before slow message/interaction handling. +- [x] Wrong workspace/user/conversation, bot/self, subtype, missing ID, non-DM, and external/shared events are ignored. +- [x] Duplicate event IDs and duplicate interaction IDs are ignored across the bridge-lifetime window. +- [x] Event-ID storage evicts old entries at its bound. +- [x] Disconnect/reconnect lifecycle updates health without duplicate delivery. + +### Pairing and interactions + +- [x] Pairing codes are CSPRNG-derived, expire, compare safely, and are single-use. +- [x] Only a matching DM in the configured workspace stores user/conversation IDs; pairing text never reaches the agent. +- [x] Pairing persistence failure leaves the runtime unauthorized and listener rejection is isolated. +- [x] Slack Block Kit questions have bounded opaque values and accessible fallback text. +- [x] Question fallback text escapes Slack mention/control syntax. +- [x] Valid option/Skip actions write one digit/Escape and finalize once. +- [x] Wrong-user, wrong-conversation, expired, malformed, and replayed actions are acknowledged and ignored. + +### CLI setup/status + +- [x] Slack connect prompts for both secrets, validates identity, rejects incomplete token identity, and saves no config on failure. +- [x] List/status render provider-neutral workspace/bot/authorization data without tokens. +- [x] Named Slack foreground and daemon starts pass the actual channel type to the registry. +- [x] Daemon command/log/registry contain no app or bot token. + +## Integration Tests + +- [ ] Slack DM → normalized event → allowlist → `TtyWriter` flow. +- [ ] Agent assistant/system output → renderer → queue → parent/thread Web API calls. +- [x] Agent `AskUserQuestion` request → Slack blocks → action → raw terminal key. +- [x] Existing Telegram message, Markdown, callback, start/status, and daemon suites remain green. +- [ ] Duplicate event plus simulated rate limit/reconnect produces one terminal input and ordered output. + +## End-to-End Tests + +- [ ] Mocked custom-app setup, pairing, bridge start, message round trip, question action, and graceful stop. +- [ ] Unpaired/wrong-user attempt remains unable to control the agent. +- [x] Fresh full repository lint, typecheck/build, and relevant test suites pass. + +## Test Data + +- Synthetic Slack team/user/conversation/app/bot/event/envelope IDs. +- Official-SDK-shaped message and block-action fixtures. +- Fake Socket Mode emitter and Web API methods injected at adapter boundaries. +- Fake clock, sleeper, terminal writer, config path, bridge registry, and agent conversation/request stores. +- Tokens use unmistakably fake placeholders and are asserted absent from captured output/logs. + +## Test Reporting & Coverage + +- Targeted: `npx nx test channel-connector --coverage` and `npx nx test cli --coverage` where supported. +- Regression: `npm test` or repository-native affected/full commands discovered from package scripts. +- Static: package lint, typecheck, build, feature lint, and base lint. +- Final pre-commit gate exited 0: base/feature lifecycle lint, repository lint, six-project build, full repository tests, both coverage suites, and `git diff --check`. +- Connector coverage: 109 tests; 87.27% statements, 77.04% branches, 91.40% functions, 88.88% lines. +- CLI coverage: 921 tests; 71.00% statements, 61.09% branches, 69.48% functions, 72.10% lines. Slack question service: 96.66% statements, 95.83% branches, 100% functions/lines. + +## Manual Testing + +- [ ] Optional sandbox Slack workspace: create app from documented manifest, install, supply fake-free real tokens locally, pair via DM, start a bridge to a disposable agent, send/receive text and a question, force reconnect, inspect threaded long output, stop/disconnect, and revoke tokens. +- Not required for automated acceptance because CI and contributors must not possess Slack credentials. + +## Performance and Reliability Testing + +- [x] Deferred-handler test proves acknowledgment is not blocked by agent work. +- [x] Burst of more than queue capacity remains bounded. +- [x] 4,000+ character output produces ordered parent/thread calls. +- [x] Reconnect and duplicate delivery fixtures preserve exactly-once agent input within the local dedupe window. + +## Bug Tracking + +- Blocking security/correctness failures return to implementation immediately. +- Coverage gaps are added to the planning document before review. +- Credential-dependent Slack behavior not exercised automatically is recorded as residual manual risk in the PR. diff --git a/package-lock.json b/package-lock.json index 53c8e562..809daaa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5354,6 +5354,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@slack/logger": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-5.0.0.tgz", + "integrity": "sha512-VGXhmmgsAo9shdQYh4tFDndd+7nsgp0Y5h0UPDaUp8K359pBasI6YdkMqFW3mCOxLQkq09qj7o7cq6f3DuXcJQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=20" + }, + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, + "node_modules/@slack/socket-mode": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-3.0.0.tgz", + "integrity": "sha512-QShO60SB0E+HH+TbcKj3CBEQbodToRyiXnxuSB4t1kvUlqEmuGA1nOOjrRDkDJbOECAZ13PLe4ek9SrntpfoYg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^5.0.0", + "@slack/web-api": "^8.0.0", + "@types/node": ">=20", + "eventemitter3": "^5" + }, + "engines": { + "node": ">=20", + "npm": ">=9.6.4" + }, + "peerDependencies": { + "undici": "^7.0.0" + } + }, + "node_modules/@slack/types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-3.0.0.tgz", + "integrity": "sha512-KNOqpnNAlsFt5Jk9XBclslQ0lobRIg/0tnhpmvZJAglHJx9E8oceN8hC3gaBzkR6UzQ9Wzq4rLsJ98wUcxWPfw==", + "license": "MIT", + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, + "node_modules/@slack/web-api": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-8.0.0.tgz", + "integrity": "sha512-ORx3XQryQPq2Jnxv5giSKXVoQRUeylrrymIR2S9fPzLjPcCts8RayMeBSZMcpfpAqp6fnBRuPW2UB6dUPUTEZA==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^5.0.0", + "@slack/types": "^3.0.0", + "@types/node": ">=20", + "@types/retry": "0.12.0", + "eventemitter3": "^5.0.1", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 20", + "npm": ">=9.6.4" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -6035,7 +6097,6 @@ }, "node_modules/@types/node": { "version": "20.19.21", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6056,6 +6117,12 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "dev": true, @@ -8376,6 +8443,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -11148,6 +11221,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-limit": { "version": "3.1.0", "license": "MIT", @@ -11174,6 +11256,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/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/p-queue/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/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-timeout": { "version": "4.1.0", "license": "MIT", @@ -11692,6 +11821,15 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "license": "MIT", @@ -12821,9 +12959,18 @@ "through": "^2.3.8" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", - "devOptional": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -13440,6 +13587,8 @@ "version": "0.12.0", "license": "MIT", "dependencies": { + "@slack/socket-mode": "^3.0.0", + "@slack/web-api": "^8.0.0", "marked": "^15.0.12", "telegraf": "^4.16.3", "uuid": "14.0.0" diff --git a/packages/channel-connector/README.md b/packages/channel-connector/README.md index 983b5fea..5b8b192f 100644 --- a/packages/channel-connector/README.md +++ b/packages/channel-connector/README.md @@ -2,7 +2,7 @@ Bridge AI DevKit agent sessions to external messaging channels. -This package powers the `ai-devkit channel` commands. Use it when you need the lower-level connector layer that routes messages between running AI coding agents and channels such as Telegram. +This package powers the `ai-devkit channel` commands. Use it when you need the lower-level connector layer that routes messages between running AI coding agents and channels such as Telegram and private Slack DMs. ## What It Provides @@ -17,11 +17,14 @@ Most users should use the CLI: ```bash ai-devkit channel connect telegram +ai-devkit channel connect slack --name work-slack ai-devkit channel start --agent ``` Use this package directly only when building custom channel integrations or extending AI DevKit's remote-control surface. +Slack uses the official `@slack/socket-mode` and `@slack/web-api` clients. The supported MVP is a user-owned, single-workspace, DM-only Socket Mode app with explicit pairing; public channels, OAuth distribution, files, and multi-workspace routing are not supported. + ## Documentation Full guides and workflow examples: **[ai-devkit.com/docs](https://ai-devkit.com/docs/)** diff --git a/packages/channel-connector/package.json b/packages/channel-connector/package.json index 81d2985d..84513fc6 100644 --- a/packages/channel-connector/package.json +++ b/packages/channel-connector/package.json @@ -39,6 +39,8 @@ "directory": "packages/channel-connector" }, "dependencies": { + "@slack/socket-mode": "^3.0.0", + "@slack/web-api": "^8.0.0", "marked": "^15.0.12", "telegraf": "^4.16.3", "uuid": "14.0.0" diff --git a/packages/channel-connector/src/ConfigStore.ts b/packages/channel-connector/src/ConfigStore.ts index 83c7581a..e3becb8a 100644 --- a/packages/channel-connector/src/ConfigStore.ts +++ b/packages/channel-connector/src/ConfigStore.ts @@ -60,5 +60,6 @@ export class ConfigStore { const dir = path.dirname(this.configPath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + fs.chmodSync(this.configPath, 0o600); } } diff --git a/packages/channel-connector/src/__tests__/ChannelManager.test.ts b/packages/channel-connector/src/__tests__/ChannelManager.test.ts index 000110f1..6cf7f92c 100644 --- a/packages/channel-connector/src/__tests__/ChannelManager.test.ts +++ b/packages/channel-connector/src/__tests__/ChannelManager.test.ts @@ -7,7 +7,7 @@ function createMockAdapter(type: string): Mocked { type, start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), - sendMessage: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn().mockResolvedValue({ messageId: '1' }), onMessage: vi.fn(), isHealthy: vi.fn().mockResolvedValue(true), }; diff --git a/packages/channel-connector/src/__tests__/ConfigStore.test.ts b/packages/channel-connector/src/__tests__/ConfigStore.test.ts index 45ac25fe..9a7e9af8 100644 --- a/packages/channel-connector/src/__tests__/ConfigStore.test.ts +++ b/packages/channel-connector/src/__tests__/ConfigStore.test.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ConfigStore } from '../ConfigStore.js'; +import { isSlackEntry } from '../types.js'; import type { ChannelEntry } from '../types.js'; describe('ConfigStore', () => { @@ -29,6 +30,22 @@ describe('ConfigStore', () => { }, }; + const slackEntry: ChannelEntry = { + type: 'slack', + enabled: true, + createdAt: '2026-08-06T00:00:00Z', + config: { + appToken: 'xapp-fake', + botToken: 'xoxb-fake', + appId: 'A123', + botUserId: 'U-BOT', + workspaceId: 'T123', + workspaceName: 'Sandbox', + transport: 'socket-mode', + audience: 'dm', + }, + }; + describe('constructor', () => { it('should use default path when no configPath provided', async () => { const defaultStore = new ConfigStore(); @@ -39,6 +56,12 @@ describe('ConfigStore', () => { }); describe('getConfig', () => { + it('narrows provider configuration from the channel type', () => { + expect(isSlackEntry(slackEntry)).toBe(true); + if (!isSlackEntry(slackEntry)) throw new Error('expected Slack entry'); + expect(slackEntry.config.workspaceId).toBe('T123'); + }); + it('should return default empty config when file does not exist', async () => { const config = await store.getConfig(); expect(config).toEqual({ channels: {} }); @@ -87,12 +110,22 @@ describe('ConfigStore', () => { expect(mode).toBe('600'); }); + it('should repair permissive permissions on an existing config file', async () => { + fs.writeFileSync(configPath, JSON.stringify({ channels: {} }), { mode: 0o644 }); + fs.chmodSync(configPath, 0o644); + + await store.saveChannel('slack', slackEntry); + + expect(fs.statSync(configPath).mode & 0o777).toBe(0o600); + }); + it('should preserve existing channels when adding a new one', async () => { await store.saveChannel('telegram', sampleEntry); - await store.saveChannel('slack', { ...sampleEntry, type: 'slack' }); + await store.saveChannel('slack', slackEntry); const config = await store.getConfig(); expect(Object.keys(config.channels)).toEqual(['telegram', 'slack']); + expect(config.channels.slack).toEqual(slackEntry); }); it('should preserve separate Telegram configs by channel name', async () => { diff --git a/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts b/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts new file mode 100644 index 00000000..cc0465bf --- /dev/null +++ b/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts @@ -0,0 +1,255 @@ +import { EventEmitter } from 'node:events'; +import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../adapters/SlackAdapter.js'; +import type { SlackConfig } from '../../types.js'; +import { SlackPairingSession } from '../../utils/SlackPairingSession.js'; + +class FakeSocketClient extends EventEmitter { + start = vi.fn().mockResolvedValue(undefined); + disconnect = vi.fn().mockResolvedValue(undefined); +} + +const config: SlackConfig = { + appToken: 'xapp-fake', + botToken: 'xoxb-fake', + appId: 'A123', + botUserId: 'U-BOT', + workspaceId: 'T123', + authorizedUserId: 'U123', + authorizedConversationId: 'D123', + transport: 'socket-mode', + audience: 'dm', +}; + +function envelope(overrides: Record = {}) { + return { + type: 'events_api', + ack: vi.fn().mockResolvedValue(undefined), + body: { + team_id: 'T123', + event_id: 'Ev123', + event: { + type: 'message', + channel_type: 'im', + channel: 'D123', + user: 'U123', + text: 'run tests', + ts: '100.1', + event_ts: '100.1', + ...overrides, + }, + }, + }; +} + +describe('SlackAdapter', () => { + it('validates bot identity without returning either credential', async () => { + const authTest = vi.fn().mockResolvedValue({ ok: true, app_id: 'A123', user_id: 'U-BOT', team_id: 'T123', team: 'Sandbox' }); + await expect(validateSlackCredentials('xoxb-fake', { authTest })).resolves.toEqual({ + appId: 'A123', botUserId: 'U-BOT', workspaceId: 'T123', workspaceName: 'Sandbox', + }); + }); + + it('rejects incomplete Slack credential identity', async () => { + const authTest = vi.fn().mockResolvedValue({ ok: true, team_id: 'T123' }); + await expect(validateSlackCredentials('xoxb-fake', { authTest })).rejects.toThrow('Slack bot token returned incomplete identity'); + }); + + it('validates an app token through apps.connections.open', async () => { + const open = vi.fn().mockResolvedValue({ ok: true, url: 'wss://wss-primary.slack.com/link/' }); + await expect(validateSlackAppToken('xapp-fake', { open })).resolves.toBeUndefined(); + expect(open).toHaveBeenCalledOnce(); + }); + + it('rejects an app token that cannot open Socket Mode', async () => { + const open = vi.fn().mockResolvedValue({ ok: false }); + await expect(validateSlackAppToken('xapp-fake', { open })).rejects.toThrow('Slack app token cannot open Socket Mode'); + }); + + it('starts and stops the official Socket Mode client and reports health', async () => { + const socket = new FakeSocketClient(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + expect(await adapter.isHealthy()).toBe(false); + await adapter.start(); + expect(socket.start).toHaveBeenCalledOnce(); + expect(await adapter.isHealthy()).toBe(true); + await adapter.stop(); + expect(socket.disconnect).toHaveBeenCalledOnce(); + expect(await adapter.isHealthy()).toBe(false); + }); + + it('does not register duplicate listeners across restarts', async () => { + const socket = new FakeSocketClient(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + await adapter.start(); + await adapter.stop(); + await adapter.start(); + expect(socket.listenerCount('slack_event')).toBe(1); + expect(socket.listenerCount('interactive')).toBe(1); + socket.emit('disconnected'); + expect(await adapter.isHealthy()).toBe(false); + socket.emit('connected'); + expect(await adapter.isHealthy()).toBe(true); + }); + + it('acknowledges before delivering one normalized authorized DM event', async () => { + const socket = new FakeSocketClient(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + const order: string[] = []; + adapter.onMessage(vi.fn(async (message) => { + order.push('handler'); + expect(message).toMatchObject({ + channelType: 'slack', chatId: 'D123', userId: 'U123', workspaceId: 'T123', messageId: 'Ev123', text: 'run tests', + }); + })); + await adapter.start(); + const item = envelope(); + item.ack.mockImplementation(async () => { order.push('ack'); }); + socket.emit('slack_event', item); + await vi.waitFor(() => expect(order).toEqual(['ack', 'handler'])); + socket.emit('slack_event', item); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(['ack', 'handler', 'ack']); + }); + + it.each([ + ['wrong workspace', {}, { team_id: 'T999' }], + ['wrong user', { user: 'U999' }, {}], + ['wrong conversation', { channel: 'D999' }, {}], + ['bot message', { bot_id: 'B123' }, {}], + ['self message', { user: 'U-BOT' }, {}], + ['subtype', { subtype: 'message_changed' }, {}], + ['non-DM', { channel_type: 'channel' }, {}], + ])('ignores %s events', async (_name, eventOverrides, bodyOverrides) => { + const socket = new FakeSocketClient(); + const handler = vi.fn(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + adapter.onMessage(handler); + await adapter.start(); + const item = envelope(eventOverrides); + Object.assign(item.body, bodyOverrides); + socket.emit('slack_event', item); + await vi.waitFor(() => expect(item.ack).toHaveBeenCalled()); + expect(handler).not.toHaveBeenCalled(); + }); + + it('pairs an unconfigured workspace DM without forwarding the code', async () => { + const socket = new FakeSocketClient(); + const handler = vi.fn(); + const onPaired = vi.fn().mockResolvedValue(undefined); + const unpaired = { ...config, authorizedUserId: undefined, authorizedConversationId: undefined }; + const adapter = new SlackAdapter(unpaired, { + socketClient: socket, + webClient: { chat: { postMessage: vi.fn() } }, + pairingSession: new SlackPairingSession({ code: 'ABCDEF123456' }), + onPaired, + }); + adapter.onMessage(handler); + await adapter.start(); + const item = envelope({ text: 'ABCDEF123456' }); + socket.emit('slack_event', item); + await vi.waitFor(() => expect(onPaired).toHaveBeenCalledWith({ userId: 'U123', conversationId: 'D123' })); + expect(handler).not.toHaveBeenCalled(); + expect(adapter.getPairingCode()).toBeUndefined(); + }); + + it('fails closed when the paired identity cannot be persisted', async () => { + const socket = new FakeSocketClient(); + const handler = vi.fn(); + const unpaired = { ...config, authorizedUserId: undefined, authorizedConversationId: undefined }; + const adapter = new SlackAdapter(unpaired, { + socketClient: socket, + webClient: { chat: { postMessage: vi.fn() } }, + pairingSession: new SlackPairingSession({ code: 'ABCDEF123456' }), + onPaired: vi.fn().mockRejectedValue(new Error('disk unavailable')), + }); + adapter.onMessage(handler); + await adapter.start(); + socket.emit('slack_event', envelope({ text: 'ABCDEF123456' })); + await new Promise((resolve) => setTimeout(resolve, 0)); + const ordinary = envelope({ text: 'run tests' }); + ordinary.body.event_id = 'Ev-after-failed-persist'; + socket.emit('slack_event', ordinary); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('sends an accessible Block Kit question and finalizes its actions', async () => { + const socket = new FakeSocketClient(); + const postMessage = vi.fn().mockResolvedValue({ ok: true, ts: '400.1' }); + const update = vi.fn().mockResolvedValue({ ok: true }); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage, update } } }); + await expect(adapter.sendQuestion('D123', { + id: 'q1', header: 'Scope', question: 'Choose one', allowSkip: true, + options: [{ label: 'Safe', description: 'Read only', value: '1' }], + })).resolves.toEqual({ messageId: '400.1' }); + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ + channel: 'D123', text: 'Scope: Choose one', blocks: expect.any(Array), + })); + await adapter.finalizeInteraction('D123', '400.1'); + expect(update).toHaveBeenCalledWith({ channel: 'D123', ts: '400.1', blocks: [] }); + }); + + it('escapes Slack control syntax in question fallback text', async () => { + const postMessage = vi.fn().mockResolvedValue({ ok: true, ts: '400.1' }); + const adapter = new SlackAdapter(config, { + socketClient: new FakeSocketClient(), + webClient: { chat: { postMessage } }, + }); + await adapter.sendQuestion('D123', { + id: 'q1', header: '', question: 'Choose & continue', allowSkip: false, + options: [{ label: 'Safe', value: '1' }], + }); + expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ + text: '<!channel>: Choose <one> & continue', + })); + }); + + it('acknowledges and delivers one authorized block action', async () => { + const socket = new FakeSocketClient(); + const handler = vi.fn(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + adapter.onInteraction(handler); + await adapter.start(); + const item = { + ack: vi.fn().mockResolvedValue(undefined), + body: { + type: 'block_actions', team: { id: 'T123' }, user: { id: 'U123' }, + channel: { id: 'D123' }, container: { message_ts: '400.1' }, + actions: [{ action_id: 'ai_devkit_question', action_ts: '401.1', value: 'q1:1' }], + }, + }; + socket.emit('interactive', item); + await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce()); + expect(item.ack).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ + workspaceId: 'T123', chatId: 'D123', userId: 'U123', messageId: '400.1', value: 'q1:1', + })); + socket.emit('interactive', item); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(handler).toHaveBeenCalledOnce(); + expect(item.ack).toHaveBeenCalledTimes(2); + }); + + it('does not acknowledge interactive envelopes a second time on the generic event stream', async () => { + const socket = new FakeSocketClient(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + await adapter.start(); + const ack = vi.fn().mockResolvedValue(undefined); + socket.emit('slack_event', { type: 'interactive', ack, body: {} }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ack).not.toHaveBeenCalled(); + }); + + it('acknowledges malformed and unauthorized block actions without delivery', async () => { + const socket = new FakeSocketClient(); + const handler = vi.fn(); + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); + adapter.onInteraction(handler); + await adapter.start(); + const ack = vi.fn().mockResolvedValue(undefined); + socket.emit('interactive', { ack, body: { type: 'block_actions', team: { id: 'T999' }, actions: [] } }); + await vi.waitFor(() => expect(ack).toHaveBeenCalled()); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts b/packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts new file mode 100644 index 00000000..dc970ce4 --- /dev/null +++ b/packages/channel-connector/src/__tests__/utils/SlackDeliveryQueue.test.ts @@ -0,0 +1,71 @@ +import { SlackDeliveryQueue } from '../../utils/SlackDeliveryQueue.js'; + +describe('SlackDeliveryQueue', () => { + it('serializes long output as a parent followed by threaded continuations', async () => { + const postMessage = vi.fn() + .mockResolvedValueOnce({ ok: true, ts: '100.1' }) + .mockResolvedValue({ ok: true, ts: '100.2' }); + const queue = new SlackDeliveryQueue({ postMessage }, { maxMessageLength: 20 }); + const result = await queue.send('D123', 'first paragraph\n\nsecond paragraph that is long'); + expect(result).toEqual({ messageId: '100.1', threadId: '100.1' }); + expect(postMessage).toHaveBeenCalledTimes(3); + expect(postMessage.mock.calls[0][0]).not.toHaveProperty('thread_ts'); + expect(postMessage.mock.calls[1][0]).toMatchObject({ channel: 'D123', thread_ts: '100.1' }); + expect(postMessage.mock.calls[2][0]).toMatchObject({ channel: 'D123', thread_ts: '100.1' }); + }); + + it('waits for retryAfter before retrying a rate-limited API call', async () => { + const error = Object.assign(new Error('rate limited'), { retryAfter: 2 }); + const postMessage = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ ok: true, ts: '200.1' }); + const sleep = vi.fn().mockResolvedValue(undefined); + const queue = new SlackDeliveryQueue({ postMessage }, { sleep }); + await expect(queue.send('D123', 'hello')).resolves.toEqual({ messageId: '200.1', threadId: '200.1' }); + expect(sleep).toHaveBeenCalledWith(2000); + expect(postMessage).toHaveBeenCalledTimes(2); + }); + + it('caps an excessive rate-limit delay', async () => { + const error = Object.assign(new Error('rate limited'), { retryAfter: 86_400 }); + const postMessage = vi.fn().mockRejectedValueOnce(error).mockResolvedValue({ ok: true, ts: '200.1' }); + const sleep = vi.fn().mockResolvedValue(undefined); + const queue = new SlackDeliveryQueue({ postMessage }, { sleep }); + await queue.send('D123', 'hello'); + expect(sleep).toHaveBeenCalledWith(60_000); + }); + + it('rejects overflow while a conversation send is pending', async () => { + let release!: () => void; + const pending = new Promise<{ ok: true; ts: string }>((resolve) => { + release = () => resolve({ ok: true, ts: '300.1' }); + }); + const postMessage = vi.fn().mockReturnValue(pending); + const queue = new SlackDeliveryQueue({ postMessage }, { maxQueueSize: 1 }); + const first = queue.send('D123', 'first'); + await expect(queue.send('D123', 'second')).rejects.toThrow('Slack delivery queue is full'); + release(); + await first; + }); + + it('rejects empty output and responses without a timestamp', async () => { + const postMessage = vi.fn().mockResolvedValue({ ok: true }); + const queue = new SlackDeliveryQueue({ postMessage }); + await expect(queue.send('D123', '')).rejects.toThrow('Cannot send an empty Slack message'); + await expect(queue.send('D123', 'hello')).rejects.toThrow('Slack did not return a message timestamp'); + }); + + it('propagates permanent and invalid rate-limit errors without sleeping', async () => { + const sleep = vi.fn(); + const permanent = new Error('invalid_auth'); + const postMessage = vi.fn().mockRejectedValue(permanent); + const queue = new SlackDeliveryQueue({ postMessage }, { sleep }); + await expect(queue.send('D123', 'hello')).rejects.toBe(permanent); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('allows separate conversation queues to progress independently', async () => { + const postMessage = vi.fn().mockImplementation(async ({ channel }) => ({ ok: true, ts: `${channel}.1` })); + const queue = new SlackDeliveryQueue({ postMessage }); + await expect(Promise.all([queue.send('D1', 'one'), queue.send('D2', 'two')])).resolves.toHaveLength(2); + expect(postMessage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts b/packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts new file mode 100644 index 00000000..537a1f6a --- /dev/null +++ b/packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts @@ -0,0 +1,26 @@ +import { SlackPairingSession } from '../../utils/SlackPairingSession.js'; + +describe('SlackPairingSession', () => { + it('generates a non-trivial pairing code and consumes one exact match', () => { + const session = new SlackPairingSession({ now: () => 1000 }); + expect(session.code).toMatch(/^[A-Z0-9]{12}$/); + expect(session.consume(`${session.code}x`)).toBe(false); + expect(session.consume(session.code)).toBe(true); + expect(session.consume(session.code)).toBe(false); + }); + + it('expires after ten minutes', () => { + let now = 1000; + const session = new SlackPairingSession({ now: () => now, code: 'ABCDEF123456' }); + now += 10 * 60 * 1000 + 1; + expect(session.consume('ABCDEF123456')).toBe(false); + expect(session.isExpired()).toBe(true); + }); + + it('normalizes surrounding whitespace but not letter case', () => { + const session = new SlackPairingSession({ code: 'ABCDEF123456' }); + expect(session.consume(' ABCDEF123456\n')).toBe(true); + const second = new SlackPairingSession({ code: 'ABCDEF123456' }); + expect(second.consume('abcdef123456')).toBe(false); + }); +}); diff --git a/packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts b/packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts new file mode 100644 index 00000000..8e1fb58b --- /dev/null +++ b/packages/channel-connector/src/__tests__/utils/slackMarkdown.test.ts @@ -0,0 +1,53 @@ +import { + SLACK_MAX_MESSAGE_LENGTH, + chunkMarkdownForSlack, + markdownToSlackMrkdwn, +} from '../../utils/slackMarkdown.js'; + +describe('Slack Markdown', () => { + it('renders common Markdown without activating broad mentions', () => { + expect(markdownToSlackMrkdwn('# Result\n\n**Passed** & @channel [docs](https://example.com)')) + .toBe('*Result*\n\n*Passed* & <safe> @channel '); + }); + + it('preserves inline and fenced code while escaping Slack control characters', () => { + expect(markdownToSlackMrkdwn('Use ``\n\n```ts\nconst x = "";\n```')) + .toContain('`<tag>`\n\n```\nconst x = "<ok>";\n```'); + }); + + it('chunks long fenced code into independently fenced Slack messages', () => { + const chunks = chunkMarkdownForSlack(`\`\`\`ts\n${'const value = 1;\n'.repeat(400)}\`\`\``); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= SLACK_MAX_MESSAGE_LENGTH)).toBe(true); + expect(chunks.every((chunk) => chunk.startsWith('```') && chunk.endsWith('```'))).toBe(true); + }); + + it('accounts for escaped control-character expansion in code chunks', () => { + const chunks = chunkMarkdownForSlack(`\`\`\`\n${'<&>'.repeat(2000)}\n\`\`\``); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= SLACK_MAX_MESSAGE_LENGTH)).toBe(true); + expect(chunks.every((chunk) => chunk.startsWith('```') && chunk.endsWith('```'))).toBe(true); + }); + + it('splits Unicode text without breaking code points', () => { + const chunks = chunkMarkdownForSlack('🎉'.repeat(5000)); + expect(chunks.join('')).toBe('🎉'.repeat(5000)); + expect(chunks.every((chunk) => chunk.length <= SLACK_MAX_MESSAGE_LENGTH)).toBe(true); + }); + + it('renders lists, quotes, emphasis, strike, breaks, and rules conservatively', () => { + const rendered = markdownToSlackMrkdwn('1. _one_\n2. ~~two~~\n\n> quoted \n> next\n\n---'); + expect(rendered).toContain('1. _one_'); + expect(rendered).toContain('2. ~two~'); + expect(rendered).toContain('> quoted'); + expect(rendered).toContain('> next'); + expect(rendered).toContain('—'); + }); + + it('packs short semantic blocks and splits oversized plain paragraphs', () => { + expect(chunkMarkdownForSlack('one\n\ntwo', 20)).toEqual(['one\n\ntwo']); + const chunks = chunkMarkdownForSlack('word '.repeat(30), 25); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.every((chunk) => chunk.length <= 25)).toBe(true); + }); +}); diff --git a/packages/channel-connector/src/adapters/ChannelAdapter.ts b/packages/channel-connector/src/adapters/ChannelAdapter.ts index ace540a3..32513ef7 100644 --- a/packages/channel-connector/src/adapters/ChannelAdapter.ts +++ b/packages/channel-connector/src/adapters/ChannelAdapter.ts @@ -1,4 +1,10 @@ -import type { IncomingMessage } from '../types.js'; +import type { + ChannelQuestion, + IncomingInteraction, + IncomingMessage, + SendMessageOptions, + SentMessage, +} from '../types.js'; /** * Interface for messaging platform adapters. @@ -21,7 +27,7 @@ export interface ChannelAdapter { * Implementations should handle platform-specific limits * (e.g., chunking at 4096 chars for Telegram). */ - sendMessage(chatId: string, text: string): Promise; + sendMessage(chatId: string, text: string, options?: SendMessageOptions): Promise; /** * Register a handler for incoming text messages. @@ -33,3 +39,15 @@ export interface ChannelAdapter { /** Check if the adapter is connected and healthy */ isHealthy(): Promise; } + +export interface InteractiveChannelAdapter extends ChannelAdapter { + onInteraction(handler: (interaction: IncomingInteraction) => Promise): void; + sendQuestion(chatId: string, question: ChannelQuestion): Promise; + finalizeInteraction(chatId: string, messageId: string): Promise; +} + +export function isInteractiveChannelAdapter(adapter: ChannelAdapter): adapter is InteractiveChannelAdapter { + return 'onInteraction' in adapter + && 'sendQuestion' in adapter + && 'finalizeInteraction' in adapter; +} diff --git a/packages/channel-connector/src/adapters/SlackAdapter.ts b/packages/channel-connector/src/adapters/SlackAdapter.ts new file mode 100644 index 00000000..745c6e20 --- /dev/null +++ b/packages/channel-connector/src/adapters/SlackAdapter.ts @@ -0,0 +1,308 @@ +import { SocketModeClient } from '@slack/socket-mode'; +import { WebClient } from '@slack/web-api'; +import type { InteractiveChannelAdapter } from './ChannelAdapter.js'; +import type { + ChannelQuestion, + IncomingInteraction, + IncomingMessage, + SentMessage, + SlackConfig, +} from '../types.js'; +import { SlackDeliveryQueue } from '../utils/SlackDeliveryQueue.js'; +import { SlackPairingSession } from '../utils/SlackPairingSession.js'; +import { escapeSlackText } from '../utils/slackMarkdown.js'; + +export const SLACK_CHANNEL_TYPE = 'slack'; + +interface SlackAuthClient { + authTest(): Promise<{ ok?: boolean; app_id?: string; user_id?: string; team_id?: string; team?: string }>; +} + +export interface SlackIdentity { + appId: string; + botUserId: string; + workspaceId: string; + workspaceName?: string; +} + +interface SlackConnectionsClient { + open(): Promise<{ ok?: boolean; url?: string }>; +} + +export async function validateSlackAppToken(appToken: string, client?: SlackConnectionsClient): Promise { + const connections = client ?? new WebClient(appToken).apps.connections; + const result = await connections.open(); + if (!result.ok || !result.url) throw new Error('Slack app token cannot open Socket Mode'); +} + +export async function validateSlackCredentials(botToken: string, client?: SlackAuthClient): Promise { + const authClient = client ?? { + authTest: () => new WebClient(botToken).auth.test(), + }; + const identity = await authClient.authTest(); + if (!identity.ok || !identity.app_id || !identity.user_id || !identity.team_id) { + throw new Error('Slack bot token returned incomplete identity'); + } + return { + appId: identity.app_id, + botUserId: identity.user_id, + workspaceId: identity.team_id, + workspaceName: identity.team, + }; +} + +interface SocketClientLike { + on(event: string, listener: (payload?: unknown) => void): unknown; + start(): Promise; + disconnect(): Promise; +} + +interface WebClientLike { + chat: { + postMessage(input: Record): Promise<{ ok?: boolean; ts?: string }>; + update?(input: Record): Promise; + }; +} + +interface SlackAdapterDependencies { + socketClient?: SocketClientLike; + webClient?: WebClientLike; + pairingSession?: SlackPairingSession; + onPaired?: (identity: { userId: string; conversationId: string }) => Promise; +} + +interface SlackEventEnvelope { + type?: string; + ack?: () => Promise; + body?: { + team_id?: string; + event_id?: string; + is_ext_shared_channel?: boolean; + event?: Record; + }; +} + +export class SlackAdapter implements InteractiveChannelAdapter { + readonly type = SLACK_CHANNEL_TYPE; + private readonly socket: SocketClientLike; + private readonly web: WebClientLike; + private readonly delivery: SlackDeliveryQueue; + private pairingSession: SlackPairingSession | undefined; + private readonly onPaired?: SlackAdapterDependencies['onPaired']; + private readonly recentEventIds = new Map(); + private messageHandler: ((message: IncomingMessage) => Promise) | null = null; + private interactionHandler: ((interaction: IncomingInteraction) => Promise) | null = null; + private running = false; + private listenersRegistered = false; + + constructor(private readonly config: SlackConfig, dependencies: SlackAdapterDependencies = {}) { + this.socket = dependencies.socketClient ?? new SocketModeClient({ appToken: config.appToken }); + this.web = dependencies.webClient ?? new WebClient(config.botToken) as unknown as WebClientLike; + this.delivery = new SlackDeliveryQueue({ + postMessage: (input) => this.web.chat.postMessage(input), + }); + this.pairingSession = dependencies.pairingSession + ?? (!config.authorizedUserId || !config.authorizedConversationId ? new SlackPairingSession() : undefined); + this.onPaired = dependencies.onPaired; + } + + async start(): Promise { + if (!this.listenersRegistered) { + this.socket.on('slack_event', (payload) => { + void this.handleSlackEvent(payload as SlackEventEnvelope).catch(() => undefined); + }); + this.socket.on('interactive', (payload) => { + void this.handleInteraction(payload as SlackInteractionEnvelope).catch(() => undefined); + }); + this.socket.on('connected', () => { this.running = true; }); + this.socket.on('disconnected', () => { this.running = false; }); + this.listenersRegistered = true; + } + await this.socket.start(); + this.running = true; + } + + async stop(): Promise { + this.running = false; + await this.socket.disconnect(); + } + + onMessage(handler: (message: IncomingMessage) => Promise): void { + this.messageHandler = handler; + } + + onInteraction(handler: (interaction: IncomingInteraction) => Promise): void { + this.interactionHandler = handler; + } + + async sendMessage(chatId: string, text: string): Promise { + return this.delivery.send(chatId, text); + } + + async isHealthy(): Promise { + return this.running; + } + + async sendQuestion(chatId: string, question: ChannelQuestion): Promise { + const fallback = escapeSlackText(`${question.header ? `${question.header}: ` : ''}${question.question}`); + const elements = question.options.map((option) => ({ + type: 'button', + action_id: 'ai_devkit_question', + text: { type: 'plain_text', text: option.label.slice(0, 75) }, + value: `${question.id}:${option.value}`.slice(0, 2000), + })); + if (question.allowSkip) { + elements.push({ + type: 'button', action_id: 'ai_devkit_question', + text: { type: 'plain_text', text: 'Skip' }, value: `${question.id}:skip`, + }); + } + const response = await this.web.chat.postMessage({ + channel: chatId, + text: fallback, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: `*${escapeSlackText(question.header ?? 'Question')}*\n${escapeSlackText(question.question)}` }, + }, + { type: 'actions', elements }, + ], + }); + if (!response.ts) throw new Error('Slack did not return a question message timestamp'); + return { messageId: response.ts }; + } + + async finalizeInteraction(chatId: string, messageId: string): Promise { + if (!this.web.chat.update) throw new Error('Slack Web API chat.update is unavailable'); + await this.web.chat.update({ channel: chatId, ts: messageId, blocks: [] }); + } + + getPairingCode(): string | undefined { + return this.pairingSession?.isExpired() ? undefined : this.pairingSession?.code; + } + + private async handleSlackEvent(envelope: SlackEventEnvelope): Promise { + if (envelope.type !== 'events_api') return; + await envelope.ack?.(); + const body = envelope.body; + const event = body?.event; + const eventId = body?.event_id; + if (!body || !event || !eventId || !this.messageHandler) return; + if (await this.tryPair(body, event)) return; + if (!this.isAuthorizedMessage(body, event)) return; + if (this.recentEventIds.has(eventId)) return; + this.rememberEvent(eventId); + + try { + await this.messageHandler({ + channelType: SLACK_CHANNEL_TYPE, + chatId: String(event.channel), + userId: String(event.user), + text: String(event.text), + timestamp: new Date(Number(event.event_ts ?? event.ts) * 1000), + messageId: eventId, + threadId: typeof event.thread_ts === 'string' ? event.thread_ts : undefined, + workspaceId: body.team_id, + metadata: { slackTs: event.ts }, + }); + } catch { + // The channel consumer reports terminal errors; never reject a Socket Mode listener. + } + } + + private async tryPair(body: NonNullable, event: Record): Promise { + if (!this.pairingSession) return false; + if (body.team_id !== this.config.workspaceId + || body.is_ext_shared_channel === true + || event.type !== 'message' + || event.channel_type !== 'im' + || typeof event.channel !== 'string' + || typeof event.user !== 'string' + || typeof event.text !== 'string' + || event.user === this.config.botUserId + || event.bot_id !== undefined + || event.subtype !== undefined) return true; + if (!this.pairingSession.consume(event.text)) return true; + + const identity = { userId: event.user, conversationId: event.channel }; + await this.onPaired?.(identity); + this.config.authorizedUserId = identity.userId; + this.config.authorizedConversationId = identity.conversationId; + this.pairingSession = undefined; + return true; + } + + private isAuthorizedMessage(body: NonNullable, event: Record): boolean { + return body.team_id === this.config.workspaceId + && body.is_ext_shared_channel !== true + && event.type === 'message' + && event.channel_type === 'im' + && event.channel === this.config.authorizedConversationId + && event.user === this.config.authorizedUserId + && event.user !== this.config.botUserId + && typeof event.text === 'string' + && typeof event.ts === 'string' + && event.subtype === undefined + && event.bot_id === undefined; + } + + private rememberEvent(eventId: string): void { + this.recentEventIds.set(eventId, Date.now()); + while (this.recentEventIds.size > 1000) { + const oldest = this.recentEventIds.keys().next().value as string | undefined; + if (!oldest) break; + this.recentEventIds.delete(oldest); + } + } + + private async handleInteraction(envelope: SlackInteractionEnvelope): Promise { + await envelope.ack?.(); + const body = envelope.body; + const action = body?.actions?.[0]; + const interactionId = action?.action_ts; + const workspaceId = body?.team?.id; + const userId = body?.user?.id; + const chatId = body?.channel?.id; + const messageId = body?.container?.message_ts; + if (!body || !action || !interactionId || !this.interactionHandler) return; + if (body.type !== 'block_actions' + || typeof workspaceId !== 'string' + || typeof userId !== 'string' + || typeof chatId !== 'string' + || workspaceId !== this.config.workspaceId + || userId !== this.config.authorizedUserId + || chatId !== this.config.authorizedConversationId + || typeof messageId !== 'string' + || action.action_id !== 'ai_devkit_question' + || typeof action.value !== 'string' + || this.recentEventIds.has(`interaction:${interactionId}`)) return; + this.rememberEvent(`interaction:${interactionId}`); + try { + await this.interactionHandler({ + channelType: SLACK_CHANNEL_TYPE, + chatId, + userId, + workspaceId, + interactionId, + messageId, + actionId: action.action_id, + value: action.value, + timestamp: new Date(Number(interactionId) * 1000), + }); + } catch { + // Keep Socket Mode listener failures isolated from the SDK event loop. + } + } +} + +interface SlackInteractionEnvelope { + ack?: () => Promise; + body?: { + type?: string; + team?: { id?: string }; + user?: { id?: string }; + channel?: { id?: string }; + container?: { message_ts?: string }; + actions?: Array<{ action_id?: string; action_ts?: string; value?: string }>; + }; +} diff --git a/packages/channel-connector/src/index.ts b/packages/channel-connector/src/index.ts index a8876b76..30cdf3c7 100644 --- a/packages/channel-connector/src/index.ts +++ b/packages/channel-connector/src/index.ts @@ -1,9 +1,25 @@ export { ChannelManager } from './ChannelManager.js'; export { ConfigStore } from './ConfigStore.js'; export { TelegramAdapter, TELEGRAM_CHANNEL_TYPE, TELEGRAM_MAX_MESSAGE_LENGTH } from './adapters/TelegramAdapter.js'; +export { + SlackAdapter, + SLACK_CHANNEL_TYPE, + validateSlackAppToken, + validateSlackCredentials, +} from './adapters/SlackAdapter.js'; +export type { SlackIdentity } from './adapters/SlackAdapter.js'; +export { + SLACK_MAX_MESSAGE_LENGTH, + chunkMarkdownForSlack, + escapeSlackText, + markdownToSlackMrkdwn, +} from './utils/slackMarkdown.js'; +export { SlackDeliveryQueue } from './utils/SlackDeliveryQueue.js'; +export { SlackPairingSession } from './utils/SlackPairingSession.js'; export type { TelegramAdapterOptions } from './adapters/TelegramAdapter.js'; -export type { ChannelAdapter } from './adapters/ChannelAdapter.js'; +export { isInteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; +export type { ChannelAdapter, InteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; export type { IncomingMessage, @@ -12,6 +28,14 @@ export type { ChannelEntry, ChannelType, TelegramConfig, + SlackConfig, + SlackChannelEntry, + SendMessageOptions, + SentMessage, + ChannelQuestion, + ChannelQuestionOption, + IncomingInteraction, + InteractionHandler, InlineKeyboardButton, InlineKeyboard, IncomingCallback, diff --git a/packages/channel-connector/src/types.ts b/packages/channel-connector/src/types.ts index cc580315..2dfa606c 100644 --- a/packages/channel-connector/src/types.ts +++ b/packages/channel-connector/src/types.ts @@ -8,6 +8,9 @@ export interface IncomingMessage { userId: string; text: string; timestamp: Date; + messageId?: string; + threadId?: string; + workspaceId?: string; metadata?: Record; } @@ -27,13 +30,28 @@ export interface ChannelConfig { /** * Configuration entry for a single channel. */ -export interface ChannelEntry { - type: ChannelType; +interface BaseChannelEntry { enabled: boolean; createdAt: string; +} + +export interface TelegramChannelEntry extends BaseChannelEntry { + type: 'telegram'; config: TelegramConfig; } +export interface SlackChannelEntry extends BaseChannelEntry { + type: 'slack'; + config: SlackConfig; +} + +export interface OtherChannelEntry extends BaseChannelEntry { + type: Exclude; + config: Record; +} + +export type ChannelEntry = TelegramChannelEntry | SlackChannelEntry | OtherChannelEntry; + /** * Supported channel types. */ @@ -48,6 +66,60 @@ export interface TelegramConfig { authorizedChatId?: number; } +export interface SlackConfig { + appToken: string; + botToken: string; + appId: string; + botUserId: string; + workspaceId: string; + workspaceName?: string; + authorizedUserId?: string; + authorizedConversationId?: string; + transport: 'socket-mode'; + audience: 'dm'; +} + +export function isSlackEntry(entry: ChannelEntry): entry is SlackChannelEntry { + return entry.type === 'slack'; +} + +export interface SendMessageOptions { + threadId?: string; +} + +export interface SentMessage { + messageId: string; + threadId?: string; +} + +export interface ChannelQuestionOption { + label: string; + description?: string; + value: string; +} + +export interface ChannelQuestion { + id: string; + question: string; + header?: string; + options: ChannelQuestionOption[]; + allowSkip: boolean; +} + +export interface IncomingInteraction { + channelType: string; + chatId: string; + userId: string; + interactionId: string; + messageId: string; + actionId: string; + value: string; + workspaceId?: string; + timestamp: Date; +} + +export type InteractionHandler = (interaction: IncomingInteraction) => Promise; + /** * A single button in a Telegram-style inline keyboard. */ diff --git a/packages/channel-connector/src/utils/SlackDeliveryQueue.ts b/packages/channel-connector/src/utils/SlackDeliveryQueue.ts new file mode 100644 index 00000000..39109d2c --- /dev/null +++ b/packages/channel-connector/src/utils/SlackDeliveryQueue.ts @@ -0,0 +1,107 @@ +import type { SentMessage } from '../types.js'; +import { chunkMarkdownForSlack, SLACK_MAX_MESSAGE_LENGTH } from './slackMarkdown.js'; + +interface SlackPoster { + postMessage(input: { + channel: string; + text: string; + mrkdwn: true; + unfurl_links: false; + unfurl_media: false; + thread_ts?: string; + blocks?: unknown[]; + }): Promise<{ ok?: boolean; ts?: string }>; +} + +interface DeliveryOptions { + maxMessageLength?: number; + maxQueueSize?: number; + sleep?: (milliseconds: number) => Promise; +} + +interface ConversationState { + tail: Promise; + pending: number; +} + +const defaultSleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export class SlackDeliveryQueue { + private readonly states = new Map(); + private readonly maxMessageLength: number; + private readonly maxQueueSize: number; + private readonly sleep: (milliseconds: number) => Promise; + + constructor(private readonly client: SlackPoster, options: DeliveryOptions = {}) { + this.maxMessageLength = options.maxMessageLength ?? SLACK_MAX_MESSAGE_LENGTH; + this.maxQueueSize = options.maxQueueSize ?? 100; + this.sleep = options.sleep ?? defaultSleep; + } + + async send(channel: string, markdown: string): Promise { + const state = this.states.get(channel) ?? { tail: Promise.resolve(), pending: 0 }; + if (state.pending >= this.maxQueueSize) { + throw new Error('Slack delivery queue is full'); + } + state.pending += 1; + this.states.set(channel, state); + + let resolveResult!: (result: SentMessage) => void; + let rejectResult!: (error: unknown) => void; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + + state.tail = state.tail.then(async () => { + try { + resolveResult(await this.deliver(channel, markdown)); + } catch (error) { + rejectResult(error); + } finally { + state.pending -= 1; + if (state.pending === 0) this.states.delete(channel); + } + }); + return result; + } + + private async deliver(channel: string, markdown: string): Promise { + const chunks = chunkMarkdownForSlack(markdown, this.maxMessageLength); + if (chunks.length === 0) throw new Error('Cannot send an empty Slack message'); + let parentTs: string | undefined; + + for (const text of chunks) { + const response = await this.postWithRateLimit({ + channel, + text, + mrkdwn: true, + unfurl_links: false, + unfurl_media: false, + ...(parentTs ? { thread_ts: parentTs } : {}), + }); + if (!response.ts) throw new Error('Slack did not return a message timestamp'); + parentTs ??= response.ts; + } + + return { messageId: parentTs!, threadId: parentTs }; + } + + private async postWithRateLimit(input: Parameters[0]): Promise<{ ok?: boolean; ts?: string }> { + try { + return await this.client.postMessage(input); + } catch (error) { + const retryAfter = readRetryAfter(error); + if (retryAfter === undefined) throw error; + await this.sleep(Math.min(retryAfter * 1000, 60_000)); + return this.client.postMessage(input); + } + } +} + +function readRetryAfter(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined; + const value = (error as { retryAfter?: unknown }).retryAfter; + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} diff --git a/packages/channel-connector/src/utils/SlackPairingSession.ts b/packages/channel-connector/src/utils/SlackPairingSession.ts new file mode 100644 index 00000000..7c84b1d1 --- /dev/null +++ b/packages/channel-connector/src/utils/SlackPairingSession.ts @@ -0,0 +1,33 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; + +interface PairingSessionOptions { + code?: string; + now?: () => number; + ttlMs?: number; +} + +export class SlackPairingSession { + readonly code: string; + private readonly expiresAt: number; + private readonly now: () => number; + private consumed = false; + + constructor(options: PairingSessionOptions = {}) { + this.now = options.now ?? Date.now; + this.code = options.code ?? randomBytes(6).toString('hex').toUpperCase(); + this.expiresAt = this.now() + (options.ttlMs ?? 10 * 60 * 1000); + } + + consume(candidate: string): boolean { + if (this.consumed || this.isExpired()) return false; + const expected = Buffer.from(this.code); + const actual = Buffer.from(candidate.trim()); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return false; + this.consumed = true; + return true; + } + + isExpired(): boolean { + return this.now() > this.expiresAt; + } +} diff --git a/packages/channel-connector/src/utils/slackMarkdown.ts b/packages/channel-connector/src/utils/slackMarkdown.ts new file mode 100644 index 00000000..998d75e4 --- /dev/null +++ b/packages/channel-connector/src/utils/slackMarkdown.ts @@ -0,0 +1,165 @@ +import { Marked, type Token, type Tokens } from 'marked'; + +export const SLACK_MAX_MESSAGE_LENGTH = 4000; +const lexer = new Marked(); + +export function escapeSlackText(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +export function markdownToSlackMrkdwn(markdown: string): string { + return renderBlocks(lexer.lexer(markdown)).trimEnd(); +} + +export function chunkMarkdownForSlack( + markdown: string, + maxLength = SLACK_MAX_MESSAGE_LENGTH, +): string[] { + const tokens = lexer.lexer(markdown); + const chunks: string[] = []; + let current = ''; + + const append = (rendered: string): void => { + if (!rendered) return; + if (rendered.length > maxLength) { + if (current) { + chunks.push(current.trimEnd()); + current = ''; + } + chunks.push(...splitRendered(rendered.trimEnd(), maxLength)); + return; + } + const candidate = current ? `${current}${rendered}` : rendered; + if (candidate.trimEnd().length <= maxLength) { + current = candidate; + } else { + chunks.push(current.trimEnd()); + current = rendered; + } + }; + + for (const token of tokens) { + if (token.type === 'code') { + const rendered = renderCode(token as Tokens.Code); + if (rendered.length > maxLength) { + if (current) { + chunks.push(current.trimEnd()); + current = ''; + } + chunks.push(...splitCode((token as Tokens.Code).text, maxLength)); + continue; + } + } + append(renderBlock(token)); + } + if (current.trimEnd()) chunks.push(current.trimEnd()); + return chunks; +} + +function renderBlocks(tokens: Token[]): string { + return tokens.map(renderBlock).join(''); +} + +function renderBlock(token: Token): string { + switch (token.type) { + case 'heading': + return `*${renderInline((token as Tokens.Heading).tokens)}*\n\n`; + case 'paragraph': + return `${renderInline((token as Tokens.Paragraph).tokens)}\n\n`; + case 'text': { + const text = token as Tokens.Text; + return text.tokens ? renderInline(text.tokens) : escapeSlackText(text.text); + } + case 'code': + return `${renderCode(token as Tokens.Code)}\n\n`; + case 'blockquote': + return renderBlocks((token as Tokens.Blockquote).tokens) + .trimEnd().split('\n').map((line) => `> ${line}`).join('\n') + '\n\n'; + case 'list': + return renderList(token as Tokens.List); + case 'space': + return ''; + case 'hr': + return '—\n\n'; + default: + return escapeSlackText(token.raw ?? ''); + } +} + +function renderInline(tokens: Token[]): string { + return tokens.map((token) => { + switch (token.type) { + case 'text': + return escapeSlackText((token as Tokens.Text).text); + case 'strong': + return `*${renderInline((token as Tokens.Strong).tokens)}*`; + case 'em': + return `_${renderInline((token as Tokens.Em).tokens)}_`; + case 'del': + return `~${renderInline((token as Tokens.Del).tokens)}~`; + case 'codespan': + return `\`${escapeSlackText((token as Tokens.Codespan).text)}\``; + case 'link': { + const link = token as Tokens.Link; + return `<${escapeLinkTarget(link.href)}|${renderInline(link.tokens)}>`; + } + case 'br': + return '\n'; + default: + return escapeSlackText(token.raw ?? ''); + } + }).join(''); +} + +function renderCode(token: Tokens.Code): string { + return `\`\`\`\n${escapeSlackText(token.text)}\n\`\`\``; +} + +function renderList(token: Tokens.List): string { + return token.items.map((item, index) => { + const marker = token.ordered ? `${(token.start || 1) + index}.` : '•'; + return `${marker} ${renderBlocks(item.tokens).trim()}\n`; + }).join('') + '\n'; +} + +function escapeLinkTarget(href: string): string { + return href.replace(/&/g, '&').replace(/>/g, '%3E').replace(/\|/g, '%7C'); +} + +function splitCode(code: string, maxLength: number): string[] { + const wrapperLength = 8; + const contentLimit = Math.max(1, maxLength - wrapperLength); + const parts: string[] = []; + let current = ''; + for (const char of Array.from(code)) { + const escaped = escapeSlackText(char); + if (current.length + escaped.length > contentLimit && current) { + parts.push(current); + current = ''; + } + current += escaped; + } + if (current) parts.push(current); + return parts.map((part) => `\`\`\`\n${part}\n\`\`\``); +} + +function splitRendered(text: string, maxLength: number): string[] { + return splitText(text, maxLength); +} + +function splitText(text: string, maxLength: number): string[] { + const chunks: string[] = []; + let current = ''; + for (const unit of text.match(/.*(?:\n|$)|./gu) ?? []) { + if (!unit) continue; + for (const char of Array.from(unit)) { + if (current.length + char.length > maxLength && current) { + chunks.push(current); + current = ''; + } + current += char; + } + } + if (current) chunks.push(current); + return chunks; +} diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index bd8ede17..17a55f78 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -16,6 +16,8 @@ const mockConfigStore = { const mockConfirm = vi.fn<(...args: unknown[]) => Promise>(); const mockPassword = vi.fn<(...args: unknown[]) => Promise>(); const mockGetMe = vi.fn<() => Promise<{ username: string }>>(); +const mockValidateSlackCredentials = vi.fn(); +const mockValidateSlackAppToken = vi.fn(); const mockSpinner = { start: vi.fn(), succeed: vi.fn(), @@ -63,6 +65,9 @@ vi.mock('@ai-devkit/channel-connector', () => ({ ConfigStore: vi.fn(function () { return mockConfigStore; }), TelegramAdapter: vi.fn(function () { return mockTelegramAdapter; }), TELEGRAM_CHANNEL_TYPE: 'telegram', + SLACK_CHANNEL_TYPE: 'slack', + validateSlackCredentials: (...args: unknown[]) => mockValidateSlackCredentials(...args), + validateSlackAppToken: (...args: unknown[]) => mockValidateSlackAppToken(...args), }), { virtual: true }); vi.mock('@ai-devkit/agent-manager', () => ({ @@ -198,6 +203,12 @@ describe('startOutputPolling', () => { }, }); mockGetMe.mockResolvedValue({ username: 'test_bot' }); + mockValidateSlackCredentials.mockReset(); + mockValidateSlackAppToken.mockReset(); + mockValidateSlackAppToken.mockResolvedValue(undefined); + mockValidateSlackCredentials.mockResolvedValue({ + appId: 'A123', botUserId: 'U-BOT', workspaceId: 'T123', workspaceName: 'Sandbox', + }); vi.clearAllMocks(); }); @@ -502,6 +513,25 @@ describe('channel command', () => { })); }); + it('connects a named Slack channel using validated app and bot tokens', async () => { + mockPassword.mockResolvedValueOnce('xapp-fake').mockResolvedValueOnce('xoxb-fake'); + mockConfigStore.getChannel.mockResolvedValue(undefined); + mockChannelService.resolveConnectChannelName.mockReturnValue('work-slack'); + const program = new Command(); + registerChannelCommand(program); + await program.parseAsync(['node', 'test', 'channel', 'connect', 'slack', '--name', 'work-slack']); + expect(mockValidateSlackCredentials).toHaveBeenCalledWith('xoxb-fake'); + expect(mockValidateSlackAppToken).toHaveBeenCalledWith('xapp-fake'); + expect(mockConfigStore.saveChannel).toHaveBeenCalledWith('work-slack', expect.objectContaining({ + type: 'slack', enabled: true, + config: { + appToken: 'xapp-fake', botToken: 'xoxb-fake', appId: 'A123', botUserId: 'U-BOT', + workspaceId: 'T123', workspaceName: 'Sandbox', transport: 'socket-mode', audience: 'dm', + }, + })); + expect(ui.success).toHaveBeenCalledWith('Slack channel "work-slack" configured successfully!'); + }); + it('lists named Telegram channels with authorization state', async () => { mockConfigStore.getConfig.mockResolvedValue({ channels: { @@ -522,7 +552,7 @@ describe('channel command', () => { await program.parseAsync(['node', 'test', 'channel', 'list']); expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ - headers: ['Name', 'Type', 'Status', 'Bot', 'Authorized', 'Bridge', 'Created'], + headers: ['Name', 'Type', 'Status', 'Identity', 'Authorized', 'Bridge', 'Created'], rows: expect.arrayContaining([ expect.arrayContaining(['personal', 'telegram', expect.any(String), '@personal_bot', 'no']), expect.arrayContaining(['work', 'telegram', expect.any(String), '@work_bot', 'yes']), diff --git a/packages/cli/src/__tests__/services/channel/slack-question.test.ts b/packages/cli/src/__tests__/services/channel/slack-question.test.ts new file mode 100644 index 00000000..fccbda79 --- /dev/null +++ b/packages/cli/src/__tests__/services/channel/slack-question.test.ts @@ -0,0 +1,83 @@ +import type { IncomingInteraction, InteractiveChannelAdapter } from '@ai-devkit/channel-connector'; +import { SlackQuestionService } from '../../../services/channel/slack-question.js'; + +function adapter() { + return { + sendQuestion: vi.fn().mockResolvedValue({ messageId: '100.1' }), + finalizeInteraction: vi.fn().mockResolvedValue(undefined), + } as unknown as InteractiveChannelAdapter; +} + +const input = { + questions: [{ + question: 'Choose one', header: 'Scope', multiSelect: false, + options: [{ label: 'Safe', description: 'Read only' }, { label: 'Fast' }], + }], +}; + +describe('SlackQuestionService', () => { + it('renders a single-select question and sends one selected digit', async () => { + const slack = adapter(); + const sendKey = vi.fn().mockResolvedValue(undefined); + const service = new SlackQuestionService(slack, sendKey); + expect(await service.tryHandle(input, 'D123')).toBe(true); + const question = vi.mocked(slack.sendQuestion).mock.calls[0][1]; + expect(question.options.map((option) => option.value)).toEqual(['1', '2']); + const interaction: IncomingInteraction = { + channelType: 'slack', chatId: 'D123', userId: 'U123', workspaceId: 'T123', + interactionId: 'I1', messageId: '100.1', actionId: 'ai_devkit_question', + value: `${question.id}:2`, timestamp: new Date(), + }; + await service.handleInteraction(interaction); + await service.handleInteraction(interaction); + expect(sendKey).toHaveBeenCalledOnce(); + expect(sendKey).toHaveBeenCalledWith('2'); + expect(slack.finalizeInteraction).toHaveBeenCalledWith('D123', '100.1'); + }); + + it('sends Escape for Skip and ignores stale or malformed actions', async () => { + const slack = adapter(); + const sendKey = vi.fn().mockResolvedValue(undefined); + const service = new SlackQuestionService(slack, sendKey); + await service.tryHandle(input, 'D123'); + const question = vi.mocked(slack.sendQuestion).mock.calls[0][1]; + const base = { + channelType: 'slack', chatId: 'D123', userId: 'U123', interactionId: 'I1', + messageId: '100.1', actionId: 'ai_devkit_question', timestamp: new Date(), + }; + await service.handleInteraction({ ...base, value: 'missing:1' }); + await service.handleInteraction({ ...base, value: `${question.id}:skip` }); + expect(sendKey).toHaveBeenCalledOnce(); + expect(sendKey).toHaveBeenCalledWith('\x1b'); + }); + + it('falls back for multi-select and malformed question payloads', async () => { + const slack = adapter(); + const service = new SlackQuestionService(slack, vi.fn()); + expect(await service.tryHandle({ questions: [{ ...input.questions[0], multiSelect: true }] }, 'D123')).toBe(false); + expect(await service.tryHandle({}, 'D123')).toBe(false); + expect(slack.sendQuestion).not.toHaveBeenCalled(); + }); + + it('rejects an interaction after the question expires', async () => { + const slack = adapter(); + const sendKey = vi.fn().mockResolvedValue(undefined); + let now = 1_000; + const service = new SlackQuestionService(slack, sendKey, { + now: () => now, + ttlMs: 10, + }); + await service.tryHandle(input, 'D123'); + const question = vi.mocked(slack.sendQuestion).mock.calls[0][1]; + now = 1_011; + + await service.handleInteraction({ + channelType: 'slack', chatId: 'D123', userId: 'U123', workspaceId: 'T123', + interactionId: 'I1', messageId: '100.1', actionId: 'ai_devkit_question', + value: `${question.id}:1`, timestamp: new Date(), + }); + + expect(sendKey).not.toHaveBeenCalled(); + expect(slack.finalizeInteraction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 539e562a..42488ee4 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -5,8 +5,12 @@ import chalk from 'chalk'; import { Telegraf } from 'telegraf'; import { TELEGRAM_CHANNEL_TYPE, + SLACK_CHANNEL_TYPE, + validateSlackAppToken, + validateSlackCredentials, ConfigStore, type ChannelEntry, + type SlackConfig, type TelegramConfig, } from '@ai-devkit/channel-connector'; import { ui } from '../util/terminal-ui.js'; @@ -50,8 +54,8 @@ export function registerChannelCommand(program: Command): void { .description('Connect a messaging channel (e.g., telegram)') .option('--name ', 'Channel instance name') .action(withErrorHandler('connect channel', async (type: string, options: { name?: string }) => { - if (type !== TELEGRAM_CHANNEL_TYPE) { - ui.error(`Unsupported channel type: ${type}. Supported: ${TELEGRAM_CHANNEL_TYPE}`); + if (type !== TELEGRAM_CHANNEL_TYPE && type !== SLACK_CHANNEL_TYPE) { + ui.error(`Unsupported channel type: ${type}. Supported: ${TELEGRAM_CHANNEL_TYPE}, ${SLACK_CHANNEL_TYPE}`); return; } @@ -59,6 +63,47 @@ export function registerChannelCommand(program: Command): void { const configStore = new ConfigStore(); const existing = await configStore.getChannel(channelName); + if (type === SLACK_CHANNEL_TYPE) { + ui.info('Create a single-workspace Slack app from the AI DevKit Socket Mode manifest.'); + const appToken = String(await password({ + message: 'Enter your Slack app-level token (xapp-…):', + validate: (input: string) => input.trim().startsWith('xapp-') || 'App token must start with xapp-', + })).trim(); + const botToken = String(await password({ + message: 'Enter your Slack bot token (xoxb-…):', + validate: (input: string) => input.trim().startsWith('xoxb-') || 'Bot token must start with xoxb-', + })).trim(); + const spinner = ui.spinner('Validating Slack bot identity...'); + spinner.start(); + try { + await validateSlackAppToken(appToken); + const identity = await validateSlackCredentials(botToken); + const entry: ChannelEntry = { + type: SLACK_CHANNEL_TYPE, + enabled: true, + createdAt: existing?.createdAt ?? new Date().toISOString(), + config: { + appToken, + botToken, + ...identity, + transport: 'socket-mode', + audience: 'dm', + ...(existing?.type === SLACK_CHANNEL_TYPE ? { + authorizedUserId: existing.config.authorizedUserId, + authorizedConversationId: existing.config.authorizedConversationId, + } : {}), + }, + }; + await configStore.saveChannel(channelName, entry); + spinner.succeed(`Connected to Slack workspace ${identity.workspaceName ?? identity.workspaceId}`); + ui.success(`Slack channel "${channelName}" configured successfully!`); + ui.info(`Run "ai-devkit channel start ${channelName} --agent " and pair by DM.`); + } catch { + spinner.fail('Invalid Slack credentials. Please check and try again.'); + } + return; + } + ui.info('To connect Telegram, you need a bot token from @BotFather.'); ui.info('Open Telegram, search for @BotFather, and create a new bot.\n'); @@ -126,20 +171,25 @@ export function registerChannelCommand(program: Command): void { ui.text('Configured Channels:', { breakline: true }); const rows = channels.map(([name, entry]) => { - const telegramConfig = entry.config as TelegramConfig; + const identity = entry.type === SLACK_CHANNEL_TYPE + ? (entry.config as SlackConfig).workspaceName ?? (entry.config as SlackConfig).workspaceId + : `@${(entry.config as TelegramConfig).botUsername}`; + const authorized = entry.type === SLACK_CHANNEL_TYPE + ? Boolean((entry.config as SlackConfig).authorizedUserId && (entry.config as SlackConfig).authorizedConversationId) + : Boolean((entry.config as TelegramConfig).authorizedChatId); return [ name, entry.type, entry.enabled ? chalk.green('enabled') : chalk.dim('disabled'), - telegramConfig.botUsername ? `@${telegramConfig.botUsername}` : '-', - telegramConfig.authorizedChatId ? 'yes' : 'no', + identity || '-', + authorized ? 'yes' : 'no', liveByChannel.has(name) ? chalk.green('running') : chalk.dim('stopped'), entry.createdAt ? new Date(entry.createdAt).toLocaleDateString() : '-', ]; }); ui.table({ - headers: ['Name', 'Type', 'Status', 'Bot', 'Authorized', 'Bridge', 'Created'], + headers: ['Name', 'Type', 'Status', 'Identity', 'Authorized', 'Bridge', 'Created'], rows, }); })); @@ -211,7 +261,7 @@ export function registerChannelCommand(program: Command): void { const bridge = await channelService.startDaemonBridge({ channelName, - channelType: TELEGRAM_CHANNEL_TYPE, + channelType: channelEntry.type, agentName: options.agent, command: daemonLaunch.command, args: daemonArgs, @@ -270,12 +320,17 @@ export function registerChannelCommand(program: Command): void { } for (const [name, entry] of channels) { - const telegramConfig = entry.config as TelegramConfig; const bridge = liveByChannel.get(name); + const identity = entry.type === SLACK_CHANNEL_TYPE + ? `${(entry.config as SlackConfig).workspaceName ?? (entry.config as SlackConfig).workspaceId} (bot ${(entry.config as SlackConfig).botUserId})` + : `@${(entry.config as TelegramConfig).botUsername || 'unknown'}`; + const authorized = entry.type === SLACK_CHANNEL_TYPE + ? Boolean((entry.config as SlackConfig).authorizedUserId && (entry.config as SlackConfig).authorizedConversationId) + : Boolean((entry.config as TelegramConfig).authorizedChatId); ui.text(`${chalk.bold(name)} (${entry.type})`); ui.text(` Enabled: ${entry.enabled ? chalk.green('yes') : chalk.red('no')}`); - ui.text(` Bot: @${telegramConfig.botUsername || 'unknown'}`); - ui.text(` Authorized: ${telegramConfig.authorizedChatId ? 'yes' : 'no'}`); + ui.text(` Identity: ${identity}`); + ui.text(` Authorized: ${authorized ? 'yes' : 'no'}`); ui.text(` Bridge: ${bridge ? chalk.green(`running (PID: ${bridge.bridgePid}, agent: ${bridge.agentName})`) : chalk.dim('stopped')}`); if (bridge?.logPath) { ui.text(` Logs: ${bridge.logPath}`); diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index 18b35c19..74b71cd9 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -17,10 +17,13 @@ import { } from '@ai-devkit/agent-manager'; import { ChannelManager, + type ChannelAdapter, ConfigStore, + SlackAdapter, TelegramAdapter, + SLACK_CHANNEL_TYPE, TELEGRAM_CHANNEL_TYPE, - type TelegramConfig, + type SlackConfig, } from '@ai-devkit/channel-connector'; import { ui } from '../../util/terminal-ui.js'; import { getErrorMessage } from '../../util/text.js'; @@ -28,6 +31,7 @@ import { createLogger } from '../../util/debug.js'; import { select } from '@inquirer/prompts'; import { ChannelService } from './channel.service.js'; import { AskUserQuestionService } from './ask-user-question.js'; +import { SlackQuestionService } from './slack-question.js'; const debug = createLogger('channel'); const AGENT_POLL_INTERVAL_MS = 2000; @@ -81,12 +85,12 @@ async function resolveTargetAgent(agentManager: AgentManager, agentName: string) } function setupInputHandler( - telegram: TelegramAdapter, + channel: ChannelAdapter, terminalLocation: TerminalLocation, chatIdRef: { value: string | null }, onAuthorize?: (chatId: string) => Promise, ): void { - telegram.onMessage(async (msg) => { + channel.onMessage(async (msg) => { debug(`Received message from chat ID: ${msg.chatId}, text length: ${msg.text?.length ?? 0}`); if (!chatIdRef.value) { @@ -97,7 +101,7 @@ function setupInputHandler( if (msg.chatId !== chatIdRef.value) { debug(`Rejected message from unauthorized chat ID: ${msg.chatId}`); - await telegram.sendMessage(msg.chatId, 'Unauthorized. Only the first user is allowed.'); + await channel.sendMessage(msg.chatId, 'Unauthorized. Only the configured user is allowed.'); return; } @@ -107,7 +111,7 @@ function setupInputHandler( } catch (error: unknown) { const message = getErrorMessage(error); ui.error(`Failed to send to agent: ${message}`); - await telegram.sendMessage(msg.chatId, `Failed to send to agent: ${message}`); + await channel.sendMessage(msg.chatId, `Failed to send to agent: ${message}`); } }); } @@ -126,11 +130,11 @@ function formatPromptMessage(toolName: string, toolInput: Record; } export function startOutputPolling( - telegram: TelegramAdapter, + channel: Pick, agentAdapter: AgentAdapter, agent: AgentInfo, chatIdRef: { value: string | null }, @@ -207,7 +211,7 @@ export function startOutputPolling( } try { - await telegram.sendMessage(chatIdRef.value, msg.content); + await channel.sendMessage(chatIdRef.value, msg.content); debug(`Sent agent response to Telegram (role: ${msg.role}, length: ${contentLen})`); } catch (error: unknown) { const message = getErrorMessage(error); @@ -232,7 +236,7 @@ export function startOutputPolling( handled = await askQuestion.tryHandle(agentRequest.toolInput, chatIdRef.value); } if (!handled) { - await telegram.sendMessage(chatIdRef.value, formatPromptMessage(agentRequest.toolName, agentRequest.toolInput)); + await channel.sendMessage(chatIdRef.value, formatPromptMessage(agentRequest.toolName, agentRequest.toolInput)); } debug(`Sent agent request notification to Telegram (handled=${handled})`); } catch (error: unknown) { @@ -283,8 +287,7 @@ export async function runChannelBridge(input: RunChannelBridgeInput): Promise { - const latest = await configStore.getChannel(input.channelName); - if (!latest) return; - const latestTelegramConfig = latest.config as TelegramConfig; - await configStore.saveChannel(input.channelName, { - ...latest, - config: { - ...latestTelegramConfig, - authorizedChatId: Number(chatId), + let channel: ChannelAdapter; + let askUserQuestionService: Pick | undefined; + const chatIdRef: { value: string | null } = { value: null }; + + if (channelEntry.type === TELEGRAM_CHANNEL_TYPE) { + const telegramConfig = channelEntry.config; + const telegram = new TelegramAdapter({ botToken: telegramConfig.botToken }); + channel = telegram; + chatIdRef.value = telegramConfig.authorizedChatId !== undefined ? String(telegramConfig.authorizedChatId) : null; + setupInputHandler(telegram, terminalLocation, chatIdRef, async (chatId) => { + const latest = await configStore.getChannel(input.channelName); + if (!latest || latest.type !== TELEGRAM_CHANNEL_TYPE) return; + await configStore.saveChannel(input.channelName, { + ...latest, + config: { ...latest.config, authorizedChatId: Number(chatId) }, + }); + }); + const telegramQuestions = new AskUserQuestionService( + telegram, + (key) => TtyWriter.sendKey(terminalLocation, key), + ); + askUserQuestionService = telegramQuestions; + telegram.onCallback(async (cb) => { + if (cb.chatId !== chatIdRef.value) return; + await telegramQuestions.handleCallback(cb); + }); + } else if (channelEntry.type === SLACK_CHANNEL_TYPE) { + const slackConfig = channelEntry.config; + chatIdRef.value = slackConfig.authorizedConversationId ?? null; + const slack = new SlackAdapter(slackConfig, { + onPaired: async ({ userId, conversationId }) => { + const latest = await configStore.getChannel(input.channelName); + if (!latest || latest.type !== SLACK_CHANNEL_TYPE) return; + const config: SlackConfig = { ...latest.config, authorizedUserId: userId, authorizedConversationId: conversationId }; + await configStore.saveChannel(input.channelName, { ...latest, config }); + chatIdRef.value = conversationId; + ui.success(`Slack user paired for channel "${input.channelName}".`); }, }); - }); - const askUserQuestionService = new AskUserQuestionService( - telegram, - // AskUserQuestion picker reacts to raw digit keystrokes (1-N), not to - // pasted text. Use sendKey to bypass bracketed paste / auto-Enter. - (key) => TtyWriter.sendKey(terminalLocation, key), - ); - telegram.onCallback(async (cb) => { - if (cb.chatId !== chatIdRef.value) { - debug(`callback rejected: chatId=${cb.chatId} not authorized`); - return; - } - await askUserQuestionService.handleCallback(cb); - }); + channel = slack; + setupInputHandler(slack, terminalLocation, chatIdRef); + const slackQuestions = new SlackQuestionService(slack, (key) => TtyWriter.sendKey(terminalLocation, key)); + askUserQuestionService = slackQuestions; + slack.onInteraction((interaction) => slackQuestions.handleInteraction(interaction)); + const pairingCode = slack.getPairingCode(); + if (pairingCode) ui.info(`DM this pairing code to the Slack app within 10 minutes: ${pairingCode}`); + } else { + ui.error(`Unsupported channel type: ${channelEntry.type}`); + return; + } debug(`Starting output polling (interval: ${AGENT_POLL_INTERVAL_MS}ms)`); - const pollInterval = startOutputPolling(telegram, agentAdapter, agent, chatIdRef, { + const pollInterval = startOutputPolling(channel, agentAdapter, agent, chatIdRef, { askUserQuestionService, }); const manager = new ChannelManager(); - manager.registerAdapter(telegram); + manager.registerAdapter(channel); setupGracefulShutdown(manager, pollInterval, channelService, input.channelName); - ui.success(`Bridge started: ${input.channelName} (@${telegramConfig.botUsername}) <-> Agent "${agent.name}" (PID: ${agent.pid})`); - ui.info('Send a message to your Telegram bot to start chatting.'); + ui.success(`Bridge started: ${input.channelName} (${channelEntry.type}) <-> Agent "${agent.name}" (PID: ${agent.pid})`); + ui.info(`Send a message to your ${channelEntry.type} app to start chatting.`); ui.info('Press Ctrl+C to stop.\n'); await channelService.registerBridge({ channelName: input.channelName, - channelType: TELEGRAM_CHANNEL_TYPE, + channelType: channelEntry.type, agentName: agent.name, agentPid: agent.pid, bridgePid: process.pid, diff --git a/packages/cli/src/services/channel/slack-question.ts b/packages/cli/src/services/channel/slack-question.ts new file mode 100644 index 00000000..408e299a --- /dev/null +++ b/packages/cli/src/services/channel/slack-question.ts @@ -0,0 +1,78 @@ +import type { + ChannelQuestion, + IncomingInteraction, + InteractiveChannelAdapter, +} from '@ai-devkit/channel-connector'; +import { parseAskUserQuestionInput } from './ask-user-question.js'; + +interface ActiveQuestion { + id: string; + chatId: string; + messageId: string; + validValues: Set; + expiresAt: number; +} + +interface SlackQuestionOptions { + now?: () => number; + ttlMs?: number; +} + +export class SlackQuestionService { + private readonly active = new Map(); + private nextId = 1; + + constructor( + private readonly slack: Pick, + private readonly sendKey: (key: string) => Promise, + private readonly options: SlackQuestionOptions = {}, + ) {} + + async tryHandle(toolInput: Record, chatId: string): Promise { + const spec = parseAskUserQuestionInput(toolInput); + if (!spec || spec.multiSelect) return false; + const id = (this.nextId++).toString(36); + const question: ChannelQuestion = { + id, + question: spec.question, + header: spec.header, + allowSkip: true, + options: spec.options.map((option, index) => ({ + ...option, + value: String(index + 1), + })), + }; + const sent = await this.slack.sendQuestion(chatId, question); + this.active.set(id, { + id, + chatId, + messageId: sent.messageId, + validValues: new Set(question.options.map((option) => option.value)), + expiresAt: this.now() + (this.options.ttlMs ?? 10 * 60 * 1000), + }); + return true; + } + + async handleInteraction(interaction: IncomingInteraction): Promise { + const separator = interaction.value.indexOf(':'); + if (separator <= 0) return; + const id = interaction.value.slice(0, separator); + const value = interaction.value.slice(separator + 1); + const session = this.active.get(id); + if (session && this.now() > session.expiresAt) { + this.active.delete(id); + return; + } + if (!session + || session.chatId !== interaction.chatId + || session.messageId !== interaction.messageId + || (value !== 'skip' && !session.validValues.has(value))) return; + this.active.delete(id); + await this.slack.finalizeInteraction(session.chatId, session.messageId); + await this.sendKey(value === 'skip' ? '\x1b' : value); + } + + private now(): number { + return (this.options.now ?? Date.now)(); + } +} diff --git a/web/content/docs/12-channel.md b/web/content/docs/12-channel.md index 267946ae..8c6201f5 100644 --- a/web/content/docs/12-channel.md +++ b/web/content/docs/12-channel.md @@ -1,6 +1,6 @@ --- title: Channel -description: Connect AI agents with messaging channels like Telegram for remote interaction +description: Connect AI agents with Telegram or a private Slack Socket Mode app slug: channel order: 12 --- @@ -14,7 +14,8 @@ The `channel` command lets you bridge a running AI agent to a messaging platform - **AI DevKit** installed globally (see [Getting Started](/docs/1-getting-started)) - **A running AI agent** (Claude Code or Codex) detected by AI DevKit (see [Agent Management](/docs/8-agent-management)) -- **A Telegram bot token** from [@BotFather](https://t.me/BotFather) +- **Telegram:** a bot token from [@BotFather](https://t.me/BotFather), or +- **Slack:** a custom single-workspace app with Socket Mode, an `xapp-` app token, and an `xoxb-` bot token - **Terminal environment**: The agent must be running in **tmux**, **iTerm2**, or **Apple Terminal** (same requirements as `agent open`) ## How It Works @@ -40,11 +41,56 @@ Configure a messaging channel by providing your bot token. ```bash ai-devkit channel connect telegram ai-devkit channel connect telegram --name personal +ai-devkit channel connect slack --name work-slack ``` You will be prompted to enter your Telegram bot token. AI DevKit validates the token by calling the Telegram API, then stores the configuration locally. -> **Note**: Channel configuration is stored in `~/.ai-devkit/config.json`. The bot token is saved in plaintext — do not commit this file to version control. +> **Note**: Channel configuration is stored in `~/.ai-devkit/channels.json` with file mode `0600`. Tokens are local plaintext secrets: never commit, paste into chat, or include this file in support logs. + +### Configure a private Slack app + +Slack support is a local-first, DM-only Socket Mode integration for one workspace and one paired user. It does not expose an HTTP endpoint and is not a distributable OAuth or Marketplace app. + +Create an app from this manifest in the [Slack app dashboard](https://api.slack.com/apps): + +```yaml +_metadata: + major_version: 1 +display_information: + name: AI DevKit +features: + app_home: + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: AI DevKit + always_online: false +oauth_config: + scopes: + bot: + - chat:write + - im:history +settings: + event_subscriptions: + bot_events: + - message.im + interactivity: + is_enabled: true + socket_mode_enabled: true + org_deploy_enabled: false + is_hosted: false +``` + +Then: + +1. Install the app to its development workspace and copy the `xoxb-` bot token. +2. Under **Basic Information → App-Level Tokens**, create an `xapp-` token with `connections:write`. +3. Run `ai-devkit channel connect slack --name work-slack`. Both secrets are entered through hidden prompts and validated before storage. +4. Start the bridge with `ai-devkit channel start work-slack --agent `. +5. Copy the short-lived pairing code printed only in the local terminal and DM it to the app within ten minutes. + +The bridge then accepts only the exact workspace, Slack user, and DM conversation established by pairing. Pairing text is not forwarded to the agent. Public channels, mentions, Slack Connect, files, OAuth, and multi-workspace installs are intentionally unsupported. By default, the channel is named `telegram`. Use `--name ` when you want multiple Telegram bot connections, such as `personal` and `team`. Channel names must use lowercase letters, numbers, and hyphens. @@ -60,8 +106,8 @@ ai-devkit channel list **Table output includes:** -| Name | Type | Status | Bot | Authorized | Bridge | Created | -|------|------|--------|-----|------------|--------|---------| +| Name | Type | Status | Identity | Authorized | Bridge | Created | +|------|------|--------|----------|------------|--------|---------| | `telegram` | `telegram` | enabled | `@my_bot` | yes | running | 4/21/2026 | ### Start the Bridge @@ -244,6 +290,22 @@ Specify which bridge to stop: ai-devkit channel stop personal ``` +### Slack pairing expires + +Restart an unpaired bridge to generate a new ten-minute code. Codes are single-use and case-sensitive. Pairing is accepted only from a direct message in the configured workspace. + +### Slack app cannot connect + +- Confirm Socket Mode, interactivity, and App Home messages are enabled. +- Confirm the app-level token starts with `xapp-` and has `connections:write`. +- Confirm the installed bot token starts with `xoxb-` and has `chat:write` and `im:history`. +- Confirm `message.im` is subscribed. Reinstall the app after changing scopes. +- Re-run `channel connect slack --name ` after rotating either token. + +### Optional Slack sandbox validation + +Use a disposable workspace and agent. Connect and pair, exchange a short message, trigger a single-select agent question, send a response longer than 4,000 characters with fenced code, verify threaded continuation, interrupt the network to observe reconnect health, stop the bridge, disconnect the config, and revoke both tokens. Real Slack credentials are never required by the automated test suite. + ### Messages not appearing in Telegram - Ensure you are the first user to message the bot (only the first user is authorized). - Check that the agent has a session file by running `ai-devkit agent detail --id `. From 420ee9519e946d1b1f6f153581dda4685bb8b91a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 6 Aug 2026 17:00:24 +0200 Subject: [PATCH 2/2] fix(channel): simplify Slack proof-of-concept flow --- ...6-08-06-feature-slack-channel-connector.md | 38 ++++++----- ...6-08-06-feature-slack-channel-connector.md | 28 ++++----- ...6-08-06-feature-slack-channel-connector.md | 14 ++--- ...6-08-06-feature-slack-channel-connector.md | 29 +++++---- ...6-08-06-feature-slack-channel-connector.md | 24 +++---- packages/channel-connector/README.md | 2 +- .../__tests__/adapters/SlackAdapter.test.ts | 63 +++++++------------ .../utils/SlackPairingSession.test.ts | 26 -------- .../src/adapters/SlackAdapter.ts | 49 ++------------- packages/channel-connector/src/index.ts | 1 - packages/channel-connector/src/types.ts | 4 +- .../src/utils/SlackPairingSession.ts | 33 ---------- .../src/__tests__/commands/channel.test.ts | 47 ++++++++++++++ .../services/channel/channel-runner.test.ts | 62 +++++++++++++++++- packages/cli/src/commands/channel.ts | 43 ++++++++----- .../src/services/channel/channel-runner.ts | 34 +++++----- web/content/docs/12-channel.md | 49 +++++++++++---- 17 files changed, 283 insertions(+), 263 deletions(-) delete mode 100644 packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts delete mode 100644 packages/channel-connector/src/utils/SlackPairingSession.ts diff --git a/docs/ai/design/2026-08-06-feature-slack-channel-connector.md b/docs/ai/design/2026-08-06-feature-slack-channel-connector.md index ecbc3fea..6aa0db96 100644 --- a/docs/ai/design/2026-08-06-feature-slack-channel-connector.md +++ b/docs/ai/design/2026-08-06-feature-slack-channel-connector.md @@ -10,7 +10,7 @@ description: Provider-neutral bridge architecture with a local Slack Socket Mode ```mermaid graph LR - U[Paired Slack user] -->|DM message.im / block action| SM[Slack Socket Mode] + U[Slack workspace user] -->|DM message.im / block action| SM[Slack Socket Mode] SM --> SA[SlackAdapter] SA -->|normalized message/action| BR[Provider-neutral ChannelBridge] BR -->|TtyWriter| AG[Bound local agent] @@ -23,7 +23,7 @@ graph LR ID[(bounded event IDs)] --> SA ``` -`channel-connector` remains unaware of agents. It owns provider adapters, normalized transport types, rendering, SDK integration, and local channel configuration. The CLI owns agent discovery, terminal writes, conversation/request polling, authorization policy coordination, and bridge lifecycle. +`channel-connector` remains unaware of agents. It owns provider adapters, normalized transport types, rendering, SDK integration, and local channel configuration. The CLI owns agent discovery, terminal writes, active-conversation routing, conversation/request polling, and bridge lifecycle. ## Technology Choices @@ -47,12 +47,10 @@ type ChannelEntry = interface SlackConfig { appToken: string; botToken: string; - appId: string; + appId?: string; botUserId: string; workspaceId: string; workspaceName?: string; - authorizedUserId?: string; - authorizedConversationId?: string; transport: 'socket-mode'; audience: 'dm'; } @@ -114,18 +112,18 @@ The optional send return/options are backward-compatible at runtime; Telegram ca - Start/stop Socket Mode and expose connection health. - Acknowledge every recognized envelope before awaiting agent work. - Normalize only plain-text `message.im` events. -- Reject wrong team, bots/self, subtypes, missing IDs, shared-channel contexts, and unauthorized identities. +- Reject wrong-team, bot/self, subtype, missing-ID, non-DM, and shared-channel events. - Maintain a bounded bridge-lifetime event-ID set and mark IDs before handler dispatch. -- Normalize `block_actions`, acknowledge immediately, and pass authorized action values to the CLI. +- Normalize `block_actions`, acknowledge immediately, and pass valid workspace actions to the CLI question-session check. - Render and enqueue outbound messages. -## Pairing and Authorization +## Proof-of-Concept DM Routing -1. Setup validates tokens and stores verified app/workspace/bot identity, but no Slack user. -2. Starting an unpaired bridge generates a CSPRNG pairing code with a ten-minute TTL and prints it only to the local terminal. -3. The adapter accepts only DM events for the configured workspace. A message matching the active code atomically stores `authorizedUserId` and `authorizedConversationId`; the code is invalidated. -4. All subsequent messages and interactions must match workspace, user, and conversation. Authorization is rechecked immediately before terminal writes to prevent workflow bypass. -5. Pairing messages are consumed by the bridge and never sent to the agent. +1. Setup validates the app token through `apps.connections.open` and the bot token through `auth.test`, then stores the verified workspace/bot identity but no Slack user. Slack does not guarantee `app_id` in bot-token `auth.test` responses, so it is stored only when returned. +2. The adapter accepts valid DM events from the configured workspace immediately; no pairing code or Slack-user allowlist is used in the proof of concept. +3. The CLI selects the first DM seen after bridge startup as the active response destination for that process. Its first message is forwarded to the agent. +4. A message from another DM is rejected for routing consistency and instructs the user to restart the bridge to switch conversations. +5. This active-DM selection is not persisted and is explicitly not an authorization boundary. ## Rendering, Chunking, and Delivery @@ -141,9 +139,9 @@ The optional send return/options are backward-compatible at runtime; Telegram ca - Move question parsing/specification and terminal-key mapping out of the Telegram-specific service. - Provider renderers implement question presentation; Slack uses Block Kit section/actions with stable action IDs and short opaque values. -- Active question state is keyed by an opaque request ID and bound to workspace, conversation, user, agent session, and expiry. +- Active question state is keyed by an opaque request ID and bound to conversation, message, and expiry. - The adapter acknowledges the Slack action before the CLI writes the digit/Escape key. -- Replays, stale actions, and mismatched identities are acknowledged and ignored. +- Replays, stale actions, and mismatched conversations are acknowledged and ignored. - Non-question agent requests remain notifications. Generic Slack text is delivered as normal terminal input and is never reclassified as approval by message content. ## CLI and Setup Integration @@ -151,16 +149,16 @@ The optional send return/options are backward-compatible at runtime; Telegram ca - `channel connect --name` dispatches to a provider setup strategy. - Slack setup prompts secretly for app and bot tokens, validates with official SDKs, and persists the verified entry. - `channel start` resolves a named entry regardless of type; omission retains the legacy exactly-one-Telegram behavior unless exactly one total channel exists. -- The runner uses an adapter factory and provider-neutral authorization/interaction helpers. +- The runner uses an adapter factory and provider-neutral routing/interaction helpers. - List/status use provider display metadata rather than Telegram casts. - Daemon arguments include only channel and agent names; tokens remain in `channels.json`. ## Security Boundaries -- Trust boundaries: Slack network → official SDK event → adapter validation → CLI authorization → local TTY; agent output → renderer → external Slack API. +- Trust boundaries: Slack network → official SDK event → workspace/DM validation → active bridge conversation → local TTY; agent output → renderer → external Slack API. - Tokens are password inputs, stored only in mode-`0600` config, never interpolated into shell commands or logs. -- IDs are exact-match allowlisted and treated as opaque Slack identifiers. -- Pairing codes use `crypto.randomBytes`, expire, are single-use, and use timing-safe comparison. +- Workspace IDs are exact-match checked and all Slack IDs are treated as opaque identifiers. +- Proof-of-concept warning: there is no Slack-user authorization. Any workspace member who becomes the active DM can send text to the bound agent terminal. - External text is data. It is not executed, used as a path/URL, or automatically converted into privileged Slack mentions. - Queue, text, block actions, event IDs, and question sessions have explicit bounds. - SDK TLS verification stays enabled. @@ -177,7 +175,7 @@ The optional send return/options are backward-compatible at runtime; Telegram ca - Incoming envelope acknowledgment begins synchronously and completes within Slack's three-second expectation. - Normal online round trip remains within one existing two-second agent poll plus Slack API latency. -- Queue defaults are bounded (100 outbound jobs per conversation; 1,000 recent event IDs; ten-minute interaction/pairing TTL). +- Queue defaults are bounded (100 outbound jobs per conversation; 1,000 recent event IDs; ten-minute interaction TTL). - Reconnects are delegated to the official Socket Mode client; `isHealthy` reflects connection lifecycle. - All new code is mockable through injected SDK-shaped clients and clocks/sleep functions. - No public API removal; Telegram remains fully supported. diff --git a/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md b/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md index b4a6a59b..2c6729ea 100644 --- a/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md +++ b/docs/ai/implementation/2026-08-06-feature-slack-channel-connector.md @@ -19,7 +19,7 @@ description: Living implementation record for the Slack Socket Mode connector - `packages/channel-connector/src/types.ts`: discriminated config and normalized provider-neutral events. - `packages/channel-connector/src/adapters/`: Telegram and Slack transport implementations plus capability contracts. - `packages/channel-connector/src/utils/`: provider-specific Markdown rendering/chunking and bounded delivery helpers. -- `packages/cli/src/services/channel/`: provider setup/factory, generic bridge runner, authorization, and structured questions. +- `packages/cli/src/services/channel/`: provider setup/factory, generic bridge runner, active-conversation routing, and structured questions. - `packages/cli/src/commands/channel.ts`: provider-neutral connect/list/start/status UX. - `web/content/docs/12-channel.md`: user setup, manifest, security, and manual validation. @@ -54,18 +54,18 @@ description: Living implementation record for the Slack Socket Mode connector - Green/refactor: nine tests cover lifecycle/health, prompt acknowledgment order, normalization, idempotency, and identity/message filtering; typecheck passes. - Trust boundary: stable event IDs are recorded before consumer dispatch and listener failures cannot reject the SDK event loop. -### Task 2.2 — Explicit pairing +### Task 2.2 — Proof-of-concept DM routing -- Added `SlackPairingSession` and unpaired adapter flow with persistence callback. -- Red: pairing utility/adapter tests failed on missing behavior. -- Green/refactor: CSPRNG code generation, timing-safe comparison, whitespace normalization, strict case, ten-minute expiry, single use, exact workspace/DM constraints, and consumed pairing input pass 13 tests plus typecheck. +- Removed pairing and persisted Slack-user allowlists for the proof of concept. +- Red: an ordinary configured-workspace DM was consumed instead of reaching the message handler. +- Green/refactor: valid DMs are normalized immediately; the CLI uses the first DM as the process-local response destination while the adapter continues rejecting wrong-workspace, bot/self, subtype, non-DM, and Slack Connect events. ### Tasks 2.3 and 3.1-3.3 — Questions, CLI, runtime, and docs - Added Slack Block Kit question rendering and `SlackQuestionService`; valid option/Skip actions finalize once and write one digit/Escape. -- Added `channel connect slack` with hidden prompts plus official `apps.connections.open` and `auth.test` validation. -- Generalized runner input/output, provider construction, pairing persistence, bridge type metadata, list/status identity, and daemon launch without credential arguments. -- Added the exact minimal Slack manifest, pairing/security/troubleshooting guidance, and optional sandbox validation to channel docs. +- Added `channel connect slack` with hidden prompts plus official `apps.connections.open` and `auth.test` validation. Bot identity requires Slack's documented `user_id` and `team_id`; optional `app_id` is retained when returned. +- Generalized runner input/output, provider construction, active-DM routing, bridge type metadata, list/status identity, and daemon launch without credential arguments. +- Added the exact minimal Slack manifest, proof-of-concept security warning, troubleshooting guidance, and optional sandbox validation to channel docs. - Red/green evidence: missing question service, setup behavior, app-token validation, and discriminated runner compilation each failed before implementation; 16 adapter tests, 22 targeted CLI tests, connector typecheck, and both package builds pass. - Design deviation: the runner branches at its provider composition root rather than introducing a separate factory file; provider SDK details remain inside `channel-connector` and the branch is exhaustive over implemented providers. @@ -78,7 +78,7 @@ description: Living implementation record for the Slack Socket Mode connector ## Error Handling -- Reject malformed/unauthorized inbound events without terminal side effects. +- Reject malformed, wrong-workspace, bot/self, non-DM, and shared-channel events without terminal side effects. - Acknowledge Slack envelopes/actions before asynchronous processing. - Retry only rate-limit/transient outbound failures with explicit bounds. - Preserve plain-text delivery fallback when rendering fails. @@ -93,7 +93,7 @@ description: Living implementation record for the Slack Socket Mode connector ## Security Notes -- Exact workspace/user/conversation allowlist plus expiring CSPRNG pairing. +- Exact workspace validation and DM-only transport filtering; Slack-user authorization is intentionally deferred during the proof of concept. - No first-message authorization. - No automatic mention parsing or generic approval inference. - Mode-`0600` config/registry/log files and credential-free daemon argv. @@ -105,14 +105,14 @@ The installed `ai-devkit:security-review` checklist was applied to the complete - **Credentials and process exposure:** app/bot tokens enter through hidden prompts, are passed only to official SDK constructors, and are absent from daemon argv, bridge registry, status/list output, debug statements, and user-facing errors. Slack setup deliberately replaces SDK errors with a credential-safe message. - **Storage and migration:** `channels.json` persists secrets in the existing local store and now forces `0600` after every write, including overwriting a permissive existing file. Telegram entries retain their prior shape. Missing/corrupt/unknown channel configurations do not construct a Slack adapter and therefore fail closed. -- **Inbound authorization:** exact workspace, paired user, and DM conversation IDs are required. Pairing uses 48 random bits encoded as 12 hex characters, timing-safe comparison, ten-minute expiry, single use, and persistence before runtime authorization. Persistence failure leaves the adapter unauthorized. Bot/self/subtype/non-DM/Slack Connect events are acknowledged and rejected. +- **Inbound proof-of-concept boundary:** exact workspace and DM event shape are required, but no Slack user is authorized. The first DM becomes the process-local response destination; bot/self/subtype/non-DM/Slack Connect events are acknowledged and rejected. Any workspace member who reaches that active DM can write text to the bound agent terminal. - **Replay and acknowledgment:** Socket Mode event/action envelopes are acknowledged before consumer work. A bounded 1,000-ID bridge-lifetime set suppresses retries and reconnect duplicates; question state additionally binds conversation/message/value, expires after ten minutes, and is consumed before terminal input. -- **Approval boundary and terminal input:** ordinary authorized DM text uses the existing message-to-bound-TTY path and is never interpreted as approval. Only a current `AskUserQuestion` Block Kit action can call `sendKey`, and accepted values are exactly one generated option digit or Escape for Skip. +- **Approval boundary and terminal input:** ordinary active-DM text uses the existing message-to-bound-TTY path and is never interpreted as approval. Only a current `AskUserQuestion` Block Kit action can call `sendKey`, and accepted values are exactly one generated option digit or Escape for Skip. - **Outbound safety and availability:** Slack control characters are escaped in rendered content and question fallback text, so ordinary Markdown cannot create mentions. Output is capped at 4,000 characters per call, unfurls are disabled, queues are bounded to 100 jobs per conversation, retry occurs once, and an excessive `Retry-After` is capped at 60 seconds. -- **Dependencies:** the lockfile resolves official `@slack/socket-mode@3.0.0` and `@slack/web-api@8.0.0`. `npm audit --audit-level=critical --omit=dev` exits 0 with no critical advisory. Its 22 high, 7 moderate, and 2 low reports are pre-existing transitive dependency findings outside the introduced Slack SDK path; broad upgrades are outside this feature and should be handled separately. +- **Dependencies:** the lockfile resolves official `@slack/socket-mode@3.0.0` and `@slack/web-api@8.0.0`. `npm audit --audit-level=critical --omit=dev` exits 0 with no critical advisory. Its 5 high, 5 moderate, and 1 low reports are pre-existing transitive dependency findings outside the introduced Slack SDK path; broad upgrades are outside this feature and should be handled separately. - **Compatibility:** the discriminated provider seam preserves Telegram configuration and behavior; the full repository lint/build/test gate exercises existing Telegram suites. -Review-driven red/green fixes covered permissive existing config permissions, pairing-persistence fail-closed behavior and listener rejection isolation, question fallback mention escaping, excessive rate-limit delay, duplicate SDK acknowledgment, and expired interactive actions. +Review-driven red/green fixes covered permissive existing config permissions, listener rejection isolation, question fallback mention escaping, excessive rate-limit delay, duplicate SDK acknowledgment, and expired interactive actions. Pairing was later removed explicitly for proof-of-concept simplicity. ## Deviations and Follow-ups diff --git a/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md b/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md index 2815de34..8d374fb4 100644 --- a/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md +++ b/docs/ai/planning/2026-08-06-feature-slack-channel-connector.md @@ -9,7 +9,7 @@ description: Ordered strict-TDD plan for the local Slack Socket Mode connector ## Milestones - [x] Milestone 1: Provider-neutral contracts and Slack-safe delivery foundation -- [x] Milestone 2: Secure Slack Socket Mode transport, pairing, and interaction support +- [x] Milestone 2: Slack Socket Mode transport, proof-of-concept DM routing, and interaction support - [x] Milestone 3: CLI/daemon integration, documentation, regression coverage, and release readiness ## Task Breakdown @@ -20,10 +20,10 @@ description: Ordered strict-TDD plan for the local Slack Socket Mode connector - [x] **Task 1.2 — Slack Markdown renderer and semantic chunker.** Outcome: safe independently valid chunks at or below 4,000 characters with code preservation and plain fallback. Dependency: 1.1. Evidence: renderer coverage. Scenarios: renderer/chunker matrix. - [x] **Task 1.3 — Rate-limit-aware threaded delivery queue.** Outcome: bounded ordered per-conversation sends with parent/thread continuity and retry metadata. Dependencies: 1.1-1.2. Evidence: fake-timer Web API tests. Scenarios: delivery queue and burst limits. -### Phase 2: Transport, pairing, and questions +### Phase 2: Transport, DM routing, and questions - [x] **Task 2.1 — Slack adapter on official SDKs.** Outcome: injectable Socket Mode/Web API clients, event normalization, prompt acknowledgment, filtering, idempotency, health, and lifecycle. Dependencies: Phase 1. Evidence: SDK-mocked adapter tests. Scenarios: adapter events/health. -- [x] **Task 2.2 — Explicit pairing and allowlist persistence.** Outcome: expiring single-use CSPRNG pairing with exact team/user/DM authorization and no first-speaker takeover. Dependency: 2.1. Evidence: pairing/security unit and integration tests. Scenarios: pairing/authorization. +- [x] **Task 2.2 — Proof-of-concept DM routing.** Outcome: valid configured-workspace DMs reach the bridge immediately; the first DM becomes the process-local response destination without persisted Slack-user authorization. Dependency: 2.1. Evidence: adapter and runner tests. Scenarios: immediate DM input and active-conversation routing. - [x] **Task 2.3 — Provider-neutral structured questions.** Outcome: shared question parsing/state with Telegram compatibility and Slack Block Kit option/Skip actions. Dependencies: 1.1, 2.1-2.2. Evidence: interaction and terminal-key tests. Scenarios: questions and replays. ### Phase 3: CLI and product integration @@ -37,8 +37,8 @@ description: Ordered strict-TDD plan for the local Slack Socket Mode connector - Every production behavior follows red → green → refactor; each task begins with a targeted failing test. - Phase 1 creates the stable API used by transport and CLI work. -- Pairing precedes accepting any agent input. -- Interaction handling depends on stable authorization and idempotency. +- Workspace/DM validation precedes accepting agent input. +- Interaction handling depends on active-question conversation binding and idempotency. - CLI integration follows adapter behavior so command tests mock a real contract rather than inventing one. - Phase 6 planning reconciliation occurs after every completed task. @@ -48,7 +48,7 @@ description: Ordered strict-TDD plan for the local Slack Socket Mode connector |---|---| | Provider abstraction grows beyond MVP | Add only capabilities required by Telegram and Slack tests; keep Slack SDK types inside adapter modules. | | Slack retry shapes differ across SDK versions | Test public SDK error fields and prefer SDK retry behavior where documented; keep injected sleeper/clients. | -| First-user takeover | Never auto-authorize; require expiring local pairing code and exact identity tuple. | +| Any workspace member can reach the local agent | Explicitly accepted for the proof of concept; keep DM-only/workspace validation, document the risk, and restore real authorization before wider release. | | Duplicate terminal input | Acknowledge promptly, mark stable IDs before dispatch, bound/persist enough state for bridge lifetime. | | Long output hits rate limits | Serialize per conversation, thread chunks, honor retry delay, bound the queue. | | Telegram regressions | Preserve defaults and run existing package/CLI suites after each integration task. | @@ -64,4 +64,4 @@ description: Ordered strict-TDD plan for the local Slack Socket Mode connector ## Progress Summary -All tasks are complete under TDD: provider contracts, rendering/chunking, queued delivery, official SDK transport, explicit pairing, expiring Slack interactions, dual-token setup validation, generic runner/daemon/status integration, user documentation, and security review. Task tracing is unavailable because `npx ai-devkit@latest task list --name slack-channel-connector --json` returns `unknown command 'task'`. Fresh lint, build, full tests, targeted coverage, and diff checks pass. +All tasks are complete under TDD: provider contracts, rendering/chunking, queued delivery, official SDK transport, proof-of-concept DM routing, expiring Slack interactions, dual-token setup validation, generic runner/daemon/status integration, user documentation, and security review. Task tracing is unavailable because `npx ai-devkit@latest task list --name slack-channel-connector --json` returns `unknown command 'task'`. Fresh lint, build, full tests, targeted coverage, and diff checks pass. diff --git a/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md b/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md index 696fa2d1..da0d5a2b 100644 --- a/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md +++ b/docs/ai/requirements/2026-08-06-feature-slack-channel-connector.md @@ -18,17 +18,17 @@ The target user is an individual developer or small team operator running AI Dev - Add a bidirectional Slack connector using official `@slack/socket-mode` and `@slack/web-api` SDKs. - Preserve the local-first daemon model: outbound WebSocket/API connections only, with no public endpoint. -- Support one custom Slack app, workspace, paired Slack user, DM, and local agent per channel instance. +- Support one custom Slack app, workspace, active DM, and local agent per channel instance. - Generalize the channel runner/configuration/interaction seams without changing Telegram behavior. - Deliver safe Slack `mrkdwn`, semantic long-message chunks, threaded continuations, structured single-question interactions, prompt notifications, event idempotency, paced delivery, and reconnect-aware health. -- Make authorization fail closed and keep credentials out of process arguments, bridge registries, status, and logs. +- Keep credentials out of process arguments, bridge registries, status, and logs. - Position Slack as an assurance and orchestration supervision surface: completions, verification evidence, failures, blockers, reviews, and bounded decisions. ### Non-goals - Public channels, `app_mention`, all-channel listening, slash commands, or Slack Connect. - OAuth, distribution, Marketplace listing, multi-workspace installations, or hosted Events API endpoints. -- Multiple Slack users controlling one bridge. +- Concurrent routing across multiple Slack DMs in one bridge session. - File upload or ingestion. - Starting/killing arbitrary agents, arbitrary terminal selection, or generic remote-shell controls from Slack. - Durable cloud delivery while the local daemon is offline. @@ -37,29 +37,28 @@ The target user is an individual developer or small team operator running AI Dev ## User Stories & Use Cases - As a local developer, I can configure a Slack custom app with app and bot tokens and verify its identity without exposing either token. -- As a developer, I pair by sending a short-lived code in a DM so the first unrelated workspace user cannot claim my agent. -- As the paired user, I can DM an instruction to an explicitly bound running agent and receive new assistant/system output in the same DM. -- As the paired user, I receive long Markdown and fenced code as readable Slack-safe messages, with continuation chunks kept in a thread. -- As the paired user, I can answer a supported single-select agent question or skip it using Slack buttons. -- As the paired user, I receive other tool/approval prompts as notifications and can respond through the existing terminal input path; ordinary messages are never silently interpreted as an approval action. +- As a developer, I can DM an instruction immediately after starting the bridge and receive new assistant/system output in the same DM. +- As a Slack user in the configured workspace, I receive long Markdown and fenced code as readable Slack-safe messages, with continuation chunks kept in a thread. +- As a Slack user in the active DM, I can answer a supported single-select agent question or skip it using Slack buttons. +- As a Slack user in the active DM, I receive other tool/approval prompts as notifications and can respond through the existing terminal input path; ordinary messages are never silently interpreted as an approval action. - As an operator, I can start, stop, list, and inspect Slack bridges using existing named-channel and daemon commands. - As an operator, I can see degraded connector health without tokens or sensitive prompt bodies being logged. ### Edge cases -- Events from another workspace, user, DM, bot, edited message, message subtype, Slack Connect context, or the connector itself are ignored or rejected. +- Events from another workspace, bot, edited message, message subtype, Slack Connect context, or the connector itself are ignored or rejected. - Duplicate Socket Mode envelopes/events and repeated button actions do not reach the agent twice. -- A stale or wrong-user button is acknowledged but cannot write to the terminal. +- A stale or wrong-conversation button is acknowledged but cannot write to the terminal. - HTTP 429 responses pause only the affected conversation queue according to `Retry-After`. -- WebSocket disconnect/reconnect does not create duplicate listeners or lose persisted pairing. +- WebSocket disconnect/reconnect does not create duplicate listeners. - Queue growth and idempotency state are bounded. - Markdown rendering failure falls back to escaped plain text. ## Success Criteria 1. `channel connect slack --name ` validates official SDK credentials and persists a discriminated Slack config in the existing `0600` channel store. -2. Pairing requires a cryptographically random, expiring code delivered by the intended user in a Slack DM; stored allowlists include team, user, and conversation IDs. -3. Only allowlisted `message.im` text events reach `TtyWriter`; bot/self/subtype/duplicate/wrong-identity events never do. +2. A valid `message.im` event from the configured workspace reaches the bridge immediately without pairing or persisted Slack-user authorization. +3. The first DM used in a bridge session becomes its response destination; other conversations must restart or use a separate named bridge. 4. New assistant/system output and agent request notifications are delivered through a provider-neutral runner without a Telegram regression. 5. Slack output escapes platform control characters, suppresses unintended mentions, preserves code, targets at most 4,000 characters per message, and sends continuation chunks with the first message's `thread_ts`. 6. A per-conversation sender serializes delivery, honors SDK rate-limit retry metadata, and enforces a bounded queue. @@ -77,13 +76,13 @@ The target user is an individual developer or small team operator running AI Dev - Slack `mrkdwn` differs from CommonMark; rendering is provider-specific. - Slack recommends short messages; the implementation uses a conservative 4,000-character ceiling and Slack threads rather than file uploads. - `channels.json` remains the compatibility store and is protected with mode `0600`. OS keychain integration is a follow-up. -- Pairing is completed while the bridge is running and the generated code is held in memory; only the resulting allowlist is persisted. +- Slack user/conversation authorization is intentionally omitted for this proof of concept; workspace and DM event validation remain in place. - Socket Mode is not Marketplace-compatible, and Slack Marketplace policy is not part of this local custom-app MVP. - Existing agent conversation polling limitations remain unless a provider-neutral change is necessary for Slack correctness. ## Alternatives Considered -- **Incoming webhook notifier:** fastest assurance-only validation but cannot support pairing, inbound commands, or questions. +- **Incoming webhook notifier:** fastest assurance-only validation but cannot support inbound commands or questions. - **Public Events API:** supports hosted scale and OAuth but violates the MVP's local-first/no-public-endpoint constraint. - **Socket Mode DM-only:** chosen because it matches Telegram's outbound daemon model while supporting Events API and interactivity. diff --git a/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md b/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md index f2074f44..604ac8c9 100644 --- a/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md +++ b/docs/ai/testing/2026-08-06-feature-slack-channel-connector.md @@ -41,33 +41,33 @@ description: Credential-free SDK-mocked validation for Slack transport, security ### Slack adapter events and health - [x] Socket Mode start/stop updates health and registers listeners once. -- [x] Valid paired `message.im` normalizes all stable IDs and reaches the handler once. +- [x] A valid configured-workspace `message.im` reaches the handler immediately without pairing. - [x] Envelope acknowledgment occurs before slow message/interaction handling. -- [x] Wrong workspace/user/conversation, bot/self, subtype, missing ID, non-DM, and external/shared events are ignored. +- [x] Wrong-workspace, bot/self, subtype, missing-ID, non-DM, and external/shared events are ignored. - [x] Duplicate event IDs and duplicate interaction IDs are ignored across the bridge-lifetime window. - [x] Event-ID storage evicts old entries at its bound. - [x] Disconnect/reconnect lifecycle updates health without duplicate delivery. -### Pairing and interactions +### Proof-of-concept routing and interactions -- [x] Pairing codes are CSPRNG-derived, expire, compare safely, and are single-use. -- [x] Only a matching DM in the configured workspace stores user/conversation IDs; pairing text never reaches the agent. -- [x] Pairing persistence failure leaves the runtime unauthorized and listener rejection is isolated. +- [x] An ordinary configured-workspace DM is delivered immediately without a pairing message. +- [x] The first DM becomes the process-local output destination and later conversations receive a routing-conflict response. +- [x] No Slack user or conversation authorization is persisted in `channels.json`. - [x] Slack Block Kit questions have bounded opaque values and accessible fallback text. - [x] Question fallback text escapes Slack mention/control syntax. - [x] Valid option/Skip actions write one digit/Escape and finalize once. -- [x] Wrong-user, wrong-conversation, expired, malformed, and replayed actions are acknowledged and ignored. +- [x] Wrong-conversation, expired, malformed, wrong-workspace, and replayed actions are acknowledged and ignored. ### CLI setup/status -- [x] Slack connect prompts for both secrets, validates identity, rejects incomplete token identity, and saves no config on failure. +- [x] Slack connect prompts for both secrets, accepts Slack's documented bot identity without `app_id`, rejects responses missing `user_id` or `team_id`, and saves no config on failure. - [x] List/status render provider-neutral workspace/bot/authorization data without tokens. - [x] Named Slack foreground and daemon starts pass the actual channel type to the registry. - [x] Daemon command/log/registry contain no app or bot token. ## Integration Tests -- [ ] Slack DM → normalized event → allowlist → `TtyWriter` flow. +- [ ] Slack DM → normalized event → active conversation → `TtyWriter` flow. - [ ] Agent assistant/system output → renderer → queue → parent/thread Web API calls. - [x] Agent `AskUserQuestion` request → Slack blocks → action → raw terminal key. - [x] Existing Telegram message, Markdown, callback, start/status, and daemon suites remain green. @@ -75,8 +75,8 @@ description: Credential-free SDK-mocked validation for Slack transport, security ## End-to-End Tests -- [ ] Mocked custom-app setup, pairing, bridge start, message round trip, question action, and graceful stop. -- [ ] Unpaired/wrong-user attempt remains unable to control the agent. +- [ ] Mocked custom-app setup, bridge start, immediate DM round trip, question action, and graceful stop. +- [ ] A second DM receives a routing-conflict response until the bridge restarts. - [x] Fresh full repository lint, typecheck/build, and relevant test suites pass. ## Test Data @@ -98,7 +98,7 @@ description: Credential-free SDK-mocked validation for Slack transport, security ## Manual Testing -- [ ] Optional sandbox Slack workspace: create app from documented manifest, install, supply fake-free real tokens locally, pair via DM, start a bridge to a disposable agent, send/receive text and a question, force reconnect, inspect threaded long output, stop/disconnect, and revoke tokens. +- [ ] Optional sandbox Slack workspace: create app from documented manifest, install, supply fake-free real tokens locally, start a bridge to a disposable agent, send an immediate DM, exchange text and a question, force reconnect, inspect threaded long output, stop/disconnect, and revoke tokens. - Not required for automated acceptance because CI and contributors must not possess Slack credentials. ## Performance and Reliability Testing diff --git a/packages/channel-connector/README.md b/packages/channel-connector/README.md index 5b8b192f..be908029 100644 --- a/packages/channel-connector/README.md +++ b/packages/channel-connector/README.md @@ -23,7 +23,7 @@ ai-devkit channel start --agent Use this package directly only when building custom channel integrations or extending AI DevKit's remote-control surface. -Slack uses the official `@slack/socket-mode` and `@slack/web-api` clients. The supported MVP is a user-owned, single-workspace, DM-only Socket Mode app with explicit pairing; public channels, OAuth distribution, files, and multi-workspace routing are not supported. +Slack uses the official `@slack/socket-mode` and `@slack/web-api` clients. The current proof of concept is a user-owned, single-workspace, DM-only Socket Mode app without Slack-user authorization. Use it only in a disposable/private workspace with a non-sensitive agent session. Public channels, OAuth distribution, files, and multi-workspace routing are not supported. ## Documentation diff --git a/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts b/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts index cc0465bf..8f6b2e96 100644 --- a/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts +++ b/packages/channel-connector/src/__tests__/adapters/SlackAdapter.test.ts @@ -1,7 +1,6 @@ import { EventEmitter } from 'node:events'; import { SlackAdapter, validateSlackAppToken, validateSlackCredentials } from '../../adapters/SlackAdapter.js'; import type { SlackConfig } from '../../types.js'; -import { SlackPairingSession } from '../../utils/SlackPairingSession.js'; class FakeSocketClient extends EventEmitter { start = vi.fn().mockResolvedValue(undefined); @@ -14,8 +13,6 @@ const config: SlackConfig = { appId: 'A123', botUserId: 'U-BOT', workspaceId: 'T123', - authorizedUserId: 'U123', - authorizedConversationId: 'D123', transport: 'socket-mode', audience: 'dm', }; @@ -49,6 +46,21 @@ describe('SlackAdapter', () => { }); }); + it('accepts Slack documented bot identity without an app ID', async () => { + const authTest = vi.fn().mockResolvedValue({ + ok: true, + bot_id: 'B123', + user_id: 'U-BOT', + team_id: 'T123', + team: 'Sandbox', + }); + await expect(validateSlackCredentials('xoxb-fake', { authTest })).resolves.toEqual({ + botUserId: 'U-BOT', + workspaceId: 'T123', + workspaceName: 'Sandbox', + }); + }); + it('rejects incomplete Slack credential identity', async () => { const authTest = vi.fn().mockResolvedValue({ ok: true, team_id: 'T123' }); await expect(validateSlackCredentials('xoxb-fake', { authTest })).rejects.toThrow('Slack bot token returned incomplete identity'); @@ -91,7 +103,7 @@ describe('SlackAdapter', () => { expect(await adapter.isHealthy()).toBe(true); }); - it('acknowledges before delivering one normalized authorized DM event', async () => { + it('acknowledges before delivering one normalized workspace DM event', async () => { const socket = new FakeSocketClient(); const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); const order: string[] = []; @@ -113,8 +125,6 @@ describe('SlackAdapter', () => { it.each([ ['wrong workspace', {}, { team_id: 'T999' }], - ['wrong user', { user: 'U999' }, {}], - ['wrong conversation', { channel: 'D999' }, {}], ['bot message', { bot_id: 'B123' }, {}], ['self message', { user: 'U-BOT' }, {}], ['subtype', { subtype: 'message_changed' }, {}], @@ -132,46 +142,21 @@ describe('SlackAdapter', () => { expect(handler).not.toHaveBeenCalled(); }); - it('pairs an unconfigured workspace DM without forwarding the code', async () => { + it('delivers an ordinary workspace DM immediately without pairing', async () => { const socket = new FakeSocketClient(); const handler = vi.fn(); - const onPaired = vi.fn().mockResolvedValue(undefined); - const unpaired = { ...config, authorizedUserId: undefined, authorizedConversationId: undefined }; - const adapter = new SlackAdapter(unpaired, { + const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } }, - pairingSession: new SlackPairingSession({ code: 'ABCDEF123456' }), - onPaired, }); adapter.onMessage(handler); await adapter.start(); - const item = envelope({ text: 'ABCDEF123456' }); - socket.emit('slack_event', item); - await vi.waitFor(() => expect(onPaired).toHaveBeenCalledWith({ userId: 'U123', conversationId: 'D123' })); - expect(handler).not.toHaveBeenCalled(); - expect(adapter.getPairingCode()).toBeUndefined(); - }); - it('fails closed when the paired identity cannot be persisted', async () => { - const socket = new FakeSocketClient(); - const handler = vi.fn(); - const unpaired = { ...config, authorizedUserId: undefined, authorizedConversationId: undefined }; - const adapter = new SlackAdapter(unpaired, { - socketClient: socket, - webClient: { chat: { postMessage: vi.fn() } }, - pairingSession: new SlackPairingSession({ code: 'ABCDEF123456' }), - onPaired: vi.fn().mockRejectedValue(new Error('disk unavailable')), - }); - adapter.onMessage(handler); - await adapter.start(); - socket.emit('slack_event', envelope({ text: 'ABCDEF123456' })); - await new Promise((resolve) => setTimeout(resolve, 0)); - const ordinary = envelope({ text: 'run tests' }); - ordinary.body.event_id = 'Ev-after-failed-persist'; - socket.emit('slack_event', ordinary); - await new Promise((resolve) => setTimeout(resolve, 0)); + socket.emit('slack_event', envelope({ text: 'run tests' })); - expect(handler).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(handler).toHaveBeenCalledWith(expect.objectContaining({ + chatId: 'D123', userId: 'U123', text: 'run tests', workspaceId: 'T123', + }))); }); it('sends an accessible Block Kit question and finalizes its actions', async () => { @@ -205,7 +190,7 @@ describe('SlackAdapter', () => { })); }); - it('acknowledges and delivers one authorized block action', async () => { + it('acknowledges and delivers one valid workspace block action', async () => { const socket = new FakeSocketClient(); const handler = vi.fn(); const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); @@ -241,7 +226,7 @@ describe('SlackAdapter', () => { expect(ack).not.toHaveBeenCalled(); }); - it('acknowledges malformed and unauthorized block actions without delivery', async () => { + it('acknowledges malformed and wrong-workspace block actions without delivery', async () => { const socket = new FakeSocketClient(); const handler = vi.fn(); const adapter = new SlackAdapter(config, { socketClient: socket, webClient: { chat: { postMessage: vi.fn() } } }); diff --git a/packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts b/packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts deleted file mode 100644 index 537a1f6a..00000000 --- a/packages/channel-connector/src/__tests__/utils/SlackPairingSession.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { SlackPairingSession } from '../../utils/SlackPairingSession.js'; - -describe('SlackPairingSession', () => { - it('generates a non-trivial pairing code and consumes one exact match', () => { - const session = new SlackPairingSession({ now: () => 1000 }); - expect(session.code).toMatch(/^[A-Z0-9]{12}$/); - expect(session.consume(`${session.code}x`)).toBe(false); - expect(session.consume(session.code)).toBe(true); - expect(session.consume(session.code)).toBe(false); - }); - - it('expires after ten minutes', () => { - let now = 1000; - const session = new SlackPairingSession({ now: () => now, code: 'ABCDEF123456' }); - now += 10 * 60 * 1000 + 1; - expect(session.consume('ABCDEF123456')).toBe(false); - expect(session.isExpired()).toBe(true); - }); - - it('normalizes surrounding whitespace but not letter case', () => { - const session = new SlackPairingSession({ code: 'ABCDEF123456' }); - expect(session.consume(' ABCDEF123456\n')).toBe(true); - const second = new SlackPairingSession({ code: 'ABCDEF123456' }); - expect(second.consume('abcdef123456')).toBe(false); - }); -}); diff --git a/packages/channel-connector/src/adapters/SlackAdapter.ts b/packages/channel-connector/src/adapters/SlackAdapter.ts index 745c6e20..b4c2783e 100644 --- a/packages/channel-connector/src/adapters/SlackAdapter.ts +++ b/packages/channel-connector/src/adapters/SlackAdapter.ts @@ -9,7 +9,6 @@ import type { SlackConfig, } from '../types.js'; import { SlackDeliveryQueue } from '../utils/SlackDeliveryQueue.js'; -import { SlackPairingSession } from '../utils/SlackPairingSession.js'; import { escapeSlackText } from '../utils/slackMarkdown.js'; export const SLACK_CHANNEL_TYPE = 'slack'; @@ -19,7 +18,7 @@ interface SlackAuthClient { } export interface SlackIdentity { - appId: string; + appId?: string; botUserId: string; workspaceId: string; workspaceName?: string; @@ -40,11 +39,11 @@ export async function validateSlackCredentials(botToken: string, client?: SlackA authTest: () => new WebClient(botToken).auth.test(), }; const identity = await authClient.authTest(); - if (!identity.ok || !identity.app_id || !identity.user_id || !identity.team_id) { + if (!identity.ok || !identity.user_id || !identity.team_id) { throw new Error('Slack bot token returned incomplete identity'); } return { - appId: identity.app_id, + ...(identity.app_id ? { appId: identity.app_id } : {}), botUserId: identity.user_id, workspaceId: identity.team_id, workspaceName: identity.team, @@ -67,8 +66,6 @@ interface WebClientLike { interface SlackAdapterDependencies { socketClient?: SocketClientLike; webClient?: WebClientLike; - pairingSession?: SlackPairingSession; - onPaired?: (identity: { userId: string; conversationId: string }) => Promise; } interface SlackEventEnvelope { @@ -87,8 +84,6 @@ export class SlackAdapter implements InteractiveChannelAdapter { private readonly socket: SocketClientLike; private readonly web: WebClientLike; private readonly delivery: SlackDeliveryQueue; - private pairingSession: SlackPairingSession | undefined; - private readonly onPaired?: SlackAdapterDependencies['onPaired']; private readonly recentEventIds = new Map(); private messageHandler: ((message: IncomingMessage) => Promise) | null = null; private interactionHandler: ((interaction: IncomingInteraction) => Promise) | null = null; @@ -101,9 +96,6 @@ export class SlackAdapter implements InteractiveChannelAdapter { this.delivery = new SlackDeliveryQueue({ postMessage: (input) => this.web.chat.postMessage(input), }); - this.pairingSession = dependencies.pairingSession - ?? (!config.authorizedUserId || !config.authorizedConversationId ? new SlackPairingSession() : undefined); - this.onPaired = dependencies.onPaired; } async start(): Promise { @@ -177,10 +169,6 @@ export class SlackAdapter implements InteractiveChannelAdapter { await this.web.chat.update({ channel: chatId, ts: messageId, blocks: [] }); } - getPairingCode(): string | undefined { - return this.pairingSession?.isExpired() ? undefined : this.pairingSession?.code; - } - private async handleSlackEvent(envelope: SlackEventEnvelope): Promise { if (envelope.type !== 'events_api') return; await envelope.ack?.(); @@ -188,8 +176,7 @@ export class SlackAdapter implements InteractiveChannelAdapter { const event = body?.event; const eventId = body?.event_id; if (!body || !event || !eventId || !this.messageHandler) return; - if (await this.tryPair(body, event)) return; - if (!this.isAuthorizedMessage(body, event)) return; + if (!this.isValidDirectMessage(body, event)) return; if (this.recentEventIds.has(eventId)) return; this.rememberEvent(eventId); @@ -210,35 +197,11 @@ export class SlackAdapter implements InteractiveChannelAdapter { } } - private async tryPair(body: NonNullable, event: Record): Promise { - if (!this.pairingSession) return false; - if (body.team_id !== this.config.workspaceId - || body.is_ext_shared_channel === true - || event.type !== 'message' - || event.channel_type !== 'im' - || typeof event.channel !== 'string' - || typeof event.user !== 'string' - || typeof event.text !== 'string' - || event.user === this.config.botUserId - || event.bot_id !== undefined - || event.subtype !== undefined) return true; - if (!this.pairingSession.consume(event.text)) return true; - - const identity = { userId: event.user, conversationId: event.channel }; - await this.onPaired?.(identity); - this.config.authorizedUserId = identity.userId; - this.config.authorizedConversationId = identity.conversationId; - this.pairingSession = undefined; - return true; - } - - private isAuthorizedMessage(body: NonNullable, event: Record): boolean { + private isValidDirectMessage(body: NonNullable, event: Record): boolean { return body.team_id === this.config.workspaceId && body.is_ext_shared_channel !== true && event.type === 'message' && event.channel_type === 'im' - && event.channel === this.config.authorizedConversationId - && event.user === this.config.authorizedUserId && event.user !== this.config.botUserId && typeof event.text === 'string' && typeof event.ts === 'string' @@ -270,8 +233,6 @@ export class SlackAdapter implements InteractiveChannelAdapter { || typeof userId !== 'string' || typeof chatId !== 'string' || workspaceId !== this.config.workspaceId - || userId !== this.config.authorizedUserId - || chatId !== this.config.authorizedConversationId || typeof messageId !== 'string' || action.action_id !== 'ai_devkit_question' || typeof action.value !== 'string' diff --git a/packages/channel-connector/src/index.ts b/packages/channel-connector/src/index.ts index 30cdf3c7..9d9701e7 100644 --- a/packages/channel-connector/src/index.ts +++ b/packages/channel-connector/src/index.ts @@ -15,7 +15,6 @@ export { markdownToSlackMrkdwn, } from './utils/slackMarkdown.js'; export { SlackDeliveryQueue } from './utils/SlackDeliveryQueue.js'; -export { SlackPairingSession } from './utils/SlackPairingSession.js'; export type { TelegramAdapterOptions } from './adapters/TelegramAdapter.js'; export { isInteractiveChannelAdapter } from './adapters/ChannelAdapter.js'; diff --git a/packages/channel-connector/src/types.ts b/packages/channel-connector/src/types.ts index 2dfa606c..7f3c60d6 100644 --- a/packages/channel-connector/src/types.ts +++ b/packages/channel-connector/src/types.ts @@ -69,12 +69,10 @@ export interface TelegramConfig { export interface SlackConfig { appToken: string; botToken: string; - appId: string; + appId?: string; botUserId: string; workspaceId: string; workspaceName?: string; - authorizedUserId?: string; - authorizedConversationId?: string; transport: 'socket-mode'; audience: 'dm'; } diff --git a/packages/channel-connector/src/utils/SlackPairingSession.ts b/packages/channel-connector/src/utils/SlackPairingSession.ts deleted file mode 100644 index 7c84b1d1..00000000 --- a/packages/channel-connector/src/utils/SlackPairingSession.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { randomBytes, timingSafeEqual } from 'node:crypto'; - -interface PairingSessionOptions { - code?: string; - now?: () => number; - ttlMs?: number; -} - -export class SlackPairingSession { - readonly code: string; - private readonly expiresAt: number; - private readonly now: () => number; - private consumed = false; - - constructor(options: PairingSessionOptions = {}) { - this.now = options.now ?? Date.now; - this.code = options.code ?? randomBytes(6).toString('hex').toUpperCase(); - this.expiresAt = this.now() + (options.ttlMs ?? 10 * 60 * 1000); - } - - consume(candidate: string): boolean { - if (this.consumed || this.isExpired()) return false; - const expected = Buffer.from(this.code); - const actual = Buffer.from(candidate.trim()); - if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return false; - this.consumed = true; - return true; - } - - isExpired(): boolean { - return this.now() > this.expiresAt; - } -} diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index 17a55f78..a462daf3 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -18,6 +18,10 @@ const mockPassword = vi.fn<(...args: unknown[]) => Promise>(); const mockGetMe = vi.fn<() => Promise<{ username: string }>>(); const mockValidateSlackCredentials = vi.fn(); const mockValidateSlackAppToken = vi.fn(); +const { mockDebug, mockEnableDebug } = vi.hoisted(() => ({ + mockDebug: vi.fn(), + mockEnableDebug: vi.fn(), +})); const mockSpinner = { start: vi.fn(), succeed: vi.fn(), @@ -114,6 +118,11 @@ vi.mock('../../util/terminal-ui.js', () => ({ }, })); +vi.mock('../../util/debug.js', () => ({ + createLogger: vi.fn(() => mockDebug), + enableDebug: mockEnableDebug, +})); + vi.mock('../../services/channel/channel.service.js', () => ({ ChannelService: vi.fn(function () { return mockChannelService; }), })); @@ -532,6 +541,44 @@ describe('channel command', () => { expect(ui.success).toHaveBeenCalledWith('Slack channel "work-slack" configured successfully!'); }); + it('enables debug logging while connecting a Slack channel', async () => { + mockPassword.mockResolvedValueOnce('xapp-fake').mockResolvedValueOnce('xoxb-fake'); + mockConfigStore.getChannel.mockResolvedValue(undefined); + mockChannelService.resolveConnectChannelName.mockReturnValue('work-slack'); + const program = new Command().exitOverride(); + registerChannelCommand(program); + + await expect(program.parseAsync([ + 'node', 'test', 'channel', 'connect', 'slack', '--name', 'work-slack', '--debug', + ])).resolves.toBe(program); + + expect(mockEnableDebug).toHaveBeenCalledOnce(); + }); + + it('debugs the failed Slack validation stage without logging credentials', async () => { + const appToken = 'xapp-sensitive-app-token'; + const botToken = 'xoxb-sensitive-bot-token'; + mockPassword.mockResolvedValueOnce(appToken).mockResolvedValueOnce(botToken); + mockConfigStore.getChannel.mockResolvedValue(undefined); + mockChannelService.resolveConnectChannelName.mockReturnValue('work-slack'); + mockValidateSlackAppToken.mockRejectedValueOnce(new Error( + `socket_mode_disabled for ${appToken} and ${botToken}`, + )); + const program = new Command(); + registerChannelCommand(program); + + await program.parseAsync([ + 'node', 'test', 'channel', 'connect', 'slack', '--name', 'work-slack', '--debug', + ]); + + expect(mockDebug).toHaveBeenCalledWith( + 'Slack app token validation failed: socket_mode_disabled for [REDACTED] and [REDACTED]', + ); + expect(mockDebug.mock.calls.flat().join(' ')).not.toContain(appToken); + expect(mockDebug.mock.calls.flat().join(' ')).not.toContain(botToken); + expect(mockConfigStore.saveChannel).not.toHaveBeenCalled(); + }); + it('lists named Telegram channels with authorization state', async () => { mockConfigStore.getConfig.mockResolvedValue({ channels: { diff --git a/packages/cli/src/__tests__/services/channel/channel-runner.test.ts b/packages/cli/src/__tests__/services/channel/channel-runner.test.ts index 38a971ad..de6d39ed 100644 --- a/packages/cli/src/__tests__/services/channel/channel-runner.test.ts +++ b/packages/cli/src/__tests__/services/channel/channel-runner.test.ts @@ -3,8 +3,9 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { vi, type Mock } from 'vitest'; import type { AgentInfo, AgentRequest } from '@ai-devkit/agent-manager'; -import { AgentStatus, writeAgentRequest } from '@ai-devkit/agent-manager'; -import { startOutputPolling } from '../../../services/channel/channel-runner.js'; +import { AgentStatus, TerminalType, TtyWriter, writeAgentRequest } from '@ai-devkit/agent-manager'; +import type { IncomingMessage } from '@ai-devkit/channel-connector'; +import { setupInputHandler, startOutputPolling } from '../../../services/channel/channel-runner.js'; import { AskUserQuestionService } from '../../../services/channel/ask-user-question.js'; function makeAgent(overrides: Partial = {}): AgentInfo { @@ -40,6 +41,63 @@ function makeRequest(overrides: Partial = {}): AgentRequest { }; } +describe('channel input routing', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses the first Slack DM as the active conversation and rejects a later DM', async () => { + let handler: ((message: IncomingMessage) => Promise) | undefined; + const channel = { + type: 'slack', + onMessage: vi.fn((nextHandler) => { handler = nextHandler; }), + sendMessage: vi.fn().mockResolvedValue(undefined), + }; + const send = vi.spyOn(TtyWriter, 'send').mockResolvedValue(undefined); + const activeChat = { value: null as string | null }; + setupInputHandler(channel as never, { + type: TerminalType.UNKNOWN, identifier: 'test', tty: '/dev/ttys001', + }, activeChat); + + const message = (chatId: string, text: string): IncomingMessage => ({ + channelType: 'slack', chatId, userId: 'U123', text, + timestamp: new Date('2026-08-06T00:00:00Z'), + }); + await handler?.(message('D123', 'first')); + await handler?.(message('D999', 'second')); + + expect(activeChat.value).toBe('D123'); + expect(send).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledWith(expect.anything(), 'first'); + expect(channel.sendMessage).toHaveBeenCalledWith( + 'D999', + 'This bridge is already connected to another conversation. Restart it to switch.', + ); + }); + + it('keeps the authorization rejection for a different Telegram chat', async () => { + let handler: ((message: IncomingMessage) => Promise) | undefined; + const channel = { + type: 'telegram', + onMessage: vi.fn((nextHandler) => { handler = nextHandler; }), + sendMessage: vi.fn().mockResolvedValue(undefined), + }; + setupInputHandler(channel as never, { + type: TerminalType.UNKNOWN, identifier: 'test', tty: '/dev/ttys001', + }, { value: '123' }, vi.fn()); + + await handler?.({ + channelType: 'telegram', chatId: '999', userId: '999', text: 'hello', + timestamp: new Date('2026-08-06T00:00:00Z'), + }); + + expect(channel.sendMessage).toHaveBeenCalledWith( + '999', + 'Unauthorized. Only the configured user is allowed.', + ); + }); +}); + describe('startOutputPolling — agent requests', () => { let homeDir: string; let chatIdRef: { value: string | null }; diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 42488ee4..5de4c033 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -16,6 +16,7 @@ import { import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; import { createLogger, enableDebug } from '../util/debug.js'; +import { getErrorMessage } from '../util/text.js'; import { confirm, password } from '@inquirer/prompts'; import { ChannelService } from '../services/channel/channel.service.js'; import { runChannelBridge } from '../services/channel/channel-runner.js'; @@ -43,6 +44,13 @@ function resolveDaemonLaunch(): { command: string; args: string[] } { }; } +function redactSecrets(message: string, secrets: string[]): string { + return secrets.reduce( + (redacted, secret) => secret ? redacted.split(secret).join('[REDACTED]') : redacted, + message, + ); +} + export function registerChannelCommand(program: Command): void { const channelService = new ChannelService(); const channelCommand = program @@ -53,7 +61,11 @@ export function registerChannelCommand(program: Command): void { .command('connect ') .description('Connect a messaging channel (e.g., telegram)') .option('--name ', 'Channel instance name') - .action(withErrorHandler('connect channel', async (type: string, options: { name?: string }) => { + .option('--debug', 'Enable debug logging') + .action(withErrorHandler('connect channel', async (type: string, options: { name?: string; debug?: boolean }) => { + if (options.debug) { + enableDebug(); + } if (type !== TELEGRAM_CHANNEL_TYPE && type !== SLACK_CHANNEL_TYPE) { ui.error(`Unsupported channel type: ${type}. Supported: ${TELEGRAM_CHANNEL_TYPE}, ${SLACK_CHANNEL_TYPE}`); return; @@ -75,8 +87,10 @@ export function registerChannelCommand(program: Command): void { })).trim(); const spinner = ui.spinner('Validating Slack bot identity...'); spinner.start(); + let stage = 'app token validation'; try { await validateSlackAppToken(appToken); + stage = 'bot token validation'; const identity = await validateSlackCredentials(botToken); const entry: ChannelEntry = { type: SLACK_CHANNEL_TYPE, @@ -88,17 +102,16 @@ export function registerChannelCommand(program: Command): void { ...identity, transport: 'socket-mode', audience: 'dm', - ...(existing?.type === SLACK_CHANNEL_TYPE ? { - authorizedUserId: existing.config.authorizedUserId, - authorizedConversationId: existing.config.authorizedConversationId, - } : {}), }, }; + stage = 'configuration save'; await configStore.saveChannel(channelName, entry); spinner.succeed(`Connected to Slack workspace ${identity.workspaceName ?? identity.workspaceId}`); ui.success(`Slack channel "${channelName}" configured successfully!`); - ui.info(`Run "ai-devkit channel start ${channelName} --agent " and pair by DM.`); - } catch { + ui.info(`Run "ai-devkit channel start ${channelName} --agent ", then DM the Slack app.`); + } catch (error: unknown) { + const message = redactSecrets(getErrorMessage(error), [appToken, botToken]); + debug(`Slack ${stage} failed: ${message}`); spinner.fail('Invalid Slack credentials. Please check and try again.'); } return; @@ -174,15 +187,15 @@ export function registerChannelCommand(program: Command): void { const identity = entry.type === SLACK_CHANNEL_TYPE ? (entry.config as SlackConfig).workspaceName ?? (entry.config as SlackConfig).workspaceId : `@${(entry.config as TelegramConfig).botUsername}`; - const authorized = entry.type === SLACK_CHANNEL_TYPE - ? Boolean((entry.config as SlackConfig).authorizedUserId && (entry.config as SlackConfig).authorizedConversationId) - : Boolean((entry.config as TelegramConfig).authorizedChatId); + const authorization = entry.type === SLACK_CHANNEL_TYPE + ? 'n/a' + : (entry.config as TelegramConfig).authorizedChatId ? 'yes' : 'no'; return [ name, entry.type, entry.enabled ? chalk.green('enabled') : chalk.dim('disabled'), identity || '-', - authorized ? 'yes' : 'no', + authorization, liveByChannel.has(name) ? chalk.green('running') : chalk.dim('stopped'), entry.createdAt ? new Date(entry.createdAt).toLocaleDateString() : '-', ]; @@ -324,13 +337,13 @@ export function registerChannelCommand(program: Command): void { const identity = entry.type === SLACK_CHANNEL_TYPE ? `${(entry.config as SlackConfig).workspaceName ?? (entry.config as SlackConfig).workspaceId} (bot ${(entry.config as SlackConfig).botUserId})` : `@${(entry.config as TelegramConfig).botUsername || 'unknown'}`; - const authorized = entry.type === SLACK_CHANNEL_TYPE - ? Boolean((entry.config as SlackConfig).authorizedUserId && (entry.config as SlackConfig).authorizedConversationId) - : Boolean((entry.config as TelegramConfig).authorizedChatId); + const authorization = entry.type === SLACK_CHANNEL_TYPE + ? 'n/a (POC)' + : (entry.config as TelegramConfig).authorizedChatId ? 'yes' : 'no'; ui.text(`${chalk.bold(name)} (${entry.type})`); ui.text(` Enabled: ${entry.enabled ? chalk.green('yes') : chalk.red('no')}`); ui.text(` Identity: ${identity}`); - ui.text(` Authorized: ${authorized ? 'yes' : 'no'}`); + ui.text(` Authorized: ${authorization}`); ui.text(` Bridge: ${bridge ? chalk.green(`running (PID: ${bridge.bridgePid}, agent: ${bridge.agentName})`) : chalk.dim('stopped')}`); if (bridge?.logPath) { ui.text(` Logs: ${bridge.logPath}`); diff --git a/packages/cli/src/services/channel/channel-runner.ts b/packages/cli/src/services/channel/channel-runner.ts index 74b71cd9..40c74d61 100644 --- a/packages/cli/src/services/channel/channel-runner.ts +++ b/packages/cli/src/services/channel/channel-runner.ts @@ -23,7 +23,6 @@ import { TelegramAdapter, SLACK_CHANNEL_TYPE, TELEGRAM_CHANNEL_TYPE, - type SlackConfig, } from '@ai-devkit/channel-connector'; import { ui } from '../../util/terminal-ui.js'; import { getErrorMessage } from '../../util/text.js'; @@ -84,7 +83,7 @@ async function resolveTargetAgent(agentManager: AgentManager, agentName: string) return resolved as AgentInfo; } -function setupInputHandler( +export function setupInputHandler( channel: ChannelAdapter, terminalLocation: TerminalLocation, chatIdRef: { value: string | null }, @@ -95,13 +94,20 @@ function setupInputHandler( if (!chatIdRef.value) { chatIdRef.value = msg.chatId; - await onAuthorize?.(msg.chatId); - ui.info(`Authorized Telegram user (chat ID: ${msg.chatId})`); + if (onAuthorize) { + await onAuthorize(msg.chatId); + ui.info(`Authorized Telegram user (chat ID: ${msg.chatId})`); + } else { + ui.info(`Connected ${channel.type} conversation ${msg.chatId} for this bridge session.`); + } } if (msg.chatId !== chatIdRef.value) { - debug(`Rejected message from unauthorized chat ID: ${msg.chatId}`); - await channel.sendMessage(msg.chatId, 'Unauthorized. Only the configured user is allowed.'); + const rejection = onAuthorize + ? 'Unauthorized. Only the configured user is allowed.' + : 'This bridge is already connected to another conversation. Restart it to switch.'; + debug(`Rejected message from ${onAuthorize ? 'unauthorized' : 'inactive'} chat ID: ${msg.chatId}`); + await channel.sendMessage(msg.chatId, rejection); return; } @@ -175,7 +181,7 @@ export function startOutputPolling( if (!chatIdRef.value) { if (tickCount % 15 === 1) { - debug(`poll skip: no authorized chat yet (tick ${tickCount})`); + debug(`poll skip: no active chat yet (tick ${tickCount})`); } return; } @@ -344,24 +350,12 @@ export async function runChannelBridge(input: RunChannelBridgeInput): Promise { - const latest = await configStore.getChannel(input.channelName); - if (!latest || latest.type !== SLACK_CHANNEL_TYPE) return; - const config: SlackConfig = { ...latest.config, authorizedUserId: userId, authorizedConversationId: conversationId }; - await configStore.saveChannel(input.channelName, { ...latest, config }); - chatIdRef.value = conversationId; - ui.success(`Slack user paired for channel "${input.channelName}".`); - }, - }); + const slack = new SlackAdapter(slackConfig); channel = slack; setupInputHandler(slack, terminalLocation, chatIdRef); const slackQuestions = new SlackQuestionService(slack, (key) => TtyWriter.sendKey(terminalLocation, key)); askUserQuestionService = slackQuestions; slack.onInteraction((interaction) => slackQuestions.handleInteraction(interaction)); - const pairingCode = slack.getPairingCode(); - if (pairingCode) ui.info(`DM this pairing code to the Slack app within 10 minutes: ${pairingCode}`); } else { ui.error(`Unsupported channel type: ${channelEntry.type}`); return; diff --git a/web/content/docs/12-channel.md b/web/content/docs/12-channel.md index 8c6201f5..b5ccf67a 100644 --- a/web/content/docs/12-channel.md +++ b/web/content/docs/12-channel.md @@ -50,7 +50,9 @@ You will be prompted to enter your Telegram bot token. AI DevKit validates the t ### Configure a private Slack app -Slack support is a local-first, DM-only Socket Mode integration for one workspace and one paired user. It does not expose an HTTP endpoint and is not a distributable OAuth or Marketplace app. +Slack support is a local-first, DM-only Socket Mode integration for one workspace. It does not expose an HTTP endpoint and is not a distributable OAuth or Marketplace app. + +> **Proof-of-concept security warning:** Slack user authorization is not enabled. Any member of the configured workspace who can DM the app can send text toward the connected local agent. Use only in a disposable/private workspace with a non-sensitive agent session. Create an app from this manifest in the [Slack app dashboard](https://api.slack.com/apps): @@ -82,15 +84,26 @@ settings: is_hosted: false ``` -Then: +Before copying tokens, verify every required setting in the Slack app dashboard. Do not skip this checklist even when you created the app from the manifest: + +- **Socket Mode:** Under **Socket Mode**, confirm **Enable Socket Mode** is on. +- **App Home:** Under **App Home**, confirm the **Messages Tab** is enabled and users are allowed to send messages. +- **Bot scopes:** Under **OAuth & Permissions → Bot Token Scopes**, confirm both `chat:write` and `im:history` are present. +- **DM event:** Under **Event Subscriptions → Subscribe to bot events**, confirm `message.im` is present, then save the change. Without this event, the bridge can connect successfully but never receive your DMs. +- **Interactivity:** Under **Interactivity & Shortcuts**, confirm interactivity is enabled. This is required for answering agent questions from Slack. + +Then complete setup in this order: + +1. Under **Install App**, select **Install to Workspace**. If the app was already installed before you added scopes or events, select **Reinstall to Workspace** and approve the permissions again. Existing bot tokens do not gain newly added scopes until the app is reinstalled. +2. From **OAuth & Permissions**, copy the **Bot User OAuth Token** beginning with `xoxb-`. +3. Under **Basic Information → App-Level Tokens**, create an `xapp-` token with the `connections:write` scope. +4. Run `ai-devkit channel connect slack --name work-slack`. Both secrets are entered through hidden prompts and validated before storage. +5. Start the bridge with `ai-devkit channel start work-slack --agent --debug`. +6. In Slack, open the app from **Apps**, select its **Messages** tab, and send a DM. The first message is forwarded immediately; you do not need a pairing code or an `@mention`. -1. Install the app to its development workspace and copy the `xoxb-` bot token. -2. Under **Basic Information → App-Level Tokens**, create an `xapp-` token with `connections:write`. -3. Run `ai-devkit channel connect slack --name work-slack`. Both secrets are entered through hidden prompts and validated before storage. -4. Start the bridge with `ai-devkit channel start work-slack --agent `. -5. Copy the short-lived pairing code printed only in the local terminal and DM it to the app within ten minutes. +When setup is correct, the debug output includes `Received message from chat ID` after your first DM. A repeating `poll skip: no active chat yet` message means the bridge is running but has not received a usable Slack DM. -The bridge then accepts only the exact workspace, Slack user, and DM conversation established by pairing. Pairing text is not forwarded to the agent. Public channels, mentions, Slack Connect, files, OAuth, and multi-workspace installs are intentionally unsupported. +The first DM used after startup becomes that bridge process's response destination. Restart the bridge to switch conversations. This routing choice is not persisted and is not user authorization. Public channels, mentions, Slack Connect, files, OAuth, and multi-workspace installs are intentionally unsupported. By default, the channel is named `telegram`. Use `--name ` when you want multiple Telegram bot connections, such as `personal` and `team`. Channel names must use lowercase letters, numbers, and hyphens. @@ -290,9 +303,9 @@ Specify which bridge to stop: ai-devkit channel stop personal ``` -### Slack pairing expires +### Slack bridge is using another DM -Restart an unpaired bridge to generate a new ten-minute code. Codes are single-use and case-sensitive. Pairing is accepted only from a direct message in the configured workspace. +Each bridge process routes responses to the first DM it receives. Restart the bridge, then send a message from the desired DM to switch conversations. ### Slack app cannot connect @@ -301,10 +314,24 @@ Restart an unpaired bridge to generate a new ten-minute code. Codes are single-u - Confirm the installed bot token starts with `xoxb-` and has `chat:write` and `im:history`. - Confirm `message.im` is subscribed. Reinstall the app after changing scopes. - Re-run `channel connect slack --name ` after rotating either token. +- Add `--debug` to the connect command to identify whether app-token validation, bot-token validation, or configuration storage failed. Credential values are redacted from these logs. + +Slack's `auth.test` response for bot tokens always identifies the workspace and bot user, but may omit `app_id`. AI DevKit accepts that documented response and stores the app ID only when Slack provides it. + +### Slack bridge connects but does not respond to DMs + +If debug output repeatedly shows `poll skip: no active chat yet`, the bridge has not received a usable Slack DM: + +1. Under **Event Subscriptions → Subscribe to bot events**, add `message.im` and save the change. +2. Under **OAuth & Permissions → Bot Token Scopes**, add `im:history` and `chat:write`. +3. Under **App Home**, enable the **Messages Tab** and allow users to send messages. +4. Select **Install App → Reinstall to Workspace**. This step is mandatory after adding scopes; restarting the bridge alone is not enough. +5. Copy the current `xoxb-` token, run `channel connect slack --name ` again, and restart the bridge with `--debug`. +6. DM the app in its **Messages** tab. Channel messages and `@mentions` are not supported by this proof of concept. ### Optional Slack sandbox validation -Use a disposable workspace and agent. Connect and pair, exchange a short message, trigger a single-select agent question, send a response longer than 4,000 characters with fenced code, verify threaded continuation, interrupt the network to observe reconnect health, stop the bridge, disconnect the config, and revoke both tokens. Real Slack credentials are never required by the automated test suite. +Use a disposable workspace and agent because Slack user authorization is not enabled. Connect, start the bridge, send an immediate DM, exchange a short message, trigger a single-select agent question, send a response longer than 4,000 characters with fenced code, verify threaded continuation, interrupt the network to observe reconnect health, stop the bridge, disconnect the config, and revoke both tokens. Real Slack credentials are never required by the automated test suite. ### Messages not appearing in Telegram - Ensure you are the first user to message the bot (only the first user is authorized).