SLOPSHOPPER

telegram-notify

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

newpanecommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · telegram-notify
│ ┃ Telegram-уведомления ✕ › fix the failing auth test and add an audit log call │ ┃ Telegram-уведомления │ ┃ ⏺ Read(src/auth.ts) │ ┃ 1. В Telegram открой @BotFather, отправь /n ⎿ Read 6 lines │ ┃ скопируй токен, который он пришлёт. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Открыть @BotFather https://t.me/BotFather ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 2. Вставь токен сюда и нажми Enter: │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Токен бота: 123456789:AAH… ⏎ проверить │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Токен хранится в хранилище мода и не попада │ ┃ чат, ни в код. › /notify │ ⎿ telegram-notify: Telegram ещё не подключён: открыл настройку. Вс │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Telegram-уведомления
Telegram-уведомления 1. В Telegram открой @BotFather, отправь /newbot и скопируй токен, который он пришлёт. Открыть @BotFather https://t.me/BotFather 2. Вставь токен сюда и нажми Enter: Токен бота: 123456789:AAH… ⏎ проверить Токен хранится в хранилище мода и не попадает ни в чат, ни в код.
README

Telegram-уведомления для Claude Code

English below

Мод для Claude Code. Пишет тебе в Telegram, когда Claude закончил работу и ждёт тебя. Дал большую задачу, набрал /notify и пошёл пить кофе: бот сообщит, какая была задача, сколько она заняла и чем закончилась.

Этот мод написал Claude Code прямо в ролике канала Paragon, по одному промпту. Защиту от чужих чатов, лимит ожидания и остальные детали Claude продумал сам.

Неофициальный мод: не связан с Anthropic и Telegram. Моды Claude Code — новая возможность, их API ещё может меняться между версиями. Если после обновления Claude Code что-то сломалось, загляни сюда за новой версией.

✅ Готово, жду тебя
📁 coffee-landing
📝 Собери в этой папке страницу меню для кофейни: menu.html с…
⏱ 4 мин 12 с
💬 Готово: собрал menu.html с восемью позициями и ценами.

Что нужно

  • Claude Code с поддержкой модов: в приложении Claude (вкладка Code) или в терминале. Проверено на версии 2.1.288.
  • Telegram.

Установка

Способ 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 или начни новую сессию.

Настройка (2 минуты)

  1. В Telegram открой @BotFather, отправь /newbot, придумай имя и username (должен заканчиваться на bot). BotFather пришлёт токен вида 123456789:AAH…. Скопируй его. Бот нужен новый, только для уведомлений: мод забирает все входящие сообщения бота, и если бот уже работает в другом сервисе, тот их недосчитается.
  2. В Claude набери /notify setup. Откроется панель «Telegram-уведомления». Вставь токен в поле и нажми Enter. Мод проверит токен.
  3. Нажми ссылку на бота в панели, в Telegram нажми Start. В панели появится «✅ Подключено», бот пришлёт приветствие.
  4. /notify test: проверочное сообщение.

Если ссылка не открывается, найди своего бота в Telegram и отправь ему команду /start <код> с кодом из панели. Start нужно нажать в течение 10 минут.

Команды

КомандаЧто делает
/notifyОдно уведомление: когда Claude закончит и будет ждать тебя. Потом выключается само
/notify alwaysУведомлять после каждой задачи. Сохраняется в новых сессиях и после /clear
/notify offВыключить уведомления
/notify testПробное сообщение
/notify setupПанель настройки: подключить бота, сменить бота или отключиться
  • /notify можно набрать, даже пока Claude работает.
  • Пока уведомления включены, внизу в строке состояния горит «🔔 Telegram».
  • Если ты прервал задачу (Esc), сообщения не будет: ты и так у экрана.
  • Если Claude запустил фоновых агентов, мод ждёт, пока закончат и они.
  • Если работа остановилась из-за ошибки, придёт «⚠️ Остановился из-за ошибки, жду тебя».

Безопасность и приватность

  • Токен не попадает в чат, если вставлять его в поле панели мода. Это не сообщение: модель его не видит, в историю диалога он не попадает, в тексты ошибок тоже. Не вставляй токен в обычное поле сообщения Claude: тогда он уйдёт в историю. Если так случилось, отзови токен.
  • Поле не скрывает символы. Не вставляй токен на стриме или записи экрана. Если токен попал в кадр, отзови его.
  • Где хранится. Токен и chat_id лежат только на твоём компьютере, в хранилище мода: файл plugins/store/telegram-notify_…json в папке настроек Claude Code (обычно ~/.claude). Файл не зашифрован, как и у большинства CLI-инструментов.
  • Куда ходит мод. Только на api.telegram.org: адрес зашит в коде, в начале hooks/register.tsx. Отчёт claude plugin validate показывает, что мод не читает твои файлы, не запускает программы и не вызывает модель.
  • Чужие не подключатся. В ссылке Start есть случайный одноразовый код. Мод подключает только чат, который прислал именно его. Если кто-то ещё напишет твоему боту, мод это проигнорирует. Код действует 10 минут, пока он на экране, никому его не показывай.
  • Что уходит в Telegram: имя папки проекта, первые слова задачи, время работы и первая строка ответа Claude. Если проект секретный, учитывай это. Чаты с ботами в Telegram не защищены сквозным шифрованием, а текст может всплывать на заблокированном экране телефона. Если запускаешь Claude прямо в домашней папке, имя папки совпадает с именем пользователя компьютера.
  • Если токен утёк: @BotFather → /mybots → твой бот → API Token → Revoke current token. Потом заново /notify setup с новым токеном.
  • Моды Claude Code работают без песочницы. Другие установленные моды теоретически видят то же, что и этот. Ставь только моды, которым доверяешь, и смотри их код. Весь код этого мода лежит в папке hooks/, около 600 строк.

