From 8d568e5eda1618c5c55d7e8174d46b08e35c06d3 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 6 Aug 2026 09:50:47 +0000 Subject: [PATCH] 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 `.