Зачастую в процессе работы с 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, сделал эскейпинг для таких символов.
Сейчас плагин вроде работет норм, код в статье обновил на актуальный :)