diff --git a/docs/ai/design/feature-channel-connector.md b/docs/ai/design/feature-channel-connector.md new file mode 100644 index 00000000..206e4340 --- /dev/null +++ b/docs/ai/design/feature-channel-connector.md @@ -0,0 +1,305 @@ +--- +phase: design +title: "Channel Connector: System Design" +description: Technical architecture for the channel-connector package as a generic messaging bridge +--- + +# System Design: Channel Connector + +## Architecture Overview + +The key architectural principle: **channel-connector is a pure message pipe**. It knows nothing about agents. CLI is the orchestrator that connects channel-connector with agent-manager. + +```mermaid +graph TD + subgraph Telegram + TG_USER[Developer on Phone] + TG_API[Telegram Bot API] + end + + subgraph "@ai-devkit/channel-connector" + CM[ChannelManager] + TA[TelegramAdapter] + CA[ChannelAdapter Interface] + CS[ConfigStore] + end + + subgraph "@ai-devkit/cli" + CMD[channel commands] + INPUT_HANDLER[Input Handler] + OUTPUT_LOOP[Output Polling Loop] + end + + subgraph "@ai-devkit/agent-manager" + AM[AgentManager] + TW[TtyWriter] + GC[getConversation] + end + + subgraph Running Agent + A1[Claude Code] + SF[Session JSONL File] + end + + TG_USER -->|send message| TG_API + TG_API -->|long polling| TA + TA -->|incoming msg| INPUT_HANDLER + INPUT_HANDLER -->|fire-and-forget| TW + TW -->|keyboard input| A1 + + A1 -->|writes output| SF + OUTPUT_LOOP -->|poll getConversation| GC + GC -->|read| SF + OUTPUT_LOOP -->|new assistant msgs| TA + TA -->|sendMessage| TG_API + TG_API -->|push| TG_USER + + CMD -->|configure| CS + CMD -->|create & start| CM +``` + +### Key Separation of Concerns + +| Layer | Package | Responsibility | +|-------|---------|---------------| +| **Channel** | `channel-connector` | Connect to messaging platforms, receive/send text. No agent knowledge. | +| **Orchestration** | `cli` | Wire channel-connector to agent-manager. Provide message handler. | +| **Agent** | `agent-manager` | Detect agents, send input (TtyWriter), read conversation history. | + +### Key Components (channel-connector only) + +| Component | Responsibility | +|-----------|---------------| +| `ChannelManager` | Registers adapters, manages lifecycle, routes messages to/from handler callback | +| `ChannelAdapter` | Interface for messaging platforms (Telegram, future: Slack, WhatsApp) | +| `TelegramAdapter` | Telegram Bot API integration via long polling | +| `ConfigStore` | Persists channel configurations (tokens, chat IDs, preferences) | + +### Technology Choices +- **Telegram library**: `telegraf` (mature, TypeScript-native, active maintenance) +- **Config storage**: JSON file at `~/.ai-devkit/channels.json` +- **Process model**: Foreground process via `ai-devkit channel start` + +## Data Models + +### ChannelConfig +```typescript +interface ChannelConfig { + channels: Record; +} + +interface ChannelEntry { + type: 'telegram' | 'slack' | 'whatsapp'; + enabled: boolean; + createdAt: string; + config: TelegramConfig; // extend with union for future channel types +} + +interface TelegramConfig { + botToken: string; + botUsername: string; + authorizedChatId?: number; // auto-set from first user to message the bot +} +``` + +### Message Types (generic, no agent concepts) +```typescript +interface IncomingMessage { + channelType: string; + chatId: string; + userId: string; + text: string; + timestamp: Date; + metadata?: Record; +} + +/** Handler function provided by the consumer (CLI). Fire-and-forget — returns void. */ +type MessageHandler = (message: IncomingMessage) => Promise; +``` + +### Authorization Model +- First user to message the bot is auto-authorized: their `chatId` is stored in config via `ConfigStore` +- Subsequent messages from other chat IDs are rejected +- CLI tracks the authorized `chatId` in-memory for the output polling loop (captured from first incoming message) + +## API Design + +### ChannelAdapter Interface +```typescript +interface ChannelAdapter { + readonly type: string; + + /** Start listening for messages */ + start(): Promise; + + /** Stop listening */ + stop(): Promise; + + /** Send a message to a specific chat. Automatically chunks messages exceeding platform limits (e.g., 4096 chars for Telegram), splitting at newline boundaries. */ + sendMessage(chatId: string, text: string): Promise; + + /** Register handler for incoming text messages (fire-and-forget, responses sent via sendMessage) */ + onMessage(handler: (msg: IncomingMessage) => Promise): void; + + /** Check if adapter is connected and healthy */ + isHealthy(): Promise; +} +``` + +### ChannelManager API +```typescript +class ChannelManager { + registerAdapter(adapter: ChannelAdapter): void; + getAdapter(type: string): ChannelAdapter | undefined; + startAll(): Promise; + stopAll(): Promise; +} +``` + +### ConfigStore API +```typescript +class ConfigStore { + constructor(configPath?: string); // defaults to ~/.ai-devkit/channels.json + + getConfig(): Promise; + saveChannel(name: string, entry: ChannelEntry): Promise; + removeChannel(name: string): Promise; + getChannel(name: string): Promise; +} +``` + +### CLI Integration Pattern +```typescript +// In CLI's `channel start --agent ` command +import { ChannelManager, TelegramAdapter, ConfigStore } from '@ai-devkit/channel-connector'; +import { AgentManager, ClaudeCodeAdapter, CodexAdapter, TtyWriter } from '@ai-devkit/agent-manager'; + +// 1. Set up agent-manager and resolve target agent by name +const agentManager = new AgentManager(); +agentManager.registerAdapter(new ClaudeCodeAdapter()); +agentManager.registerAdapter(new CodexAdapter()); +const agents = await agentManager.listAgents(); +const agent = agents.find(a => a.name === targetAgentName); + +// 2. Set up channel-connector +const config = await configStore.getChannel('telegram'); +const telegram = new TelegramAdapter({ botToken: config.botToken }); + +// 3. Track chatId from first incoming message +let activeChatId: string | null = null; + +// 4. Register message handler (fire-and-forget to agent, capture chatId) +telegram.onMessage(async (msg) => { + if (!activeChatId) { + activeChatId = msg.chatId; // auto-authorize first user + } + if (msg.chatId !== activeChatId) return; // reject unauthorized + await ttyWriter.write(agent.pid, msg.text); // send to agent, don't wait +}); + +// 5. Start agent output observation loop (polls and pushes to Telegram) +let lastMessageCount = 0; +const pollInterval = setInterval(() => { + if (!activeChatId) return; // no user connected yet + const conversation = agentAdapter.getConversation(agent.sessionFilePath); + const newMessages = conversation.slice(lastMessageCount) + .filter(m => m.role === 'assistant'); + for (const msg of newMessages) { + telegram.sendMessage(activeChatId, msg.content); // auto-chunks at 4096 chars + } + lastMessageCount = conversation.length; +}, 2000); + +// 5. Start channel +const manager = new ChannelManager(); +manager.registerAdapter(telegram); +await manager.startAll(); +``` + +### CLI Commands +``` +ai-devkit channel connect telegram # Interactive setup (bot token prompt) +ai-devkit channel list # Show configured channels +ai-devkit channel disconnect telegram # Remove channel config +ai-devkit channel start --agent # Start bridge to specific agent +ai-devkit channel stop # Stop running bridge +ai-devkit channel status # Show bridge process status +``` + +## Component Breakdown + +### Package: `@ai-devkit/channel-connector` +``` +packages/channel-connector/ + src/ + index.ts # Public exports + ChannelManager.ts # Adapter registry and lifecycle + ConfigStore.ts # Config persistence (~/.ai-devkit/) + adapters/ + ChannelAdapter.ts # Interface definition + TelegramAdapter.ts # Telegram Bot API implementation + types.ts # Shared type definitions + __tests__/ + ChannelManager.test.ts + ConfigStore.test.ts + adapters/ + TelegramAdapter.test.ts + package.json + tsconfig.json + tsconfig.build.json + jest.config.ts +``` + +### CLI Integration (in `@ai-devkit/cli`) +``` +packages/cli/src/commands/ + channel.ts # channel connect/list/disconnect/start/stop/status +``` + +## Design Decisions + +### 1. Channel-connector has zero agent knowledge +**Decision**: No dependency on agent-manager. The package is a pure messaging bridge. +**Why**: Clean separation of concerns. Channel-connector can be used independently of agents (e.g., for notifications, other integrations). CLI is the natural integration point since it already depends on both packages. + +### 2. Async fire-and-forget message handling +**Decision**: Consumer registers a `MessageHandler` callback that returns `void`. Responses are sent separately via `sendMessage()`. +**Why**: Agent responses are inherently async (can take seconds to minutes). Decoupling input and output avoids blocking. CLI runs a separate polling loop that observes agent conversation via `getConversation()` and pushes new assistant messages to the channel. This also naturally handles unsolicited agent output (errors, completions). + +### 3. One agent per session (v1) +**Decision**: `channel start --agent ` binds one channel to one agent. +**Why**: Simplest mental model. No agent-routing logic needed in channel-connector. Developer explicitly chooses which agent to bridge. Can evolve to multi-agent in CLI later without changing channel-connector. + +### 4. Adapter pattern (consistent with agent-manager) +**Decision**: Use pluggable adapter interface for channel implementations. +**Why**: Proven pattern in this codebase. Makes adding Slack/WhatsApp straightforward. + +### 5. Long polling for Telegram (not webhooks) +**Decision**: Use Telegram's long polling via telegraf. +**Why**: No need for a public server/URL. Works behind firewalls and NAT. Simpler for CLI tool users. + +### 6. Single config file at `~/.ai-devkit/` +**Decision**: Store channel configs globally, not per-project. +**Why**: A Telegram bot bridges to agents across projects. Global config avoids re-setup per project. + +## Non-Functional Requirements + +### Performance +- Message delivery latency: < 3 seconds (Telegram → handler → Telegram) +- Memory footprint: < 50MB for the bridge process + +### Reliability +- Auto-reconnect on network failure with exponential backoff +- Graceful shutdown on SIGINT/SIGTERM +- Message queue for offline/reconnecting scenarios (in-memory, bounded) + +### Security +- Bot token stored with file permissions 0600 +- Only authorized chat IDs can interact with the bot +- No sensitive data logged or exposed in error messages +- Token validation on connect before persisting + +### Scalability +- v1 targets single-user, single-machine use +- Adapter interface supports future multi-channel, multi-user scenarios +- Handler pattern allows CLI to evolve routing without channel-connector changes diff --git a/docs/ai/implementation/feature-channel-connector.md b/docs/ai/implementation/feature-channel-connector.md new file mode 100644 index 00000000..6d2d6310 --- /dev/null +++ b/docs/ai/implementation/feature-channel-connector.md @@ -0,0 +1,108 @@ +--- +phase: implementation +title: "Channel Connector: Implementation Guide" +description: Technical implementation details for the channel-connector package (pure messaging bridge) +--- + +# Implementation Guide: Channel Connector + +## Development Setup + +### Prerequisites +- Node.js >= 20.20.0 +- npm (workspace-aware) +- Telegram account + bot created via BotFather + +### Package Setup +```bash +# Package lives at packages/channel-connector/ +# Build: tsc (consistent with agent-manager) +# Test: jest with ts-jest +``` + +## Code Structure + +``` +packages/channel-connector/ + src/ + index.ts # Public API exports + ChannelManager.ts # Adapter registry + lifecycle + ConfigStore.ts # ~/.ai-devkit/channels.json persistence + adapters/ + ChannelAdapter.ts # Interface definition + TelegramAdapter.ts # Telegraf-based implementation + types.ts # Shared types (no agent concepts) + __tests__/ + ChannelManager.test.ts + ConfigStore.test.ts + adapters/ + TelegramAdapter.test.ts +``` + +## Implementation Notes + +### Core Features + +**ChannelAdapter interface**: Generic messaging contract. Methods: `start()`, `stop()`, `sendMessage()`, `onMessage()`, `isHealthy()`. No agent-specific methods. + +**ChannelManager**: Holds registered adapters, manages their lifecycle (startAll/stopAll). Simple registry pattern consistent with AgentManager. + +**TelegramAdapter**: Wraps `telegraf` library. Uses long polling. On incoming text message: calls registered `MessageHandler` (fire-and-forget, handler returns void). `sendMessage()` allows the consumer (CLI) to push agent responses and notifications proactively. + +**ConfigStore**: Simple JSON file read/write at `~/.ai-devkit/channels.json`. Creates directory if needed. Sets file permissions to 0600 for token security. + +### Patterns & Best Practices + +- Follow existing patterns from agent-manager (adapter registration, type exports) +- Use same tsconfig, jest config patterns as sibling packages +- Keep telegraf as the only external dependency for the Telegram adapter +- All async operations return Promises (no callbacks except MessageHandler) +- No agent-manager imports — channel-connector is a standalone package + +## Integration Points + +### Consumer Pattern (CLI wires both packages) +```typescript +// In CLI — this is the ONLY place where both packages meet +import { ChannelManager, TelegramAdapter, ConfigStore } from '@ai-devkit/channel-connector'; +import { AgentManager, ClaudeCodeAdapter, TtyWriter } from '@ai-devkit/agent-manager'; + +const telegram = new TelegramAdapter({ botToken }); + +// Input: fire-and-forget to agent +telegram.onMessage(async (msg) => { + await ttyWriter.write(agent.pid, msg.text); // no waiting +}); + +// Output: polling loop pushes agent responses to Telegram +let lastCount = 0; +setInterval(() => { + const msgs = adapter.getConversation(agent.sessionFilePath); + const newAssistant = msgs.slice(lastCount).filter(m => m.role === 'assistant'); + for (const m of newAssistant) { + telegram.sendMessage(chatId, m.content); + } + lastCount = msgs.length; +}, 2000); + +await telegram.start(); +``` + +### Key Principle +- Channel-connector exposes: `onMessage(handler)` + `sendMessage(chatId, text)` +- CLI provides: the input handler (fire-and-forget) + the output polling loop +- Input and output are fully decoupled — agent can take any amount of time to respond + +## Error Handling + +- Invalid bot token → validate on connect (ConfigStore), clear error message +- Network failure → telegraf auto-reconnect + exponential backoff in TelegramAdapter +- Handler throws → catch in adapter, send error message to user in Telegram +- Config file corruption → backup and recreate with warning + +## Security Notes + +- Bot tokens stored at `~/.ai-devkit/channels.json` with 0600 permissions +- Chat ID allowlist prevents unauthorized users from interacting +- No secrets logged or exposed in error messages +- Token validated against Telegram API before persisting diff --git a/docs/ai/planning/feature-channel-connector.md b/docs/ai/planning/feature-channel-connector.md new file mode 100644 index 00000000..adb9c205 --- /dev/null +++ b/docs/ai/planning/feature-channel-connector.md @@ -0,0 +1,105 @@ +--- +phase: planning +title: "Channel Connector: Planning & Task Breakdown" +description: Implementation plan for the channel-connector package +--- + +# Planning: Channel Connector + +## Milestones + +- [x] Milestone 1: Package scaffold and core abstractions +- [x] Milestone 2: Telegram adapter (send/receive messages via callback) +- [x] Milestone 3: CLI channel commands and agent bridge +- [ ] Milestone 4: End-to-end flow working + +## Task Breakdown + +### Phase 1: Package Foundation +- [x] Task 1.1: Scaffold `packages/channel-connector` package (package.json, tsconfig, jest config, nx project config) +- [x] Task 1.2: Define core types (`IncomingMessage`, `MessageHandler`, `ChannelConfig`, `ChannelEntry`, `TelegramConfig`) +- [x] Task 1.3: Implement `ChannelAdapter` interface +- [x] Task 1.4: Implement `ChannelManager` (adapter registration, lifecycle start/stop) +- [x] Task 1.5: Implement `ConfigStore` (read/write `~/.ai-devkit/channels.json`, file permissions) +- [x] Task 1.6: Add `index.ts` public exports + +### Phase 2: Telegram Adapter +- [x] Task 2.1: Add `telegraf` dependency, implement `TelegramAdapter` (connect, long polling, map telegraf context to `IncomingMessage`) +- [x] Task 2.2: Implement `onMessage` — call registered handler (fire-and-forget, void return) +- [x] Task 2.3: Implement `sendMessage` with message chunking — split at 4096 chars preferring newline boundaries +- [x] Task 2.4: Implement chat authorization (auto-authorize first user, reject others) +- [ ] Task 2.5: Implement auto-reconnect with exponential backoff (deferred — telegraf handles basic reconnect) +- [x] Task 2.6: Implement graceful shutdown (SIGINT/SIGTERM handling in CLI) + +### Phase 3: CLI Integration +- [x] Task 3.1: Create `channel connect telegram` command (interactive bot token setup, validation, persist via ConfigStore) +- [x] Task 3.2: Create `channel list` command (show configured channels with status) +- [x] Task 3.3: Create `channel disconnect telegram` command (remove config) +- [x] Task 3.4: Create `channel start --agent ` command — resolve agent by name, instantiate channel-connector, wire input handler and output loop +- [x] Task 3.5: Implement input handler in CLI — capture chatId from first message, fire-and-forget to agent via TtyWriter +- [x] Task 3.6: Implement output polling loop in CLI — poll `getConversation()` from agent-manager, detect new assistant messages, push to tracked chatId via `sendMessage()` +- [x] Task 3.7: Create `channel status` command +- [x] Task 3.8: Register channel commands in CLI entry point (`cli.ts`) + +### Phase 4: Integration & Polish +- [ ] Task 4.1: End-to-end testing — connect Telegram, send message, verify agent receives and responds +- [x] Task 4.2: Handle edge cases — unauthorized user rejection, invalid token validation, handler errors +- [x] Task 4.3: Update root workspace config (package.json workspaces — already included via packages/*) + +## Dependencies + +```mermaid +graph LR + T1_1[1.1 Scaffold] --> T1_2[1.2 Types] + T1_2 --> T1_3[1.3 Adapter Interface] + T1_2 --> T1_5[1.5 ConfigStore] + T1_3 --> T1_4[1.4 ChannelManager] + T1_3 --> T2_1[2.1 TelegramAdapter] + T1_4 --> T1_6[1.6 Exports] + T2_1 --> T2_2[2.2 onMessage] + T2_1 --> T2_3[2.3 sendMessage] + T2_1 --> T2_4[2.4 Auth] + T2_2 --> T2_5[2.5 Reconnect] + T2_5 --> T2_6[2.6 Shutdown] + T1_5 --> T3_1[3.1 connect cmd] + T3_1 --> T3_2[3.2 list cmd] + T3_1 --> T3_3[3.3 disconnect cmd] + T1_6 --> T3_4[3.4 start cmd] + T2_2 --> T3_5[3.5 Input handler] + T3_4 --> T3_5 + T3_5 --> T3_6[3.6 Output polling loop] + T3_6 --> T3_7[3.7 stop/status] + T3_7 --> T3_8[3.8 Register CLI] + T3_8 --> T4_1[4.1 E2E] + T4_1 --> T4_2[4.2 Edge Cases] + T4_2 --> T4_3[4.3 Workspace Config] +``` + +### External Dependencies +- `telegraf` — Telegram Bot API library (channel-connector package) +- `@ai-devkit/agent-manager` — agent discovery and terminal I/O (CLI only, NOT in channel-connector) +- `@ai-devkit/channel-connector` — messaging bridge (CLI dependency) + +## Timeline & Estimates + +| Phase | Tasks | Effort | +|-------|-------|--------| +| Phase 1: Foundation | 6 tasks | Small | +| Phase 2: Telegram | 6 tasks | Medium | +| Phase 3: CLI | 8 tasks | Medium | +| Phase 4: Polish | 3 tasks | Small | + +## Risks & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Agent response capture is unreliable (CLI responsibility) | Core feature broken | Evaluate agent-manager's `getConversation()` first; fallback to terminal monitoring | +| Telegraf API changes | Build breaks | Pin telegraf version, use stable API surface | +| TtyWriter limitations | Can't send to all agent types | Start with Claude Code (best supported), document limitations | +| Long-polling reliability | Missed messages | Implement reconnect with backoff in TelegramAdapter | + +## Resources Needed + +- **NPM package**: `telegraf` (Telegram Bot API) +- **Telegram Bot**: Created via BotFather (developer provides token) +- **Existing packages**: `@ai-devkit/agent-manager` (used by CLI, not by channel-connector) diff --git a/docs/ai/requirements/feature-channel-connector.md b/docs/ai/requirements/feature-channel-connector.md new file mode 100644 index 00000000..3db2ffe6 --- /dev/null +++ b/docs/ai/requirements/feature-channel-connector.md @@ -0,0 +1,110 @@ +--- +phase: requirements +title: "Channel Connector: Generic Messaging Bridge" +description: A generic package for connecting to messaging platforms (Telegram, Slack, WhatsApp) with callback-based message handling +--- + +# Requirements: Channel Connector + +## Problem Statement + +Developers using ai-devkit can only interact with their AI agents through the terminal. When away from their computer (commuting, in meetings, on mobile), they lose visibility into agent activity and cannot provide input. There is no mechanism to bridge the gap between a running agent and external messaging platforms. + +**Who is affected?** Developers who run long-lived agent sessions and need to monitor or interact with them remotely. + +**Current workaround:** Developers must return to their terminal or use remote desktop/SSH to check agent status and respond to prompts. + +## Goals & Objectives + +### Primary Goals +- Build a generic `@ai-devkit/channel-connector` package that provides a clean messaging abstraction for external platforms +- The package is a **pure message pipe** — receives messages, calls a handler, sends back responses. No knowledge of agents. +- Implement Telegram adapter as the first channel (via Bot API) +- CLI integrates channel-connector with agent-manager to bridge agents and messaging platforms + +### Secondary Goals +- Design the adapter interface to support future channels (Slack, WhatsApp) without breaking changes +- Keep the package independently useful beyond ai-devkit (generic messaging bridge) + +### Non-Goals (Out of Scope for v1) +- Slack and WhatsApp adapters (architecture supports them, but only Telegram is implemented) +- Rich media support (images, files, voice) — text-only for v1 +- Multi-user access control (single developer per bot) +- Web dashboard or custom UI +- End-to-end encryption beyond what Telegram provides natively +- Agent-specific logic inside channel-connector (no agent-manager dependency) + +## User Stories & Use Cases + +### US-1: Connect Telegram +> As a developer, I want to connect my Telegram bot to ai-devkit so that I can interact with my agents from my phone. + +**Flow:** `ai-devkit channel connect telegram` → prompts for bot token → validates token → stores config → confirms connection. + +### US-2: Start Channel Bridge with Agent +> As a developer, I want to start the channel bridge targeting a specific agent so that all Telegram messages are forwarded to that agent. + +**Flow:** `ai-devkit channel start --agent ` → resolves agent by name via agent-manager → starts Telegram bot → starts two concurrent loops: (1) incoming messages forwarded to agent via TtyWriter, (2) polling loop observes agent conversation and pushes new output to Telegram. + +### US-3: Chat with an Agent via Telegram +> As a developer, I want to send a message in Telegram and have it forwarded to my agent, then receive the agent's response back in Telegram. + +**Flow (async, non-blocking):** +1. Developer sends text in Telegram → channel-connector passes it to CLI-provided handler → handler sends to agent via TtyWriter (fire-and-forget, no waiting for response) +2. Separate polling loop in CLI: polls `getConversation()` from agent-manager → detects new assistant messages → calls `connector.sendMessage()` to push response to Telegram + +The two directions (input and output) are decoupled. This avoids blocking when agents take time to respond. + +### US-4: List Connected Channels +> As a developer, I want to run `ai-devkit channel list` to see all configured channels and their status. + +### US-5: Disconnect a Channel +> As a developer, I want to run `ai-devkit channel disconnect telegram` to remove a channel configuration. + +### US-6: Receive Agent Output +> As a developer, I want to see all agent output (responses, task completion, errors, prompts for input) in Telegram as they happen, without having to ask. + +**Flow:** CLI runs a continuous polling loop that reads the agent's conversation via `getConversation()` from agent-manager. When new assistant messages are detected (by tracking message count/timestamps), CLI calls `connector.sendMessage()` to push them to Telegram. This is the same loop that delivers responses for US-3 — all agent output flows through this single observation mechanism. + +### Edge Cases +- Agent terminates while user is chatting → CLI detects via agent-manager, sends notification through channel-connector +- Bot token is invalid or revoked → clear error message on connect and in Telegram +- Multiple Telegram users message the same bot → reject unauthorized users (only bot owner) +- Network interruption → reconnect with backoff, queue messages +- No agent specified at start → CLI shows error with available agents + +## Success Criteria + +1. Developer can set up Telegram connection in under 2 minutes via CLI +2. Messages round-trip (Telegram → agent → Telegram) in under 5 seconds on stable network +3. `@ai-devkit/channel-connector` has zero dependency on `@ai-devkit/agent-manager` +4. Package follows pluggable adapter pattern for extensibility +5. CLI commands (`channel connect/list/disconnect/start`) work consistently +6. Bot handles disconnections gracefully with automatic reconnection + +## Constraints & Assumptions + +### Constraints +- Must use Telegram Bot API (not Telegram client API / user accounts) +- `@ai-devkit/channel-connector` must NOT depend on `@ai-devkit/agent-manager` — all integration happens in CLI +- Must follow existing monorepo patterns (Nx, TypeScript, CommonJS, Jest) +- Package must be independently publishable as `@ai-devkit/channel-connector` + +### Assumptions +- Developer has a Telegram account and can create a bot via BotFather +- Developer runs ai-devkit on a machine with internet access +- CLI provides the message handler that bridges channel-connector to agent-manager +- One channel session connects to one agent (specified via `--agent ` flag, agent identified by `name` field from AgentInfo) + +## Resolved Decisions + +1. **Output capture**: CLI polls agent conversation via `getConversation(sessionFilePath)` from agent-manager. Tracks last seen message count/timestamp. Detects new assistant messages and pushes to channel via `sendMessage()`. No terminal monitoring needed. +2. **Config storage**: Global at `~/.ai-devkit/channels.json`. Channels are machine-wide, not per-project. +3. **Long-running process**: Foreground process via `ai-devkit channel start` for v1. Background daemon deferred. +4. **Rate limiting**: Skip for v1. Single-user use case. +5. **Agent identification**: By `name` field from `AgentInfo` (e.g., "ai-devkit"). PID is unique for disambiguation if needed. +6. **Message flow**: Fully async/non-blocking. Incoming messages fire-and-forget to agent. Separate polling loop observes agent output and pushes to channel. Handler signature is `Promise`, not `Promise`. + +## Open Items + +- None blocking for v1. diff --git a/docs/ai/testing/feature-channel-connector.md b/docs/ai/testing/feature-channel-connector.md new file mode 100644 index 00000000..8d269a9d --- /dev/null +++ b/docs/ai/testing/feature-channel-connector.md @@ -0,0 +1,92 @@ +--- +phase: testing +title: "Channel Connector: Testing Strategy" +description: Test plan for the channel-connector package (pure messaging bridge) +--- + +# Testing Strategy: Channel Connector + +## Test Coverage Goals + +- Unit test coverage target: 100% of new code +- Integration tests: Core message flow paths + error handling +- E2E tests: Manual verification of Telegram round-trip with agent + +## Coverage Results + +``` +---------------------|---------|----------|---------|---------| +File | % Stmts | % Branch | % Funcs | % Lines | +---------------------|---------|----------|---------|---------| +All files | 100 | 100 | 100 | 100 | + ChannelManager.ts | 100 | 100 | 100 | 100 | + ConfigStore.ts | 100 | 100 | 100 | 100 | + TelegramAdapter.ts | 100 | 100 | 100 | 100 | +---------------------|---------|----------|---------|---------| +``` + +**34 tests, all passing.** + +## Test Files + +- `packages/channel-connector/src/__tests__/ChannelManager.test.ts` (8 tests) +- `packages/channel-connector/src/__tests__/ConfigStore.test.ts` (12 tests) +- `packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts` (14 tests) + +## Unit Tests + +### ChannelManager (8 tests) +- [x] Register adapter and retrieve by type +- [x] startAll() calls start() on all registered adapters +- [x] stopAll() calls stop() on all registered adapters +- [x] Duplicate adapter type registration throws error +- [x] getAdapter() returns undefined for unregistered type +- [x] startAll() works with no adapters +- [x] stopAll() works with no adapters +- [x] Returns registered adapter by type + +### ConfigStore (12 tests) +- [x] Uses default path when no configPath provided +- [x] Write config creates file with correct permissions (0600) +- [x] Read config returns parsed JSON +- [x] Read missing config returns default empty config +- [x] Creates parent directory if missing +- [x] Handles corrupted JSON gracefully +- [x] saveChannel() adds entry +- [x] saveChannel() preserves existing channels +- [x] removeChannel() removes entry +- [x] removeChannel() handles non-existent channel +- [x] getChannel() returns entry +- [x] getChannel() returns undefined for non-existent channel + +### TelegramAdapter (14 tests) +- [x] Returns type "telegram" +- [x] Starts telegraf bot with correct token +- [x] Stops bot cleanly +- [x] Silently ignores messages when no handler registered +- [x] Maps telegraf message to IncomingMessage +- [x] Calls registered MessageHandler on incoming text (fire-and-forget) +- [x] Handles handler errors gracefully (Error instance) +- [x] Handles handler errors gracefully (non-Error thrown) +- [x] isHealthy() returns true after start +- [x] isHealthy() returns false before start +- [x] isHealthy() returns false after stop +- [x] sendMessage() sends text to specified chatId +- [x] sendMessage() chunks messages exceeding 4096 chars at newline boundaries +- [x] sendMessage() handles messages with no newlines (hard split at 4096) + +### CLI Channel Commands +CLI channel commands are integration-tested via manual E2E testing (requires running agent and Telegram bot). + +## Manual Testing + +- [ ] Create Telegram bot via BotFather +- [ ] Run `ai-devkit channel connect telegram` with token +- [ ] Run `ai-devkit channel list` — verify telegram shown +- [ ] Start an agent (e.g., Claude Code) +- [ ] Run `ai-devkit channel start --agent ` +- [ ] Send message in Telegram — verify agent receives input +- [ ] Verify agent response appears in Telegram +- [ ] Kill agent — verify error message in Telegram +- [ ] Test reconnection after network interruption +- [ ] Run `ai-devkit channel disconnect telegram` — verify removal diff --git a/package-lock.json b/package-lock.json index 27cacd73..4d7a518e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,10 @@ "resolved": "packages/agent-manager", "link": true }, + "node_modules/@ai-devkit/channel-connector": { + "resolved": "packages/channel-connector", + "link": true + }, "node_modules/@ai-devkit/memory": { "resolved": "packages/memory", "link": true @@ -4382,6 +4386,12 @@ "node": ">=14.16" } }, + "node_modules/@telegraf/types": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", + "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", + "license": "MIT" + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -5118,6 +5128,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5879,6 +5901,22 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "license": "MIT" + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -5889,6 +5927,12 @@ "node": "*" } }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "license": "MIT" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7043,6 +7087,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -10413,6 +10466,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10466,6 +10528,26 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10843,6 +10925,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-timeout": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", + "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -11647,12 +11738,30 @@ ], "license": "MIT" }, + "node_modules/safe-compare": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", + "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==", + "license": "MIT", + "dependencies": { + "buffer-alloc": "^1.2.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sandwich-stream": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", + "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -12212,6 +12321,28 @@ "node": ">=6" } }, + "node_modules/telegraf": { + "version": "4.16.3", + "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz", + "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==", + "license": "MIT", + "dependencies": { + "@telegraf/types": "^7.1.0", + "abort-controller": "^3.0.0", + "debug": "^4.3.4", + "mri": "^1.2.0", + "node-fetch": "^2.7.0", + "p-timeout": "^4.1.0", + "safe-compare": "^1.1.4", + "sandwich-stream": "^2.0.2" + }, + "bin": { + "telegraf": "lib/cli.mjs" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -12380,6 +12511,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -12848,6 +12985,22 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13048,12 +13201,34 @@ "node": ">=20.20.0" } }, + "packages/channel-connector": { + "name": "@ai-devkit/channel-connector", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "telegraf": "^4.16.3" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.4.5", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20.20.0" + } + }, "packages/cli": { "name": "ai-devkit", "version": "0.21.1", "license": "MIT", "dependencies": { "@ai-devkit/agent-manager": "0.7.0", + "@ai-devkit/channel-connector": "0.1.0", "@ai-devkit/memory": "0.8.0", "chalk": "^4.1.2", "commander": "^11.1.0", diff --git a/packages/channel-connector/.eslintrc.json b/packages/channel-connector/.eslintrc.json new file mode 100644 index 00000000..ddc47aaf --- /dev/null +++ b/packages/channel-connector/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "plugins": ["@typescript-eslint"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "node": true, + "es6": true, + "jest": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-var-requires": "error" + }, + "overrides": [ + { + "files": ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-var-requires": "off" + } + } + ] +} diff --git a/packages/channel-connector/jest.config.js b/packages/channel-connector/jest.config.js new file mode 100644 index 00000000..6e5e0508 --- /dev/null +++ b/packages/channel-connector/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'], + collectCoverageFrom: [ + 'src/**/*.{ts,js}', + '!src/**/*.d.ts', + '!src/index.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } +}; diff --git a/packages/channel-connector/package.json b/packages/channel-connector/package.json new file mode 100644 index 00000000..54ec5dca --- /dev/null +++ b/packages/channel-connector/package.json @@ -0,0 +1,46 @@ +{ + "name": "@ai-devkit/channel-connector", + "version": "0.1.0", + "description": "Generic messaging bridge for connecting to external communication platforms", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "test": "jest", + "test:coverage": "jest --coverage", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "keywords": [ + "ai", + "channel", + "connector", + "telegram", + "messaging" + ], + "author": "", + "license": "MIT", + "dependencies": { + "telegraf": "^4.16.3" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^20.11.5", + "@typescript-eslint/eslint-plugin": "^6.19.1", + "@typescript-eslint/parser": "^6.19.1", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.4.5", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20.20.0" + } +} diff --git a/packages/channel-connector/src/ChannelManager.ts b/packages/channel-connector/src/ChannelManager.ts new file mode 100644 index 00000000..4667e68d --- /dev/null +++ b/packages/channel-connector/src/ChannelManager.ts @@ -0,0 +1,43 @@ +import type { ChannelAdapter } from './adapters/ChannelAdapter'; + +/** + * Central registry for channel adapters. + * Manages adapter lifecycle (start/stop). + */ +export class ChannelManager { + private adapters: Map = new Map(); + + /** + * Register a channel adapter. + * @throws If an adapter for the same type is already registered. + */ + registerAdapter(adapter: ChannelAdapter): void { + if (this.adapters.has(adapter.type)) { + throw new Error(`Adapter for type "${adapter.type}" is already registered`); + } + this.adapters.set(adapter.type, adapter); + } + + /** + * Get a registered adapter by type. + */ + getAdapter(type: string): ChannelAdapter | undefined { + return this.adapters.get(type); + } + + /** + * Start all registered adapters. + */ + async startAll(): Promise { + const startPromises = Array.from(this.adapters.values()).map(a => a.start()); + await Promise.all(startPromises); + } + + /** + * Stop all registered adapters. + */ + async stopAll(): Promise { + const stopPromises = Array.from(this.adapters.values()).map(a => a.stop()); + await Promise.all(stopPromises); + } +} diff --git a/packages/channel-connector/src/ConfigStore.ts b/packages/channel-connector/src/ConfigStore.ts new file mode 100644 index 00000000..f79a7afe --- /dev/null +++ b/packages/channel-connector/src/ConfigStore.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import type { ChannelConfig, ChannelEntry } from './types'; + +const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.ai-devkit', 'channels.json'); +const DEFAULT_CONFIG: ChannelConfig = { channels: {} }; + +/** + * Persists channel configurations to disk. + * Default location: ~/.ai-devkit/channels.json + * File permissions are set to 0600 to protect tokens. + */ +export class ConfigStore { + private configPath: string; + + constructor(configPath?: string) { + this.configPath = configPath ?? DEFAULT_CONFIG_PATH; + } + + /** + * Read the full config. Returns default empty config if file is missing or corrupt. + */ + async getConfig(): Promise { + try { + const raw = fs.readFileSync(this.configPath, 'utf-8'); + return JSON.parse(raw) as ChannelConfig; + } catch { + return { ...DEFAULT_CONFIG, channels: {} }; + } + } + + /** + * Save a channel entry. Creates the file and parent directory if needed. + */ + async saveChannel(name: string, entry: ChannelEntry): Promise { + const config = await this.getConfig(); + config.channels[name] = entry; + await this.writeConfig(config); + } + + /** + * Remove a channel entry by name. + */ + async removeChannel(name: string): Promise { + const config = await this.getConfig(); + delete config.channels[name]; + await this.writeConfig(config); + } + + /** + * Get a single channel entry by name. + */ + async getChannel(name: string): Promise { + const config = await this.getConfig(); + return config.channels[name]; + } + + private async writeConfig(config: ChannelConfig): Promise { + const dir = path.dirname(this.configPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), { mode: 0o600 }); + } +} diff --git a/packages/channel-connector/src/__tests__/ChannelManager.test.ts b/packages/channel-connector/src/__tests__/ChannelManager.test.ts new file mode 100644 index 00000000..abd6bac9 --- /dev/null +++ b/packages/channel-connector/src/__tests__/ChannelManager.test.ts @@ -0,0 +1,86 @@ +import { ChannelManager } from '../ChannelManager'; +import type { ChannelAdapter } from '../adapters/ChannelAdapter'; + +function createMockAdapter(type: string): jest.Mocked { + return { + type, + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + sendMessage: jest.fn().mockResolvedValue(undefined), + onMessage: jest.fn(), + isHealthy: jest.fn().mockResolvedValue(true), + }; +} + +describe('ChannelManager', () => { + let manager: ChannelManager; + + beforeEach(() => { + manager = new ChannelManager(); + }); + + describe('registerAdapter', () => { + it('should register an adapter', () => { + const adapter = createMockAdapter('telegram'); + manager.registerAdapter(adapter); + expect(manager.getAdapter('telegram')).toBe(adapter); + }); + + it('should throw on duplicate adapter type', () => { + const adapter1 = createMockAdapter('telegram'); + const adapter2 = createMockAdapter('telegram'); + manager.registerAdapter(adapter1); + expect(() => manager.registerAdapter(adapter2)).toThrow( + 'Adapter for type "telegram" is already registered' + ); + }); + }); + + describe('getAdapter', () => { + it('should return undefined for unregistered type', () => { + expect(manager.getAdapter('slack')).toBeUndefined(); + }); + + it('should return the registered adapter', () => { + const adapter = createMockAdapter('telegram'); + manager.registerAdapter(adapter); + expect(manager.getAdapter('telegram')).toBe(adapter); + }); + }); + + describe('startAll', () => { + it('should call start() on all registered adapters', async () => { + const telegram = createMockAdapter('telegram'); + const slack = createMockAdapter('slack'); + manager.registerAdapter(telegram); + manager.registerAdapter(slack); + + await manager.startAll(); + + expect(telegram.start).toHaveBeenCalledTimes(1); + expect(slack.start).toHaveBeenCalledTimes(1); + }); + + it('should work with no adapters', async () => { + await expect(manager.startAll()).resolves.toBeUndefined(); + }); + }); + + describe('stopAll', () => { + it('should call stop() on all registered adapters', async () => { + const telegram = createMockAdapter('telegram'); + const slack = createMockAdapter('slack'); + manager.registerAdapter(telegram); + manager.registerAdapter(slack); + + await manager.stopAll(); + + expect(telegram.stop).toHaveBeenCalledTimes(1); + expect(slack.stop).toHaveBeenCalledTimes(1); + }); + + it('should work with no adapters', async () => { + await expect(manager.stopAll()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/channel-connector/src/__tests__/ConfigStore.test.ts b/packages/channel-connector/src/__tests__/ConfigStore.test.ts new file mode 100644 index 00000000..744408e6 --- /dev/null +++ b/packages/channel-connector/src/__tests__/ConfigStore.test.ts @@ -0,0 +1,126 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ConfigStore } from '../ConfigStore'; +import type { ChannelEntry } from '../types'; + +describe('ConfigStore', () => { + let tmpDir: string; + let configPath: string; + let store: ConfigStore; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-connector-test-')); + configPath = path.join(tmpDir, 'channels.json'); + store = new ConfigStore(configPath); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const sampleEntry: ChannelEntry = { + type: 'telegram', + enabled: true, + createdAt: '2026-04-11T00:00:00Z', + config: { + botToken: 'test-token-123', + botUsername: 'test_bot', + }, + }; + + describe('constructor', () => { + it('should use default path when no configPath provided', async () => { + const defaultStore = new ConfigStore(); + // Should not throw — just uses default path + const config = await defaultStore.getConfig(); + expect(config).toBeDefined(); + }); + }); + + describe('getConfig', () => { + it('should return default empty config when file does not exist', async () => { + const config = await store.getConfig(); + expect(config).toEqual({ channels: {} }); + }); + + it('should return parsed config when file exists', async () => { + fs.writeFileSync(configPath, JSON.stringify({ + channels: { telegram: sampleEntry } + })); + + const config = await store.getConfig(); + expect(config.channels.telegram).toEqual(sampleEntry); + }); + + it('should handle corrupted JSON gracefully', async () => { + fs.writeFileSync(configPath, 'not valid json{{{'); + + const config = await store.getConfig(); + expect(config).toEqual({ channels: {} }); + }); + }); + + describe('saveChannel', () => { + it('should create config file with channel entry', async () => { + await store.saveChannel('telegram', sampleEntry); + + const raw = fs.readFileSync(configPath, 'utf-8'); + const config = JSON.parse(raw); + expect(config.channels.telegram).toEqual(sampleEntry); + }); + + it('should create parent directory if missing', async () => { + const nestedPath = path.join(tmpDir, 'nested', 'dir', 'channels.json'); + const nestedStore = new ConfigStore(nestedPath); + + await nestedStore.saveChannel('telegram', sampleEntry); + + expect(fs.existsSync(nestedPath)).toBe(true); + }); + + it('should set file permissions to 0600', async () => { + await store.saveChannel('telegram', sampleEntry); + + const stats = fs.statSync(configPath); + const mode = (stats.mode & 0o777).toString(8); + expect(mode).toBe('600'); + }); + + it('should preserve existing channels when adding a new one', async () => { + await store.saveChannel('telegram', sampleEntry); + await store.saveChannel('slack', { ...sampleEntry, type: 'slack' }); + + const config = await store.getConfig(); + expect(Object.keys(config.channels)).toEqual(['telegram', 'slack']); + }); + }); + + describe('removeChannel', () => { + it('should remove a channel entry', async () => { + await store.saveChannel('telegram', sampleEntry); + await store.removeChannel('telegram'); + + const config = await store.getConfig(); + expect(config.channels.telegram).toBeUndefined(); + }); + + it('should not throw when removing non-existent channel', async () => { + await expect(store.removeChannel('nonexistent')).resolves.toBeUndefined(); + }); + }); + + describe('getChannel', () => { + it('should return the channel entry', async () => { + await store.saveChannel('telegram', sampleEntry); + + const entry = await store.getChannel('telegram'); + expect(entry).toEqual(sampleEntry); + }); + + it('should return undefined for non-existent channel', async () => { + const entry = await store.getChannel('slack'); + expect(entry).toBeUndefined(); + }); + }); +}); diff --git a/packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts b/packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts new file mode 100644 index 00000000..90cfd059 --- /dev/null +++ b/packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts @@ -0,0 +1,192 @@ +import { TelegramAdapter } from '../../adapters/TelegramAdapter'; +import type { IncomingMessage } from '../../types'; + +// Mock telegraf +jest.mock('telegraf', () => { + const handlers: Record any> = {}; + const mockBot = { + launch: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + on: jest.fn((event: string, handler: (...args: any[]) => any) => { + handlers[event] = handler; + }), + telegram: { + sendMessage: jest.fn().mockResolvedValue(undefined), + getMe: jest.fn().mockResolvedValue({ username: 'test_bot' }), + }, + _handlers: handlers, + _triggerText: async (chatId: number, userId: number, text: string) => { + const ctx = { + message: { + chat: { id: chatId }, + from: { id: userId }, + text, + date: Math.floor(Date.now() / 1000), + }, + reply: jest.fn().mockResolvedValue(undefined), + }; + if (handlers['text']) { + await handlers['text'](ctx); + } + return ctx; + }, + }; + return { + Telegraf: jest.fn(() => mockBot), + __mockBot: mockBot, + }; +}); + +function getMockBot() { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('telegraf').__mockBot; +} + +describe('TelegramAdapter', () => { + let adapter: TelegramAdapter; + + beforeEach(() => { + jest.clearAllMocks(); + adapter = new TelegramAdapter({ botToken: 'test-token-123' }); + }); + + describe('type', () => { + it('should return "telegram"', () => { + expect(adapter.type).toBe('telegram'); + }); + }); + + describe('start', () => { + it('should launch the telegraf bot', async () => { + const bot = getMockBot(); + await adapter.start(); + expect(bot.launch).toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + it('should stop the telegraf bot', async () => { + const bot = getMockBot(); + await adapter.start(); + await adapter.stop(); + expect(bot.stop).toHaveBeenCalled(); + }); + }); + + describe('onMessage', () => { + it('should silently ignore messages when no handler is registered', async () => { + // Don't register a handler + await adapter.start(); + + const bot = getMockBot(); + const ctx = await bot._triggerText(12345, 67890, 'hello'); + + // Should not throw or reply + expect(ctx.reply).not.toHaveBeenCalled(); + }); + + it('should handle non-Error thrown by handler', async () => { + const handler = jest.fn().mockRejectedValue('string error'); + adapter.onMessage(handler); + await adapter.start(); + + const bot = getMockBot(); + const ctx = await bot._triggerText(12345, 67890, 'hello'); + + expect(ctx.reply).toHaveBeenCalledWith( + 'Error processing message: Unknown error' + ); + }); + + it('should call handler with IncomingMessage on incoming text', async () => { + const handler = jest.fn().mockResolvedValue(undefined); + adapter.onMessage(handler); + await adapter.start(); + + const bot = getMockBot(); + await bot._triggerText(12345, 67890, 'hello agent'); + + expect(handler).toHaveBeenCalledTimes(1); + const msg: IncomingMessage = handler.mock.calls[0][0]; + expect(msg.channelType).toBe('telegram'); + expect(msg.chatId).toBe('12345'); + expect(msg.userId).toBe('67890'); + expect(msg.text).toBe('hello agent'); + expect(msg.timestamp).toBeInstanceOf(Date); + }); + + it('should handle handler errors gracefully', async () => { + const handler = jest.fn().mockRejectedValue(new Error('handler failed')); + adapter.onMessage(handler); + await adapter.start(); + + const bot = getMockBot(); + const ctx = await bot._triggerText(12345, 67890, 'hello'); + + // Should not throw, and should reply with error + expect(ctx.reply).toHaveBeenCalledWith( + expect.stringContaining('Error processing message') + ); + }); + }); + + describe('sendMessage', () => { + it('should send text to the specified chat', async () => { + const bot = getMockBot(); + await adapter.sendMessage('12345', 'hello from bot'); + + expect(bot.telegram.sendMessage).toHaveBeenCalledWith('12345', 'hello from bot'); + }); + + it('should chunk messages exceeding 4096 chars at newline boundaries', async () => { + const bot = getMockBot(); + // Create a message with lines that total > 4096 chars + const line = 'A'.repeat(100) + '\n'; + const longMessage = line.repeat(50); // 50 * 101 = 5050 chars + + await adapter.sendMessage('12345', longMessage); + + // Should have been called multiple times (chunked) + expect(bot.telegram.sendMessage.mock.calls.length).toBeGreaterThan(1); + // Each chunk should be <= 4096 chars + for (const call of bot.telegram.sendMessage.mock.calls) { + expect(call[1].length).toBeLessThanOrEqual(4096); + } + }); + + it('should hard split at 4096 when no newlines available', async () => { + const bot = getMockBot(); + const longMessage = 'A'.repeat(5000); + + await adapter.sendMessage('12345', longMessage); + + expect(bot.telegram.sendMessage.mock.calls.length).toBe(2); + expect(bot.telegram.sendMessage.mock.calls[0][1].length).toBe(4096); + expect(bot.telegram.sendMessage.mock.calls[1][1].length).toBe(904); + }); + + it('should send short messages in a single call', async () => { + const bot = getMockBot(); + await adapter.sendMessage('12345', 'short message'); + + expect(bot.telegram.sendMessage).toHaveBeenCalledTimes(1); + }); + }); + + describe('isHealthy', () => { + it('should return true after start', async () => { + await adapter.start(); + expect(await adapter.isHealthy()).toBe(true); + }); + + it('should return false before start', async () => { + expect(await adapter.isHealthy()).toBe(false); + }); + + it('should return false after stop', async () => { + await adapter.start(); + await adapter.stop(); + expect(await adapter.isHealthy()).toBe(false); + }); + }); +}); diff --git a/packages/channel-connector/src/adapters/ChannelAdapter.ts b/packages/channel-connector/src/adapters/ChannelAdapter.ts new file mode 100644 index 00000000..a19a3351 --- /dev/null +++ b/packages/channel-connector/src/adapters/ChannelAdapter.ts @@ -0,0 +1,35 @@ +import type { IncomingMessage } from '../types'; + +/** + * Interface for messaging platform adapters. + * + * Implementations connect to a specific platform (Telegram, Slack, etc.) + * and provide a generic send/receive abstraction. + */ +export interface ChannelAdapter { + /** Identifier for this channel type (e.g., 'telegram') */ + readonly type: string; + + /** Start listening for incoming messages */ + start(): Promise; + + /** Stop listening and clean up resources */ + stop(): Promise; + + /** + * Send a message to a specific chat. + * Implementations should handle platform-specific limits + * (e.g., chunking at 4096 chars for Telegram). + */ + sendMessage(chatId: string, text: string): Promise; + + /** + * Register a handler for incoming text messages. + * Fire-and-forget — handler returns void. + * Responses are sent separately via sendMessage(). + */ + onMessage(handler: (msg: IncomingMessage) => Promise): void; + + /** Check if the adapter is connected and healthy */ + isHealthy(): Promise; +} diff --git a/packages/channel-connector/src/adapters/TelegramAdapter.ts b/packages/channel-connector/src/adapters/TelegramAdapter.ts new file mode 100644 index 00000000..9ad3e391 --- /dev/null +++ b/packages/channel-connector/src/adapters/TelegramAdapter.ts @@ -0,0 +1,110 @@ +import { Telegraf } from 'telegraf'; +import type { ChannelAdapter } from './ChannelAdapter'; +import type { IncomingMessage } from '../types'; + +export const TELEGRAM_CHANNEL_TYPE = 'telegram'; +export const TELEGRAM_MAX_MESSAGE_LENGTH = 4096; + +export interface TelegramAdapterOptions { + botToken: string; +} + +/** + * Telegram Bot API adapter using telegraf with long polling. + */ +export class TelegramAdapter implements ChannelAdapter { + readonly type = TELEGRAM_CHANNEL_TYPE; + + private bot: Telegraf; + private messageHandler: ((msg: IncomingMessage) => Promise) | null = null; + private running = false; + + constructor(options: TelegramAdapterOptions) { + this.bot = new Telegraf(options.botToken); + } + + async start(): Promise { + this.bot.on('text', async (ctx) => { + if (!this.messageHandler) return; + + const msg: IncomingMessage = { + channelType: TELEGRAM_CHANNEL_TYPE, + chatId: String(ctx.message.chat.id), + userId: String(ctx.message.from.id), + text: ctx.message.text, + timestamp: new Date(ctx.message.date * 1000), + }; + + try { + await this.messageHandler(msg); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + await ctx.reply(`Error processing message: ${errorMessage}`); + } + }); + + await this.bot.launch(); + this.running = true; + } + + async stop(): Promise { + this.running = false; + await this.bot.stop(); + } + + /** + * Send a message to a chat. Automatically chunks messages exceeding + * Telegram's 4096-char limit, preferring newline boundaries. + */ + async sendMessage(chatId: string, text: string): Promise { + const chunks = chunkMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH); + for (const chunk of chunks) { + await this.bot.telegram.sendMessage(chatId, chunk); + } + } + + onMessage(handler: (msg: IncomingMessage) => Promise): void { + this.messageHandler = handler; + } + + async isHealthy(): Promise { + return this.running; + } +} + +/** + * Split text into chunks of maxLen or fewer characters, + * preferring to split at newline boundaries. + */ +function chunkMessage(text: string, maxLen: number): string[] { + if (text.length <= maxLen) { + return [text]; + } + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > 0) { + if (remaining.length <= maxLen) { + chunks.push(remaining); + break; + } + + // Find the last newline within the limit + const searchArea = remaining.slice(0, maxLen); + const lastNewline = searchArea.lastIndexOf('\n'); + + let splitAt: number; + if (lastNewline > 0) { + splitAt = lastNewline + 1; // include the newline in the current chunk + } else { + // No newline found — hard split at maxLen + splitAt = maxLen; + } + + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt); + } + + return chunks; +} diff --git a/packages/channel-connector/src/index.ts b/packages/channel-connector/src/index.ts new file mode 100644 index 00000000..489feb20 --- /dev/null +++ b/packages/channel-connector/src/index.ts @@ -0,0 +1,15 @@ +export { ChannelManager } from './ChannelManager'; +export { ConfigStore } from './ConfigStore'; +export { TelegramAdapter, TELEGRAM_CHANNEL_TYPE, TELEGRAM_MAX_MESSAGE_LENGTH } from './adapters/TelegramAdapter'; +export type { TelegramAdapterOptions } from './adapters/TelegramAdapter'; + +export type { ChannelAdapter } from './adapters/ChannelAdapter'; + +export type { + IncomingMessage, + MessageHandler, + ChannelConfig, + ChannelEntry, + ChannelType, + TelegramConfig, +} from './types'; diff --git a/packages/channel-connector/src/types.ts b/packages/channel-connector/src/types.ts new file mode 100644 index 00000000..c05e5e79 --- /dev/null +++ b/packages/channel-connector/src/types.ts @@ -0,0 +1,49 @@ +/** + * An incoming message from a messaging platform. + * Generic — no agent-specific concepts. + */ +export interface IncomingMessage { + channelType: string; + chatId: string; + userId: string; + text: string; + timestamp: Date; + metadata?: Record; +} + +/** + * Handler function provided by the consumer (e.g., CLI). + * Fire-and-forget — returns void. Responses are sent separately via sendMessage(). + */ +export type MessageHandler = (message: IncomingMessage) => Promise; + +/** + * Root configuration for all channels. + */ +export interface ChannelConfig { + channels: Record; +} + +/** + * Configuration entry for a single channel. + */ +export interface ChannelEntry { + type: ChannelType; + enabled: boolean; + createdAt: string; + config: TelegramConfig; +} + +/** + * Supported channel types. + */ +export type ChannelType = 'telegram' | 'slack' | 'whatsapp'; + +/** + * Telegram-specific configuration. + */ +export interface TelegramConfig { + botToken: string; + botUsername: string; + authorizedChatId?: number; +} diff --git a/packages/channel-connector/tsconfig.json b/packages/channel-connector/tsconfig.json new file mode 100644 index 00000000..9f3762b6 --- /dev/null +++ b/packages/channel-connector/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "src/__tests__" + ] +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 9df15409..f50a3635 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -28,6 +28,7 @@ "license": "MIT", "dependencies": { "@ai-devkit/agent-manager": "0.7.0", + "@ai-devkit/channel-connector": "0.1.0", "@ai-devkit/memory": "0.8.0", "chalk": "^4.1.2", "commander": "^11.1.0", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6ded0e2a..16841678 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { installCommand } from './commands/install'; import { registerMemoryCommand } from './commands/memory'; import { registerSkillCommand } from './commands/skill'; import { registerAgentCommand } from './commands/agent'; +import { registerChannelCommand } from './commands/channel'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require('../package.json') as { version: string }; @@ -57,5 +58,6 @@ program registerMemoryCommand(program); registerSkillCommand(program); registerAgentCommand(program); +registerChannelCommand(program); program.parse(); diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts new file mode 100644 index 00000000..e9929b53 --- /dev/null +++ b/packages/cli/src/commands/channel.ts @@ -0,0 +1,380 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { + AgentManager, + ClaudeCodeAdapter, + CodexAdapter, + TerminalFocusManager, + TtyWriter, + type AgentAdapter, + type AgentInfo, + type TerminalLocation, +} from '@ai-devkit/agent-manager'; +import { Telegraf } from 'telegraf'; +import { + ChannelManager, + TelegramAdapter, + TELEGRAM_CHANNEL_TYPE, + ConfigStore, + type ChannelEntry, + type TelegramConfig, +} from '@ai-devkit/channel-connector'; +import { ui } from '../util/terminal-ui'; + +const AGENT_POLL_INTERVAL_MS = 2000; + +function createAgentManager(): AgentManager { + const manager = new AgentManager(); + manager.registerAdapter(new ClaudeCodeAdapter()); + manager.registerAdapter(new CodexAdapter()); + return manager; +} + +function getAgentAdapter(agentType: string): AgentAdapter | null { + const adapters: Record = { + claude: new ClaudeCodeAdapter(), + codex: new CodexAdapter(), + }; + return adapters[agentType] ?? null; +} + +async function resolveTargetAgent(agentManager: AgentManager, agentName: string): Promise { + const agents = await agentManager.listAgents(); + + if (agents.length === 0) { + ui.error('No running agents detected.'); + return null; + } + + const resolved = agentManager.resolveAgent(agentName, agents); + if (!resolved) { + ui.error(`No agent found matching "${agentName}".`); + ui.info('Available agents:'); + agents.forEach(a => console.log(` - ${a.name}`)); + return null; + } + + if (Array.isArray(resolved)) { + const { selectedAgent } = await inquirer.prompt([{ + type: 'list', + name: 'selectedAgent', + message: 'Multiple agents match. Select one:', + choices: resolved.map(a => ({ + name: `${a.name} (PID: ${a.pid})`, + value: a, + })), + }]); + return selectedAgent; + } + + return resolved as AgentInfo; +} + +function setupInputHandler( + telegram: TelegramAdapter, + terminalLocation: TerminalLocation, + chatIdRef: { value: string | null }, +): void { + telegram.onMessage(async (msg) => { + if (!chatIdRef.value) { + chatIdRef.value = msg.chatId; + ui.info(`Authorized Telegram user (chat ID: ${msg.chatId})`); + } + + if (msg.chatId !== chatIdRef.value) { + await telegram.sendMessage(msg.chatId, 'Unauthorized. Only the first user is allowed.'); + return; + } + + try { + await TtyWriter.send(terminalLocation, msg.text); + } catch (error: any) { + ui.error(`Failed to send to agent: ${error.message}`); + await telegram.sendMessage(msg.chatId, `Failed to send to agent: ${error.message}`); + } + }); +} + +function startOutputPolling( + telegram: TelegramAdapter, + agentAdapter: AgentAdapter, + agent: AgentInfo, + chatIdRef: { value: string | null }, +): NodeJS.Timeout { + let lastMessageCount = 0; + + // Initialize with current conversation length to avoid sending history + if (agent.sessionFilePath) { + try { + const existing = agentAdapter.getConversation(agent.sessionFilePath); + lastMessageCount = existing.length; + } catch { + // Session file might not exist yet + } + } + + return setInterval(async () => { + if (!chatIdRef.value || !agent.sessionFilePath) return; + + try { + const conversation = agentAdapter.getConversation(agent.sessionFilePath); + const newMessages = conversation.slice(lastMessageCount); + lastMessageCount = conversation.length; + + for (const msg of newMessages) { + if (msg.role !== 'user' && msg.content) { + await telegram.sendMessage(chatIdRef.value, msg.content); + } + } + } catch { + // Agent may have terminated — check later + } + }, AGENT_POLL_INTERVAL_MS); +} + +function setupGracefulShutdown(manager: ChannelManager, pollInterval: NodeJS.Timeout): void { + const shutdown = async () => { + ui.info('\nShutting down...'); + clearInterval(pollInterval); + await manager.stopAll(); + ui.success('Channel bridge stopped.'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +export function registerChannelCommand(program: Command): void { + const channelCommand = program + .command('channel') + .description('Connect agents with messaging channels'); + + channelCommand + .command('connect ') + .description('Connect a messaging channel (e.g., telegram)') + .action(async (type: string) => { + try { + if (type !== TELEGRAM_CHANNEL_TYPE) { + ui.error(`Unsupported channel type: ${type}. Supported: ${TELEGRAM_CHANNEL_TYPE}`); + return; + } + + const configStore = new ConfigStore(); + const existing = await configStore.getChannel(TELEGRAM_CHANNEL_TYPE); + if (existing) { + const { overwrite } = await inquirer.prompt([{ + type: 'confirm', + name: 'overwrite', + message: 'Telegram is already configured. Overwrite?', + default: false, + }]); + if (!overwrite) 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'); + + const { botToken } = await inquirer.prompt([{ + type: 'password', + name: 'botToken', + message: 'Enter your Telegram bot token:', + validate: (input: string) => { + if (!input.trim()) return 'Bot token is required'; + if (!input.includes(':')) return 'Invalid token format (expected number:hash)'; + return true; + }, + }]); + + // Validate token by calling getMe + const spinner = ui.spinner('Validating bot token...'); + spinner.start(); + + let botUsername: string; + try { + const bot = new Telegraf(botToken.trim()); + const me = await bot.telegram.getMe(); + botUsername = me.username; + spinner.succeed(`Connected to bot @${botUsername}`); + } catch (error: any) { + spinner.fail('Invalid bot token. Please check and try again.'); + return; + } + + const entry: ChannelEntry = { + type: TELEGRAM_CHANNEL_TYPE, + enabled: true, + createdAt: new Date().toISOString(), + config: { + botToken: botToken.trim(), + botUsername, + } as TelegramConfig, + }; + + await configStore.saveChannel(TELEGRAM_CHANNEL_TYPE, entry); + ui.success('Telegram channel configured successfully!'); + ui.info(`Bot: @${botUsername}`); + ui.info('Run "ai-devkit channel start --agent " to start the bridge.'); + + } catch (error: any) { + ui.error(`Failed to connect channel: ${error.message}`); + process.exit(1); + } + }); + + channelCommand + .command('list') + .description('List configured channels') + .action(async () => { + try { + const configStore = new ConfigStore(); + const config = await configStore.getConfig(); + const channels = Object.entries(config.channels); + + if (channels.length === 0) { + ui.info('No channels configured. Run "ai-devkit channel connect telegram" to set up.'); + return; + } + + ui.text('Configured Channels:', { breakline: true }); + + const rows = channels.map(([name, entry]) => { + const telegramConfig = entry.config as TelegramConfig; + return [ + name, + entry.type, + entry.enabled ? chalk.green('enabled') : chalk.dim('disabled'), + telegramConfig.botUsername ? `@${telegramConfig.botUsername}` : '-', + entry.createdAt ? new Date(entry.createdAt).toLocaleDateString() : '-', + ]; + }); + + ui.table({ + headers: ['Name', 'Type', 'Status', 'Bot', 'Created'], + rows, + }); + + } catch (error: any) { + ui.error(`Failed to list channels: ${error.message}`); + process.exit(1); + } + }); + + channelCommand + .command('disconnect ') + .description('Remove a channel configuration') + .action(async (type: string) => { + try { + const configStore = new ConfigStore(); + const existing = await configStore.getChannel(type); + + if (!existing) { + ui.info(`No ${type} channel configured.`); + return; + } + + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `Remove ${type} channel configuration?`, + default: false, + }]); + + if (!confirm) return; + + await configStore.removeChannel(type); + ui.success(`${type} channel disconnected.`); + + } catch (error: any) { + ui.error(`Failed to disconnect channel: ${error.message}`); + process.exit(1); + } + }); + + channelCommand + .command('start') + .description('Start the channel bridge to a running agent') + .requiredOption('--agent ', 'Name of the agent to bridge') + .action(async (options) => { + try { + const configStore = new ConfigStore(); + const channelEntry = await configStore.getChannel(TELEGRAM_CHANNEL_TYPE); + + if (!channelEntry) { + ui.error('No Telegram channel configured. Run "ai-devkit channel connect telegram" first.'); + return; + } + + const telegramConfig = channelEntry.config as TelegramConfig; + + // Resolve agent + const agentManager = createAgentManager(); + const agent = await resolveTargetAgent(agentManager, options.agent); + if (!agent) return; + + // Get the adapter for reading conversation + const agentAdapter = getAgentAdapter(agent.type); + if (!agentAdapter) { + ui.error(`Unsupported agent type: ${agent.type}`); + return; + } + + // Find agent terminal + const focusManager = new TerminalFocusManager(); + const terminalLocation = await focusManager.findTerminal(agent.pid); + + if (!terminalLocation) { + ui.error(`Cannot find terminal for agent "${agent.name}" (PID: ${agent.pid}).`); + return; + } + + // Set up channel bridge + const telegram = new TelegramAdapter({ botToken: telegramConfig.botToken }); + const chatIdRef = { value: null as string | null }; + + setupInputHandler(telegram, terminalLocation, chatIdRef); + const pollInterval = startOutputPolling(telegram, agentAdapter, agent, chatIdRef); + + // Start the bot + const manager = new ChannelManager(); + manager.registerAdapter(telegram); + setupGracefulShutdown(manager, pollInterval); + + ui.success(`Bridge started: Telegram @${telegramConfig.botUsername} <-> Agent "${agent.name}" (PID: ${agent.pid})`); + ui.info('Send a message to your Telegram bot to start chatting.'); + ui.info('Press Ctrl+C to stop.\n'); + + await manager.startAll(); + + // Keep process running + await new Promise(() => {}); + } catch (error: any) { + ui.error(`Failed to start channel bridge: ${error.message}`); + process.exit(1); + } + }); + + channelCommand + .command('status') + .description('Show channel bridge status') + .action(async () => { + const configStore = new ConfigStore(); + const config = await configStore.getConfig(); + const channels = Object.entries(config.channels); + + if (channels.length === 0) { + ui.info('No channels configured.'); + return; + } + + for (const [name, entry] of channels) { + const telegramConfig = entry.config as TelegramConfig; + console.log(`${chalk.bold(name)} (${entry.type})`); + console.log(` Enabled: ${entry.enabled ? chalk.green('yes') : chalk.red('no')}`); + console.log(` Bot: @${telegramConfig.botUsername || 'unknown'}`); + console.log(` Configured: ${entry.createdAt || 'unknown'}`); + console.log(); + } + }); +}