SLOPSHOPPER

pager

Pager: Claude Code pings you in Telegram when a task is done or it waits for your permission, and you text back the next prompt, /status, /last or /stop from…

newcommandtoastpromptnetworktimer
v0.3.1MITupdated 2026-10-11vihrea1337/pager
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pager
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pager ⎿ pager: pager: token missing — /pager token <token> ⎿ pager: chat not paired — text the bot, then /pager pair <code> ⎿ pager: listening in another session ⎿ pager: notify after 0 s · language en ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Pager 📟

Claude Code in your pocket: a Telegram message when a task is done or Claude waits for your permission — and you text back the next prompt.

A mod (function-hooks plugin) for Claude Code. No server, no Bun, no extra flags: the mod talks to your own Telegram bot through Claude Code's network, and only the chat you paired can talk back.

Русская версия

<img src="docs/img/01-chat.png" width="320" alt="A prompt texted from the phone and the finished answer back in Telegram">

What it does

  • ✅ Done — when a turn that took longer than N seconds ends, the paired chat gets the project folder, how long it took and Claude's answer (long answers are cut at a word, /last sends the whole one).
  • ⏸ Waiting for you — when Claude stops to ask for permission or input, you get the question.
  • 📨 Text the next prompt — any message to the bot is queued into the session as your own prompt; it runs as soon as Claude is free. Its turn is always reported back, however short.
  • /status — idle, or what Claude is working on and for how long
  • /last — the last answer in full
  • /stop — stop the running task
  • Several Claude Code windows open? They all send, but only one listens to the bot at a time (a lease in the mod's store; Telegram allows a single reader per bot).
✅ Done · 📁 galleria · 3 min 05 s

All 49 unit tests pass. I fixed the cache: the museum's API sent `Vary: *`…

Bot commands

any textqueued into the session as your own prompt
/statusidle, or what Claude is working on and for how long
/lastthe last answer in full
/stopstop the running task
/helpthe list above

The bot's Menu button lists them too — Pager fills it in on start and on pairing.

<img src="docs/img/02-commands.png" width="300" alt="/status while idle, /last repeating the whole answer, /stop with nothing running"> <img src="docs/img/03-status-stop.png" width="300" alt="A prompt from Telegram, /status showing the running task, /stop ending it">

Install

/plugin install pager --marketplace vihrea1337/pager

Answer y to add the marketplace, pick the user scope, and paste your bot token when asked (it goes to Claude Code's secure storage).

Set up the bot (2 minutes)

  1. In Telegram, open @BotFather → /newbot → a name → a username ending in bot. Copy the token it sends (1234567890:AA…).
  2. If you skipped the token at install: /pager token <token> in Claude Code.
  3. Text your bot anything. It answers with a 6-digit code.
  4. In Claude Code: /pager pair <code>. Done — /pager test sends a test message.

From then on the bot listens to that chat only; messages from anyone else are dropped silently.

Settings (/config → pager)

SettingDefault
Notify after (seconds)0Every turn is reported. Raise it and quick answers you watched yourself stop buzzing your phone; turns started from Telegram are always reported.
Notify when waitingonThe "waiting for permission / input" message
Accept prompts from TelegramonOff = notifications only
Desktop notification toooffAlso a system notification on this computer
Languageenen or ru

/pager alone shows what is set up: token, paired chat, which session is listening.

Security

A message to your bot becomes a prompt in a session that can edit files and run commands, so:

  • only the paired chat is heard — pairing needs a code shown in that chat and typed at your terminal;
  • the token is kept in Claude Code's secure storage (or the mod's store if you used /pager token); nobody else should see it — if it leaks, /revoke it in @BotFather;
  • permission prompts still stop the session: approving tool use from Telegram is deliberately not in 0.1.

How it works

HookWhat for
turn.start / turn.completeremembers what is running; on completion sends the answer (e.answer, e.durationMs, e.reason)
classic.Notificationpermission / input prompts → "waiting for you" (with a .catch that always passes the engine's own notification on)
session.startregisters /pager, starts the poll timer ($.clock.every)
timer → $.http.fetchTelegram getUpdates long-poll; text → $.prompt.submit({ asUser: true }); /stop → $.turn.abort
session.endhands the polling lease back

All logic is pure functions in hooks/core.ts (message formatting, update parsing, the lease); hooks/register.ts wires them to the engine. Tests run against the engine's own test kit with Telegram and the store faked: claude plugin test .

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .      # a session with the mod loaded from this folder

Requires Claude Code 2.1.287 or newer (function-hooks mods).

Compared to the official Telegram channel

Claude Code's research-preview channels also bridge Telegram, as an MCP server on Bun started with --channels. Pager is a lighter take on the other half: it notifies you first (a channel only answers what you send), needs nothing but the mod, and works in every session you start. Don't point both at the same bot — Telegram lets one reader poll it.

License

MIT

Source 2 files
hooks/register.ts 410 lines
1// Pager — the hooks. Logic lives in core.ts; this file talks to the engine and to Telegram.
2//
3//   turn.complete           → "✅ Done · 📁 project · 3 min" + the answer, to the paired chat
4//   classic.Notification    → "⏸ Waiting for you" (permission prompts and the like)
5//   a timer (session.start) → long-polls the bot: text → $.prompt.submit, /status /last /stop /help
6//   /pager                  → token <token> · pair <code> · status · test · unpair
7//
8// Network goes through $.http.fetch (the host's network); nothing is read or written on disk
9// but the mod's own store (paired chat, poll offset, the token if set with /pager token).
10
11import type { EngineInterface, PluginOptions, Register } from 'claude-code'
12import {
13  type Lang,
14  type Lease,
15  type TgUpdate,
16  LEASE_MS,
17  MAX_ANSWER,
18  PAIRING_TTL_MS,
19  classify,
20  doneMessages,
21  escapeHtml,
22  htmlToPlain,
23  looksLikeToken,
24  markdownToHtml,
25  mayPoll,
26  pairingCode,
27  projectName,
28  shorten,
29  splitMarkdown,
30  statusMessage,
31  waitingMessage,
32  words,
33} from './core'
34
35const API = 'https://api.telegram.org'
36/** getUpdates long-poll: Telegram holds the request up to this long when nothing comes. */
37const POLL_SECONDS = 20
38
39/** The module's own memory; a reload starts it over, the store keeps what must survive. */
40type Ctx = {
41  options: PluginOptions
42  lang: Lang
43  sessionId: string
44  project: string
45  running?: { turnId: string; since: number; task: string }
46  /** What Claude wrote between tool calls this turn: shown under "Along the way" in "Done". */
47  notes: string[]
48  lastAnswer: string
49  /** Texts submitted from Telegram: their turns are always reported. */
50  remoteTexts: Set<string>
51  remoteTurn: boolean
52  /** The next turn was started by a background task's notification, not by a person. */
53  taskPrompt: boolean
54  taskTurn: boolean
55  /** Background tasks still running when the last turn stopped (classic Stop's background_tasks). */
56  background: number
57  pending?: { code: string; chatId: number; until: number }
58  polling: boolean
59  warnedToken: boolean
60}
61
62// --- store -----------------------------------------------------------------------------------
63
64async function botToken($: EngineInterface, ctx: Ctx): Promise<string> {
65  const fromOptions = String(ctx.options.botToken ?? '').trim()
66  if (fromOptions !== '') return fromOptions
67  return String((await $.store.get('token')) ?? '').trim()
68}
69
70async function pairedChat($: EngineInterface): Promise<number | undefined> {
71  const id = await $.store.get('chatId')
72  return typeof id === 'number' ? id : undefined
73}
74
75// --- Telegram --------------------------------------------------------------------------------
76
77async function tg($: EngineInterface, ctx: Ctx, method: string, body: Record<string, unknown>): Promise<any> {
78  const token = await botToken($, ctx)
79  if (token === '') return undefined
80  const res = await $.http.fetch(`${API}/bot${token}/${method}`, {
81    method: 'POST',
82    headers: { 'content-type': 'application/json' },
83    body: JSON.stringify(body),
84  })
85  let data: any
86  try {
87    data = JSON.parse(res.text)
88  } catch {
89    data = undefined
90  }
91  if (!res.ok || !data?.ok) throw new Error(`telegram ${method}: ${res.status} ${data?.description ?? ''}`.trim())
92  return data.result
93}
94
95async function send($: EngineInterface, ctx: Ctx, chatId: number, html: string): Promise<void> {
96  try {
97    await tg($, ctx, 'sendMessage', { chat_id: chatId, text: html, parse_mode: 'HTML', disable_web_page_preview: true })
98  } catch (err) {
99    // 400 "can't parse entities": the same words without markup still get through.
100    if (/400/.test(String(err))) {
101      try {
102        await tg($, ctx, 'sendMessage', { chat_id: chatId, text: htmlToPlain(html).slice(0, 4096), disable_web_page_preview: true })
103        return
104      } catch (again) {
105        err = again
106      }
107    }
108    $.ui.toast(`pager: ${String(err).slice(0, 120)}`)
109  }
110}
111
112/** The bot's "Menu" button: the commands it answers, in the chosen language. */
113async function setCommands($: EngineInterface, ctx: Ctx): Promise<void> {
114  const c = words(ctx.lang).commands
115  try {
116    await tg($, ctx, 'setMyCommands', {
117      commands: Object.entries(c).map(([command, description]) => ({ command, description })),
118    })
119  } catch {}
120}
121
122async function notifyPaired($: EngineInterface, ctx: Ctx, html: string | readonly string[]): Promise<void> {
123  const chatId = await pairedChat($)
124  if (chatId === undefined) return
125  for (const part of typeof html === 'string' ? [html] : html) await send($, ctx, chatId, part)
126}
127
128// --- polling (one session at a time holds the lease) -----------------------------------------
129
130async function poll($: EngineInterface, ctx: Ctx): Promise<void> {
131  if (ctx.polling) return
132  ctx.polling = true
133  try {
134    if ((await botToken($, ctx)) === '') {
135      if (!ctx.warnedToken) {
136        ctx.warnedToken = true
137        $.ui.toast('pager: no bot token yet — /pager token <token> (or /config)')
138      }
139      return
140    }
141    const now = await $.clock.now()
142    const lease = (await $.store.get('lease')) as Lease | undefined
143    if (!mayPoll(lease, ctx.sessionId, now)) return
144    await $.store.set('lease', { sessionId: ctx.sessionId, until: now + LEASE_MS } satisfies Lease)
145
146    const offset = Number((await $.store.get('offset')) ?? 0)
147    const updates = ((await tg($, ctx, 'getUpdates', {
148      offset,
149      timeout: POLL_SECONDS,
150      allowed_updates: ['message'],
151    })) ?? []) as TgUpdate[]
152    for (const u of updates) {
153      await $.store.set('offset', u.update_id + 1)
154      await handle($, ctx, u)
155    }
156  } catch {
157    // A network hiccup or Telegram's 409 (another poller): try again next period.
158  } finally {
159    ctx.polling = false
160  }
161}
162
163async function handle($: EngineInterface, ctx: Ctx, update: TgUpdate): Promise<void> {
164  const w = words(ctx.lang)
165  const incoming = classify(update, await pairedChat($))
166  switch (incoming.kind) {
167    case 'ignore':
168      return
169    case 'pair-request': {
170      const code = pairingCode(Math.random())
171      ctx.pending = { code, chatId: incoming.chatId, until: (await $.clock.now()) + PAIRING_TTL_MS }
172      await send($, ctx, incoming.chatId, w.pairHello(code))
173      $.ui.toast(`pager: @${incoming.name} asks to pair — /pager pair <code from Telegram>`, { timeoutMs: 15_000 })
174      return
175    }
176    case 'prompt': {
177      if (ctx.options.acceptPrompts === false) return send($, ctx, incoming.chatId, w.promptsOff)
178      ctx.remoteTexts.add(incoming.text)
179      await send($, ctx, incoming.chatId, ctx.running ? w.queued : w.sent)
180      void $.prompt.submit({ text: incoming.text, asUser: true })
181      return
182    }
183    case 'command':
184      return botCommand($, ctx, incoming.chatId, incoming.name)
185  }
186}
187
188async function botCommand($: EngineInterface, ctx: Ctx, chatId: number, name: string): Promise<void> {
189  const w = words(ctx.lang)
190  switch (name) {
191    case 'start':
192    case 'help':
193      return send($, ctx, chatId, w.help)
194    case 'status':
195      return send(
196        $,
197        ctx,
198        chatId,
199        statusMessage({
200          runningSinceMs: ctx.running?.since,
201          nowMs: await $.clock.now(),
202          task: ctx.running?.task,
203          background: ctx.running ? undefined : ctx.background,
204          lang: ctx.lang,
205        }),
206      )
207    case 'last': {
208      if (ctx.lastAnswer.trim() === '') return send($, ctx, chatId, w.noLast)
209      // The whole answer, in as many messages as it takes.
210      for (const part of splitMarkdown(ctx.lastAnswer, MAX_ANSWER)) await send($, ctx, chatId, markdownToHtml(part))
211      return
212    }
213    case 'stop': {
214      if (!ctx.running) return send($, ctx, chatId, w.nothingToStop)
215      try {
216        await $.turn.abort({ turnId: ctx.running.turnId })
217        return send($, ctx, chatId, w.stoppedTurn)
218      } catch {
219        return send($, ctx, chatId, w.nothingToStop)
220      }
221    }
222    default:
223      return send($, ctx, chatId, w.unknown(name))
224  }
225}
226
227/** `/pager` in the terminal. */
228async function pagerCommand($: EngineInterface, ctx: Ctx, args: string): Promise<string> {
229  const w = words(ctx.lang)
230  const [sub = 'status', ...rest] = args.trim().split(/\s+/)
231  const arg = rest.join(' ').trim()
232  switch (sub) {
233    case 'token': {
234      if (!looksLikeToken(arg)) return 'pager: that does not look like a bot token (1234567890:AA...).'
235      await $.store.set('token', arg)
236      ctx.warnedToken = false
237      try {
238        const me = await tg($, ctx, 'getMe', {})
239        await setCommands($, ctx)
240        return `pager: token saved — bot @${me?.username}. Now text the bot anything; it answers with a code for /pager pair <code>.`
241      } catch (err) {
242        return `pager: token saved, but Telegram refused it: ${String(err)}`
243      }
244    }
245    case 'pair': {
246      const now = await $.clock.now()
247      if (!ctx.pending || ctx.pending.until < now) return 'pager: no pairing in progress — text the bot first, it answers with a code.'
248      if (arg !== ctx.pending.code) return 'pager: wrong code.'
249      await $.store.set('chatId', ctx.pending.chatId)
250      await send($, ctx, ctx.pending.chatId, w.paired)
251      await setCommands($, ctx)
252      ctx.pending = undefined
253      return 'pager: paired — notifications go to that chat, and only it can send prompts.'
254    }
255    case 'unpair':
256      // `delete`, not `set(key, undefined)`: the store takes JSON data, and
257      // `undefined` is not — the set rejected, the command answered nothing, and
258      // the engine said "no command.run hook answered it" (found live 2026-10-11).
259      await $.store.delete('chatId')
260      return 'pager: unpaired. Text the bot again to pair a chat.'
261    case 'test': {
262      const chatId = await pairedChat($)
263      if (chatId === undefined) return 'pager: no chat paired yet.'
264      for (const part of doneMessages({ project: ctx.project, reason: 'answer', durationMs: 75_000, answer: 'Test from Pager 👋', lang: ctx.lang })) {
265        await send($, ctx, chatId, part)
266      }
267      return 'pager: test message sent.'
268    }
269    default: {
270      const token = await botToken($, ctx)
271      const chatId = await pairedChat($)
272      const lease = (await $.store.get('lease')) as Lease | undefined
273      const where = ctx.options.acceptPrompts === false ? 'off' : lease?.sessionId === ctx.sessionId ? 'in this session' : 'in another session'
274      return [
275        `pager: token ${token ? 'set' : 'missing — /pager token <token>'}`,
276        `chat ${chatId !== undefined ? 'paired' : 'not paired — text the bot, then /pager pair <code>'}`,
277        `listening ${where}`,
278        `notify after ${Number(ctx.options.minSeconds ?? 0)} s · language ${ctx.lang}`,
279      ].join('\n')
280    }
281  }
282}
283
284async function releaseLease($: EngineInterface, ctx: Ctx): Promise<void> {
285  try {
286    const lease = (await $.store.get('lease')) as Lease | undefined
287    if (lease?.sessionId === ctx.sessionId) await $.store.delete('lease')
288  } catch {}
289}
290
291async function reportTurn(
292  $: EngineInterface,
293  ctx: Ctx,
294  turn: { reason: 'answer' | 'aborted' | 'refusal' | 'error'; durationMs: number; answer: string; notes: readonly string[] },
295): Promise<void> {
296  const minMs = Math.max(0, Number(ctx.options.minSeconds ?? 0)) * 1000
297  if (turn.durationMs < minMs && !ctx.remoteTurn && !ctx.taskTurn) return
298  await notifyPaired(
299    $,
300    ctx,
301    doneMessages({ project: ctx.project, ...turn, lang: ctx.lang, afterTask: ctx.taskTurn, background: ctx.background }),
302  )
303  if (ctx.options.desktop === true) {
304    await $.ui.notify(shorten(turn.answer.trim() || words(ctx.lang).done, 180).text, { title: `Claude · ${ctx.project}` })
305  }
306}
307
308// --- hooks -----------------------------------------------------------------------------------
309
310export const register: Register = (on, options) => {
311  const ctx: Ctx = {
312    options,
313    lang: options.language === 'ru' ? 'ru' : 'en',
314    sessionId: '',
315    project: '',
316    lastAnswer: '',
317    notes: [],
318    remoteTexts: new Set(),
319    remoteTurn: false,
320    taskPrompt: false,
321    taskTurn: false,
322    background: 0,
323    polling: false,
324    warnedToken: false,
325  }
326
327  on('session.start', async ($, e, next) => {
328    ctx.sessionId = await $.session.id()
329    ctx.project = projectName(e.cwd)
330    await $.command.register({
331      name: 'pager',
332      description: 'Telegram pager: token <token> · pair <code> · status · test · unpair',
333      argumentHint: 'token <token> | pair <code> | status | test | unpair',
334    })
335    if (e.isInteractive && options.acceptPrompts !== false) {
336      // One period = one long-poll at most; ctx.polling keeps them from overlapping.
337      $.clock.every(1500, () => void poll($, ctx))
338      void setCommands($, ctx)
339    }
340    return next(e)
341  })
342
343  on('session.end', async ($, e, next) => {
344    // Hand the lease back so another open session can take the bot at once.
345    await releaseLease($, ctx)
346    return next(e)
347  })
348
349  on('prompt.submit', ($, e, next) => {
350    // Only an idle session's notification starts a turn of its own; one delivered into a running turn does not.
351    if (e.origin.kind === 'task-notification' && e.turnId === undefined) ctx.taskPrompt = true
352    return next(e)
353  }).catch(($, e, next) => next(e))
354
355  on('turn.start', ($, e, next) => {
356    ctx.running = { turnId: e.turnId, since: Date.now(), task: e.text }
357    ctx.remoteTurn = ctx.remoteTexts.delete(e.text)
358    ctx.taskTurn = ctx.taskPrompt
359    ctx.taskPrompt = false
360    ctx.notes = []
361    return next(e)
362  })
363
364  // Text written before a tool call is not the turn's answer (turn.complete carries only the last);
365  // keep it so "Done" can show it. A subagent's steps carry its own run's id and are left out.
366  on('turn.step', async function* ($, e, next) {
367    const result = yield* next(e)
368    if (result.toolUses.length > 0 && result.answer.trim() !== '' && e.turnId === ctx.running?.turnId) {
369      ctx.notes.push(result.answer)
370    }
371    return result
372  })
373
374  // Stop is where the engine lists background work still in flight; it runs before turn.complete's report.
375  on('classic.Stop', ($, e, next) => {
376    ctx.background = e.background_tasks?.length ?? 0
377    return next(e)
378  }).catch(($, e, next) => next(e))
379
380  on('turn.complete', async ($, e, next) => {
381    const result = await next(e)
382    if (e.agentId !== undefined) return result // a subagent's run, not the session's turn
383    ctx.running = undefined
384    ctx.lastAnswer = e.answer
385    void reportTurn($, ctx, { reason: e.reason, durationMs: e.durationMs, answer: e.answer, notes: ctx.notes })
386    ctx.notes = []
387    ctx.remoteTurn = false
388    ctx.taskTurn = false
389    return result
390  })
391
392  on('classic.Notification', async ($, e, next) => {
393    if (options.notifyPermission !== false && /permission|input|waiting/i.test(`${e.notification_type} ${e.message}`)) {
394      void notifyPaired($, ctx, waitingMessage({ project: ctx.project, message: e.message, lang: ctx.lang }))
395    }
396    return next(e)
397  }).catch(($, e, next) => next(e)) // a pager that failed must never swallow the engine's own notification
398
399  // Always answers, even when something inside throws: an unanswered command.run
400  // leaves the person with the engine's "no hook answered it", which says nothing
401  // about what went wrong.
402  on('command.run', { command: 'pager' }, async ($, e) => {
403    try {
404      return { text: await pagerCommand($, ctx, e.args) }
405    } catch (err) {
406      return { text: `pager: ${String(err).slice(0, 300)}` }
407    }
408  })
409}
410
hooks/core.ts 294 lines
1// Pager: all the logic as pure functions — formatting, parsing Telegram updates,
2// the polling lease. register.tsx wires them to the engine; tests call them directly.
3
4export type Lang = 'en' | 'ru'
5
6/** Telegram allows 4096 characters per message; one part of an answer leaves room for the header and tags. */
7export const MAX_ANSWER = 3500
8
9const WORDS = {
10  en: {
11    done: 'Done',
12    stopped: 'Stopped',
13    failed: 'Ended with an error',
14    refused: 'Refused',
15    waiting: 'Waiting for you',
16    noAnswer: '(no text answer)',
17    along: 'Along the way:',
18    result: 'Result:',
19    queued: '📨 Queued — Claude takes it when the current task is done.',
20    sent: '📨 Sent to the session.',
21    pairHello: (code: string) =>
22      `👋 To link this chat, type in Claude Code:\n<code>/pager pair ${code}</code>\n\nThe code works for 10 minutes.`,
23    paired: '✅ Paired. Text me the next prompt, or /help.',
24    idle: '💤 Idle — waiting for a prompt.',
25    idleBackground: (n: number) => `💤 Idle · ${n} background task${n === 1 ? '' : 's'} running — Claude picks up when ${n === 1 ? 'it ends' : 'they end'}.`,
26    backgroundNote: (n: number) => `\n\n<i>+ ${n} background task${n === 1 ? '' : 's'} running</i>`,
27    afterTask: 'after a background task',
28    working: (s: string, task: string) => `⏳ Working for ${s} on:\n<i>${task}</i>`,
29    nothingToStop: 'Nothing is running.',
30    stoppedTurn: '⏹ Stopped the running task.',
31    noLast: 'No answer yet in this session.',
32    promptsOff: 'Prompts from Telegram are turned off in this session (/config → pager).',
33    help:
34      '<b>Pager</b> — this chat is linked to a Claude Code session.\n\n' +
35      'Any text — queued as the next prompt\n' +
36      '/status — what Claude is doing\n' +
37      '/last — the last answer in full\n' +
38      '/stop — stop the running task\n' +
39      '/help — this message',
40    unknown: (c: string) => `Unknown command /${c} — /help`,
41    commands: { status: 'What Claude is doing', last: 'The last answer in full', stop: 'Stop the running task', help: 'How this bot works' },
42    sec: 's',
43    min: 'min',
44    h: 'h',
45  },
46  ru: {
47    done: 'Готово',
48    stopped: 'Остановлено',
49    failed: 'Завершилось с ошибкой',
50    refused: 'Отказ',
51    waiting: 'Ждёт вас',
52    noAnswer: '(без текстового ответа)',
53    along: 'По ходу:',
54    result: 'Итог:',
55    queued: '📨 В очереди — Claude возьмётся, когда закончит текущую задачу.',
56    sent: '📨 Отправлено в сессию.',
57    pairHello: (code: string) =>
58      `👋 Чтобы привязать этот чат, введите в Claude Code:\n<code>/pager pair ${code}</code>\n\nКод действует 10 минут.`,
59    paired: '✅ Привязано. Пишите следующий запрос или /help.',
60    idle: '💤 Свободен — ждёт запроса.',
61    idleBackground: (n: number) => `💤 Ход закончен · фоновых задач: ${n} — Claude продолжит, когда ${n === 1 ? 'она завершится' : 'они завершатся'}.`,
62    backgroundNote: (n: number) => `\n\n<i>+ фоновых задач: ${n}</i>`,
63    afterTask: 'после фоновой задачи',
64    working: (s: string, task: string) => `⏳ Работает ${s} над:\n<i>${task}</i>`,
65    nothingToStop: 'Сейчас ничего не выполняется.',
66    stoppedTurn: '⏹ Текущая задача остановлена.',
67    noLast: 'В этой сессии ещё не было ответа.',
68    promptsOff: 'Запросы из Telegram в этой сессии выключены (/config → pager).',
69    help:
70      '<b>Pager</b> — этот чат привязан к сессии Claude Code.\n\n' +
71      'Любой текст — следующий запрос в очередь\n' +
72      '/status — чем занят Claude\n' +
73      '/last — последний ответ целиком\n' +
74      '/stop — остановить текущую задачу\n' +
75      '/help — это сообщение',
76    unknown: (c: string) => `Неизвестная команда /${c} — /help`,
77    commands: { status: 'Чем занят Claude', last: 'Последний ответ целиком', stop: 'Остановить текущую задачу', help: 'Как пользоваться ботом' },
78    sec: 'с',
79    min: 'мин',
80    h: 'ч',
81  },
82} as const
83
84export const words = (lang: Lang) => WORDS[lang] ?? WORDS.en
85
86/** Telegram's HTML mode needs only these three escaped. */
87export const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
88
89/** Telegram's limit for one message, after entities are parsed. */
90export const TG_LIMIT = 4096
91
92/**
93 * Claude answers in Markdown; Telegram's HTML mode shows only a handful of tags. Fenced code →
94 * <pre>, `code` → <code>, **bold** / # headings → <b>, *italic* / _italic_ → <i>, ~~x~~ → <s>,
95 * [text](url) → <a>. Everything else is escaped text, so the result always parses.
96 */
97export function markdownToHtml(md: string): string {
98  const out: string[] = []
99  // Split off fenced blocks first: nothing inside them is Markdown.
100  const fence = /^```[^\n]*\n([\s\S]*?)^```[ \t]*$/gm
101  let last = 0
102  for (let m = fence.exec(md); m !== null; m = fence.exec(md)) {
103    out.push(inlineMarkdown(md.slice(last, m.index)))
104    out.push(`<pre>${escapeHtml((m[1] ?? '').replace(/\n$/, ''))}</pre>`)
105    last = m.index + m[0].length
106  }
107  out.push(inlineMarkdown(md.slice(last)))
108  return out.join('')
109}
110
111function inlineMarkdown(text: string): string {
112  // Inline code is cut out before escaping and emphasis, then put back.
113  const codes: string[] = []
114  const held = text.replace(/`([^`\n]+)`/g, (_, c: string) => `\u0000${codes.push(c) - 1}\u0000`)
115  const lines = escapeHtml(held)
116    .split('\n')
117    .map((line) => {
118      const heading = /^#{1,6}\s+(.+?)\s*#*$/.exec(line)
119      if (heading) return `<b>${heading[1]}</b>`
120      return line.replace(/^(\s*)[*-]\s+/, '$1• ')
121    })
122    .join('\n')
123  return lines
124    .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, (_, t: string, u: string) => `<a href="${u.replace(/"/g, '&quot;')}">${t}</a>`)
125    .replace(/\*\*(?=\S)([^*\n]+?)\*\*/g, '<b>$1</b>')
126    .replace(/__(?=\S)([^_\n]+?)__/g, '<b>$1</b>')
127    .replace(/~~(?=\S)([^~\n]+?)~~/g, '<s>$1</s>')
128    .replace(/(^|[^\w*])\*(?=\S)([^*\n]+?)\*(?![\w*])/g, '$1<i>$2</i>')
129    .replace(/(^|[^\w])_(?=\S)([^_\n]+?)_(?!\w)/g, '$1<i>$2</i>')
130    .replace(/\u0000(\d+)\u0000/g, (_, i: string) => `<code>${escapeHtml(codes[Number(i)] ?? '')}</code>`)
131}
132
133/** The same message without tags: the fallback when Telegram refuses the HTML. */
134export const htmlToPlain = (html: string) =>
135  html
136    .replace(/<[^>]+>/g, '')
137    .replace(/&lt;/g, '<')
138    .replace(/&gt;/g, '>')
139    .replace(/&quot;/g, '"')
140    .replace(/&amp;/g, '&')
141
142/** 42 s · 3 min 05 s · 1 h 12 min */
143export function formatDuration(ms: number, lang: Lang): string {
144  const w = words(lang)
145  const total = Math.max(0, Math.round(ms / 1000))
146  if (total < 60) return `${total} ${w.sec}`
147  const minutes = Math.floor(total / 60)
148  if (minutes < 60) return `${minutes} ${w.min} ${String(total % 60).padStart(2, '0')} ${w.sec}`
149  return `${Math.floor(minutes / 60)} ${w.h} ${minutes % 60} ${w.min}`
150}
151
152/** Cuts at a line or word boundary near `max`, so a message never ends mid-word. */
153export function shorten(text: string, max: number): { text: string; isCut: boolean } {
154  if (text.length <= max) return { text, isCut: false }
155  const head = text.slice(0, max)
156  const at = Math.max(head.lastIndexOf('\n'), head.lastIndexOf(' '))
157  return { text: (at > max * 0.6 ? head.slice(0, at) : head).trimEnd(), isCut: true }
158}
159
160export type TurnEnd = 'answer' | 'aborted' | 'refusal' | 'error'
161
162/**
163 * Splits Markdown into parts of at most `max` characters at line breaks, so every part can go
164 * as its own message. A code fence open at a cut is closed there and reopened in the next part.
165 */
166export function splitMarkdown(md: string, max: number): string[] {
167  const parts: string[] = []
168  let rest = md.trim()
169  let reopen = ''
170  while (rest.length > 0) {
171    let { text } = shorten(rest, max)
172    rest = rest.slice(text.length).replace(/^\s*\n/, '')
173    text = reopen + text
174    const fences = text.match(/^```.*$/gm) ?? []
175    if (fences.length % 2 === 1 && rest.length > 0) {
176      text += '\n```'
177      reopen = `${fences[fences.length - 1]}\n`
178    } else reopen = ''
179    parts.push(text)
180  }
181  return parts.length > 0 ? parts : ['']
182}
183
184/**
185 * The "task is done" messages: folder, outcome, duration, then — when Claude wrote along the
186 * way, between tool calls — those notes, and the final answer. Nothing is cut: a long answer
187 * goes as several messages, "(2/3)" on the ones after the first.
188 *
189 * A turn a background task's notification started says so instead of a duration: its length is
190 * that of the short follow-up, not of the task, and read as the task's it misleads.
191 */
192export function doneMessages(p: {
193  project: string
194  reason: TurnEnd
195  durationMs: number
196  answer: string
197  lang: Lang
198  notes?: readonly string[]
199  afterTask?: boolean
200  background?: number
201}): string[] {
202  const w = words(p.lang)
203  const icon = { answer: '✅', aborted: '⏹', refusal: '🚫', error: '⚠️' }[p.reason]
204  const title = { answer: w.done, aborted: w.stopped, refusal: w.refused, error: w.failed }[p.reason]
205  const when = p.afterTask ? w.afterTask : formatDuration(p.durationMs, p.lang)
206  const header = `${icon} <b>${title}</b> · 📁 ${escapeHtml(p.project)} · ${when}\n\n`
207  const footer = p.background ? w.backgroundNote(p.background) : ''
208  const answer = p.answer.trim() === '' ? w.noAnswer : p.answer.trim()
209  const notes = (p.notes ?? []).map((n) => n.trim()).filter((n) => n !== '' && n !== p.answer.trim())
210  const along = notes.map((n) => `- ${n.replace(/\n+/g, '\n  ')}`).join('\n')
211  const body = notes.length === 0 ? answer : `**${w.along}**\n${along}\n\n**${w.result}**\n${answer}`
212  const parts = splitMarkdown(body, MAX_ANSWER)
213  return parts.map((part, i) => {
214    const top = i === 0 ? header : `<i>(${i + 1}/${parts.length})</i>\n\n`
215    const end = i === parts.length - 1 ? footer : ''
216    const rich = top + markdownToHtml(part) + end
217    // Tags count toward nothing, but a part heavy with them can still outgrow the limit.
218    return htmlToPlain(rich).length <= TG_LIMIT ? rich : top + escapeHtml(part) + end
219  })
220}
221
222/** "Claude waits for you": permission prompts and the like, as the engine words them. */
223export function waitingMessage(p: { project: string; message: string; lang: Lang }): string {
224  return `⏸ <b>${words(p.lang).waiting}</b> · 📁 ${escapeHtml(p.project)}\n\n${escapeHtml(p.message)}`
225}
226
227/** The answer to /status: the running turn, or idle — with the background tasks still at work. */
228export function statusMessage(p: { runningSinceMs?: number; nowMs: number; task?: string; background?: number; lang: Lang }): string {
229  const w = words(p.lang)
230  const background = p.background ?? 0
231  if (p.runningSinceMs === undefined) return background > 0 ? w.idleBackground(background) : w.idle
232  const task = shorten(p.task ?? '', 300).text
233  return w.working(formatDuration(p.nowMs - p.runningSinceMs, p.lang), escapeHtml(task)) + (background > 0 ? w.backgroundNote(background) : '')
234}
235
236/** The parts of a Telegram update Pager reads (getUpdates → result[i]). */
237export type TgUpdate = {
238  update_id: number
239  message?: {
240    text?: string
241    chat: { id: number; type?: string }
242    from?: { id: number; first_name?: string; username?: string }
243  }
244}
245
246export type Incoming =
247  | { kind: 'ignore' }
248  | { kind: 'pair-request'; chatId: number; name: string }
249  | { kind: 'command'; chatId: number; name: string; args: string }
250  | { kind: 'prompt'; chatId: number; text: string }
251
252/**
253 * What an update asks for. Only private chats count. Before pairing, every message is a
254 * pair request (the bot answers with a code); after it, everything from another chat is
255 * dropped silently — that chat is the only one allowed to type into the session.
256 */
257export function classify(update: TgUpdate, pairedChatId: number | undefined): Incoming {
258  const m = update.message
259  if (!m || typeof m.text !== 'string' || m.chat.type !== undefined && m.chat.type !== 'private') return { kind: 'ignore' }
260  const chatId = m.chat.id
261  if (pairedChatId === undefined) {
262    return { kind: 'pair-request', chatId, name: m.from?.username ?? m.from?.first_name ?? String(chatId) }
263  }
264  if (chatId !== pairedChatId) return { kind: 'ignore' }
265  const text = m.text.trim()
266  if (text === '') return { kind: 'ignore' }
267  const command = /^\/([a-z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/i.exec(text)
268  if (command) return { kind: 'command', chatId, name: (command[1] ?? '').toLowerCase(), args: (command[2] ?? '').trim() }
269  return { kind: 'prompt', chatId, text }
270}
271
272/** A 6-digit pairing code from any random number in [0, 1). */
273export const pairingCode = (random: number) => String(Math.floor(random * 1_000_000)).padStart(6, '0')
274
275export const PAIRING_TTL_MS = 10 * 60_000
276
277/**
278 * Only one session may long-poll a bot (Telegram answers 409 to a second one), so the
279 * sessions share a lease in the store: whoever holds a fresh one polls, the others only
280 * send. A lease not renewed for LEASE_MS is free to take.
281 */
282export type Lease = { sessionId: string; until: number }
283export const LEASE_MS = 60_000
284
285export function mayPoll(lease: Lease | undefined, sessionId: string, now: number): boolean {
286  return lease === undefined || lease.sessionId === sessionId || lease.until < now
287}
288
289/** "1234567890:AA..." — a quick sanity check before the first request. */
290export const looksLikeToken = (t: string) => /^\d{5,}:[\w-]{20,}$/.test(t.trim())
291
292/** The project's folder name for headers: the last segment of the session's cwd. */
293export const projectName = (cwd: string) => cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd
294