Пишет в Telegram, когда Claude закончил работу и ждёт тебя: /notify, /notify always, /notify off, /notify test, /notify setup

Мод для Claude Code. Пишет тебе в Telegram, когда Claude закончил работу и ждёт тебя. Дал большую задачу, набрал /notify и пошёл пить кофе: бот сообщит, какая была задача, сколько она заняла и чем закончилась.
Этот мод написал Claude Code прямо в ролике канала Paragon, по одному промпту. Защиту от чужих чатов, лимит ожидания и остальные детали Claude продумал сам.
Неофициальный мод: не связан с Anthropic и Telegram. Моды Claude Code — новая возможность, их API ещё может меняться между версиями. Если после обновления Claude Code что-то сломалось, загляни сюда за новой версией.
✅ Готово, жду тебя
📁 coffee-landing
📝 Собери в этой папке страницу меню для кофейни: menu.html с…
⏱ 4 мин 12 с
💬 Готово: собрал menu.html с восемью позициями и ценами.
Способ 1. Попроси Claude (как в ролике):
Установи мод: https://github.com/paragonvideomaking/paragon-claude-telegram-notify
Способ 2. Командами в терминале:
claude plugin marketplace add paragonvideomaking/paragon-claude-telegram-notify
claude plugin install telegram-notify@paragon-mods
После установки набери в Claude /reload-plugins или начни новую сессию.
/newbot, придумай имя и username (должен заканчиваться на bot). BotFather пришлёт токен вида 123456789:AAH…. Скопируй его. Бот нужен новый, только для уведомлений: мод забирает все входящие сообщения бота, и если бот уже работает в другом сервисе, тот их недосчитается./notify setup. Откроется панель «Telegram-уведомления». Вставь токен в поле и нажми Enter. Мод проверит токен./notify test: проверочное сообщение.Если ссылка не открывается, найди своего бота в Telegram и отправь ему команду /start <код> с кодом из панели. Start нужно нажать в течение 10 минут.
| Команда | Что делает |
|---|---|
/notify | Одно уведомление: когда Claude закончит и будет ждать тебя. Потом выключается само |
/notify always | Уведомлять после каждой задачи. Сохраняется в новых сессиях и после /clear |
/notify off | Выключить уведомления |
/notify test | Пробное сообщение |
/notify setup | Панель настройки: подключить бота, сменить бота или отключиться |
/notify можно набрать, даже пока Claude работает.plugins/store/telegram-notify_…json в папке настроек Claude Code (обычно ~/.claude). Файл не зашифрован, как и у большинства CLI-инструментов.api.telegram.org: адрес зашит в коде, в начале hooks/register.tsx. Отчёт claude plugin validate показывает, что мод не читает твои файлы, не запускает программы и не вызывает модель./mybots → твой бот → API Token → Revoke current token. Потом заново /notify setup с новым токеном.hooks/, около 600 строк./notify setup → «Отключить»: мод сотрёт токен и chat_id. Кнопка видна, когда бот подключён.claude plugin uninstall telegram-notify@paragon-mods.plugins/store/telegram-notify_…json в папке настроек Claude Code и отзови токен в @BotFather./mybots → бот → Delete Bot.claude plugin validate .
claude plugin test .
Напиши мод для Claude Code — уведомления в Telegram.
— Команда /notify: когда закончишь работу и будешь ждать меня, пришли сообщение через Telegram-бота: какая была задача (первые слова промпта), сколько заняла и чем закончилась.
— После одного уведомления /notify выключается сам. /notify always — уведомлять всегда, /notify off — выключить, /notify test — пробное сообщение.
— Пока уведомление включено, показывай «🔔 Telegram» в строке состояния внизу.
— Настройка — прямо здесь, в приложении, без терминала: /notify setup открывает панель мода с полем «Токен бота». Я вставляю токен — мод сам его проверяет, показывает ссылку на бота и ждёт, когда я нажму Start; chat_id определяет сам и пишет в панели «✅ Подключено».
— Токен не должен попадать в чат и в код: храни его в хранилище мода.
Проверь мод тестами и скажи, что мне сделать, чтобы попробовать.
<a id="english"></a>
A Claude Code mod that messages you on Telegram when Claude finishes its work and waits for you. Give it a big task, type /notify, go grab a coffee: the bot tells you what the task was, how long it took and how it ended.
Claude Code wrote this mod live in a Paragon channel video, from a single prompt. Protection against strangers' chats, the wait limit and the other details Claude worked out on its own. The mod's interface and messages are in Russian.
Unofficial mod, not affiliated with Anthropic or Telegram. Claude Code mods are a new feature and their API may still change between versions. If something breaks after a Claude Code update, check here for a new version.
Option 1. Ask Claude (as in the video):
Install this mod: https://github.com/paragonvideomaking/paragon-claude-telegram-notify
Option 2. From the terminal:
claude plugin marketplace add paragonvideomaking/paragon-claude-telegram-notify
claude plugin install telegram-notify@paragon-mods
Then type /reload-plugins in Claude or start a new session.
/newbot, pick a name and a username ending in bot. BotFather replies with a token like 123456789:AAH…. Copy it. Use a new bot just for notifications: the mod takes all of the bot's incoming messages, so a bot that already works for another service would lose them there./notify setup. The «Telegram-уведомления» panel opens. Paste the token into the field and press Enter. The mod checks the token./notify test sends a test message.If the link doesn't open, find your bot in Telegram and send it /start <code> with the code from the panel. You have 10 minutes to press Start.
| Command | What it does |
|---|---|
/notify | One notification when Claude finishes and waits for you, then turns itself off |
/notify always | Notify after every task. Survives new sessions and /clear |
/notify off | Turn notifications off |
/notify test | Send a test message |
/notify setup | Setup panel: connect a bot, switch bots or disconnect |
/notify while Claude is still working.plugins/store/telegram-notify_…json in the Claude Code config folder (usually ~/.claude). The file is not encrypted, as with most CLI tools.api.telegram.org: the address is hard-coded at the top of hooks/register.tsx. The claude plugin validate report shows that the mod doesn't read your files, run programs or call the model./mybots → your bot → API Token → Revoke current token. Then run /notify setup again with the new token.hooks/, about 600 lines./notify setup → «Отключить» erases the token and chat_id. The button shows while the bot is connected.claude plugin uninstall telegram-notify@paragon-mods.plugins/store/telegram-notify_…json in the Claude Code config folder and revoke the token in @BotFather./mybots → your bot → Delete Bot.claude plugin validate .
claude plugin test .
hooks/register.tsx 446 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Mode, Setup, Task } from '../types'
5import {
6 IDLE,
7 USAGE,
8 botName,
9 buildMessage,
10 findStart,
11 folderName,
12 isTokenLike,
13 makeCode,
14 parseVerb,
15 previewPrompt,
16 readReply,
17 scrub,
18} from './core'
19import type { Reply } from './core'
20
21const PANE = 'telegram-notify-setup'
22const API = 'https://api.telegram.org/bot'
23const STATUS = '🔔 Telegram'
24const POLL_MS = 2000
25const WAIT_LIMIT_MS = 10 * 60_000
26
27// Промпт, набранный самим человеком, начинает новую задачу; уведомление фоновой
28// задачи или сообщение другого агента продолжают текущую.
29const TYPED = new Set(['composer', 'bridge', 'sdk'])
30
31const modeAtom = atom({ plugin: 'telegram-notify', key: 'mode' } as const, 'off' as Mode)
32const setupAtom = atom({ plugin: 'telegram-notify', key: 'setup' } as const, IDLE)
33const taskAtom = atom({ plugin: 'telegram-notify', key: 'task' } as const, null as Task | null)
34const backgroundAtom = atom({ plugin: 'telegram-notify', key: 'background' } as const, [] as string[])
35
36type Creds = { token: string; chatId: number }
37
38// Токен и chat_id лежат только в хранилище мода ($.store), не в state и не в чате.
39async function creds($: EngineInterface): Promise<Creds | null> {
40 const token = await $.store.get('token')
41 const chatId = await $.store.get('chatId')
42
43 return typeof token === 'string' && typeof chatId === 'number' ? { token, chatId } : null
44}
45
46async function telegram($: EngineInterface, token: string, method: string, body: Record<string, unknown> = {}): Promise<Reply> {
47 try {
48 const res = await $.http.fetch(`${API}${token}/${method}`, {
49 method: 'POST',
50 headers: { 'content-type': 'application/json' },
51 body: JSON.stringify(body),
52 })
53 const reply = readReply(res.status, res.text)
54
55 return reply.ok ? reply : { ...reply, error: scrub(reply.error, token) }
56 } catch {
57 return { ok: false, status: 0, error: 'нет связи с api.telegram.org' }
58 }
59}
60
61async function send($: EngineInterface, text: string): Promise<Reply> {
62 const c = await creds($)
63
64 if (c === null) return { ok: false, status: 0, error: 'бот не подключён' }
65
66 return telegram($, c.token, 'sendMessage', { chat_id: c.chatId, text, disable_web_page_preview: true })
67}
68
69async function refreshStatus($: EngineInterface) {
70 $.ui.status((await read($, modeAtom)) === 'off' ? undefined : STATUS)
71}
72
73async function setMode($: EngineInterface, mode: Mode) {
74 await update($, modeAtom, () => mode)
75 await $.store.set('always', mode === 'always')
76 await refreshStatus($)
77}
78
79async function openSetup($: EngineInterface) {
80 return $.ui.open({ id: PANE, title: 'Telegram-уведомления', focus: true, closeOnEscape: true })
81}
82
83// Настройка из хранилища: в начале сессии и после /clear, /resume, /branch,
84// которые сбрасывают state к значениям по умолчанию.
85async function restore($: EngineInterface) {
86 const c = await creds($)
87 const always = (await $.store.get('always')) === true
88 const bot = await $.store.get('bot')
89 const chat = await $.store.get('chat')
90
91 await update($, modeAtom, () => (c !== null && always ? 'always' : 'off'))
92 await update($, setupAtom, () =>
93 c === null
94 ? IDLE
95 : { ...IDLE, phase: 'connected', bot: typeof bot === 'string' ? bot : null, chat: typeof chat === 'string' ? chat : null },
96 )
97}
98
99// Живут до перезагрузки модуля; перезагрузка снова вызывает session.start.
100let folder = ''
101let poll: Timer | undefined
102let isPolling = false
103let offset: number | null = null
104
105function stopPolling() {
106 poll?.cancel()
107 poll = undefined
108}
109
110async function pollOnce($: EngineInterface) {
111 if (isPolling) return
112 isPolling = true
113
114 try {
115 const s = await read($, setupAtom)
116 const token = await $.store.get('token')
117
118 if (s.phase !== 'waiting' || s.code === null || typeof token !== 'string') {
119 stopPolling()
120
121 return
122 }
123
124 if ((await $.clock.now()) - s.waitingSince > WAIT_LIMIT_MS) {
125 stopPolling()
126 await update($, setupAtom, () => ({ ...IDLE, error: 'Не дождался нажатия Start за 10 минут. Вставь токен ещё раз.' }))
127
128 return
129 }
130
131 const reply = await telegram($, token, 'getUpdates', { ...(offset === null ? {} : { offset }), timeout: 0, allowed_updates: ['message'] })
132
133 if (!reply.ok) {
134 // 409: у бота настроен webhook, getUpdates с ним не работает.
135 if (reply.status === 409) {
136 stopPolling()
137 await update($, setupAtom, () => ({ ...IDLE, error: 'У этого бота настроен webhook, getUpdates недоступен. Создай отдельного бота для уведомлений.' }))
138 }
139
140 return
141 }
142
143 const found = findStart(reply.result, s.code)
144
145 if (found.nextOffset !== null) offset = found.nextOffset
146 if (found.chat === null) return
147
148 stopPolling()
149 await $.store.set('chatId', found.chat.id)
150 await $.store.set('chat', found.chat.name)
151 await update($, setupAtom, x => ({ ...x, phase: 'connected', chat: found.chat?.name ?? null, code: null, link: null, error: null }))
152 await send($, '✅ Claude Code подключён. Сюда будут приходить уведомления, когда я закончу работу и буду ждать тебя.')
153 $.ui.toast('Telegram подключён')
154 } finally {
155 isPolling = false
156 }
157}
158
159function startPolling($: EngineInterface) {
160 stopPolling()
161 offset = null
162 poll = $.clock.every(POLL_MS, () => void pollOnce($))
163}
164
165async function verify($: EngineInterface, token: string) {
166 const reply = await telegram($, token, 'getMe')
167 const bot = reply.ok ? botName(reply.result) : null
168
169 if (!reply.ok || bot === null) {
170 const error = !reply.ok && (reply.status === 401 || reply.status === 404)
171 ? 'Telegram не принял токен. Скопируй его целиком из сообщения @BotFather.'
172 : `Не удалось проверить токен: ${reply.ok ? 'непонятный ответ Telegram' : reply.error}`
173 await update($, setupAtom, () => ({ ...IDLE, error }))
174
175 return
176 }
177
178 const code = makeCode()
179
180 // Новый токен: старый чат больше не годится, уведомления ждут нового Start.
181 await $.store.set('token', token)
182 await $.store.set('bot', bot)
183 await $.store.delete('chatId')
184 await $.store.delete('chat')
185 if ((await read($, modeAtom)) !== 'off') await setMode($, 'off')
186
187 const now = await $.clock.now()
188 await update($, setupAtom, () => ({
189 ...IDLE,
190 phase: 'waiting',
191 bot,
192 code,
193 link: `https://t.me/${bot}?start=${code}`,
194 waitingSince: now,
195 }))
196 startPolling($)
197}
198
199async function disconnect($: EngineInterface) {
200 stopPolling()
201 for (const key of ['token', 'chatId', 'chat', 'bot', 'always']) await $.store.delete(key)
202 await update($, modeAtom, () => 'off')
203 await update($, setupAtom, () => IDLE)
204 await refreshStatus($)
205}
206
207async function deliver($: EngineInterface, text: string) {
208 const reply = await send($, text)
209
210 if (!reply.ok) $.ui.toast(`Telegram: не удалось отправить уведомление (${reply.error})`)
211}
212
213export const register: Register = on => {
214 on('session.start', async ($, e, next) => {
215 folder = folderName(e.cwd)
216 await $.command.register({
217 name: 'notify',
218 description: 'Уведомление в Telegram, когда закончу и буду ждать тебя',
219 argumentHint: '[always | off | test | setup]',
220 immediate: true,
221 })
222
223 // session.start приходит и при перезагрузке мода: state тогда уже на месте.
224 const held = await $.state.get({ plugin: 'telegram-notify', key: 'mode' })
225
226 if (held.version === 0) {
227 await restore($)
228 }
229
230 const s = await read($, setupAtom)
231
232 if (s.phase === 'waiting') startPolling($)
233 if (s.phase === 'checking') await update($, setupAtom, () => IDLE)
234
235 await refreshStatus($)
236
237 return next(e)
238 })
239
240 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
241 await restore($)
242 await refreshStatus($)
243
244 return next(e)
245 })
246
247 on('command.run', { command: 'notify' }, async ($, e) => {
248 const verb = parseVerb(e.args)
249
250 if (verb === 'unknown') return { text: USAGE }
251
252 if (verb === 'setup') {
253 await openSetup($)
254
255 return {}
256 }
257
258 if (verb === 'off') {
259 await setMode($, 'off')
260
261 return { text: '🔕 Уведомления в Telegram выключены.' }
262 }
263
264 if ((await creds($)) === null) {
265 await openSetup($)
266
267 return { text: 'Telegram ещё не подключён: открыл настройку. Вставь токен бота в панели «Telegram-уведомления».' }
268 }
269
270 if (verb === 'test') {
271 const reply = await send($, `🔔 Тестовое сообщение от Claude Code${folder ? ` (📁 ${folder})` : ''}. Всё работает.`)
272
273 return { text: reply.ok ? '📨 Пробное сообщение отправлено в Telegram.' : `Не удалось отправить: ${reply.error}` }
274 }
275
276 await setMode($, verb)
277
278 return {
279 text:
280 verb === 'once'
281 ? '🔔 Напишу в Telegram, когда закончу и буду ждать тебя. После этого уведомления выключатся сами.'
282 : '🔔 Буду писать в Telegram после каждой задачи. Выключить: /notify off.',
283 }
284 })
285
286 on('prompt.submit', async ($, e, next) => {
287 const result = await next(e)
288
289 if (result.drop === undefined && TYPED.has(e.origin.kind) && e.turnId === undefined && !/^\s*\/notify\b/.test(result.text)) {
290 const now = await $.clock.now()
291 await update($, taskAtom, () => ({ text: previewPrompt(result.text), startedAt: now }))
292 }
293
294 return result
295 })
296
297 // Ход без набранного промпта (например, после /clear) всё равно даёт задаче имя.
298 on('turn.start', async ($, e, next) => {
299 if ((await read($, taskAtom)) === null) {
300 const now = await $.clock.now()
301 await update($, taskAtom, () => ({ text: previewPrompt(e.text), startedAt: now }))
302 }
303
304 return next(e)
305 })
306
307 // Пока работает фоновый агент, Claude ещё не ждёт тебя: его отчёт начнёт новый ход.
308 on('agent.spawn', async ($, e, next) => {
309 const started = await next(e)
310 const id = started.agentId
311
312 if (e.background && id !== undefined) {
313 await update($, backgroundAtom, list => [...list.filter(x => x !== id), id])
314 }
315
316 return started
317 })
318
319 on('turn.complete', async ($, e, next) => {
320 const done = await next(e)
321 const agentId = e.agentId
322
323 if (agentId !== undefined) {
324 await update($, backgroundAtom, list => list.filter(x => x !== agentId))
325
326 return done
327 }
328
329 const mode = await read($, modeAtom)
330
331 // Прерывание (Esc) значит, что ты и так у экрана.
332 if (mode === 'off' || e.reason === 'aborted' || (await read($, backgroundAtom)).length > 0) {
333 return done
334 }
335
336 const task = await read($, taskAtom)
337 const now = await $.clock.now()
338 const text = buildMessage({
339 reason: e.reason,
340 task: task?.text ?? '',
341 durationMs: task === null ? e.durationMs : now - task.startedAt,
342 folder,
343 answer: e.answer,
344 })
345
346 await update($, taskAtom, () => null)
347 if (mode === 'once') await setMode($, 'off')
348
349 // Отправка в отдельном вызове таймера, чтобы конец хода её не ждал.
350 $.clock.after(1, () => void deliver($, text))
351
352 return done
353 })
354
355 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
356 const ui = $.ui.resolve(e)
357
358 if (e.surface === 'mobile' || !('Input' in ui) || !('Link' in ui)) {
359 return <ui.Text>Настройка Telegram-уведомлений открывается в терминале или в десктопном приложении.</ui.Text>
360 }
361
362 const { Box, Text, Button, Input, Link } = ui
363 const s: Setup = await read($, setupAtom)
364 const mode = await read($, modeAtom)
365 const modeLabel = mode === 'off' ? 'выключены' : mode === 'once' ? 'один раз' : 'всегда'
366
367 const submit = (value: string) => {
368 const token = value.trim()
369
370 if (!isTokenLike(token)) {
371 void update($, setupAtom, () => ({ ...IDLE, error: 'Это не похоже на токен бота. Он выглядит так: 123456789:AAH… (цифры, двоеточие и длинный код).' }))
372
373 return
374 }
375
376 void update($, setupAtom, () => ({ ...IDLE, phase: 'checking' }))
377 // Проверка идёт в своём вызове таймера: работа, начатая нажатием, обрывается с его концом.
378 $.clock.after(1, () => void verify($, token))
379 }
380
381 const body =
382 s.phase === 'connected' ? (
383 <Box flexDirection="column" rowGap={1}>
384 <Text color="success" bold>
385 ✅ Подключено
386 </Text>
387 <Text>{`Бот @${s.bot ?? '?'} пишет в чат «${s.chat ?? '?'}». Уведомления: ${modeLabel}.`}</Text>
388 <Box columnGap={2} flexWrap="wrap">
389 <Button
390 key="test"
391 hotkey="t"
392 variant="primary"
393 label="Пробное сообщение"
394 onPress={() =>
395 void send($, `🔔 Тестовое сообщение от Claude Code${folder ? ` (📁 ${folder})` : ''}. Всё работает.`).then(reply =>
396 $.ui.toast(reply.ok ? 'Отправлено в Telegram' : `Не удалось отправить: ${reply.error}`),
397 )
398 }
399 />
400 <Button key="reconnect" hotkey="r" label="Другой бот" onPress={() => void update($, setupAtom, () => IDLE)} />
401 <Button key="disconnect" hotkey="d" label="Отключить" onPress={() => void disconnect($)} />
402 </Box>
403 <Text dimColor>/notify — один раз · /notify always — всегда · /notify off — выключить</Text>
404 </Box>
405 ) : s.phase === 'checking' ? (
406 <Text>Проверяю токен…</Text>
407 ) : s.phase === 'waiting' ? (
408 <Box flexDirection="column" rowGap={1}>
409 <Text color="success">{`Бот @${s.bot ?? '?'} найден ✓`}</Text>
410 <Text>Открой ссылку в Telegram и нажми Start:</Text>
411 <Link href={s.link ?? 'https://t.me'} label={`t.me/${s.bot ?? ''}`} />
412 <Text dimColor>{`Если ссылка не открывается, найди бота и отправь ему: /start ${s.code ?? ''}`}</Text>
413 <Text>⏳ Жду нажатия Start…</Text>
414 <Box>
415 <Button
416 key="cancel"
417 label="Отмена"
418 onPress={() => {
419 stopPolling()
420 void update($, setupAtom, () => IDLE)
421 }}
422 />
423 </Box>
424 </Box>
425 ) : (
426 <Box flexDirection="column" rowGap={1}>
427 <Text>1. В Telegram открой @BotFather, отправь /newbot и скопируй токен, который он пришлёт.</Text>
428 <Link href="https://t.me/BotFather" label="Открыть @BotFather" />
429 <Text>2. Вставь токен сюда и нажми Enter:</Text>
430 <Input key="token" label="Токен бота" placeholder="123456789:AAH…" submitLabel="проверить" value="" autoFocus onSubmit={submit} />
431 {s.error === null ? null : <Text color="error">{s.error}</Text>}
432 <Text dimColor>Токен хранится в хранилище мода и не попадает ни в чат, ни в код.</Text>
433 </Box>
434 )
435
436 return (
437 <Box flexDirection="column" paddingX={1} width={Math.max(30, e.props.bodyColumns - 2)}>
438 <Text bold>Telegram-уведомления</Text>
439 <Box marginTop={1} flexDirection="column">
440 {body}
441 </Box>
442 </Box>
443 )
444 })
445}
446hooks/core.ts 154 lines1// Чистые функции: разбор команд, текст уведомления, ответы Telegram. Ничего здесь не
2// трогает `$`, поэтому всё проверяется тестами напрямую.
3import type { Setup } from '../types'
4
5export const IDLE: Setup = { phase: 'idle', bot: null, chat: null, link: null, code: null, error: null, waitingSince: 0 }
6
7// Токен бота от @BotFather: числовой id, двоеточие, секрет.
8const TOKEN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/
9
10export const isTokenLike = (text: string) => TOKEN.test(text.trim())
11
12export type Verb = 'once' | 'always' | 'off' | 'test' | 'setup' | 'unknown'
13
14export function parseVerb(args: string): Verb {
15 const word = args.trim().toLowerCase()
16
17 if (word === '' || word === 'once' || word === 'on') return 'once'
18 if (word === 'always' || word === 'off' || word === 'test' || word === 'setup') return word
19
20 return 'unknown'
21}
22
23export const USAGE = [
24 '/notify — одно уведомление в Telegram, когда закончу и буду ждать тебя',
25 '/notify always — уведомлять после каждой задачи',
26 '/notify off — выключить',
27 '/notify test — пробное сообщение',
28 '/notify setup — подключить бота',
29].join('\n')
30
31// Первые слова промпта: без тегов и переносов, не длиннее `max` символов.
32export function previewPrompt(text: string, words = 10, max = 90): string {
33 const plain = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
34 const head = plain.split(' ').slice(0, words).join(' ')
35 const cut = head.length > max ? `${head.slice(0, max - 1).trimEnd()}…` : head
36
37 return cut.length < plain.length && !cut.endsWith('…') ? `${cut}…` : cut
38}
39
40export function fmtDuration(ms: number): string {
41 const s = Math.max(0, Math.round(ms / 1000))
42
43 if (s < 60) return `${s} с`
44
45 const m = Math.floor(s / 60)
46
47 if (m < 60) return `${m} мин ${s % 60} с`
48
49 return `${Math.floor(m / 60)} ч ${String(m % 60).padStart(2, '0')} мин`
50}
51
52// Первая содержательная строка ответа, без разметки markdown.
53export function answerLine(answer: string, max = 200): string {
54 const line =
55 answer
56 .split('\n')
57 .map(l => l.replace(/\*\*|__|`/g, '').replace(/^[#>*\s|-]+/, '').trim())
58 .find(l => l !== '') ?? ''
59
60 return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line
61}
62
63export type Outcome = 'answer' | 'error' | 'refusal' | 'aborted'
64
65const HEADLINES: Record<Outcome, string> = {
66 answer: '✅ Готово, жду тебя',
67 error: '⚠️ Остановился из-за ошибки, жду тебя',
68 refusal: '⛔ Модель отказалась продолжать, жду тебя',
69 aborted: '⏹ Задача прервана',
70}
71
72export function buildMessage(n: { reason: Outcome; task: string; durationMs: number; folder: string; answer: string }): string {
73 const summary = answerLine(n.answer)
74
75 return [
76 HEADLINES[n.reason],
77 n.folder ? `📁 ${n.folder}` : null,
78 `📝 ${n.task || '(задача без текста)'}`,
79 `⏱ ${fmtDuration(n.durationMs)}`,
80 summary ? `💬 ${summary}` : null,
81 ]
82 .filter((line): line is string => line !== null)
83 .join('\n')
84}
85
86export const folderName = (cwd: string) => cwd.split(/[\\/]/).filter(Boolean).pop() ?? ''
87
88// Код в ссылке t.me/<bot>?start=<код>: связывает нажатие Start с этой настройкой,
89// чтобы бот не подключился к чужому чату, написавшему ему /start.
90export function makeCode(): string {
91 const alphabet = 'abcdefghijkmnpqrstuvwxyz23456789'
92 const bytes = new Uint8Array(10)
93
94 if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
95 crypto.getRandomValues(bytes)
96 } else {
97 for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256)
98 }
99
100 return Array.from(bytes, b => alphabet[b % alphabet.length]).join('')
101}
102
103export type Reply = { ok: true; result: unknown } | { ok: false; status: number; error: string }
104
105// Ответ Bot API: `{ ok, result }` или `{ ok: false, description }`.
106export function readReply(status: number, text: string): Reply {
107 let body: unknown = null
108
109 try {
110 body = JSON.parse(text)
111 } catch {
112 body = null
113 }
114
115 const data = (typeof body === 'object' && body !== null ? body : {}) as { ok?: unknown; result?: unknown; description?: unknown }
116
117 if (data.ok === true) return { ok: true, result: data.result }
118
119 return { ok: false, status, error: typeof data.description === 'string' ? data.description : `HTTP ${status}` }
120}
121
122export function botName(result: unknown): string | null {
123 const name = (result as { username?: unknown } | null)?.username
124
125 return typeof name === 'string' && /^[A-Za-z0-9_]{3,64}$/.test(name) ? name : null
126}
127
128type Update = { update_id?: unknown; message?: { text?: unknown; chat?: { id?: unknown; first_name?: unknown; username?: unknown; title?: unknown } } }
129
130// Ищет в getUpdates сообщение `/start <код>`; возвращает чат и следующий offset.
131export function findStart(result: unknown, code: string): { chat: { id: number; name: string } | null; nextOffset: number | null } {
132 const updates = Array.isArray(result) ? (result as Update[]) : []
133 let last: number | null = null
134 let chat: { id: number; name: string } | null = null
135 const wanted = new RegExp(`^/start(?:@\\w+)?\\s+${code}$`)
136
137 for (const u of updates) {
138 if (typeof u.update_id === 'number') last = Math.max(last ?? u.update_id, u.update_id)
139
140 const text = u.message?.text
141 const c = u.message?.chat
142
143 if (chat === null && typeof text === 'string' && wanted.test(text.trim()) && typeof c?.id === 'number') {
144 const name = [c.first_name, c.title, c.username].find((v): v is string => typeof v === 'string' && v !== '')
145 chat = { id: c.id, name: name ?? String(c.id) }
146 }
147 }
148
149 return { chat, nextOffset: last === null ? null : last + 1 }
150}
151
152// Описание ошибки Telegram на всякий случай очищается от токена.
153export const scrub = (text: string, token: string) => (token ? text.split(token).join('•••') : text)
154types/index.d.ts 31 lines1// off: молчим. once: одно уведомление, потом off. always: после каждой задачи.
2export type Mode = 'off' | 'once' | 'always'
3
4// Этапы настройки в панели. idle: ждём токен; checking: проверяем его через getMe;
5// waiting: бот найден, ждём /start с кодом; connected: chat_id известен.
6export type Phase = 'idle' | 'checking' | 'waiting' | 'connected'
7
8export type Setup = {
9 phase: Phase
10 bot: string | null
11 chat: string | null
12 link: string | null
13 code: string | null
14 error: string | null
15 waitingSince: number
16}
17
18// Задача, о которой придёт уведомление: начало промпта и когда его отправили.
19export type Task = { text: string; startedAt: number }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'telegram-notify': {
24 mode: Mode
25 setup: Setup
26 task: Task | null
27 background: string[]
28 }
29 }
30}
31