← Back to Blog
AI Tools & Automation

Нотификации из Claude Code и OpenCode в ваш Telegram

Как получать уведомления об окончании работы AI-агента прямо в мессенджер

📅 March 3, 2026 🌐 Russian

Зачастую в процессе работы с LLM приходится долго ждать ответа. Или модели медленные, или задача сильно большая, или агент очень активно траблшутит проблему и настойчиво пытается ее решить. Идешь пить чай, каждые пару минут возвращаешься за ноут, а работа все еще идет. Удобно было бы получать нотификации, например в телегу, которая всегда под рукой. И реализуется это все с помощью хуков. Недавно сделал себе такое, и в целом доволен, мелочь, а приятно :) Плюс когда даешь задачи на ресерч или брейншторминг, с телефона почитать даже удобнее. Решил поделиться рецептом с вами.

В Claude Code и Курсоре все просто, хуки бывают двух типов, command и prompt ( на определенное событие запустить шел команду или отправить промпт модели), описываются в hooks.json. Сообщение через бота можно послать просто используя curl. Но мне изначально было интересно сделать нотификацию из OpenCode, и там уже все чуть сложнее. Опенкод не поддерживает обычные хуки, но поддерживает typescript плагины, кастомный код который подгружается вместе с клиентом и может обрабатывать события и по ним запускать нужную логику.

Но начнем мы с простого бота.

Идем в телеграм, ищем поиском BotFather, отправляем команду /newbot и создаем нашего нового бота. После создания нам дадут токен в формате “айдибота:токен”, сохраняем это себе.

Дальше или идем в личку боту и просто что нибудь ему пишем. Или создаем новую группу и добавляем в нее нашего бота (кликаем на бота в контактах, дальше more > add to group). Я выбрал второй вариант, так как в планах использовать несколько ботов (каждый привязан к разным ide или к разных машинам) и читать обновления в одной группе.

Дальше запускаем curl и получаем апдейты с бота,

curl https://api.telegram.org/bot<BOT_TOKEN>/getUpdates

в ответе видим список сообщений который получал бот, там chatId нашего приватного или группового чата, сохраняем.

Дальше в случае Claude Code все просто, добавляем json с хуками в .claude/settings.json

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "agent",
            "prompt": "Check the last result of the session, remember it as a <LAST_MESSAGE> (make sure it is not more then 4000 symbols). And then run in the shell: curl -s -X POST https://api.telegram.org/bot<TOKEN>/sendMessage -H "Content-Type: application/json" -d '{"chat_id": "<CHAT_ID>", "text": "<LAST_MESSAGE>"}' "
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "curl -s -X POST https://api.telegram.org/bot<TOKEN>/sendMessage -H "Content-Type: application/json" -d '{"chat_id": "<CHAT_ID>", "text": "⚠️Permission requeired!"}'"
          }          
        ]
      }
    ]
  }
}

В Cursor все аналогично, можете прочитать про хуки и глянуть примеры в https://cursor.com/docs/agent/hooks Помещаем аналогичный json с событием>хуком в .cursor/hooks.json, если нужно что-то посложнее, например обработку транскрипт файлов, то пишем баш скрипт, кладем в .cursor/hooks/ директорию и вызываем скрипт через command хук.

В OpenCode же пошли своим путем. На анонсе писали, что добавят что-то покруче…

Но оказалось, что в реализации все не так просто. Вобщем спорно, “круче” - это когда гениально просто, или когда это сложный навороченый конструктор для гиков?))

Вобщем надо писать плагины на js/typescript, которые будут обрабатывать события и ходить в API. Кстати только узнал, что в opencode клиент серверная архитектура, твой TUI - это клиент, а еще опенкод поднимает локальный апи сервер, через который TUI или cli, или любой другой клиент может управлять чатами и сессиями.

Итого пришлось повайбкодить, а еще почитать документацию по плагинам и ивентам: https://opencode.ai/docs/plugins/ и документацию по апи опенкода: https://opencode.ai/docs/server/#apis

Потому что свайбкодить все за один присест не получилось, модель писала какую-то нерабочую фигню, пришлось подсказывать ей какие апи использовать, какие респонсы мы ожидаем итд. :D

В итоге получился такой код. Сохраняем тг токен и айди чата в ~/.bashrc в переменных OP_TELEGRAM_TOKEN и OP_TELEGRAM_CHAT_ID, чтобы хранить их секьюрно.

Скрипт работает в фоне и следит за ивентами, на ивенте session.idle (когда аи вам ответила в чате и ждет новых команд) забираем номер сессии, по номеру сессии забераем последний мессейдж агента. По факту там лежит все, и рассуждения, и вызовы тулов, разбираем этот лист и достаем только финальный реплай. Если размер больше 4к символов - транкейтим, чтобы влезло в сообщение телеграм (с этим есть какой-то нюанс, мне кажется с транкейтом не совсем корреткно работало, надо потестить больше :)). И дальше отсылаем в тг апи через обычный fetch.

/**
 * TelegramNotifyPlugin for OpenCode
 * 
 * Sends Telegram notifications with the final agent response when sessions complete.
 * Uses OpenCode API: GET /session/:id/message/:messageID
 */

const MIN_TEXT_LENGTH = 10;
const TELEGRAM_MAX_LENGTH = 4096;
const TELEGRAM_TIMEOUT_MS = 5000;

const pendingOperations: Promise<void>[] = [];

function addPendingOperation(promise: Promise<void>): void {
  pendingOperations.push(promise);
  promise.then(() => {
    const idx = pendingOperations.indexOf(promise);
    if (idx > -1) pendingOperations.splice(idx, 1);
  });
}

