food-market/deploy/telegram-bridge/cc-tg-notify-stop
nns d2305b7d40 feat(infra): event-driven Telegram bridge — webhook + Stop hook
Полный отказ от 2-секундного polling tmux'а в пользу реактивной схемы:

OUTBOUND (server-Claude → Telegram) через Stop hook:
- /usr/local/bin/cc-tg-notify-stop (Bash) читает transcript из stdin
  (Claude Code передаёт {transcript_path}), достаёт последнюю
  assistant-запись с непустым text-блоком (jq), чанкует ≤4000 символов
  с префиксом «🤖 [food-market]», POST'ит в Telegram через curl.
  Логи /var/log/cc-tg-notify.log. Если turn без текстового ответа
  (только tool calls) — выходит молча.
- Зарегистрирован в ~/.claude/settings.json под Stop event с пустым
  matcher (все turns).

INBOUND (Telegram → bridge → tmux) через webhook:
- bridge.py переписан с run_polling на run_webhook listening
  127.0.0.1:8765 на /tg-webhook. python-telegram-bot[webhooks]
  (tornado) ставится через pip.
- При старте сам делает setWebhook к Telegram API с secret_token
  из TELEGRAM_WEBHOOK_SECRET (osprandom 24 hex), Telegram присылает
  его обратно в X-Telegram-Bot-Api-Secret-Token — PTB валидирует
  до вызова handler'ов.
- Сохранены: whitelist по chat_id, paste-в-tmux через
  send-keys -l + Enter, /ping команда. Удалён poll_and_forward,
  diff/clean логика, recently_sent_lines дедуп — больше не нужны.

Nginx: новый location = /tg-webhook на food-market-stage.conf,
проксирует на 127.0.0.1:8765 с прокидыванием X-Telegram-Bot-Api-
Secret-Token. Smoke-test: curl с неверным секретом → 403.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:38:44 +05:00

100 lines
3.5 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Claude Code Stop hook: вытаскивает финальный assistant-ответ из transcript'а
# и пушит в Telegram. Устанавливается на /usr/local/bin/cc-tg-notify-stop.
#
# Hook runtime передаёт JSON на stdin с полем .transcript_path; раньше это
# приходило как $CLAUDE_TRANSCRIPT_PATH env-var, но в новых версиях стрим
# переехал в stdin. Поддерживаем оба варианта.
#
# Конфиг — /etc/food-market/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID).
# Логи — /var/log/cc-tg-notify.log (rotated externally).
set -u
ENV_FILE="/etc/food-market/telegram.env"
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
PROJECT_TAG="${CC_TG_TAG:-food-market}"
MAX_CHUNK=4000
log() { printf '%s [stop-hook] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; source "$ENV_FILE"; set +a
fi
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
if [[ -z "$TOKEN" || -z "$CHAT_ID" ]]; then
log "missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID"
exit 0
fi
# Читаем JSON со stdin (если пришёл) или берём env-vars (legacy).
INPUT_JSON=""
if [[ -t 0 ]]; then
INPUT_JSON=""
else
INPUT_JSON="$(cat)"
fi
TRANSCRIPT="${CLAUDE_TRANSCRIPT_PATH:-}"
if [[ -z "$TRANSCRIPT" && -n "$INPUT_JSON" ]]; then
TRANSCRIPT="$(printf '%s' "$INPUT_JSON" | jq -r '.transcript_path // empty' 2>/dev/null || true)"
fi
if [[ -z "$TRANSCRIPT" || ! -r "$TRANSCRIPT" ]]; then
log "no transcript path (stdin=${#INPUT_JSON} chars, env=${CLAUDE_TRANSCRIPT_PATH:-unset})"
exit 0
fi
# Последняя assistant-запись с непустым text-блоком. JSONL: одна запись на строку.
TEXT="$(jq -r '
select(.type == "assistant")
| .message.content[]?
| select(.type == "text" and (.text // "" | length) > 0)
| .text
' "$TRANSCRIPT" 2>/dev/null \
| awk 'BEGIN{RS=""}{a=$0} END{print a}')"
# awk выше склеивает все записи в одну; нам нужна именно ПОСЛЕДНЯЯ assistant-запись,
# поэтому делаем второй проход: берём индекс последней записи и достаём её text-блоки.
LAST_TEXT="$(jq -s -r '
map(select(.type == "assistant")) | last as $m
| ($m.message.content // [])
| map(select(.type == "text" and (.text // "" | length) > 0) | .text)
| join("\n")
' "$TRANSCRIPT" 2>/dev/null)"
if [[ -n "$LAST_TEXT" ]]; then TEXT="$LAST_TEXT"; fi
if [[ -z "$TEXT" ]]; then
log "no text in last assistant turn (only tool calls?)"
exit 0
fi
# Чанкуем по строкам с лимитом MAX_CHUNK; первый чанк — с префиксом.
PREFIX="🤖 [${PROJECT_TAG}]"
send_chunk() {
local body="$1"
curl -fsS -m 15 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
--data-urlencode "chat_id=${CHAT_ID}" \
--data-urlencode "text=${body}" \
--data-urlencode "disable_web_page_preview=true" \
>/dev/null 2>&1 || log "send failed (curl rc=$?)"
}
CHUNK="$PREFIX"$'\n'
EMITTED=0
while IFS= read -r line; do
if (( ${#CHUNK} + ${#line} + 1 > MAX_CHUNK )); then
send_chunk "$CHUNK"
EMITTED=$((EMITTED+1))
CHUNK=""
fi
CHUNK+="$line"$'\n'
done <<<"$TEXT"
if [[ -n "$CHUNK" ]]; then
send_chunk "$CHUNK"
EMITTED=$((EMITTED+1))
fi
log "sent $EMITTED chunk(s), text=${#TEXT} chars"
exit 0