Полный отказ от 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>
100 lines
3.5 KiB
Bash
Executable file
100 lines
3.5 KiB
Bash
Executable file
#!/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
|