diff --git a/docs/ai/design/feature-multi-telegram-channels.md b/docs/ai/design/feature-multi-telegram-channels.md new file mode 100644 index 0000000..8976522 --- /dev/null +++ b/docs/ai/design/feature-multi-telegram-channels.md @@ -0,0 +1,261 @@ +--- +phase: design +title: "Multi Telegram Channels: System Design" +description: Architecture for named Telegram channel instances and concurrent agent bridges +--- + +# System Design: Multi Telegram Channels + +## Architecture Overview + +The current channel architecture remains valid: channel-connector is a pure message pipe, and CLI owns agent orchestration. This feature changes the CLI and config identity model from a single implicit `telegram` channel to named channel instances. + +```mermaid +graph TD + subgraph Config["~/.ai-devkit/channels.json"] + C1["personal: telegram token A"] + C2["work: telegram token B"] + end + + subgraph CLI["ai-devkit channel start --agent "] + S1["Bridge process: personal -> agent A"] + S2["Bridge process: work -> agent B"] + PM["ChannelService"] + end + + subgraph Connector["@ai-devkit/channel-connector"] + TA1["TelegramAdapter(token A)"] + TA2["TelegramAdapter(token B)"] + end + + subgraph Agents["@ai-devkit/agent-manager"] + A1["Agent A process"] + A2["Agent B process"] + end + + C1 --> S1 + C2 --> S2 + S1 --> TA1 + S2 --> TA2 + S1 --> A1 + S2 --> A2 + PM --> S1 + PM --> S2 +``` + +### Key Principles + +- A **channel instance** is identified by a user-facing name such as `personal` or `work`. +- Each channel instance has one channel type, one Telegram bot token, and one authorization scope. +- Each running bridge process maps one channel instance to one agent process. +- Multiple bridge processes are allowed when their channel names differ. +- `@ai-devkit/channel-connector` remains unaware of agents and channel names beyond adapter construction. + +## Data Models + +### Channel Configuration + +Keep the existing map shape and make channel names first-class: + +```typescript +interface ChannelConfig { + channels: Record; +} + +interface ChannelEntry { + type: 'telegram' | 'slack' | 'whatsapp'; + enabled: boolean; + createdAt: string; + updatedAt?: string; + config: TelegramConfig; +} + +interface TelegramConfig { + botToken: string; + botUsername: string; + authorizedChatId?: number; +} +``` + +Compatibility rule: if an existing config has only `channels.telegram`, it is treated as a named channel instance called `telegram`. + +### Bridge Process Metadata + +Store a small runtime file for channel bridge processes. Do not overload agent session utilities, because they describe historical agent sessions rather than currently running channel bridge processes. + +```typescript +interface ChannelBridgeProcess { + channelName: string; + channelType: 'telegram'; + agentName: string; + agentPid: number; + bridgePid: number; + startedAt: string; +} +``` + +Store this metadata in a small registry file such as `~/.ai-devkit/channel-bridges.json`. Do not persist bot tokens or transcript content in process metadata. + +Status commands should treat registry entries as advisory and verify liveness with a PID check before reporting a bridge as running. Stale entries are removed when detected. + +## API Design + +### CLI Commands + +```bash +ai-devkit channel connect telegram --name +ai-devkit channel list +ai-devkit channel disconnect +ai-devkit channel start --agent +ai-devkit channel status [] +``` + +Backwards compatibility: + +```bash +ai-devkit channel connect telegram +ai-devkit channel disconnect telegram +ai-devkit channel start --agent +``` + +When `connect telegram` is called without `--name`: + +- Create or update the default `telegram` channel entry. + +When `start` is called without a name, use `telegram` if it exists and there is only one configured Telegram channel. If multiple Telegram channels exist, require an explicit name. + +Commander shape: + +```typescript +channelCommand + .command('connect ') + .option('--name ', 'Channel instance name') + +channelCommand + .command('start [name]') + .requiredOption('--agent ', 'Name of the agent to bridge') + +channelCommand + .command('status [name]') +``` + +### ConfigStore + +The current `ConfigStore` API already supports named entries: + +```typescript +saveChannel(name: string, entry: ChannelEntry): Promise; +removeChannel(name: string): Promise; +getChannel(name: string): Promise; +``` + +Implementation should validate names before saving and avoid special-casing channel type as the config key. + +CLI should reject duplicate Telegram bot tokens across different channel names before saving a channel. The check compares the target token against every other configured Telegram channel, excluding the channel being updated. + +### Channel Service + +Create a focused CLI service boundary for channel rules and foreground bridge process metadata. `ChannelService` owns the runtime bridge file directly; no separate bridge registry abstraction is needed until daemon support creates real pressure for one. + +```typescript +class ChannelService { + resolveConnectChannelName(name?: string): string; + assertUniqueTelegramToken(config: ChannelConfig, targetName: string, botToken: string): void; + resolveStartChannelName(config: ChannelConfig, name?: string): string; + getLiveBridges(): Promise; + getLiveBridgeByChannel(channelName: string): Promise; + registerBridge(process: ChannelBridgeProcess): Promise; + unregisterBridge(channelName: string): Promise; +} +``` + +The service checks whether `bridgePid` is still alive and removes stale entries. This lets `channel status` distinguish configured channels from actively running bridge processes without introducing daemon lifecycle management. The bridge file is intentionally platform-neutral: `channelType` can be `telegram`, `slack`, `discord`, or another adapter type in the future, while platform-specific secrets stay in `channels.json`. + +### Runtime Routing + +For each started channel instance, CLI creates an isolated bridge context: + +```typescript +interface BridgeContext { + channelName: string; + agent: AgentInfo; + adapter: TelegramAdapter; + activeChatId: string | null; + lastMessageCount: number; +} +``` + +The message handler and output polling loop close over this context, preventing cross-channel delivery. + +On bridge start, `activeChatId` is initialized from the selected channel entry's `authorizedChatId` when present. On the first accepted incoming message for a channel without an authorized chat ID, CLI saves that chat ID back to the same channel entry. This keeps authorization scoped per channel and stable across restarts. + +## Component Breakdown + +### `packages/channel-connector` +- No broad architecture change. +- Ensure `ConfigStore` correctly preserves arbitrary channel names. +- Ensure Telegram authorization state is stored per channel instance. +- Add tests for multiple named entries with different tokens and chat IDs. + +### `packages/cli/src/commands/channel.ts` +- Parse channel instance names for `start`, `disconnect`, and `status`. +- Add `--name` to `connect telegram`. +- Use `telegram` as the default channel name when connecting without `--name`. +- Reject duplicate Telegram bot tokens across different channel names. +- Resolve ambiguous default behavior. +- Start bridge contexts using the selected channel config. +- Register or report running bridge process status per channel name. + +### `packages/cli/src/services/channel/` +- Add `channel.service.ts` for channel naming, duplicate-token validation, bridge lookup, and command-facing channel rules. +- Store foreground bridge metadata in `~/.ai-devkit/channel-bridges.json` from `ChannelService`. +- Track active foreground bridge processes by channel name. +- Prune stale bridge PIDs before reporting status or starting a bridge. +- Keep existing agent session utilities unchanged. + +## Design Decisions + +1. **Named channel instances instead of type-only config keys** + - Reason: users need multiple Telegram configs, all with the same type but different tokens. + +2. **One bridge process per channel-agent mapping** + - Reason: matches the existing foreground process model and isolates long polling, chat authorization, and output polling. + +3. **Require explicit channel name when multiple configs exist** + - Reason: avoids accidentally sending a bot token or agent messages through the wrong channel. + +4. **Reject duplicate Telegram tokens** + - Reason: concurrent long polling for the same Telegram bot token can conflict and route messages unpredictably. + +5. **Use a channel service boundary** + - Reason: channel behavior now includes naming rules, duplicate-token policy, runtime bridge metadata, and future daemon integration. Keeping this behind `ChannelService` avoids putting business rules in Commander actions or generic utilities. + +6. **Persist authorized chat ID per channel** + - Reason: each Telegram bot should keep its own authorization scope across restarts, and one channel's first user must not affect another channel. + +7. **No channel-connector dependency on agent-manager** + - Reason: preserves the design boundary from `feature-channel-connector`. + +## Non-Functional Requirements + +### Security +- Never print bot tokens. +- Store config file with mode `0600`. +- Keep authorized chat ID scoped per channel name. +- Store bridge metadata without bot tokens. +- Do not persist agent transcript content in channel process metadata. + +### Reliability +- Failure in one bridge process must not stop other bridge processes. +- Starting an already-running channel name should fail clearly. +- Stale bridge metadata entries should be pruned before status/start decisions. +- SIGINT/SIGTERM cleanup should unregister only the current channel bridge process. +- Managed `channel stop ` behavior is out of scope until daemon support exists. + +### Performance +- Per-bridge polling behavior should remain equivalent to current channel start behavior. +- Running multiple bridges should scale linearly with the number of channels and active agents. + +### Compatibility +- Existing `channels.telegram` config remains usable as the `telegram` channel instance. +- Existing commands without names continue to work when unambiguous. diff --git a/docs/ai/implementation/feature-multi-telegram-channels.md b/docs/ai/implementation/feature-multi-telegram-channels.md new file mode 100644 index 0000000..7273a90 --- /dev/null +++ b/docs/ai/implementation/feature-multi-telegram-channels.md @@ -0,0 +1,97 @@ +--- +phase: implementation +title: "Multi Telegram Channels: Implementation Guide" +description: Technical notes for implementing named Telegram channel instances +--- + +# Implementation Guide: Multi Telegram Channels + +## Development Setup + +- Work in branch/worktree `feature-multi-telegram-channels`. +- Use the root `package-lock.json` and `npm ci` for dependency bootstrap. +- Validate feature docs with `npx ai-devkit@latest lint --feature multi-telegram-channels`. + +## Code Structure + +Expected touch points: + +- `packages/channel-connector/src/ConfigStore.ts` +- `packages/channel-connector/src/types.ts` +- `packages/channel-connector/src/__tests__/ConfigStore.test.ts` +- `packages/cli/src/commands/channel.ts` +- `packages/cli/src/__tests__/commands/channel.test.ts` +- `packages/cli/src/services/channel/channel.service.ts` +- `packages/cli/src/__tests__/services/channel/channel.service.test.ts` + +## Implementation Notes + +### Named Channel Instances + +- Treat the `channels` record key as the channel instance name. +- Do not use channel type as the unique identifier once a name is available. +- Keep `telegram` as the default compatibility name. + +### CLI Parsing + +- Prefer `channel start --agent ` for explicit multi-channel use. +- Preserve `channel start --agent ` when the target channel can be resolved unambiguously. +- Add `--name ` to `channel connect telegram`. +- When connecting without `--name`, create or update the default `telegram` channel entry. +- Reject duplicate Telegram bot tokens across channel entries. +- Do not implement `channel stop ` in this feature. + +### Runtime Isolation + +- Create a new Telegram adapter per bridge process. +- Keep `activeChatId`, output polling cursor, and agent mapping inside the bridge context. +- Register running process metadata under channel name. +- Initialize `activeChatId` from the selected channel entry's `authorizedChatId` when present. +- Persist the first accepted chat ID back to the same channel entry when no authorization exists. +- Use a dedicated channel bridge metadata file to report running status and prune stale bridge PIDs. + +## Implemented Behavior + +- `channel connect telegram --name ` stores a named Telegram channel. +- `channel connect telegram` creates or updates the default `telegram` channel. +- Duplicate Telegram bot tokens are rejected across different channel names. +- `channel list` includes authorization and bridge running state. +- `channel disconnect ` removes a named channel. +- `channel start [name] --agent ` starts the selected channel; omitting `name` is allowed only when exactly one Telegram channel is configured. +- `channel status [name]` reports configured channel details plus live bridge metadata. +- `channel stop ` remains out of scope. + +## Integration Points + +- `ConfigStore` persists named channel entries in `~/.ai-devkit/channels.json`. +- `ChannelService` owns channel naming, duplicate-token validation, live bridge lookup, bridge registration/removal, and active foreground bridge metadata persistence in `~/.ai-devkit/channel-bridges.json`. +- CLI resolves agents through `@ai-devkit/agent-manager`. +- Telegram adapter continues to own Bot API long polling and message sending. + +## Error Handling + +- Unknown channel name: show a clear error and available names. +- Ambiguous default start: require explicit channel name. +- Already-running channel name: fail clearly and show the existing bridge PID when available. +- Invalid token: reject connect/start without printing the token. +- Duplicate token: reject connect/update without printing the token. + +## Performance Considerations + +- Each channel bridge has its own Telegram long-polling loop and agent output polling loop. +- Avoid shared timers or global mutable chat state that can cross channel boundaries. + +## Security Notes + +- Never log bot tokens. +- Keep config permissions at `0600`. +- Scope authorized chat IDs to the channel instance. +- Do not write transcript content into bridge process metadata. + +## Verification Evidence + +- `packages/cli`: `npm test -- --runTestsByPath src/__tests__/commands/channel.test.ts src/__tests__/services/channel/channel.service.test.ts` passed with 25 tests. +- `packages/cli`: `npm test -- --coverage --runTestsByPath src/__tests__/services/channel/channel.service.test.ts --collectCoverageFrom=src/services/channel/channel.service.ts --coverageThreshold='{}'` reported `channel.service.ts` at 92.85% statements, 83.33% branches, 93.33% functions, 92.3% lines. +- `packages/cli`: `npm run build` passed. +- `packages/cli`: `npm run lint` passed with 0 errors and 4 pre-existing warnings outside touched files. +- `packages/channel-connector`: `npm test -- --runTestsByPath src/__tests__/ConfigStore.test.ts` passed with 13 tests. diff --git a/docs/ai/planning/feature-multi-telegram-channels.md b/docs/ai/planning/feature-multi-telegram-channels.md new file mode 100644 index 0000000..47284c5 --- /dev/null +++ b/docs/ai/planning/feature-multi-telegram-channels.md @@ -0,0 +1,92 @@ +--- +phase: planning +title: "Multi Telegram Channels: Planning & Task Breakdown" +description: Implementation plan for named Telegram channel instances and concurrent bridge processes +--- + +# Planning: Multi Telegram Channels + +## Milestones + +- [ ] Milestone 1: Confirm existing channel config and process tracking behavior +- [ ] Milestone 2: Add named Telegram channel configuration support +- [ ] Milestone 3: Add per-channel bridge start/status behavior +- [ ] Milestone 4: Add tests and compatibility coverage + +## Task Breakdown + +### Phase 1: Discovery and Compatibility +- [x] Task 1.1: Inspect current `channel` CLI command implementation and identify type-only assumptions. +- [x] Task 1.2: Inspect `ConfigStore` tests and implementation for multi-entry support. +- [x] Task 1.3: Inspect current channel status behavior and define bridge metadata needs. +- [x] Task 1.4: Define compatibility behavior for legacy `telegram` commands and config. + +### Phase 2: Config and CLI Naming +- [x] Task 2.1: Add channel instance name validation helper. +- [x] Task 2.2: Update `channel connect telegram` to accept `--name `. +- [x] Task 2.3: When connecting without `--name`, create or update the default `telegram` channel entry. +- [x] Task 2.4: Update `channel list` output to display all channel names, types, bot usernames, enabled states, and auth states. +- [x] Task 2.5: Update `channel disconnect` to remove by channel name. +- [x] Task 2.6: Reject duplicate Telegram bot tokens without printing the token. + +### Phase 3: Runtime Bridge Mapping +- [x] Task 3.1: Update `channel start` to accept an explicit channel name. +- [x] Task 3.2: Keep `channel start --agent ` working when exactly one Telegram channel is available. +- [x] Task 3.3: Create an isolated bridge context per started channel instance. +- [x] Task 3.4: Add channel service files under `packages/cli/src/services/channel/` to track running bridge metadata by `channelName`, `agentName`, `agentPid`, and bridge PID. +- [x] Task 3.5: Update `channel status [name]` to show per-channel process state. +- [x] Task 3.6: Keep `channel stop ` out of scope and document that managed stop will arrive with daemon support. + +### Phase 4: Tests and Documentation +- [x] Task 4.1: Add `ConfigStore` tests for multiple Telegram entries and per-entry authorized chat IDs. +- [x] Task 4.2: Add CLI tests for connect/list/disconnect with named channels. +- [x] Task 4.3: Add CLI tests for start ambiguity when multiple Telegram channels exist. +- [x] Task 4.4: Add channel service tests for multiple running bridge metadata entries and stale PID pruning. +- [x] Task 4.5: Update implementation and testing docs with final behavior and verification evidence. + +## Dependencies + +```mermaid +graph LR + D1["1.1 CLI discovery"] --> C1["2.2 connect --name"] + D2["1.2 ConfigStore discovery"] --> C1 + D3["1.3 session discovery"] --> R4["3.4 bridge metadata"] + C1 --> C2["2.3 list names"] + C1 --> R1["3.1 start "] + R1 --> R3["3.3 bridge context"] + R3 --> R4 + R4 --> R5["3.5 status"] + C2 --> T2["4.2 CLI tests"] + R1 --> T3["4.3 ambiguity tests"] + R4 --> T4["4.4 session tests"] +``` + +## Timeline & Estimates + +| Phase | Tasks | Effort | +|-------|-------|--------| +| Discovery and compatibility | 4 tasks | Small | +| Config and CLI naming | 5 tasks | Medium | +| Runtime bridge mapping | 6 tasks | Medium | +| Tests and documentation | 5 tasks | Medium | + +## Risks & Mitigation + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Existing CLI syntax conflicts with adding positional channel names | Users may hit confusing command behavior | Preserve current commands and require explicit names only when ambiguous | +| Process tracking is currently config-only | Multiple active bots cannot be reported reliably | Add channel bridge metadata keyed by `channelName` | +| Duplicate Telegram token long polling conflicts | Messages may be missed or polling may fail | Reject duplicate tokens at connect/update time | +| Existing user config migration breaks | Current users lose channel access | Treat `telegram` as the default channel instance and test the compatibility path | + +## Resources Needed + +- Existing `docs/ai/design/feature-channel-connector.md` +- `packages/channel-connector` ConfigStore and Telegram adapter tests +- `packages/cli/src/commands/channel.ts` +- `packages/cli/src/services/channel/channel.service.ts` +- Telegram bot tokens for optional manual end-to-end validation + +## Progress Summary + +Phase 4 implementation is complete for named Telegram channel configuration, duplicate-token rejection, named start/list/disconnect/status behavior, per-channel authorization persistence, and foreground bridge tracking. Channel rules and bridge metadata persistence now live in `ChannelService`, keeping Commander wiring thin without a separate registry abstraction. `channel stop ` remains intentionally out of scope until daemon support. Remaining risk is limited to manual Telegram end-to-end validation with real bot tokens and active agents; automated coverage now exercises the core config, CLI resolution, bridge file persistence, and status/list behavior. diff --git a/docs/ai/requirements/feature-multi-telegram-channels.md b/docs/ai/requirements/feature-multi-telegram-channels.md new file mode 100644 index 0000000..c654f85 --- /dev/null +++ b/docs/ai/requirements/feature-multi-telegram-channels.md @@ -0,0 +1,127 @@ +--- +phase: requirements +title: "Multi Telegram Channels" +description: Support multiple Telegram bot channels, each with its own token and active agent bridge +--- + +# Requirements: Multi Telegram Channels + +## Problem Statement + +The current channel feature effectively supports one Telegram channel configuration and one active Telegram bridge process. This blocks users who want to run multiple Telegram bots at the same time, each backed by a different bot token and mapped to a different active agent process. + +**Who is affected?** Developers who use ai-devkit to manage multiple long-running agents and want a separate Telegram bot/chat surface for each one. + +**Current situation:** The channel connector design stores channels in a named config map, but the CLI flow and examples assume a single `telegram` entry. Starting another Telegram bot with a different token risks overwriting configuration or colliding with the existing bridge process. + +## Goals & Objectives + +### Primary Goals +- Allow users to configure multiple Telegram channel entries, each with a unique channel name and bot token. +- Allow users to start multiple Telegram bot bridge processes concurrently. +- Bind each started Telegram channel instance to exactly one active agent process. +- Preserve the separation from `feature-channel-connector`: `@ai-devkit/channel-connector` remains a generic messaging pipe with no agent knowledge. +- Keep existing single-channel workflows working for users who already configured `telegram`. + +### Secondary Goals +- Make channel names visible in `channel list`, `channel start`, `channel status`, and `channel disconnect`. +- Avoid leaking bot tokens in CLI output, process metadata, logs, or errors. +- Keep the data model extensible for future non-Telegram channel instances. + +### Non-Goals +- A single Telegram bot routing to multiple agents from one chat. +- Multi-user or group-chat routing policies beyond the existing authorized chat ID model. +- Slack, WhatsApp, or other adapter implementation work. +- Background daemon management beyond the existing channel process model. +- `channel stop ` or managed daemon lifecycle commands. This will be handled by a later daemon feature. +- Shared conversation history across channel instances. + +## User Stories & Use Cases + +### US-1: Connect a Named Telegram Bot +As a user, I want to connect a Telegram bot token under a channel name so that I can create more than one Telegram channel. + +Example: +```bash +ai-devkit channel connect telegram --name personal +ai-devkit channel connect telegram --name work +``` + +### US-1a: Connect or Update the Default Telegram Bot +As a user, I want `channel connect telegram` without `--name` to use the default `telegram` channel so that the simple single-bot workflow remains unchanged. + +Example: +```bash +ai-devkit channel connect telegram +``` + +This creates `telegram` when it does not exist and updates `telegram` when it already exists. + +### US-2: List All Telegram Channel Instances +As a user, I want to list configured channel instances so that I can see each Telegram bot separately. + +Example output includes name, type, enabled state, bot username, authorization state, and whether a bridge process is active. + +### US-3: Start a Specific Channel for a Specific Agent +As a user, I want to start a named Telegram channel with a selected agent so that each bot chats with the intended agent. + +Example: +```bash +ai-devkit channel start personal --agent codex-main +ai-devkit channel start work --agent claude-review +``` + +### US-4: Run Multiple Bridges Concurrently +As a user, I want to run several Telegram channel bridge processes at the same time so that I can chat with multiple agents through different bots. + +### US-5: Disconnect One Channel Without Affecting Others +As a user, I want to disconnect a named Telegram channel so that removing one bot does not remove other channel configs. + +Example: +```bash +ai-devkit channel disconnect personal +``` + +### Edge Cases +- Starting a non-existent channel name fails with a clear message and shows available channel names. +- Starting a channel whose bot token is invalid fails without affecting other channels. +- Starting the same channel name twice should fail or identify the already-running bridge process. +- Two configured channels cannot share the same name. +- Two different configured channels cannot share the same Telegram bot token. Updating the same channel with the same token is allowed. +- Unauthorized chat IDs remain scoped to the individual channel instance. +- Existing `telegram` config entries should be migrated or read as the default channel instance. +- `channel start --agent ` remains valid only when the target channel can be resolved unambiguously; otherwise the user must pass a channel name. + +## Success Criteria + +1. A user can configure at least two Telegram channel entries with different tokens. +2. A user can start two channel bridge processes concurrently, each mapped to a different active agent. +3. Messages sent to bot A are forwarded only to agent A; messages sent to bot B are forwarded only to agent B. +4. Agent responses from agent A are sent only through bot A; responses from agent B are sent only through bot B. +5. `channel list` and `channel status` clearly distinguish configured and running channel instances by name. +6. Existing single-channel `telegram` users have a documented compatibility path. +7. `channel connect telegram` without `--name` creates or updates the default `telegram` channel entry. +8. Duplicate Telegram bot tokens are rejected across different channel entries. +9. Unit and integration tests cover config storage, CLI argument parsing, process identity, and per-channel routing. + +## Constraints & Assumptions + +### Constraints +- Must continue using Telegram Bot API long polling. +- Must follow existing monorepo TypeScript, Jest, and CLI command patterns. +- Must not add an agent-manager dependency to `@ai-devkit/channel-connector`. +- Bot tokens must remain secret and stored with restrictive file permissions. +- Bridge process metadata must identify channel instance and agent mapping without storing full tokens. + +### Assumptions +- Feature name is `multi-telegram-channels`. +- A channel instance name is kebab-case or otherwise CLI-safe. +- The default existing channel name remains `telegram` for backwards compatibility. +- Users can specify a channel instance name with `--name`. +- When `channel connect telegram` is called without `--name`, the CLI creates or updates the default `telegram` channel entry. +- Users provide one Telegram bot token per concurrently running Telegram bot. +- Agent process identity is still resolved by the CLI through agent-manager. + +## Questions & Open Items + +- None blocking for Phase 2 review. diff --git a/docs/ai/testing/feature-multi-telegram-channels.md b/docs/ai/testing/feature-multi-telegram-channels.md new file mode 100644 index 0000000..69de805 --- /dev/null +++ b/docs/ai/testing/feature-multi-telegram-channels.md @@ -0,0 +1,87 @@ +--- +phase: testing +title: "Multi Telegram Channels: Testing Strategy" +description: Test plan for named Telegram channel instances and concurrent bridge processes +--- + +# Testing Strategy: Multi Telegram Channels + +## Test Coverage Goals + +- Target 100% coverage for new or changed branching logic. +- Cover compatibility with the existing implicit `telegram` channel. +- Cover multi-channel config, command parsing, ambiguity handling, and bridge process metadata. + +## Unit Tests + +### ConfigStore +- [x] Save two Telegram entries with different names and tokens. +- [x] Preserve separate `authorizedChatId` values for each channel entry. +- [x] Remove one channel without changing the other. +- [x] Reject invalid channel names in `ChannelService`; `ConfigStore` remains a persistence-only API. + +### CLI Channel Command +- [x] `connect telegram --name personal` stores a `personal` channel entry. +- [x] `connect telegram` without `--name` creates or updates the default `telegram` entry. +- [x] Duplicate Telegram bot tokens are rejected without printing the token. +- [x] `list` shows multiple configured Telegram entries. +- [x] `disconnect personal` removes only `personal`. +- [x] `start --agent ` works with one configured Telegram channel. +- [x] `start --agent ` fails with a clear ambiguity error when multiple Telegram channels exist. +- [x] `start personal --agent ` starts the selected channel. + +### Channel Bridge Registry +- [x] Running bridge metadata includes `channelName`. +- [x] Multiple bridge metadata entries can exist concurrently. +- [x] Looking up status by channel name returns the correct bridge. +- [x] Stale bridge PIDs are pruned before status/start decisions. +- [x] Shutdown cleanup removes only the current channel metadata. + +## Integration Tests + +- [ ] CLI connect/list/disconnect flow with a temporary config path. +- [ ] CLI start flow with mocked Telegram adapter and mocked agent-manager agent. +- [ ] Per-channel message handler forwards bot A messages only to agent A. +- [ ] Per-channel output polling sends agent A output only through bot A. +- [ ] First accepted chat ID is persisted to the selected channel entry only. +- [ ] Invalid or missing channel name does not instantiate a Telegram adapter. + +## End-to-End Tests + +- [ ] Configure two real Telegram bot tokens. +- [ ] Start two active agent processes. +- [ ] Start two channel bridge processes, one per channel-agent pair. +- [ ] Send messages to each Telegram bot and confirm responses are isolated. +- [ ] Stop one bridge and confirm the other bridge continues working. + +## Test Data + +- Temporary `channels.json` with `personal` and `work` entries. +- Mock bot tokens that are never printed in snapshots. +- Mock agent records with different names, PIDs, and session files. + +## Test Reporting & Coverage + +- Run focused package tests after implementation. +- Run root test suite if command runtime remains reasonable. +- Run `npx ai-devkit@latest lint --feature multi-telegram-channels` before phase transitions. + +### Latest Evidence + +- `packages/cli`: `npm test -- --runTestsByPath src/__tests__/commands/channel.test.ts src/__tests__/services/channel/channel.service.test.ts` passed with 25 tests. +- `packages/cli`: focused coverage for `src/services/channel/channel.service.ts` reported 92.85% statements, 83.33% branches, 93.33% functions, 92.3% lines. The remaining uncovered lines are the default PID checker; tests inject PID liveness to avoid probing real process IDs. +- `packages/channel-connector`: `npm test -- --runTestsByPath src/__tests__/ConfigStore.test.ts` passed with 13 tests. + +## Manual Testing + +- Manual Telegram E2E requires real bot tokens and active network access. +- Confirm token redaction in command output and error messages. + +## Performance Testing + +- Start two mocked bridge contexts and confirm polling loops stay independent. +- Verify no shared global state causes cross-channel message delivery. + +## Bug Tracking + +- Treat cross-channel message leakage, token leakage, or wrong-agent routing as blocking severity. diff --git a/packages/channel-connector/src/__tests__/ConfigStore.test.ts b/packages/channel-connector/src/__tests__/ConfigStore.test.ts index 744408e..5417330 100644 --- a/packages/channel-connector/src/__tests__/ConfigStore.test.ts +++ b/packages/channel-connector/src/__tests__/ConfigStore.test.ts @@ -94,6 +94,37 @@ describe('ConfigStore', () => { const config = await store.getConfig(); expect(Object.keys(config.channels)).toEqual(['telegram', 'slack']); }); + + it('should preserve separate Telegram configs by channel name', async () => { + await store.saveChannel('personal', { + ...sampleEntry, + config: { + botToken: 'personal-token', + botUsername: 'personal_bot', + authorizedChatId: 111, + }, + }); + await store.saveChannel('work', { + ...sampleEntry, + config: { + botToken: 'work-token', + botUsername: 'work_bot', + authorizedChatId: 222, + }, + }); + + const config = await store.getConfig(); + expect(config.channels.personal.config).toEqual({ + botToken: 'personal-token', + botUsername: 'personal_bot', + authorizedChatId: 111, + }); + expect(config.channels.work.config).toEqual({ + botToken: 'work-token', + botUsername: 'work_bot', + authorizedChatId: 222, + }); + }); }); describe('removeChannel', () => { diff --git a/packages/cli/src/__tests__/commands/channel.test.ts b/packages/cli/src/__tests__/commands/channel.test.ts index cf18e9f..fcb7ea3 100644 --- a/packages/cli/src/__tests__/commands/channel.test.ts +++ b/packages/cli/src/__tests__/commands/channel.test.ts @@ -1,22 +1,114 @@ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { Command } from 'commander'; import type { AgentAdapter, AgentInfo, ConversationMessage } from '@ai-devkit/agent-manager'; import { AgentStatus } from '@ai-devkit/agent-manager'; import type { TelegramAdapter } from '@ai-devkit/channel-connector'; import { ui } from '../../util/terminal-ui'; +const mockConfigStore = { + getConfig: jest.fn<() => Promise>(), + getChannel: jest.fn<(name: string) => Promise>(), + saveChannel: jest.fn<(name: string, entry: unknown) => Promise>(), + removeChannel: jest.fn<(name: string) => Promise>(), +}; + +const mockPrompt = jest.fn<(...args: unknown[]) => Promise>(); +const mockGetMe = jest.fn<() => Promise<{ username: string }>>(); +const mockSpinner = { + start: jest.fn(), + succeed: jest.fn(), + fail: jest.fn(), +}; +const mockChannelManager = { + registerAdapter: jest.fn(), + startAll: jest.fn<() => Promise>(), + stopAll: jest.fn<() => Promise>(), +}; +const mockAgentAdapter = { + getConversation: jest.fn(), +}; +const mockAgentManager = { + registerAdapter: jest.fn(), + listAgents: jest.fn<() => Promise>(), + resolveAgent: jest.fn<(agentName: string, agents: unknown[]) => unknown>(), + getAdapter: jest.fn<(agentType: string) => unknown>(), +}; +const mockTerminalFocusManager = { + findTerminal: jest.fn<(pid: number) => Promise>(), +}; +const mockTelegramAdapter = { + onMessage: jest.fn(), + sendMessage: jest.fn<() => Promise>(), +}; +const mockChannelService = { + resolveConnectChannelName: jest.fn((name?: string) => name ?? 'telegram'), + resolveStartChannelName: jest.fn((config: any, name?: string) => name ?? Object.keys(config.channels)[0]), + assertUniqueTelegramToken: jest.fn(), + getLiveBridges: jest.fn<() => Promise>(), + getLiveBridgeByChannel: jest.fn<(channelName: string) => Promise>(), + registerBridge: jest.fn<(entry: unknown) => Promise>(), + unregisterBridge: jest.fn<(channelName: string) => Promise>(), +}; + +jest.mock('@ai-devkit/channel-connector', () => ({ + ChannelManager: jest.fn(() => mockChannelManager), + ConfigStore: jest.fn(() => mockConfigStore), + TelegramAdapter: jest.fn(() => mockTelegramAdapter), + TELEGRAM_CHANNEL_TYPE: 'telegram', +}), { virtual: true }); + +jest.mock('@ai-devkit/agent-manager', () => ({ + AgentStatus: { + RUNNING: 'running', + }, + AgentManager: jest.fn(() => mockAgentManager), + ClaudeCodeAdapter: jest.fn(), + CodexAdapter: jest.fn(), + GeminiCliAdapter: jest.fn(), + TerminalFocusManager: jest.fn(() => mockTerminalFocusManager), + TtyWriter: { + send: jest.fn(), + }, +})); + +jest.mock('inquirer', () => ({ + __esModule: true, + default: { + prompt: (...args: unknown[]) => mockPrompt(...args), + }, +})); + +jest.mock('telegraf', () => ({ + Telegraf: jest.fn(() => ({ + telegram: { + getMe: mockGetMe, + }, + })), +})); + jest.mock('../../util/terminal-ui', () => ({ ui: { text: jest.fn(), + table: jest.fn(), info: jest.fn(), success: jest.fn(), warning: jest.fn(), error: jest.fn(), + breakline: jest.fn(), + spinner: jest.fn(() => mockSpinner), }, })); +jest.mock('../../services/channel/channel.service', () => ({ + ChannelService: jest.fn(() => mockChannelService), +})); + // Imported AFTER mocks so the module under test picks up the mocked ui // eslint-disable-next-line @typescript-eslint/no-var-requires -const { startOutputPolling } = require('../../commands/channel'); +const { + registerChannelCommand, + startOutputPolling, +} = require('../../commands/channel'); const POLL_INTERVAL_MS = 2000; @@ -56,6 +148,27 @@ describe('startOutputPolling', () => { telegram = { sendMessage: jest.fn(() => Promise.resolve()) }; chatIdRef = { value: null }; interval = null; + mockConfigStore.getConfig.mockReset(); + mockConfigStore.getChannel.mockReset(); + mockConfigStore.saveChannel.mockReset(); + mockConfigStore.removeChannel.mockReset(); + mockPrompt.mockReset(); + mockGetMe.mockReset(); + mockSpinner.start.mockReset(); + mockSpinner.succeed.mockReset(); + mockSpinner.fail.mockReset(); + mockChannelService.resolveConnectChannelName.mockClear(); + mockChannelService.resolveStartChannelName.mockClear(); + mockChannelService.assertUniqueTelegramToken.mockClear(); + mockChannelService.getLiveBridges.mockClear(); + mockChannelService.getLiveBridgeByChannel.mockClear(); + mockChannelService.registerBridge.mockClear(); + mockChannelService.unregisterBridge.mockClear(); + mockChannelService.resolveConnectChannelName.mockImplementation((name?: string) => name ?? 'telegram'); + mockChannelService.resolveStartChannelName.mockImplementation((config: any, name?: string) => name ?? Object.keys(config.channels)[0]); + mockChannelService.getLiveBridges.mockResolvedValue([]); + mockChannelService.getLiveBridgeByChannel.mockResolvedValue(undefined); + mockGetMe.mockResolvedValue({ username: 'test_bot' }); jest.clearAllMocks(); }); @@ -246,3 +359,199 @@ describe('startOutputPolling', () => { expect(telegram.sendMessage.mock.calls.some(c => c[1] === 'next-tick reply')).toBe(true); }); }); + +describe('channel command', () => { + const personalEntry = { + type: 'telegram', + enabled: true, + createdAt: '2026-05-23T00:00:00.000Z', + config: { + botToken: '123:abc', + botUsername: 'personal_bot', + }, + }; + + beforeEach(() => { + mockConfigStore.getConfig.mockReset(); + mockConfigStore.getChannel.mockReset(); + mockConfigStore.saveChannel.mockReset(); + mockConfigStore.removeChannel.mockReset(); + mockChannelService.resolveConnectChannelName.mockClear(); + mockChannelService.resolveStartChannelName.mockClear(); + mockChannelService.assertUniqueTelegramToken.mockClear(); + mockChannelService.getLiveBridges.mockClear(); + mockChannelService.getLiveBridgeByChannel.mockClear(); + mockChannelService.registerBridge.mockClear(); + mockChannelService.unregisterBridge.mockClear(); + mockChannelService.resolveConnectChannelName.mockImplementation((name?: string) => name ?? 'telegram'); + mockChannelService.resolveStartChannelName.mockImplementation((config: any, name?: string) => name ?? Object.keys(config.channels)[0]); + mockChannelService.getLiveBridges.mockResolvedValue([]); + mockChannelService.getLiveBridgeByChannel.mockResolvedValue(undefined); + mockGetMe.mockReset(); + mockGetMe.mockResolvedValue({ username: 'test_bot' }); + mockSpinner.start.mockReset(); + mockSpinner.succeed.mockReset(); + mockSpinner.fail.mockReset(); + mockChannelManager.registerAdapter.mockReset(); + mockChannelManager.startAll.mockReset(); + mockChannelManager.stopAll.mockReset(); + mockAgentManager.registerAdapter.mockReset(); + mockAgentManager.listAgents.mockReset(); + mockAgentManager.resolveAgent.mockReset(); + mockAgentManager.getAdapter.mockReset(); + mockTerminalFocusManager.findTerminal.mockReset(); + mockAgentAdapter.getConversation.mockReset(); + mockTelegramAdapter.onMessage.mockReset(); + mockTelegramAdapter.sendMessage.mockReset(); + mockPrompt.mockReset(); + jest.clearAllMocks(); + }); + + it('connects a named Telegram channel', async () => { + mockPrompt.mockResolvedValue({ botToken: '123:abc' }); + mockConfigStore.getChannel.mockResolvedValue(undefined); + mockConfigStore.getConfig.mockResolvedValue({ channels: {} }); + mockChannelService.resolveConnectChannelName.mockReturnValue('personal'); + + const program = new Command(); + registerChannelCommand(program); + await program.parseAsync(['node', 'test', 'channel', 'connect', 'telegram', '--name', 'personal']); + + expect(mockChannelService.resolveConnectChannelName).toHaveBeenCalledWith('personal'); + expect(mockChannelService.assertUniqueTelegramToken).toHaveBeenCalledWith({ channels: {} }, 'personal', '123:abc'); + expect(mockConfigStore.saveChannel).toHaveBeenCalledWith('personal', expect.objectContaining({ + type: 'telegram', + enabled: true, + config: { + botToken: '123:abc', + botUsername: 'test_bot', + authorizedChatId: undefined, + }, + })); + expect(ui.success).toHaveBeenCalledWith('Telegram channel "personal" configured successfully!'); + }); + + it('connects the default Telegram channel when --name is omitted', async () => { + mockPrompt.mockResolvedValue({ botToken: '123:abc' }); + mockConfigStore.getChannel.mockResolvedValue(undefined); + mockConfigStore.getConfig.mockResolvedValue({ channels: {} }); + + const program = new Command(); + registerChannelCommand(program); + await program.parseAsync(['node', 'test', 'channel', 'connect', 'telegram']); + + expect(mockChannelService.resolveConnectChannelName).toHaveBeenCalledWith(undefined); + expect(mockConfigStore.saveChannel).toHaveBeenCalledWith('telegram', expect.objectContaining({ + type: 'telegram', + config: expect.objectContaining({ + botToken: '123:abc', + botUsername: 'test_bot', + }), + })); + }); + + it('lists named Telegram channels with authorization state', async () => { + mockConfigStore.getConfig.mockResolvedValue({ + channels: { + personal: personalEntry, + work: { + ...personalEntry, + config: { + botToken: '456:def', + botUsername: 'work_bot', + authorizedChatId: 222, + }, + }, + }, + }); + + const program = new Command(); + registerChannelCommand(program); + await program.parseAsync(['node', 'test', 'channel', 'list']); + + expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ + headers: ['Name', 'Type', 'Status', 'Bot', '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']), + ]), + })); + }); + + it('disconnects a named channel', async () => { + mockConfigStore.getChannel.mockResolvedValue(personalEntry); + mockPrompt.mockResolvedValue({ confirm: true }); + + const program = new Command(); + registerChannelCommand(program); + await program.parseAsync(['node', 'test', 'channel', 'disconnect', 'personal']); + + expect(mockConfigStore.getChannel).toHaveBeenCalledWith('personal'); + expect(mockConfigStore.removeChannel).toHaveBeenCalledWith('personal'); + expect(ui.success).toHaveBeenCalledWith('personal channel disconnected.'); + }); + + it('shows available channels when starting a missing channel', async () => { + mockConfigStore.getConfig.mockResolvedValue({ + channels: { + personal: personalEntry, + work: { + ...personalEntry, + config: { + botToken: '456:def', + botUsername: 'work_bot', + }, + }, + }, + }); + mockChannelService.resolveStartChannelName.mockReturnValue('missing'); + + const program = new Command(); + registerChannelCommand(program); + await program.parseAsync(['node', 'test', 'channel', 'start', 'missing', '--agent', 'codex-main']); + + expect(ui.error).toHaveBeenCalledWith('No channel configured with name "missing".'); + expect(ui.info).toHaveBeenCalledWith('Available channels: personal, work'); + }); + + it('records the bridge before starting the channel manager', async () => { + jest.useFakeTimers(); + mockConfigStore.getConfig.mockResolvedValue({ + channels: { + personal: personalEntry, + }, + }); + const agent = makeAgent({ name: 'codex-main', type: 'codex', pid: 4321 }); + mockAgentManager.listAgents.mockResolvedValue([agent]); + mockAgentManager.resolveAgent.mockReturnValue(agent); + mockAgentManager.getAdapter.mockReturnValue(mockAgentAdapter); + mockTerminalFocusManager.findTerminal.mockResolvedValue({ + app: 'Terminal', + windowIndex: 1, + tabIndex: 1, + }); + mockAgentAdapter.getConversation.mockReturnValue([]); + mockChannelManager.startAll.mockResolvedValue(undefined); + + const program = new Command(); + registerChannelCommand(program); + void program.parseAsync(['node', 'test', 'channel', 'start', 'personal', '--agent', 'codex-main']); + + for (let i = 0; i < 10 && mockChannelManager.startAll.mock.calls.length === 0; i += 1) { + await Promise.resolve(); + } + + expect(mockChannelService.registerBridge).toHaveBeenCalledWith(expect.objectContaining({ + channelName: 'personal', + channelType: 'telegram', + agentName: 'codex-main', + agentPid: 4321, + bridgePid: process.pid, + })); + expect(mockChannelService.registerBridge.mock.invocationCallOrder[0]) + .toBeLessThan(mockChannelManager.startAll.mock.invocationCallOrder[0]); + + jest.clearAllTimers(); + jest.useRealTimers(); + }); +}); diff --git a/packages/cli/src/__tests__/services/channel/channel.service.test.ts b/packages/cli/src/__tests__/services/channel/channel.service.test.ts new file mode 100644 index 0000000..321733a --- /dev/null +++ b/packages/cli/src/__tests__/services/channel/channel.service.test.ts @@ -0,0 +1,168 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ChannelService } from '../../../services/channel/channel.service'; + +describe('ChannelService', () => { + let tmpDir: string; + let registryPath: string; + let alivePids: Set; + let service: ChannelService; + + const personalEntry = { + type: 'telegram' as const, + enabled: true, + createdAt: '2026-05-23T00:00:00.000Z', + config: { + botToken: '123:abc', + botUsername: 'personal_bot', + }, + }; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'channel-service-test-')); + registryPath = path.join(tmpDir, 'channel-bridges.json'); + alivePids = new Set(); + service = new ChannelService(registryPath, pid => alivePids.has(pid)); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('uses the default telegram channel name when connect --name is omitted', () => { + expect(service.resolveConnectChannelName(undefined)).toBe('telegram'); + }); + + it('uses the provided channel name when connect --name is set', () => { + expect(service.resolveConnectChannelName('personal')).toBe('personal'); + }); + + it('rejects invalid channel names', () => { + expect(() => service.resolveConnectChannelName('Personal Bot')).toThrow( + 'Channel name must be kebab-case using lowercase letters, numbers, and hyphens.', + ); + }); + + it('rejects duplicate Telegram tokens across different channel names', () => { + expect(() => service.assertUniqueTelegramToken({ + channels: { + personal: personalEntry, + }, + }, 'work', '123:abc')).toThrow('Telegram bot token is already configured for channel "personal".'); + }); + + it('allows updating the same channel with its existing Telegram token', () => { + expect(() => service.assertUniqueTelegramToken({ + channels: { + telegram: personalEntry, + }, + }, 'telegram', '123:abc')).not.toThrow(); + }); + + it('uses the explicit channel name for start when provided', () => { + expect(service.resolveStartChannelName({ + channels: { + telegram: personalEntry, + work: { ...personalEntry, config: { ...personalEntry.config, botToken: '456:def' } }, + }, + }, 'work')).toBe('work'); + }); + + it('infers the sole configured Telegram channel for start when name is omitted', () => { + expect(service.resolveStartChannelName({ + channels: { + personal: personalEntry, + }, + }, undefined)).toBe('personal'); + }); + + it('requires an explicit channel name for start when multiple Telegram channels exist', () => { + expect(() => service.resolveStartChannelName({ + channels: { + personal: personalEntry, + work: { ...personalEntry, config: { ...personalEntry.config, botToken: '456:def' } }, + }, + }, undefined)).toThrow('Multiple Telegram channels configured. Specify one: personal, work'); + }); + + it('fails start resolution when no Telegram channels exist', () => { + expect(() => service.resolveStartChannelName({ + channels: {}, + }, undefined)).toThrow('No Telegram channel configured. Run "ai-devkit channel connect telegram" first.'); + }); + + it('stores multiple bridge entries by channel name', async () => { + await service.registerBridge({ + channelName: 'personal', + channelType: 'telegram', + agentName: 'codex-main', + agentPid: 100, + bridgePid: 200, + startedAt: '2026-05-23T00:00:00.000Z', + }); + await service.registerBridge({ + channelName: 'team-slack', + channelType: 'slack', + agentName: 'claude-review', + agentPid: 101, + bridgePid: 201, + startedAt: '2026-05-23T00:01:00.000Z', + }); + alivePids.add(200); + alivePids.add(201); + + const liveBridges = await service.getLiveBridges(); + + expect(liveBridges).toEqual([ + expect.objectContaining({ channelName: 'personal', channelType: 'telegram', bridgePid: 200 }), + expect.objectContaining({ channelName: 'team-slack', channelType: 'slack', bridgePid: 201 }), + ]); + expect(await service.getLiveBridgeByChannel('team-slack')).toEqual(expect.objectContaining({ + channelName: 'team-slack', + agentName: 'claude-review', + })); + }); + + it('prunes stale bridge entries', async () => { + alivePids.add(200); + await service.registerBridge({ + channelName: 'personal', + channelType: 'telegram', + agentName: 'codex-main', + agentPid: 100, + bridgePid: 200, + startedAt: '2026-05-23T00:00:00.000Z', + }); + await service.registerBridge({ + channelName: 'work', + channelType: 'telegram', + agentName: 'claude-review', + agentPid: 101, + bridgePid: 201, + startedAt: '2026-05-23T00:01:00.000Z', + }); + + const live = await service.getLiveBridges(); + + expect(live).toEqual([ + expect.objectContaining({ channelName: 'personal', bridgePid: 200 }), + ]); + expect(await service.getLiveBridgeByChannel('work')).toBeUndefined(); + }); + + it('unregisters a bridge by channel name', async () => { + await service.registerBridge({ + channelName: 'personal', + channelType: 'telegram', + agentName: 'codex-main', + agentPid: 100, + bridgePid: 200, + startedAt: '2026-05-23T00:00:00.000Z', + }); + + await service.unregisterBridge('personal'); + + expect(await service.getLiveBridgeByChannel('personal')).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 714d6d6..9fc6258 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -25,6 +25,7 @@ import { ui } from '../util/terminal-ui'; import { withErrorHandler } from '../util/errors'; import { getErrorMessage } from '../util/text'; import { createLogger, enableDebug } from '../util/debug'; +import { ChannelService } from '../services/channel/channel.service'; const debug = createLogger('channel'); const AGENT_POLL_INTERVAL_MS = 2000; @@ -73,12 +74,14 @@ function setupInputHandler( telegram: TelegramAdapter, terminalLocation: TerminalLocation, chatIdRef: { value: string | null }, + onAuthorize?: (chatId: string) => Promise, ): void { telegram.onMessage(async (msg) => { debug(`Received message from chat ID: ${msg.chatId}, text length: ${msg.text?.length ?? 0}`); if (!chatIdRef.value) { chatIdRef.value = msg.chatId; + await onAuthorize?.(msg.chatId); ui.info(`Authorized Telegram user (chat ID: ${msg.chatId})`); } @@ -178,7 +181,12 @@ export function startOutputPolling( }, AGENT_POLL_INTERVAL_MS); } -function setupGracefulShutdown(manager: ChannelManager, pollInterval: NodeJS.Timeout): void { +function setupGracefulShutdown( + manager: ChannelManager, + pollInterval: NodeJS.Timeout, + channelService: ChannelService, + channelName: string, +): void { const shutdown = async () => { debug('Shutdown signal received'); ui.info('\nShutting down...'); @@ -186,6 +194,8 @@ function setupGracefulShutdown(manager: ChannelManager, pollInterval: NodeJS.Tim debug('Output polling stopped'); await manager.stopAll(); debug('ChannelManager stopped'); + await channelService.unregisterBridge(channelName); + debug(`Removed channel bridge entry: ${channelName}`); ui.success('Channel bridge stopped.'); process.exit(0); }; @@ -195,6 +205,7 @@ function setupGracefulShutdown(manager: ChannelManager, pollInterval: NodeJS.Tim } export function registerChannelCommand(program: Command): void { + const channelService = new ChannelService(); const channelCommand = program .command('channel') .description('Connect agents with messaging channels'); @@ -202,23 +213,16 @@ export function registerChannelCommand(program: Command): void { channelCommand .command('connect ') .description('Connect a messaging channel (e.g., telegram)') - .action(withErrorHandler('connect channel', async (type: string) => { + .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}`); return; } + const channelName = channelService.resolveConnectChannelName(options.name); 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; - } + const existing = await configStore.getChannel(channelName); 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'); @@ -248,20 +252,27 @@ export function registerChannelCommand(program: Command): void { return; } + const trimmedBotToken = botToken.trim(); + const config = await configStore.getConfig(); + channelService.assertUniqueTelegramToken(config, channelName, trimmedBotToken); + const entry: ChannelEntry = { type: TELEGRAM_CHANNEL_TYPE, enabled: true, - createdAt: new Date().toISOString(), + createdAt: existing?.createdAt ?? new Date().toISOString(), config: { - botToken: botToken.trim(), + botToken: trimmedBotToken, botUsername, + authorizedChatId: (existing?.config as TelegramConfig | undefined)?.botToken === trimmedBotToken + ? (existing?.config as TelegramConfig).authorizedChatId + : undefined, } as TelegramConfig, }; - await configStore.saveChannel(TELEGRAM_CHANNEL_TYPE, entry); - ui.success('Telegram channel configured successfully!'); + await configStore.saveChannel(channelName, entry); + ui.success(`Telegram channel "${channelName}" configured successfully!`); ui.info(`Bot: @${botUsername}`); - ui.info('Run "ai-devkit channel start --agent " to start the bridge.'); + ui.info(`Run "ai-devkit channel start ${channelName} --agent " to start the bridge.`); })); channelCommand @@ -271,6 +282,8 @@ export function registerChannelCommand(program: Command): void { const configStore = new ConfigStore(); const config = await configStore.getConfig(); const channels = Object.entries(config.channels); + const liveBridges = await channelService.getLiveBridges(); + const liveByChannel = new Map(liveBridges.map(bridge => [bridge.channelName, bridge])); if (channels.length === 0) { ui.info('No channels configured. Run "ai-devkit channel connect telegram" to set up.'); @@ -286,64 +299,77 @@ export function registerChannelCommand(program: Command): void { entry.type, entry.enabled ? chalk.green('enabled') : chalk.dim('disabled'), telegramConfig.botUsername ? `@${telegramConfig.botUsername}` : '-', + telegramConfig.authorizedChatId ? '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', 'Created'], + headers: ['Name', 'Type', 'Status', 'Bot', 'Authorized', 'Bridge', 'Created'], rows, }); })); channelCommand - .command('disconnect ') + .command('disconnect ') .description('Remove a channel configuration') - .action(withErrorHandler('disconnect channel', async (type: string) => { + .action(withErrorHandler('disconnect channel', async (name: string) => { + const channelName = channelService.resolveConnectChannelName(name); const configStore = new ConfigStore(); - const existing = await configStore.getChannel(type); + const existing = await configStore.getChannel(channelName); if (!existing) { - ui.info(`No ${type} channel configured.`); + ui.info(`No channel configured with name "${channelName}".`); return; } const { confirm } = await inquirer.prompt([{ type: 'confirm', name: 'confirm', - message: `Remove ${type} channel configuration?`, + message: `Remove "${channelName}" channel configuration?`, default: false, }]); if (!confirm) return; - await configStore.removeChannel(type); - ui.success(`${type} channel disconnected.`); + await configStore.removeChannel(channelName); + ui.success(`${channelName} channel disconnected.`); })); channelCommand - .command('start') + .command('start [name]') .description('Start the channel bridge to a running agent') .requiredOption('--agent ', 'Name of the agent to bridge') .option('--debug', 'Enable debug logging') - .action(withErrorHandler('start channel bridge', async (options) => { + .action(withErrorHandler('start channel bridge', async (name: string | undefined, options) => { if (options.debug) { enableDebug(); } - debug(`Starting channel bridge: agent=${options.agent}`); - const configStore = new ConfigStore(); debug('Loading channel configuration from ConfigStore'); - const channelEntry = await configStore.getChannel(TELEGRAM_CHANNEL_TYPE); + const config = await configStore.getConfig(); + const channelName = channelService.resolveStartChannelName(config, name); + debug(`Starting channel bridge: channel=${channelName}, agent=${options.agent}`); + const channelEntry = config.channels[channelName]; + const runningBridge = await channelService.getLiveBridgeByChannel(channelName); if (!channelEntry) { - ui.error('No Telegram channel configured. Run "ai-devkit channel connect telegram" first.'); + ui.error(`No channel configured with name "${channelName}".`); + const availableChannels = Object.keys(config.channels); + if (availableChannels.length > 0) { + ui.info(`Available channels: ${availableChannels.join(', ')}`); + } + return; + } + if (runningBridge) { + ui.error(`Channel "${channelName}" bridge is already running (PID: ${runningBridge.bridgePid}).`); return; } const telegramConfig = channelEntry.config as TelegramConfig; - debug(`Telegram channel found: bot=@${telegramConfig.botUsername}`); + debug(`Telegram channel "${channelName}" found: bot=@${telegramConfig.botUsername}`); debug(`Resolving agent: "${options.agent}"`); const agentManager = createAgentManager(); @@ -373,47 +399,84 @@ export function registerChannelCommand(program: Command): void { debug(`Terminal found: ${JSON.stringify(terminalLocation)}`); const telegram = new TelegramAdapter({ botToken: telegramConfig.botToken }); - const chatIdRef = { value: null as string | null }; + const chatIdRef = { + value: telegramConfig.authorizedChatId !== undefined + ? String(telegramConfig.authorizedChatId) + : null, + }; - setupInputHandler(telegram, terminalLocation, chatIdRef); + setupInputHandler(telegram, terminalLocation, chatIdRef, async (chatId) => { + const latest = await configStore.getChannel(channelName); + if (!latest) return; + const latestTelegramConfig = latest.config as TelegramConfig; + await configStore.saveChannel(channelName, { + ...latest, + config: { + ...latestTelegramConfig, + authorizedChatId: Number(chatId), + }, + }); + }); debug(`Starting output polling (interval: ${AGENT_POLL_INTERVAL_MS}ms)`); const pollInterval = startOutputPolling(telegram, agentAdapter, agent, chatIdRef); const manager = new ChannelManager(); manager.registerAdapter(telegram); - setupGracefulShutdown(manager, pollInterval); + setupGracefulShutdown(manager, pollInterval, channelService, channelName); - ui.success(`Bridge started: Telegram @${telegramConfig.botUsername} <-> Agent "${agent.name}" (PID: ${agent.pid})`); + ui.success(`Bridge started: ${channelName} (@${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'); - debug('Calling manager.startAll()'); - await manager.startAll(); - debug('ChannelManager started successfully'); + await channelService.registerBridge({ + channelName, + channelType: TELEGRAM_CHANNEL_TYPE, + agentName: agent.name, + agentPid: agent.pid, + bridgePid: process.pid, + startedAt: new Date().toISOString(), + }); + debug(`Registered channel bridge entry: ${channelName}`); + + try { + debug('Calling manager.startAll()'); + await manager.startAll(); + debug('ChannelManager started successfully'); + } catch (error) { + await channelService.unregisterBridge(channelName); + throw error; + } await new Promise(() => {}); })); channelCommand - .command('status') + .command('status [name]') .description('Show channel bridge status') - .action(async () => { + .action(withErrorHandler('channel status', async (name: string | undefined) => { const configStore = new ConfigStore(); const config = await configStore.getConfig(); - const channels = Object.entries(config.channels); + const channelFilter = name ? channelService.resolveConnectChannelName(name) : undefined; + const channels = Object.entries(config.channels) + .filter(([channelName]) => !channelFilter || channelName === channelFilter); + const liveBridges = await channelService.getLiveBridges(); + const liveByChannel = new Map(liveBridges.map(bridge => [bridge.channelName, bridge])); if (channels.length === 0) { - ui.info('No channels configured.'); + ui.info(channelFilter ? `No channel configured with name "${channelFilter}".` : 'No channels configured.'); return; } for (const [name, entry] of channels) { const telegramConfig = entry.config as TelegramConfig; + const bridge = liveByChannel.get(name); 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(` Bridge: ${bridge ? chalk.green(`running (PID: ${bridge.bridgePid}, agent: ${bridge.agentName})`) : chalk.dim('stopped')}`); ui.text(` Configured: ${entry.createdAt || 'unknown'}`); ui.breakline(); } - }); + })); } diff --git a/packages/cli/src/services/channel/channel.service.ts b/packages/cli/src/services/channel/channel.service.ts new file mode 100644 index 0000000..fcbff1d --- /dev/null +++ b/packages/cli/src/services/channel/channel.service.ts @@ -0,0 +1,117 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + TELEGRAM_CHANNEL_TYPE, + type ChannelConfig, + type TelegramConfig, +} from '@ai-devkit/channel-connector'; + +const DEFAULT_REGISTRY_PATH = path.join(os.homedir(), '.ai-devkit', 'channel-bridges.json'); +const DEFAULT_TELEGRAM_CHANNEL_NAME = TELEGRAM_CHANNEL_TYPE; +const CHANNEL_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + +export interface ChannelBridgeProcess { + channelName: string; + channelType: string; + agentName: string; + agentPid: number; + bridgePid: number; + startedAt: string; +} + +interface ChannelBridgeFile { + bridges: Record; +} + +type PidChecker = (pid: number) => boolean; + +function defaultPidChecker(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export class ChannelService { + constructor( + private readonly registryPath = DEFAULT_REGISTRY_PATH, + private readonly isPidAlive: PidChecker = defaultPidChecker, + ) {} + + resolveConnectChannelName(name: string | undefined): string { + const channelName = (name ?? DEFAULT_TELEGRAM_CHANNEL_NAME).trim(); + if (!CHANNEL_NAME_PATTERN.test(channelName)) { + throw new Error('Channel name must be kebab-case using lowercase letters, numbers, and hyphens.'); + } + return channelName; + } + + assertUniqueTelegramToken(config: ChannelConfig, targetName: string, botToken: string): void { + for (const [name, entry] of Object.entries(config.channels)) { + if (name === targetName || entry.type !== TELEGRAM_CHANNEL_TYPE) continue; + const telegramConfig = entry.config as TelegramConfig; + if (telegramConfig.botToken === botToken) { + throw new Error(`Telegram bot token is already configured for channel "${name}".`); + } + } + } + + resolveStartChannelName(config: ChannelConfig, name: string | undefined): string { + if (name !== undefined) return this.resolveConnectChannelName(name); + + const telegramChannels = Object.entries(config.channels) + .filter(([, entry]) => entry.type === TELEGRAM_CHANNEL_TYPE) + .map(([channelName]) => channelName); + + if (telegramChannels.length === 1) return telegramChannels[0]; + if (telegramChannels.length > 1) { + throw new Error(`Multiple Telegram channels configured. Specify one: ${telegramChannels.join(', ')}`); + } + throw new Error('No Telegram channel configured. Run "ai-devkit channel connect telegram" first.'); + } + + async getLiveBridges(): Promise { + const registry = await this.readBridgeRegistry(); + const liveEntries = Object.entries(registry.bridges) + .filter(([, bridge]) => this.isPidAlive(bridge.bridgePid)); + + const next: ChannelBridgeFile = { bridges: Object.fromEntries(liveEntries) }; + await this.writeBridgeRegistry(next); + return Object.values(next.bridges); + } + + async getLiveBridgeByChannel(channelName: string): Promise { + const liveBridges = await this.getLiveBridges(); + return liveBridges.find(bridge => bridge.channelName === channelName); + } + + async registerBridge(processInfo: ChannelBridgeProcess): Promise { + const registry = await this.readBridgeRegistry(); + registry.bridges[processInfo.channelName] = processInfo; + await this.writeBridgeRegistry(registry); + } + + async unregisterBridge(channelName: string): Promise { + const registry = await this.readBridgeRegistry(); + delete registry.bridges[channelName]; + await this.writeBridgeRegistry(registry); + } + + private async readBridgeRegistry(): Promise { + try { + const raw = fs.readFileSync(this.registryPath, 'utf-8'); + return JSON.parse(raw) as ChannelBridgeFile; + } catch { + return { bridges: {} }; + } + } + + private async writeBridgeRegistry(registry: ChannelBridgeFile): Promise { + const dir = path.dirname(this.registryPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(this.registryPath, JSON.stringify(registry, null, 2), { mode: 0o600 }); + } +}