SLOPSHOPPER

telegram-ask

Mirrors every AskUserQuestion dialog to Telegram through a local bridge; the first answer, terminal or Telegram, wins

newguardprocessnetwork
A shopper browsing a rack in a slop shop
README

telegram-ask

Mirrors every AskUserQuestion dialog of every Claude Code session on the machine to a Telegram chat. The terminal dialog opens as usual; the same question arrives in Telegram with one button per option. The first answer wins: a Telegram answer closes the terminal dialog, and a terminal answer marks the Telegram message as answered there.

Spec: docs/code-plan/specs/2026-10-23-telegram-ask-design.md (local, gitignored). Plan: issue #164.

Parts

  • hooks/register.ts: the mod. A tool.call hook on AskUserQuestion runs next(e) (the dialog) and, beside it, posts the question to the bridge and long-polls for the answer. Returning { result } while next(e) is pending aborts the dialog.
  • bridge/: the daemon (Bun). One bridge serves every session on the machine and polls its bot with getUpdates. API over a Unix socket: POST /ask, GET /wait/:id (25 s long-poll), POST /resolve/:id, POST /update (updates forwarded by another poller, see below).
  • deploy/telegram-ask-bridge.service: the systemd user unit. deploy/forwarded.conf.example: a drop-in for the forwarded setup.

Telegram

  • Single choice: tap the option. Multi-select: tap to toggle (✅), then 📨 Kirim at the bottom. Free text: tap ✏️ Lainnya, then send the answer as a normal message.
  • A call with several questions resolves only when every question is answered. Each question opens with a header row (━━ 2/3 · Header ━━, a tap does nothing), and one 📨 Kirim semua jawaban at the very bottom answers every multi-select question at once; a Send with a question still open names it in a toast.
  • Only updates from TELEGRAM_CHAT_ID count; any other chat is ignored.
  • The message names the project and the first 8 characters of the session id, and quotes Claude's latest text (cut so the message stays within 4096 characters).
  • Markdown is rendered (parse_mode: HTML, bridge/markdown.ts): bold, italic, code, links, headings, lists and quotes; a table becomes an aligned monospace block because Telegram cannot draw tables. Button labels drop the markers. If Telegram still refuses the HTML, the same message is sent again as plain text.

Install

  1. Create a dedicated bot: talk to @BotFather, send /newbot, and keep the token it gives you.
  2. Find your chat id: send your new bot any message, then, before the bridge runs (Telegram allows only one poller per bot):
   curl -s "https://api.telegram.org/bot$TOKEN/getUpdates" | jq '.result[].message.chat.id'
  1. Write the env file the bridge reads ($XDG_CONFIG_HOME/vivera-telegram-ask/.env, by default ~/.config/vivera-telegram-ask/.env; TELEGRAM_ASK_ENV overrides the path):
   mkdir -p ~/.config/vivera-telegram-ask
   install -m 600 /dev/null ~/.config/vivera-telegram-ask/.env
   printf 'TELEGRAM_BOT_TOKEN=%s\nTELEGRAM_CHAT_ID=%s\n' "$TOKEN" "$CHAT_ID" > ~/.config/vivera-telegram-ask/.env
  1. Install the unit and the mod:
   cp ~/vivera/mods/telegram-ask/deploy/telegram-ask-bridge.service ~/.config/systemd/user/
   systemctl --user daemon-reload && systemctl --user enable --now telegram-ask-bridge.service
   claude plugin marketplace add ~/vivera/mods    # once, shared with sprite-model-router
   claude plugin install telegram-ask@vivera-mods

Then /reload-plugins in a running session.

  1. If the repository is not at ~/vivera or Bun is not at ~/.bun/bin/bun (check with command -v bun), point the unit at both with a drop-in that clears ExecStart and sets it again (command -v bun fills in the Bun path; replace /path/to/vivera with your clone), then restart:
   mkdir -p ~/.config/systemd/user/telegram-ask-bridge.service.d
   printf '[Service]\nExecStart=\nExecStart=%s %s\n' "$(command -v bun)" /path/to/vivera/mods/telegram-ask/bridge/main.ts \
     > ~/.config/systemd/user/telegram-ask-bridge.service.d/paths.conf
   systemctl --user daemon-reload && systemctl --user restart telegram-ask-bridge.service

The cp and claude plugin marketplace add lines above take the same repository path.

  • In the env file, KEY=value # note drops the note; a # inside quotes or with no space before it stays part of the value.
  • The bridge never copies or logs the token. Never set a webhook on the bot: Telegram stops getUpdates while one is set.
  • Socket: $XDG_RUNTIME_DIR/vivera-telegram-ask.sock, mode 0600. Override with TELEGRAM_ASK_SOCKET for the bridge and the socketPath option in /config for the mod.

OpenCode

