Skip to content

Commit 70c86c9

Browse files
feat(channel): render AskUserQuestion as Telegram inline keyboard (#127)
* feat(channel): render AskUserQuestion as Telegram inline keyboard * feat(channel): add Skip button
1 parent 7fa1ff7 commit 70c86c9

16 files changed

Lines changed: 1600 additions & 31 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
phase: design
3+
title: System Design & Architecture
4+
description: Define the technical architecture, components, and data models
5+
---
6+
7+
# System Design & Architecture
8+
9+
## Architecture Overview
10+
11+
```mermaid
12+
graph TD
13+
Agent[Claude Code Agent] -->|hook writes agent-request| Hook[(agent-request JSONL)]
14+
Hook -->|poll| Runner[channel-runner.ts]
15+
Runner -->|tryHandle| Service[AskUserQuestionService]
16+
Service -->|sendInlineKeyboard| Adapter[TelegramAdapter]
17+
Adapter -->|HTML + reply_markup| TG[Telegram Chat]
18+
TG -->|callback_query| Adapter
19+
Adapter -->|onCallback| Runner
20+
Runner -->|handleCallback| Service
21+
Service -->|sendKey digit| TTY[TtyWriter.sendKey]
22+
TTY -->|raw keystroke| Agent
23+
```
24+
25+
| Component | Responsibility |
26+
|---|---|
27+
| `channel-runner.ts` | Orchestrate. Route `AskUserQuestion` to the service; register adapter callback handler; inject `TtyWriter.sendKey` as the agent-write callback. |
28+
| `AskUserQuestionService` (new, in `ask-user-question.ts`) | Parse input, render the keyboard (numbered options + Skip for single-select; Skip-only for multi-select), track an in-memory session per `questionId`, resolve the digit or Esc on tap, write to the agent. |
29+
| `TelegramAdapter` | Adds 4 methods: `sendInlineKeyboard`, `editInlineKeyboard`, `answerCallback`, `onCallback`. |
30+
| `TtyWriter.sendKey` (new) | Send a single raw keystroke to the agent's terminal, bypassing bracketed paste and auto-Enter. Translates the Esc byte (`\x1b`) to the backend-native representation. |
31+
32+
## Data Models
33+
34+
```ts
35+
// ask-user-question.ts (internal)
36+
37+
interface QuestionOption {
38+
label: string;
39+
description?: string;
40+
}
41+
42+
interface QuestionSpec {
43+
question: string;
44+
header?: string;
45+
options: QuestionOption[];
46+
multiSelect: boolean;
47+
}
48+
49+
interface ActiveSession {
50+
questionId: string; // short base36 counter, never sent to agent
51+
chatId: string;
52+
spec: QuestionSpec;
53+
messageId: number | null;
54+
}
55+
56+
// callback_data encoding (≤64 bytes):
57+
// q:<questionId>:o:<optionIdx> — option tap (single-select keyboards only)
58+
// q:<questionId>:skip — Skip tap (every keyboard)
59+
// questionId is a base36 counter (1-2 chars); optionIdx is decimal — both well under 64 bytes.
60+
```
61+
62+
```ts
63+
// channel-connector/src/types.ts (new exports)
64+
65+
export interface InlineKeyboardButton {
66+
text: string;
67+
callbackData: string;
68+
}
69+
export type InlineKeyboard = InlineKeyboardButton[][]; // rows of buttons
70+
71+
export interface IncomingCallback {
72+
channelType: string;
73+
chatId: string;
74+
userId: string;
75+
messageId: number;
76+
callbackData: string;
77+
callbackQueryId: string;
78+
timestamp: Date;
79+
}
80+
export type CallbackHandler = (callback: IncomingCallback) => Promise<void>;
81+
```
82+
83+
## API Design
84+
85+
### TelegramAdapter additions
86+
87+
```ts
88+
class TelegramAdapter implements ChannelAdapter {
89+
// existing: start, stop, sendMessage, onMessage, isHealthy
90+
91+
/** Send a message with an inline keyboard. Returns the Telegram message_id. */
92+
async sendInlineKeyboard(chatId: string, html: string, keyboard: InlineKeyboard): Promise<number>;
93+
94+
/** Replace (or remove with `null`) the keyboard on an existing message. */
95+
async editInlineKeyboard(chatId: string, messageId: number, keyboard: InlineKeyboard | null): Promise<void>;
96+
97+
/** Ack a callback_query, optionally with a transient toast. */
98+
async answerCallback(callbackQueryId: string, text?: string): Promise<void>;
99+
100+
/** Register a handler for inline-keyboard taps. */
101+
onCallback(handler: CallbackHandler): void;
102+
}
103+
```
104+
105+
`ChannelAdapter` (the abstract interface) stays minimal — keyboard methods live only on the concrete `TelegramAdapter`. Promote to the interface if a second platform needs them.
106+
107+
### TtyWriter addition
108+
109+
```ts
110+
class TtyWriter {
111+
static async send(location, message): Promise<void>; // existing: bracketed paste + Enter
112+
static async sendKey(location, key): Promise<void>; // NEW: raw single keystroke
113+
}
114+
```
115+
116+
- **tmux** path: `tmux send-keys -t <id> <key>` — direct, no paste buffer.
117+
- **iTerm2 / Terminal.app**: focus the target session/tab, then `tell application "System Events" to keystroke "<key>"`. Requires macOS Accessibility permission.
118+
119+
### ask-user-question.ts service
120+
121+
```ts
122+
export class AskUserQuestionService {
123+
constructor(telegram: KeyboardChannel, sendToAgent: SendToAgent);
124+
125+
/** Returns true if it owned the request; false → caller falls back to plain text. */
126+
tryHandle(toolInput: Record<string, unknown>, chatId: string): Promise<boolean>;
127+
128+
/** Resolve an inline-keyboard tap, write the digit to the agent, clear the keyboard. */
129+
handleCallback(cb: IncomingCallback): Promise<void>;
130+
}
131+
```
132+
133+
`KeyboardChannel` is a 3-method subset of `TelegramAdapter` (`sendInlineKeyboard`, `editInlineKeyboard`, `answerCallback`) — kept narrow so unit tests can supply a tiny stub.
134+
135+
`SendToAgent = (key: string) => Promise<void>` — in production wired to `(key) => TtyWriter.sendKey(terminalLocation, key)`.
136+
137+
## Component Breakdown
138+
139+
- `packages/channel-connector/src/types.ts` — add `InlineKeyboardButton`, `InlineKeyboard`, `IncomingCallback`, `CallbackHandler`.
140+
- `packages/channel-connector/src/index.ts` — re-export new types.
141+
- `packages/channel-connector/src/adapters/TelegramAdapter.ts` — 4 new methods + `bot.on('callback_query', ...)` wiring.
142+
- `packages/agent-manager/src/terminal/TtyWriter.ts` — new `sendKey` static method (tmux + iTerm2 + Terminal.app paths).
143+
- `packages/cli/src/services/channel/ask-user-question.ts` (new) — service, parser, formatter, keyboard builder, escape helper.
144+
- `packages/cli/src/services/channel/channel-runner.ts`:
145+
- `startOutputPolling` accepts optional `askUserQuestionService`.
146+
- Polling loop offers `AskUserQuestion` payloads to the service; on `false` (multi-question / malformed) falls back to `formatPromptMessage`.
147+
- `runChannelBridge` constructs the service with `TtyWriter.sendKey` and registers `telegram.onCallback`.
148+
149+
## Design Decisions
150+
151+
- **Single-question scope.** Multi-question payloads require sequencing keystrokes across pickers with timing assumptions; we reject them at the parser and fall back to plain `[Question]` text where the user can free-text reply.
152+
- **Multi-select is supported as Skip + chat reply, not toggle/Submit.** Driving multi-select toggles through the picker is just as fragile as multi-question. Instead, render the question and numbered options as a read-only formatted message with a `Skip`-only keyboard, plus a hint asking the user to reply in chat. The existing `onMessage` → `TtyWriter.send` path delivers the typed reply.
153+
- **Skip on every keyboard.** A `Skip` button is appended to every keyboard. On tap it sends the Esc byte (`\x1b`) via `sendKey`, which translates per backend (tmux `Escape`, AppleScript `key code 53`, wezterm literal byte).
154+
- **Digit keystroke via `sendKey`, not text via `send`.** The host picker reads stdin in raw mode. `TtyWriter.send` wraps payloads in bracketed paste markers `\e[200~ ... \e[201~`, which the inner TUI routes to the focused text input ("Other"), not to the picker's digit hotkey. `sendKey` sends a raw keystroke that the picker captures and selects+confirms immediately. Free-text replies (for the multi-select chat-reply path) intentionally use `send` so they reach the picker's text-input field.
155+
- **Process-memory state.** Bridge restart drops in-flight questions; on tap of a stale button we ack with a "Question expired" toast. Acceptable per requirements; avoids file-IO complexity.
156+
- **`questionId` is a short base36 counter.** Keeps `callback_data` ≤64 bytes and contains no PII.
157+
- **HTML escaping at the body builder.** Telegraf does not auto-escape; all user-controlled fields go through `escapeHtml` before insertion.
158+
- **Defense-in-depth on `chatId`.** The adapter delivers all callbacks; the runner filters by `chatIdRef.value`; the service re-checks `session.chatId`. Three independent gates.
159+
- **Keep `ChannelAdapter` interface minimal.** Inline keyboards are Telegram-specific; the runner imports `TelegramAdapter` directly. Revisit if a second adapter needs them.
160+
161+
## Non-Functional Requirements
162+
163+
- **Performance.** Callbacks are human-rate. No batching needed.
164+
- **Security.** HTML-escape every user-controlled field; AppleScript-escape every `sendKey` payload; reject callbacks whose `chatId` doesn't match the authorized chat.
165+
- **Reliability.** Callback handler errors must not crash the bridge: every external call (`editInlineKeyboard`, `answerCallback`, `sendToAgent`) is wrapped in try/catch + `debug` logging.
166+
- **Backwards compatibility.** Non-AskUserQuestion tool prompts unchanged. The legacy plain-text `[Question] <text>` path remains as the fallback.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
phase: implementation
3+
title: Implementation Guide
4+
description: What was actually built for telegram-question-buttons
5+
---
6+
7+
# Implementation Guide
8+
9+
## Files Changed
10+
11+
- `packages/channel-connector/src/types.ts` — added `InlineKeyboardButton`, `InlineKeyboard`, `IncomingCallback`, `CallbackHandler`.
12+
- `packages/channel-connector/src/index.ts` — re-exported the new types.
13+
- `packages/channel-connector/src/adapters/TelegramAdapter.ts` — added `sendInlineKeyboard`, `editInlineKeyboard`, `answerCallback`, `onCallback`; wired `bot.on('callback_query', ...)` in `start()` to normalize callbacks into `IncomingCallback` and dispatch to the handler.
14+
- `packages/cli/src/services/channel/ask-user-question.ts` (new) — `AskUserQuestionService` (in-memory session store keyed by base36 question id), `parseAskUserQuestionInput`, `formatAskUserQuestionBody`, `buildKeyboard`, `escapeHtml`. Service exposes `tryHandle(toolInput, chatId)` for the runner and `handleCallback(cb)` for adapter callbacks.
15+
- `packages/cli/src/services/channel/channel-runner.ts` — `startOutputPolling` accepts an optional `askUserQuestionService`. When the agent-request `toolName` is `AskUserQuestion`, the service is offered the input first; on `false` (malformed payload) the runner falls back to the existing `formatPromptMessage` + `sendMessage` path. `runChannelBridge` constructs the service with `(message) => TtyWriter.send(terminalLocation, message)` and registers `telegram.onCallback`.
16+
17+
## Test Files Changed
18+
19+
- `packages/cli/src/__tests__/services/channel/ask-user-question.test.ts` (new) — tests covering parser (single-select, multi-select, multi-question reject, malformed), escapeHtml, body formatter (header omit, multi-select hint, HTML escape), keyboard builder (single-select numbered + Skip; multi-select Skip-only), service flow (option tap, Skip tap, stale callback, chatId mismatch, malformed, out-of-range).
20+
- `packages/cli/src/__tests__/services/channel/channel-runner.test.ts` — replaced the two raw-JSON assertions with inline-keyboard assertions; added malformed-payload fallback test.
21+
- `packages/cli/src/__tests__/commands/channel.test.ts` — widened the `mockTelegramAdapter` shape to include the new keyboard methods.
22+
- `packages/channel-connector/src/__tests__/adapters/TelegramAdapter.test.ts` — added stub methods (`editMessageReplyMarkup`, `answerCbQuery`) and `_triggerCallback` helper to the mocked bot; added 6 tests for the new adapter methods.
23+
24+
## Scope
25+
26+
**Supported:**
27+
- Single-select, single-question `AskUserQuestion` payloads: numbered option buttons + Skip button.
28+
- Multi-select single-question payloads (`multiSelect: true`): Skip-only keyboard; user types the answer in chat.
29+
- `Skip` button on every keyboard — sends Esc (`\x1b`) to dismiss the picker.
30+
31+
**Deliberately unsupported (fall back to plain `[Question]` text):**
32+
- `questions` array with length ≠ 1 (multi-question payloads).
33+
34+
Rationale: driving multi-question through the TTY picker is fragile — it requires the bridge to sequence digit + Enter keystrokes across multiple turns, with timing assumptions about when the next picker opens. The fallback path still works for those cases; the user can type a free-text reply.
35+
36+
Multi-select was originally in the unsupported list for the same reason. We now support it as a chat-reply flow (no option buttons, just Skip) because the existing free-text path already handles arbitrary user input correctly and the Skip button gives the user a clean dismiss.
37+
38+
## Callback Data Format
39+
40+
Two shapes, each bounded ≤64 bytes (Telegram cap):
41+
- `q:<id>:o:<idx>` — option tap (single-select keyboards only)
42+
- `q:<id>:skip` — Skip tap (every keyboard)
43+
44+
`<id>` is a base36-encoded monotonic counter; never sent to the agent.
45+
46+
## State Model
47+
48+
In-memory `Map<questionId, ActiveSession>` on the service instance. Each session holds `spec`, `messageId`, and `chatId`. Session is deleted on finalization or on send failure. Bridge restart drops all sessions; subsequent taps on dropped questions get a "Question expired" toast.
49+
50+
## Final Answer Delivery
51+
52+
The bridge writes the resolved answer to the agent's TTY via the new `TtyWriter.sendKey` — **not** `TtyWriter.send`, and **not** the option label.
53+
54+
| User action | Sent to agent | Toast |
55+
|---|---|---|
56+
| Tap option N (single-select) | digit `String(optionIdx + 1)` | option `label` |
57+
| Tap Skip (any keyboard) | Esc byte `\x1b` | "Skipped" |
58+
| Type free-text in Telegram chat | the typed text (via existing `onMessage` → `TtyWriter.send` path) | n/a |
59+
60+
`TtyWriter.send` was unusable for option taps because it wraps the payload in bracketed paste markers and auto-appends Enter — the picker would see a paste of "1\n", which lands in the "Other" free-text field rather than triggering the digit hotkey. `TtyWriter.send` is still the right path for free-text replies because the picker's text-input field *is* the intended destination there.
61+
62+
`TtyWriter.sendKey` sends a raw keystroke:
63+
- **tmux:** `tmux send-keys -t <id> <key>` (single call, no paste buffer, no auto-Enter). For `\x1b` it sends the named key `Escape`.
64+
- **WezTerm:** `wezterm cli send-text --pane-id <id> --no-paste <key>` (Esc byte passes through literally).
65+
- **iTerm2 / Terminal.app:** focus the target session/tab, then `System Events keystroke "<key>"` (typeable chars) or `key code 53` (Esc). Requires Accessibility permissions on macOS.
66+
67+
The Esc translation logic is centralized in `appleScriptKeyAction()` so adding more non-typeable keys (arrows, F-keys) later is a one-line addition.
68+
69+
## Out-of-Scope / Deferred
70+
71+
- Persistent sessions across bridge restarts.
72+
- Multi-tenant chat support (bridge is single-authorized-chat by design).
73+
- Multi-question support (intentionally deferred; see Scope).
74+
- Inline-button toggle/Submit UX for multi-select (we route through chat reply instead).
75+
- Inline-keyboard support on the abstract `ChannelAdapter` interface (kept Telegram-specific in v1).
76+
- Live Telegram smoke test (owner: user).
77+
78+
## Verification
79+
80+
- `npm run build` → exit 0 across 5 projects.
81+
- `npm test` → exit 0; 1,495 tests pass.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
phase: planning
3+
title: Project Planning & Task Breakdown
4+
description: Break down work into actionable tasks and estimate timeline
5+
---
6+
7+
# Project Planning & Task Breakdown
8+
9+
Per `feedback_backlog_format.md`: flat checklist, no multi-status sections.
10+
11+
## Tasks
12+
13+
- [x] T1 — Extend `packages/channel-connector/src/types.ts` with `InlineKeyboardButton`, `InlineKeyboard`, `IncomingCallback`, `CallbackHandler`; re-export from `index.ts`.
14+
- [x] T2 — Add `sendInlineKeyboard`, `editInlineKeyboard`, `answerCallback`, `onCallback` to `TelegramAdapter`. Wire `bot.on('callback_query', ...)` in `start()`.
15+
- [x] T3 — Adapter unit tests for the four new methods using a stubbed Telegraf bot.
16+
- [x] T4 — Created new module `packages/cli/src/services/channel/ask-user-question.ts` with `AskUserQuestionService` (in-memory session map), `parseAskUserQuestionInput`, `buildKeyboard`, `formatAskUserQuestionBody`, `escapeHtml`.
17+
- [x] T5 — Registered `telegram.onCallback(handleCallback)` in `runChannelBridge`; the service is injected into `startOutputPolling`.
18+
- [x] T6 — Polling loop routes `AskUserQuestion` to `askQuestion.tryHandle(...)`; falls back to `formatPromptMessage` + `sendMessage` when the service is absent or the payload is malformed.
19+
- [x] T7 — Updated `channel-runner.test.ts:171-226` to assert keyboard shape, not raw JSON.
20+
- [x] T8 — Added multi-question fixture test in `ask-user-question.test.ts`.
21+
- [x] T9 — Full repo `npm run build` (exit 0) + `npm test` (exit 0; 1,495 tests across 5 projects).
22+
- [ ] T10 — Manual Telegram smoke test (live bot). Owner: user.
23+
- [x] T11 — Implementation doc updated.
24+
25+
## Dependencies
26+
27+
- T2 depends on T1.
28+
- T3 depends on T2.
29+
- T4 depends on T1.
30+
- T5 depends on T2, T4.
31+
- T6 depends on T4, T5.
32+
- T7, T8 depend on T6.
33+
- T9 depends on T7, T8.
34+
- T10 depends on T9.
35+
- T11 depends on T10.

0 commit comments

Comments
 (0)