Удаление

  1. /notify setup → «Отключить»: мод сотрёт токен и chat_id. Кнопка видна, когда бот подключён.
  2. claude plugin uninstall telegram-notify@paragon-mods.
  3. Если удалил мод раньше, чем нажал «Отключить», удали файл plugins/store/telegram-notify_…json в папке настроек Claude Code и отзови токен в @BotFather.
  4. Если бот больше не нужен: @BotFather → /mybots → бот → Delete Bot.

Частые вопросы

  • «У этого бота настроен webhook». Бот уже подключён к другому сервису. Создай для уведомлений отдельного бота.
  • «Не дождался нажатия Start за 10 минут». Вставь токен ещё раз и нажми Start.
  • Настройка в мобильном приложении Claude. Не поддерживается. Настрой в терминале или в приложении на компьютере.

Для разработчиков

claude plugin validate .
claude plugin test .
Напиши мод для Claude Code — уведомления в Telegram.
— Команда /notify: когда закончишь работу и будешь ждать меня, пришли сообщение через Telegram-бота: какая была задача (первые слова промпта), сколько заняла и чем закончилась.
— После одного уведомления /notify выключается сам. /notify always — уведомлять всегда, /notify off — выключить, /notify test — пробное сообщение.
— Пока уведомление включено, показывай «🔔 Telegram» в строке состояния внизу.
— Настройка — прямо здесь, в приложении, без терминала: /notify setup открывает панель мода с полем «Токен бота». Я вставляю токен — мод сам его проверяет, показывает ссылку на бота и ждёт, когда я нажму Start; chat_id определяет сам и пишет в панели «✅ Подключено».
— Токен не должен попадать в чат и в код: храни его в хранилище мода.
Проверь мод тестами и скажи, что мне сделать, чтобы попробовать.

Лицензия

MIT


<a id="english"></a>

Telegram notifications for Claude Code

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.

Requirements

  • Claude Code with mods support: in the Claude desktop app (Code tab) or in the terminal. Tested on version 2.1.288.
  • Telegram.

Install

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.

Setup (2 minutes)

  1. In Telegram, open @BotFather, send /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.
  2. In Claude, type /notify setup. The «Telegram-уведомления» panel opens. Paste the token into the field and press Enter. The mod checks the token.
  3. Click the bot link in the panel and press Start in Telegram. The panel shows «✅ Подключено» and the bot sends a greeting.
  4. /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.

Commands

CommandWhat it does
/notifyOne notification when Claude finishes and waits for you, then turns itself off
/notify alwaysNotify after every task. Survives new sessions and /clear
/notify offTurn notifications off
/notify testSend a test message
/notify setupSetup panel: connect a bot, switch bots or disconnect
  • You can type /notify while Claude is still working.
  • While notifications are on, the status line shows «🔔 Telegram».
  • If you interrupt a task (Esc), no message is sent: you're already at the screen.
  • If Claude started background agents, the mod waits for them to finish too.
  • If the work stopped on an error, you get «⚠️ Остановился из-за ошибки, жду тебя».

Security and privacy

  • The token stays out of the chat as long as you paste it into the mod's panel field. That is not a message: the model doesn't see it, and it stays out of the conversation history and error messages. Don't paste the token into Claude's regular message box: then it goes into the history. If that happens, revoke the token.
  • The field doesn't hide characters. Don't paste the token on a stream or a screen recording. If the token was on screen, revoke it.
  • Where it's stored. The token and chat_id live only on your computer, in the mod's store: the file plugins/store/telegram-notify_…json in the Claude Code config folder (usually ~/.claude). The file is not encrypted, as with most CLI tools.
  • Network. The mod talks only to 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.
  • Strangers can't connect. The Start link carries a random one-time code. The mod connects only the chat that sends that exact code and ignores anyone else who messages your bot. The code is valid for 10 minutes; while it's on screen, don't show it to anyone.
  • What goes to Telegram: the project folder name, the first words of the task, the run time and the first line of Claude's answer. Keep that in mind for confidential projects. Telegram bot chats are not end-to-end encrypted, and the text may pop up on your phone's lock screen. If you run Claude right in your home folder, the folder name is your computer's user name.
  • If the token leaks: @BotFather → /mybots → your bot → API Token → Revoke current token. Then run /notify setup again with the new token.
  • Claude Code mods are not sandboxed. Other installed mods could in theory see what this one sees. Install only mods you trust and read their code. All of this mod's code is in hooks/, about 600 lines.

Uninstall

  1. /notify setup → «Отключить» erases the token and chat_id. The button shows while the bot is connected.
  2. claude plugin uninstall telegram-notify@paragon-mods.
  3. If you uninstalled the mod before pressing «Отключить», delete the file plugins/store/telegram-notify_…json in the Claude Code config folder and revoke the token in @BotFather.
  4. If you no longer need the bot: @BotFather → /mybots → your bot → Delete Bot.

FAQ

  • «У этого бота настроен webhook» (the bot has a webhook). The bot is already connected to another service. Create a separate bot for notifications.
  • «Не дождался нажатия Start за 10 минут» (Start wasn't pressed within 10 minutes). Paste the token again and press Start.
  • Setup in the Claude mobile app. Not supported. Set it up in the terminal or the desktop app.

For developers

claude plugin validate .
claude plugin test .

License

MIT

Source 3 files
hooks/register.tsx 446 lines
1import { 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}
446
hooks/core.ts 154 lines
1// Чистые функции: разбор команд, текст уведомления, ответы 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)
154
types/index.d.ts 31 lines
1// 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