OpenCode has no AskUserQuestion hook, so the mirror there is a plugin: it reads the session form the built-in question tool opens and answers that form through the server's own HTTP API. It uses the same bridge, socket, and bot as the Claude Code half.

cp ~/vivera/mods/telegram-ask/opencode/telegram-ask.ts ~/.config/opencode/plugins/telegram-ask.ts

Restart OpenCode afterwards.

  • Measured 2026-10-23 on OpenCode 2.1.3: in a standalone TUI session and in an opencode serve --service session the dialog opens in the TUI and the same question arrives in Telegram; an answer entered in Telegram closes the dialog and reaches the model, a TUI answer marks the Telegram message, and Esc cancels it.
  • One question call posts one Telegram message; answers come back keyed by question text, which is how the plugin maps them onto the form's fields.
  • TELEGRAM_ASK_DEBUG=<file> in the environment that starts OpenCode logs wait and reply status lines, never question text, answers, or the password.
  • Do not wrap the tool's execute through ctx.tool.transform: measured the same day, even a pass-through wrapper hides the TUI dialog. bun test scripts/telegram-ask.test.mjs fails when the plugin's code names ctx.tool; aliasing it through another variable would slip past that check.
  • Revert: rm ~/.config/opencode/plugins/telegram-ask.ts, then restart OpenCode.

When another program already polls the bot

Telegram answers 409 to a second getUpdates poller on one token. If another program already polls the bot you want to use, that program stays the only poller, the bridge stops polling, and the program forwards each update to the bridge's POST /update. The bridge answers { consumed } within about a second: a tap on a telegram-ask button is always consumed, a text message only while a question waits for its ✏️ Lainnya answer, and nothing from another chat. The forwarding program keeps every update that is not consumed.

