421 lines
14 KiB
Bash
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 "$@"
|