Files
playbook/antigravity-awesome-skills/skills/telegram-bot-messaging/scripts/telegram.sh
T
2026-07-18 00:02:59 +00:00

421 lines
14 KiB
Bash

#!/usr/bin/env bash
# telegram.sh — send/receive Telegram messages via the Bot API.
# Deps: bash, curl, jq. Config: env vars or ~/.config/telegram/config.
set -euo pipefail
API_BASE="https://api.telegram.org"
CONFIG_DIR="${TELEGRAM_CONFIG_DIR:-$HOME/.config/telegram}"
CONFIG_FILE="$CONFIG_DIR/config"
usage() {
cat >&2 <<'EOF'
Usage: telegram.sh <command> [args]
Commands:
setup [--bot NAME]
Guided bot registration (BotFather walkthrough + chat-ID discovery)
send MESSAGE [--to TARGET] [--bot NAME] [--silent] [--format md|html]
Send a text message (auto-splits over 4096 chars)
file PATH [CAPTION] [--to TARGET] [--bot NAME] [--silent]
Send a document (photos for png/jpg/jpeg/gif/webp)
ask QUESTION [--options "Yes,No"] [--timeout SECS] [--to TARGET] [--bot NAME]
Ask with inline buttons, wait for tap or text reply; prints answer.
Exit 0 = answered, 2 = timeout
read [--limit N] [--bot NAME] [--all]
Print new incoming messages since last read
Config: TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID env vars, or ~/.config/telegram/config
Named bots: BOT_<NAME>_TOKEN Named targets: TARGET_<NAME>=<chat_id>
Group approvals: TELEGRAM_APPROVER_IDS or APPROVERS_<TARGET>=<user_id,...>
EOF
exit 1
}
die() { printf 'telegram.sh: %s\n' "$*" >&2; exit 1; }
check_deps() {
command -v curl >/dev/null 2>&1 || die "curl is required but not found"
command -v jq >/dev/null 2>&1 || die "jq is required but not found (brew install jq / apt install jq)"
}
# Env vars win; config file fills in whatever the environment didn't set.
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local key val
while IFS='=' read -r key val; do
case "$key" in ''|\#*) continue ;; esac
[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
if [ -z "${!key:-}" ]; then export "$key=$val"; fi
done < "$CONFIG_FILE"
}
upper_key() { printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_'; }
resolve_bot() {
local name="${1:-}"
if [ -z "$name" ]; then
BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"
BOT_KEY="default"
[ -n "$BOT_TOKEN" ] || die "no bot token — run 'telegram.sh setup' or set TELEGRAM_BOT_TOKEN"
else
local var
var="BOT_$(upper_key "$name")_TOKEN"
BOT_TOKEN="${!var:-}"
BOT_KEY="$name"
[ -n "$BOT_TOKEN" ] || die "no token for bot '$name' — run 'telegram.sh setup --bot $name' or set $var"
fi
[[ "$BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]+$ ]] \
|| die "invalid bot token format"
}
resolve_target() {
local t="${1:-}"
APPROVER_IDS_RAW="${TELEGRAM_APPROVER_IDS:-}"
if [ -z "$t" ]; then
CHAT_ID="${TELEGRAM_CHAT_ID:-}"
[ -n "$CHAT_ID" ] || die "no default chat — run 'telegram.sh setup' or set TELEGRAM_CHAT_ID"
elif [[ "$t" =~ ^-?[0-9]+$ ]]; then
CHAT_ID="$t"
else
local var approver_var
var="TARGET_$(upper_key "$t")"
CHAT_ID="${!var:-}"
[ -n "$CHAT_ID" ] || die "unknown target '$t' — add $var=<chat_id> to $CONFIG_FILE"
approver_var="APPROVERS_$(upper_key "$t")"
APPROVER_IDS_RAW="${!approver_var:-${TELEGRAM_APPROVER_IDS:-}}"
fi
}
# Private chat IDs identify the sender, so existing private-chat approval flows
# need no extra configuration. Group chat IDs are shared: fail closed unless an
# explicit sender-user allowlist is configured for that target (or globally).
resolve_approvers() {
if [[ "$CHAT_ID" != -* ]]; then
APPROVER_IDS="$CHAT_ID"
return 0
fi
[ -n "$APPROVER_IDS_RAW" ] \
|| die "group ask requires TELEGRAM_APPROVER_IDS or APPROVERS_<TARGET>"
local raw id normalized=""
raw="${APPROVER_IDS_RAW//,/ }"
for id in $raw; do
[[ "$id" =~ ^[0-9]+$ ]] \
|| die "approver IDs must be comma-separated Telegram user IDs"
normalized="${normalized}${normalized:+$'\n'}${id}"
done
[ -n "$normalized" ] || die "group ask requires at least one approver user ID"
APPROVER_IDS="$normalized"
}
# Keep the token-bearing URL out of curl's argv. The URL is supplied through a
# one-shot config on stdin; all other request arguments remain ordinary argv.
curl_telegram() {
local method="$1"
shift
printf 'url = "%s/bot%s/%s"\n' "$API_BASE" "$BOT_TOKEN" "$method" \
| curl -sS --max-time "${TELEGRAM_CURL_TIMEOUT:-35}" --config - "$@"
}
# api METHOD [curl args...] — prints the JSON response, dies if .ok != true
api() {
local method="$1" resp
shift
resp=$(curl_telegram "$method" "$@") || die "network error calling $method"
[ "$(jq -r '.ok' <<<"$resp")" = "true" ] \
|| die "$method failed: $(jq -r '.description // "unknown error"' <<<"$resp")"
printf '%s' "$resp"
}
cmd_send() {
local msg="" to="" bot="" silent="false" format=""
while [ $# -gt 0 ]; do
case "$1" in
--to) to="$2"; shift 2 ;;
--bot) bot="$2"; shift 2 ;;
--silent) silent="true"; shift ;;
--format) format="$2"; shift 2 ;;
-*) die "unknown flag for send: $1" ;;
*) if [ -z "$msg" ]; then msg="$1"; else die "unexpected argument: $1"; fi; shift ;;
esac
done
[ -n "$msg" ] || die "send needs a message (telegram.sh help)"
resolve_bot "$bot"
resolve_target "$to"
local parse_mode=""
case "$format" in
'') ;;
md) parse_mode="MarkdownV2" ;;
html) parse_mode="HTML" ;;
*) die "--format must be md or html" ;;
esac
local chunk
while [ -n "$msg" ]; do
chunk="${msg:0:4096}"
msg="${msg:4096}"
send_chunk "$chunk" "$parse_mode" "$silent"
done
}
# send_chunk TEXT PARSE_MODE SILENT — one sendMessage; formatted sends fall
# back to plain text if Telegram rejects the markup, so alerts never get lost.
send_chunk() {
local text="$1" parse_mode="$2" silent="$3" resp
if [ -n "$parse_mode" ]; then
resp=$(curl_telegram sendMessage \
-d "chat_id=$CHAT_ID" --data-urlencode "text=$text" \
-d "disable_notification=$silent" -d "parse_mode=$parse_mode") \
|| die "network error calling sendMessage"
if [ "$(jq -r '.ok' <<<"$resp")" = "true" ]; then return 0; fi
fi
api sendMessage -d "chat_id=$CHAT_ID" --data-urlencode "text=$text" \
-d "disable_notification=$silent" >/dev/null
}
cmd_file() {
local path="" caption="" to="" bot="" silent="false"
while [ $# -gt 0 ]; do
case "$1" in
--to) to="$2"; shift 2 ;;
--bot) bot="$2"; shift 2 ;;
--silent) silent="true"; shift ;;
-*) die "unknown flag for file: $1" ;;
*)
if [ -z "$path" ]; then path="$1"
elif [ -z "$caption" ]; then caption="$1"
else die "unexpected argument: $1"; fi
shift ;;
esac
done
[ -n "$path" ] || die "file needs a path (telegram.sh help)"
[ -f "$path" ] || die "file not found: $path"
resolve_bot "$bot"
resolve_target "$to"
local ext method="sendDocument" field="document"
ext=$(printf '%s' "${path##*.}" | tr '[:upper:]' '[:lower:]')
case "$ext" in
png|jpg|jpeg|gif|webp) method="sendPhoto" field="photo" ;;
esac
api "$method" -F "chat_id=$CHAT_ID" -F "$field=@$path" \
-F "caption=$caption" -F "disable_notification=$silent" >/dev/null
}
offset_file() { printf '%s/offset.%s' "$CONFIG_DIR" "$BOT_KEY"; }
get_offset() { cat "$(offset_file)" 2>/dev/null || printf '0'; }
save_offset() {
( umask 077; mkdir -p "$CONFIG_DIR"; printf '%s' "$1" > "$(offset_file)" )
}
# Chat IDs we accept incoming messages from: the default chat plus every
# TARGET_* value in the config file and the environment. Strangers who
# message a public bot never match.
allowed_chats() {
{
printf '%s\n' "${TELEGRAM_CHAT_ID:-}"
if [ -f "$CONFIG_FILE" ]; then
grep -E '^TARGET_[A-Za-z0-9_]+=' "$CONFIG_FILE" | cut -d= -f2 || true
fi
env | grep -E '^TARGET_[A-Za-z0-9_]+=' | cut -d= -f2 || true
} | grep -E '^-?[0-9]+$' | sort -u || true
}
cmd_read() {
local limit=20 bot="" all="false"
while [ $# -gt 0 ]; do
case "$1" in
--limit) limit="$2"; shift 2 ;;
--bot) bot="$2"; shift 2 ;;
--all) all="true"; shift ;;
*) die "unknown flag for read: $1" ;;
esac
done
[[ "$limit" =~ ^[0-9]+$ ]] || die "--limit must be a whole number"
resolve_bot "$bot"
local offset=0 resp
if [ "$all" != "true" ]; then offset=$(get_offset); fi
resp=$(api getUpdates -d "offset=$offset" -d "limit=$limit")
jq -r --arg allowed "$(allowed_chats)" '
($allowed | split("\n")) as $ok
| .result[]
| select(.message.text != null)
| select((.message.chat.id | tostring) as $c | $ok | index($c) != null)
| "[\(.message.chat.id)] \(.message.from.first_name // "?"): \(.message.text)"
' <<<"$resp"
local last
last=$(jq -r '.result | if length > 0 then (.[-1].update_id + 1 | tostring) else "" end' <<<"$resp")
if [ -n "$last" ] && [ "$all" != "true" ]; then
save_offset "$last"
fi
}
cmd_ask() {
local question="" options="Yes,No" timeout=300 to="" bot=""
while [ $# -gt 0 ]; do
case "$1" in
--options) options="$2"; shift 2 ;;
--timeout) timeout="$2"; shift 2 ;;
--to) to="$2"; shift 2 ;;
--bot) bot="$2"; shift 2 ;;
-*) die "unknown flag for ask: $1" ;;
*) if [ -z "$question" ]; then question="$1"; else die "unexpected argument: $1"; fi; shift ;;
esac
done
[ -n "$question" ] || die "ask needs a question (telegram.sh help)"
[[ "$timeout" =~ ^[0-9]+$ ]] || die "--timeout must be a whole number of seconds"
resolve_bot "$bot"
resolve_target "$to"
resolve_approvers
# Flush pending updates so stale messages can't answer the question.
local offset resp last
offset=$(get_offset)
resp=$(api getUpdates -d "offset=$offset" -d "timeout=0" -d "limit=100")
last=$(jq -r '.result | if length > 0 then (.[-1].update_id + 1 | tostring) else "" end' <<<"$resp")
if [ -n "$last" ]; then offset="$last"; save_offset "$offset"; fi
local keyboard msg_id
keyboard=$(jq -cn --arg opts "$options" \
'{inline_keyboard: [[$opts | split(",")[] | {text: ., callback_data: .}]]}')
resp=$(api sendMessage -d "chat_id=$CHAT_ID" \
--data-urlencode "text=$question" --data-urlencode "reply_markup=$keyboard")
msg_id=$(jq -r '.result.message_id' <<<"$resp")
local deadline now remain answer cb
deadline=$(( $(date +%s) + timeout ))
while :; do
now=$(date +%s)
remain=$(( deadline - now ))
if [ "$remain" -le 0 ]; then break; fi
if [ "$remain" -gt 25 ]; then remain=25; fi
resp=$(api getUpdates -d "offset=$offset" -d "timeout=$remain")
last=$(jq -r '.result | if length > 0 then (.[-1].update_id + 1 | tostring) else "" end' <<<"$resp")
if [ -n "$last" ]; then offset="$last"; save_offset "$offset"; fi
# Button tap on our question message?
cb=$(jq -r --argjson mid "$msg_id" --argjson chat "$CHAT_ID" --arg allowed "$APPROVER_IDS" '
($allowed | split("\n")) as $ok
| [.result[]
| select(.callback_query.message.message_id == $mid)
| select(.callback_query.message.chat.id == $chat)
| select((.callback_query.from.id | tostring) as $u | $ok | index($u) != null)
| .callback_query]
| if length > 0 then "\(.[0].data)\t\(.[0].id)" else "" end' <<<"$resp")
if [ -n "$cb" ]; then
answer="${cb%%$'\t'*}"
api answerCallbackQuery -d "callback_query_id=${cb##*$'\t'}" >/dev/null
api editMessageText -d "chat_id=$CHAT_ID" -d "message_id=$msg_id" \
--data-urlencode "text=$question
$answer" >/dev/null
printf '%s\n' "$answer"
return 0
fi
# Free-text reply from the asked chat?
answer=$(jq -r --argjson chat "$CHAT_ID" --arg allowed "$APPROVER_IDS" '
($allowed | split("\n")) as $ok
| [.result[]
| select(.message.chat.id == $chat)
| select((.message.from.id | tostring) as $u | $ok | index($u) != null)
| select(.message.text != null)
| .message.text]
| if length > 0 then .[0] else "" end' <<<"$resp")
if [ -n "$answer" ]; then
api editMessageText -d "chat_id=$CHAT_ID" -d "message_id=$msg_id" \
--data-urlencode "text=$question
💬 $answer" >/dev/null
printf '%s\n' "$answer"
return 0
fi
done
api editMessageText -d "chat_id=$CHAT_ID" -d "message_id=$msg_id" \
--data-urlencode "text=$question
⏰ timed out" >/dev/null || true
return 2
}
set_config_key() {
local key="$1" val="$2" tmp
( umask 077; mkdir -p "$CONFIG_DIR"; touch "$CONFIG_FILE" )
tmp=$(mktemp "$CONFIG_DIR/.config.XXXXXX")
grep -v "^$key=" "$CONFIG_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$val" >> "$tmp"
mv "$tmp" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}
cmd_setup() {
local bot=""
while [ $# -gt 0 ]; do
case "$1" in
--bot) bot="$2"; shift 2 ;;
*) die "unknown flag for setup: $1" ;;
esac
done
printf 'Telegram bot setup%s\n\n' "${bot:+ (named bot: $bot)}" >&2
printf ' 1. Open https://t.me/BotFather in Telegram\n' >&2
printf ' 2. Send /newbot and follow the prompts (display name, then a username ending in "bot")\n' >&2
printf ' 3. BotFather replies with an HTTP API token\n\n' >&2
printf 'Paste the token here: ' >&2
local token
read -r token
[ -n "$token" ] || die "no token entered"
BOT_TOKEN="$token"
local username
username=$(api getMe | jq -r '.result.username')
printf 'Token valid — bot is @%s\n\n' "$username" >&2
printf 'Now send any message to https://t.me/%s then press Enter... ' "$username" >&2
read -r _ || true
local chat_id
chat_id=$(api getUpdates | jq -r \
'[.result[] | .message.chat.id | select(. != null)] | if length > 0 then (.[-1] | tostring) else "" end')
[ -n "$chat_id" ] || die "no message found — send a message to @$username and rerun setup"
if [ -z "$bot" ]; then
set_config_key TELEGRAM_BOT_TOKEN "$token"
set_config_key TELEGRAM_CHAT_ID "$chat_id"
else
set_config_key "BOT_$(upper_key "$bot")_TOKEN" "$token"
if ! grep -q '^TELEGRAM_CHAT_ID=' "$CONFIG_FILE" 2>/dev/null; then
set_config_key TELEGRAM_CHAT_ID "$chat_id"
fi
fi
CHAT_ID="$chat_id"
api sendMessage -d "chat_id=$chat_id" \
--data-urlencode "text=✅ telegram.sh setup complete for @$username" >/dev/null
printf 'Config written to %s (chat %s) — confirmation sent.\n' "$CONFIG_FILE" "$chat_id" >&2
}
main() {
check_deps
load_config
[ $# -ge 1 ] || usage
local cmd="$1"
shift
case "$cmd" in
setup) cmd_setup "$@" ;;
send) cmd_send "$@" ;;
file) cmd_file "$@" ;;
ask) cmd_ask "$@" ;;
read) cmd_read "$@" ;;
-h|--help|help) usage ;;
*) die "unknown command: $cmd (telegram.sh help)" ;;
esac
}
main "$@"