Snipset desktop does this when its telegram_bridge_socket preference names the bridge socket ($XDG_RUNTIME_DIR/vivera-telegram-ask.sock unless overridden; Snipset PR belajarcarabelajar/Snipset#730).

mkdir -p ~/.config/systemd/user/telegram-ask-bridge.service.d
cp ~/vivera/mods/telegram-ask/deploy/forwarded.conf.example ~/.config/systemd/user/telegram-ask-bridge.service.d/forwarded.conf
# edit forwarded.conf: uncomment TELEGRAM_ASK_ENV and point it at the env file holding that bot's token and your chat id
systemctl --user daemon-reload && systemctl --user restart telegram-ask-bridge.service

The drop-in sets TELEGRAM_ASK_POLL=0; the bridge then logs polling off (updates are forwarded to POST /update) and only serves the socket.

Logs and failure

  • journalctl --user -u telegram-ask-bridge -f: ask ids and counts only, never question text, answers, or the token.
  • Bridge stopped or crashed: the dialog shows telegram-ask: bridge offline and works as before. A pending ask is lost when the bridge restarts; its buttons then answer "Pertanyaan ini sudah selesai".
  • There is no answer deadline while the session waits: a question stays answerable from Telegram for hours, and a ✏️ Lainnya tap keeps taking your next text as long as its question is open. An ask is dropped about 2 minutes after its session stops polling the bridge (terminal closed or killed); its buttons then answer "Pertanyaan ini sudah selesai".
  • telegram-ask: not sent to Telegram (<status>) under the dialog: the bridge is up but refused the question; the terminal dialog works as usual.
  • Exit 2 at start: a missing env file, key, or socket path, or another bridge is already listening on <path> (the unit does not restart on it). A socket file nobody answers on is replaced. The log line names the missing file or key, never a value.
  • getUpdates 409 in the log: another program polls the same bot; use the forwarded setup above.

Tests

bun test ./mods/telegram-ask/bridge/          # bridge, Bun tests named *.spec.ts
claude plugin test mods/telegram-ask          # the hook, *.test.ts
bun test scripts/telegram-ask.test.mjs        # all of the above plus the OpenCode plugin, as the repository suite runs them

The bridge tests end in .spec.ts because claude plugin test runs every *.test.ts under the mod and refuses a bun:test import. mods/telegram-ask/opencode/ therefore carries no test file: the suite test asserts there that the plugin module loads, exports id and setup, keeps the bridge socket file name and the shared TELEGRAM_ASK_SOCKET override, and never names ctx.tool in code.

Revert

systemctl --user disable --now telegram-ask-bridge.service
rm -r ~/.config/systemd/user/telegram-ask-bridge.service.d    # only if you installed the drop-in
rm ~/.config/systemd/user/telegram-ask-bridge.service && systemctl --user daemon-reload
claude plugin uninstall telegram-ask@vivera-mods
Source 1 files
hooks/register.ts 129 lines
1import type { Register } from 'claude-code'
2
3const SOCKET_NAME = 'vivera-telegram-ask.sock'
4
5// Read once per process: the runtime dir does not change under a running session.
6let runtimeDir: string | undefined
7
8type Answers = Record<string, string>
9type Outcome = { from: 'telegram'; answers: Answers } | { from: 'dialog'; value: unknown } | { from: 'dialogError'; error: unknown }
10
11function basename(path: string): string {
12  return path.replace(/\/+$/, '').split('/').pop() ?? ''
13}
14
15function hasAnswers(value: unknown): value is Answers {
16  return typeof value === 'object' && value !== null && Object.keys(value).length > 0
17}
18
19export const register: Register = (on, options) => {
20  const configured = String(options.socketPath ?? '').trim()
21
22  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
23    if (hasAnswers(e.answers)) return next(e)
24
25    const dialog = next(e)
26    const settled: Promise<Outcome> = dialog.then(
27      value => ({ from: 'dialog', value }),
28      error => ({ from: 'dialogError', error }),
29    )
30
31    let socketPath = configured
32    let id: string
33    try {
34      if (!socketPath) {
35        if (runtimeDir === undefined) {
36          const ran = await $.process.run(['printenv', 'XDG_RUNTIME_DIR'])
37          runtimeDir = ran.exitCode === 0 ? ran.stdout.trim() : ''
38        }
39        if (!runtimeDir) throw new Error('XDG_RUNTIME_DIR is not set')
40        socketPath = `${runtimeDir}/${SOCKET_NAME}`
41      }
42      const root = await $.session.root()
43      const sessionId = await $.session.id()
44      const asked = await $.http.fetch('http://bridge/ask', {
45        method: 'POST',
46        headers: { 'content-type': 'application/json' },
47        body: JSON.stringify({
48          project: basename(root),
49          session: sessionId ? sessionId.slice(0, 8) : basename(await $.session.cwd()),
50          context: await latestClaudeText(),
51          questions: e.questions,
52        }),
53        socketPath,
54      })
55      if (!asked.ok) {
56        // The bridge is up but refused this question; "offline" would send the owner to the wrong fix.
57        $.ui.notice(e.tool_use_id, `telegram-ask: not sent to Telegram (${asked.status})`)
58        return dialog
59      }
60      id = String(JSON.parse(asked.text).id)
61    } catch {
62      $.ui.notice(e.tool_use_id, 'telegram-ask: bridge offline')
63      return dialog
64    }
65    $.ui.notice(e.tool_use_id, 'telegram-ask: juga dikirim ke Telegram')
66
67    // Set once the race settles, so a /resolve the bridge never got cannot leave a poll looping.
68    let raceOver = false
69    const fromTelegram = waitForTelegram(id, socketPath)
70    const first = await Promise.race([
71      settled,
72      fromTelegram.then(answers => (answers ? ({ from: 'telegram', answers } as Outcome) : settled)),
73    ])
74    raceOver = true
75
76    if (first.from === 'telegram') return { result: { questions: e.questions, answers: first.answers } }
77
78    const result = first.from === 'dialog' ? (first.value as { result?: { answers?: unknown } }) : undefined
79    const answers = result?.result?.answers
80    await resolve(id, socketPath, hasAnswers(answers) ? { by: 'terminal', answers } : { by: 'cancelled' })
81    return dialog
82
83    async function latestClaudeText(): Promise<string> {
84      try {
85        const messages = await $.session.messages({ as: 'api' })
86        const last = [...messages].reverse().find(m => m.role === 'assistant')
87        if (!last) return ''
88        return last.content
89          .flatMap(block => (block.type === 'text' ? [block.text] : []))
90          .join('\n')
91      } catch {
92        return ''
93      }
94    }
95
96    // Polls until the bridge has answers; null when it closed, forgot the id, or failed three times in a row.
97    async function waitForTelegram(askId: string, sock: string): Promise<Answers | null> {
98      let failures = 0
99      while (!raceOver) {
100        try {
101          const res = await $.http.fetch(`http://bridge/wait/${askId}`, { socketPath: sock })
102          failures = 0
103          if (!res.ok) return null
104          const body = JSON.parse(res.text) as { status?: string; answers?: Answers }
105          if (body.status === 'answered' && body.answers) return body.answers
106          if (body.status !== 'pending') return null
107        } catch {
108          // One dropped long-poll must not end listening to Telegram for the rest of the dialog.
109          if (++failures >= 3) return null
110        }
111      }
112      return null
113    }
114
115    async function resolve(askId: string, sock: string, body: object): Promise<void> {
116      try {
117        await $.http.fetch(`http://bridge/resolve/${askId}`, {
118          method: 'POST',
119          headers: { 'content-type': 'application/json' },
120          body: JSON.stringify(body),
121          socketPath: sock,
122        })
123      } catch {
124        // The terminal answer stands whether or not the bridge heard about it.
125      }
126    }
127  }).catch(($, e, next) => next(e))
128}
129