Hook /usr/local/bin/cc-tg-notify-pretool читает JSON через stdin (tool_name + tool_input) и шлёт короткую строку прогресса в Telegram перед каждым tool-вызовом — чтобы юзер видел «он работает» а не просто ждал финального ответа от Stop hook. Форматы (по требованию ТЗ): - Bash → 🔨 ${description} либо 🔨 Bash: ${command[:80]} - Edit/Write/Read → ✏️/📝/📖 + basename(file_path) - Grep/Glob → 🔍/🌐 + pattern[:30/50] - WebFetch/Search → 🌍/🔎 + url|query[:60] - Task → 🎯 + description[:60] - TodoWrite → skip (шумно) - прочие → 🔧 ${tool_name} Дебаунс через flock + фоновый sleep: - Каждый вызов append'ит строку в /tmp/cc-tg-pretool-buffer.txt и бампит /tmp/cc-tg-pretool-last (epoch ms) под flock'ом. - Спавнит фоновый sleep 1.5s; после пробуждения проверяет, чей timestamp в LAST — если не его, выходит молча. Только «последний» hook реально шлёт батч одним сообщением. - Это даёт пачке tool-вызовов один Telegram-апдейт, а не 5–10. - Если буфер длиннее 20 строк — режется хвостом (свежие важнее). Off-switch: touch /tmp/cc-tg-quiet — pretool-hook сразу выходит. Stop hook продолжает работать. Hook регистрируется в ~/.claude/settings.json под PreToolUse с matcher="" (все tools без фильтра); фильтр TodoWrite — внутри скрипта. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
4.6 KiB
Bash
Executable file
125 lines
4.6 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# Claude Code PreToolUse hook: шлёт короткую строку в Telegram перед
|
||
# каждым tool-call'ом для ощущения «активности». Дебаунс 1.5с — пока
|
||
# tool-вызовы летят пачкой, копим в /tmp буфер и шлём одним сообщением
|
||
# через 1.5 секунды тишины.
|
||
#
|
||
# Конфиг — /etc/food-market/telegram.env. Логи — /var/log/cc-tg-notify.log.
|
||
# Off-switch: создать /tmp/cc-tg-quiet — все pretool-уведомления
|
||
# скипаются (Stop hook продолжает работать).
|
||
|
||
set -u
|
||
ENV_FILE="/etc/food-market/telegram.env"
|
||
LOG_FILE="${CC_TG_LOG:-/var/log/cc-tg-notify.log}"
|
||
BUF="/tmp/cc-tg-pretool-buffer.txt"
|
||
LAST="/tmp/cc-tg-pretool-last"
|
||
LOCK="/tmp/cc-tg-pretool.lock"
|
||
QUIET_FLAG="/tmp/cc-tg-quiet"
|
||
DEBOUNCE_SEC="1.5"
|
||
MAX_LINES=20
|
||
|
||
log() { printf '%s [pretool] %s\n' "$(date -Is)" "$*" >>"$LOG_FILE" 2>/dev/null || true; }
|
||
|
||
[[ -f "$QUIET_FLAG" ]] && exit 0
|
||
|
||
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:-}"
|
||
[[ -z "$TOKEN" || -z "$CHAT_ID" ]] && exit 0
|
||
|
||
INPUT_JSON=""
|
||
if [[ ! -t 0 ]]; then INPUT_JSON="$(cat)"; fi
|
||
[[ -z "$INPUT_JSON" ]] && exit 0
|
||
|
||
TOOL="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_name // empty' 2>/dev/null)"
|
||
[[ -z "$TOOL" || "$TOOL" == "TodoWrite" ]] && exit 0
|
||
|
||
# Извлекаем поле tool_input под нужный тип. cut -c обрезает многобайтные
|
||
# UTF-8 неаккуратно, но для urlencode результат остаётся валидным.
|
||
LINE=""
|
||
case "$TOOL" in
|
||
Bash)
|
||
DESC="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | tr '\n' ' ' | head -c 100)"
|
||
CMD="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.command // empty' 2>/dev/null | tr '\n' ' ' | head -c 80)"
|
||
if [[ -n "$DESC" ]]; then LINE="🔨 $DESC"; else LINE="🔨 Bash: $CMD"; fi
|
||
;;
|
||
Edit)
|
||
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
|
||
LINE="✏️ Edit: $(basename "${FP:-?}")"
|
||
;;
|
||
Write)
|
||
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
|
||
LINE="📝 Write: $(basename "${FP:-?}")"
|
||
;;
|
||
Read)
|
||
FP="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.file_path // empty' 2>/dev/null)"
|
||
LINE="📖 Read: $(basename "${FP:-?}")"
|
||
;;
|
||
Grep)
|
||
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 30)"
|
||
LINE="🔍 Grep: \"$P\""
|
||
;;
|
||
Glob)
|
||
P="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.pattern // empty' 2>/dev/null | head -c 50)"
|
||
LINE="🌐 Glob: $P"
|
||
;;
|
||
WebFetch)
|
||
U="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.url // empty' 2>/dev/null | head -c 60)"
|
||
LINE="🌍 Fetch: $U"
|
||
;;
|
||
WebSearch)
|
||
Q="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.query // empty' 2>/dev/null | head -c 60)"
|
||
LINE="🔎 Search: $Q"
|
||
;;
|
||
Task)
|
||
D="$(printf '%s' "$INPUT_JSON" | jq -r '.tool_input.description // empty' 2>/dev/null | head -c 60)"
|
||
LINE="🎯 Task: $D"
|
||
;;
|
||
*)
|
||
LINE="🔧 $TOOL"
|
||
;;
|
||
esac
|
||
|
||
[[ -z "$LINE" ]] && exit 0
|
||
|
||
NOW="$(date +%s%N | cut -c1-13)"
|
||
|
||
# Append + bump LAST под flock'ом — конкурентные hook'и не теряют строки.
|
||
(
|
||
flock 9
|
||
echo "$LINE" >> "$BUF"
|
||
echo "$NOW" > "$LAST"
|
||
) 9>"$LOCK"
|
||
|
||
# Дебаунс-flusher в фоне. Каждый hook спавнит свой sleep, но только
|
||
# тот, чей NOW совпал с финальным LAST после задержки, реально шлёт —
|
||
# остальные тихо выходят.
|
||
(
|
||
sleep "$DEBOUNCE_SEC"
|
||
(
|
||
flock 9
|
||
LAST_TS="$(cat "$LAST" 2>/dev/null || echo 0)"
|
||
if [[ "$LAST_TS" != "$NOW" ]]; then
|
||
# Пришёл более свежий tool — он заfflushит сам.
|
||
exit 0
|
||
fi
|
||
[[ -s "$BUF" ]] || exit 0
|
||
# Если буфер длиннее MAX_LINES — режем хвост (свежие строки важнее).
|
||
BODY="$(tail -n "$MAX_LINES" "$BUF")"
|
||
: > "$BUF"
|
||
curl -fsS -m 10 -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" \
|
||
--data-urlencode "chat_id=${CHAT_ID}" \
|
||
--data-urlencode "text=${BODY}" \
|
||
--data-urlencode "disable_notification=true" \
|
||
--data-urlencode "disable_web_page_preview=true" \
|
||
>/dev/null 2>&1 || log "send failed"
|
||
) 9>"$LOCK"
|
||
) &
|
||
|
||
# Не ждём фоновую задачу — Claude Code продолжает выполнение tool'а.
|
||
disown 2>/dev/null || true
|
||
exit 0
|