process.on('beforeExit', async () => {
  if (pendingOperations.length > 0) {
    await Promise.all(pendingOperations);
  }
});

interface OpenCodeClient {
  _client: {
    request: (config: { method: string; url: string }) => Promise<{ data: MessageListResponse }>;
  };
}

interface MessageListResponse {
  info?: { id: string };
}

interface MessagePart {
  type: string;
  text?: string;
}

interface MessageDetailResponse {
  parts?: MessagePart[];
}

interface SessionIdleEvent {
  type: "session.idle";
  properties?: { sessionID: string };
}

function escapeTelegramMarkdown(text: string): string {
  return text
    .replace(/\\/g, '\\\\')
    .replace(/_/g, '\\_')
    .replace(/\*/g, '\\*')
    .replace(/`/g, '\\`')
    .replace(/\[/g, '\\[')
    .replace(/\]/g, '\\]')
    .replace(/\(/g, '\\(')
    .replace(/\)/g, '\\)')
    .replace(/~/g, '\\~')
    .replace(/>/g, '\\>')
    .replace(/#/g, '\\#')
    .replace(/\+/g, '\\+')
    .replace(/-/g, '\\-')
    .replace(/=/g, '\\=')
    .replace(/\|/g, '\\|')
    .replace(/{/g, '\\{')
    .replace(/}/g, '\\}')
    .replace(/\./g, '\\.')
    .replace(/!/g, '\\!');
}

export const TelegramNotifyPlugin = async ({ client }: { client: OpenCodeClient }) => {
  const token = process.env.OP_TELEGRAM_TOKEN;
  const chatId = process.env.OP_TELEGRAM_CHAT_ID;

  if (!token || !chatId) {
    console.error('[TelegramNotifyPlugin] Missing OP_TELEGRAM_TOKEN or OP_TELEGRAM_CHAT_ID, notifications disabled');
    return { event: async () => {} };
  }

  return {
    event: async ({ event }) => {
      if (event.type === "session.idle") {
        const sid = (event as SessionIdleEvent).properties?.sessionID;
        if (!sid) return;

        const text = await getLastMessageContent(client, sid);
        if (!text) return;

        const prefix = "✅ *OpenCode session finished:*";
        const suffix = "\n\n\.\.\.read full message in OpenCode";
        
        // Calculate max length for raw text (accounting for prefix and suffix)
        const maxRawTextLength = TELEGRAM_MAX_LENGTH - prefix.length - suffix.length;
        
        // Truncate raw text BEFORE escaping to avoid breaking Markdown entities
        let truncatedText = text;
        if (truncatedText.length > maxRawTextLength) {
          truncatedText = truncatedText.slice(0, maxRawTextLength);
        }
        
        const finalEscapedText = escapeTelegramMarkdown(truncatedText);
        let finalText = `${prefix}\n\n${finalEscapedText}`;
        
        if (text.length > maxRawTextLength) {
          finalText += suffix;
        }

        const payload = JSON.stringify({ 
          chat_id: chatId, 
          text: finalText,
          parse_mode: "MarkdownV2"
        });

        addPendingOperation(sendTelegramMessage(token, payload));
      }
    },
  };
};

async function getLastMessageContent(client: OpenCodeClient, sessionId: string): Promise<string | null> {
  try {
    const listResponse = await client._client.request({
      method: 'GET',
      url: `/session/${sessionId}/message`,
    });

    const messages = listResponse?.data || [];
    if (messages.length === 0) return null;

    const lastMsgId = messages[messages.length - 1]?.info?.id;
    if (!lastMsgId) return null;

    const msgResponse = await client._client.request({
      method: 'GET',
      url: `/session/${sessionId}/message/${lastMsgId}`,
    });

    return extractTextFromParts((msgResponse as any).data || msgResponse);
  } catch (error) {
    console.error('[TelegramNotifyPlugin] Failed to fetch message:', error);
    return null;
  }
}

function extractTextFromParts(msgData: MessageDetailResponse): string | null {
  const parts = msgData?.parts || [];
  const textContent = parts
    .filter(p => p.type !== "reasoning" && p.type !== "thought" && p.text)
    .map(p => p.text)
    .join("\n\n");
  
  return textContent.length > MIN_TEXT_LENGTH ? textContent : null;
}

async function sendTelegramMessage(token: string, payload: string): Promise<void> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TELEGRAM_TIMEOUT_MS);

  try {
    const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: payload,
      signal: controller.signal
    });

    if (!response.ok) {
      const errorText = await response.text();
      console.error(`[TelegramNotifyPlugin] Telegram API error: ${response.status}`, errorText);
    }
  } catch (error) {
    console.error('[TelegramNotifyPlugin] Telegram notification failed:', error);
  } finally {
    clearTimeout(timeout);
  }
}

Просто кладем в ~/.config/opencode/plugins/telegram.ts , перезапускаем опенкод и смотрим сообщения в своей телеге :)

В ближайшее время думаю потестить и пофиксить транкейт, а так же подумать над обратной связью, и возможностью отправлять сообщение через бота (полюбому надо делать это секьюрно))

UPD: Дело было не в транкейте, с ним все ок. Вобщем плагин иногда не работал совершенно в рандомных случаях. Оказалось опенкод иногда завершал процесс до того как отошлется сообщение в телегу через обычный async/await. Пришлось (как я понял 😅) сделать pendingOperations промис, в который кладутся незавершенные операции, и добавлять его в основной процесс отслеживая событие beforeExit . Ну и телега иногда возвращала 400, на нечитаемые символы для Markdown, сделал эскейпинг для таких символов.

Сейчас плагин вроде работет норм, код в статье обновил на актуальный :)