Skip to content

Instantly share code, notes, and snippets.

@darknate
Last active January 27, 2026 13:18
Show Gist options
  • Select an option

  • Save darknate/0c6d09489abc7c28dfae2f80ed671361 to your computer and use it in GitHub Desktop.

Select an option

Save darknate/0c6d09489abc7c28dfae2f80ed671361 to your computer and use it in GitHub Desktop.
Telegram Digest (afadeev)

Short checklist

Step Action
1 Put prompt, send_to_telegram.py, plist.example, newsyslog.example under personal/telegram_digest/... in a project root
2 Implement a runner from the spec (time window, state, Codex + prompt, send_to_telegram)
3 Add .env with TELEGRAM_BOT_TOKEN_RPOD, TELEGRAM_CHAT_ID_MY, CODEX_BIN, CODEX_MODEL
4 Install Python deps, Codex CLI, telegram-mcp
5 Edit prompt/channels if needed
6 Copy plist example → replace paths and label → symlink to ~/Library/LaunchAgentslaunchctl load
7 Copy newsyslog example → replace path and user:group → sudo cp ... /etc/newsyslog.d/telegram-digest.conf
8 Create logs/ and state/, run the runner once by hand
9 Confirm job in launchctl list and wait or trigger a run in the time window

Runner task

Telegram Digest Runner — what it does and why you need it

The runner is the script that decides when to run the digest once per day and then runs Codex. The actual digest (channels, summarization, sending) is defined in the prompt and done by Codex; the runner only does scheduling and orchestration.

Why it’s needed

  • Run the digest at most once per day, and only within a time window (e.g. 07:00–13:00 local).
  • Invoke Codex CLI with the prompt from prompt/telegram_channels_summary.md; Codex uses telegram-mcp, builds the digest, and sends it via send_to_telegram.py (or equivalent).
  • Remember “already ran today” so repeated scheduler runs don’t start the job again.

What it does, step by step

  1. Config
    Reads from env: bot token, chat ID, path to Codex (CODEX_BIN), model (CODEX_MODEL). If any required value is missing, exit with an error.

  2. Time window
    Compares current local time to the window (e.g. 07:00–13:00). Outside the window it exits without running (exit 0).

  3. “Already ran today” check
    Reads a state file (e.g. state/last_run.json) with run_date and status (success / failed).

    • If there was a successful run today → exit without running.
    • If the last run today was failed → allow a retry (run again).
  4. Start notification
    Sends a short “Digest started for <date>” message to Telegram.

  5. Run Codex
    Runs Codex, e.g.:
    codex exec --model <CODEX_MODEL> --sandbox danger-full-access <contents of prompt/telegram_channels_summary.md>
    with a timeout (e.g. 10 minutes).
    Writes Codex stdout/stderr to logs (e.g. logs/codex_stdout_<run_id>.log, logs/codex_stderr_<run_id>.log).

  6. Interpret result

    • Non‑zero exit code → treat as failure.
    • Even on exit 0, scan stdout for __FATAL_ERROR__ at the start or for error phrases (“can’t start”, “MCP can’t connect”, “fatal error”, etc.). If found → treat as failure.
  7. After the run

    • Success: update state with run_date, status: success, and completion time.
    • Failure: set status: failed, send an error notification to Telegram (short message + “check logs at …”).
  8. Log pruning
    Delete old Codex log files (e.g. codex_*.log), keeping only the last N (e.g. 10).

  9. Job log
    Append each run to a job log (e.g. logs/job_runs.log): timestamp, result (START / SUCCESS / FAILED / SKIP / RETRY), and optional details.

Integrations

  • send_to_telegram: either call functions from send_to_telegram.py (escaping, sending) or run it as a subprocess with a JSON array of messages on stdin.
  • Error notifications: send plain‑text messages to the same Telegram chat (no Markdown to avoid escaping issues).

Scheduling
The runner is intended to be run from cron or launchd every N minutes (e.g. 30). Each run checks the time window and state and either runs the digest once or exits. No changes to files are required for this description; it’s enough to implement your own runner.

===

Required eidts

Do not forget to add in your .env file:

  • TELEGRAM_BOT_TOKEN_RPOD
  • TELEGRAM_CHAT_ID_MY
  • CODEX_BIN=codex
  • CODEX_MODEL=gpt-4o

Do not forget to correct in plist:

  • paths to files

Do not forget to correct in the prompt:

  • limit on request depending on how many you have total subscriptions
  • list of channels
  • paths to files.

However, just ask for this your coding agent. The groups can also be changed.

#!/usr/bin/env python3
"""
Send messages to Telegram with MarkdownV2 escaping.
Handles splitting, formatting, and error notifications.
Usage:
# From stdin (one message per line, or JSON array)
echo '["Message 1", "Message 2"]' | python send_to_telegram.py
# Or as module
from send_to_telegram import send_digest
send_digest(["Message 1", "Message 2"])
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
def load_env() -> dict:
"""Load environment variables from .env file."""
env = {}
# Find project root (where .env is)
script_dir = Path(__file__).resolve().parent
project_root = script_dir.parent.parent.parent # personal/telegram_digest/run -> root
env_path = project_root / '.env'
if not env_path.exists():
return env
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' not in line:
continue
key, val = line.split('=', 1)
env[key.strip()] = val.strip().strip('"').strip("'")
return env
def escape_markdown_v2(text: str) -> str:
"""
Escape special characters for Telegram MarkdownV2.
Characters to escape: _ * [ ] ( ) ~ ` > # + - = | { } . !
BUT: Don't escape characters that are part of markdown syntax.
This function escapes ALL special chars - use for plain text only.
"""
special_chars = r'_*[]()~`>#+-=|{}.!'
return re.sub(r'([' + re.escape(special_chars) + r'])', r'\\\1', text)
def escape_markdown_v2_preserving_formatting(text: str) -> str:
"""
Escape Telegram MarkdownV2 special characters while preserving:
- Bold: *text*
- Links: [text](url)
MarkdownV2 requires escaping: _ * [ ] ( ) ~ ` > # + - = | { } . !
But in link URLs, dots and other chars must NOT be escaped.
"""
# Characters to escape (excluding * for bold)
special_chars = r'_[]()~`>#+-=|{}.!'
# First, extract and protect links [text](url)
link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
links: list[tuple[str, str]] = re.findall(link_pattern, text)
# Replace links with unique placeholders (no special chars!)
placeholder_text = text
for i, (link_text, url) in enumerate(links):
original = f'[{link_text}]({url})'
placeholder_text = placeholder_text.replace(original, f'\x00LINK{i}\x00', 1)
# Normalize any pre-escaped specials to avoid double-escaping
placeholder_text = re.sub(
r'\\([' + re.escape(special_chars) + r'])', r'\1', placeholder_text
)
# Escape special characters that are not already escaped
escaped = re.sub(
r'(?<!\\)([' + re.escape(special_chars) + r'])', r'\\\1', placeholder_text
)
# Restore links with proper escaping (escape link text, but NOT url)
for i, (link_text, url) in enumerate(links):
normalized_link_text = re.sub(
r'\\([' + re.escape(special_chars) + r'])', r'\1', link_text
)
escaped_link_text = re.sub(
r'(?<!\\)([' + re.escape(special_chars) + r'])', r'\\\1', normalized_link_text
)
restored_link = f'[{escaped_link_text}]({url})'
escaped = escaped.replace(f'\x00LINK{i}\x00', restored_link)
return escaped
def send_message(token: str, chat_id: str, text: str, parse_mode: str = 'MarkdownV2') -> dict:
"""Send a single message to Telegram."""
url = f'https://api.telegram.org/bot{token}/sendMessage'
data: dict[str, object] = {
'chat_id': chat_id,
'text': text,
'disable_web_page_preview': True,
}
if parse_mode:
data['parse_mode'] = parse_mode
req = urllib.request.Request(
url,
data=json.dumps(data).encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode('utf-8'))
def send_error_notification(token: str, chat_id: str, error_msg: str) -> None:
"""Send error notification to Telegram (plain text, no escaping issues)."""
try:
url = f'https://api.telegram.org/bot{token}/sendMessage'
text = f"⚠️ Telegram Digest Error\n\n{error_msg}"
data: dict[str, object] = {
'chat_id': chat_id,
'text': text,
# No parse_mode -> plain text
}
req = urllib.request.Request(
url,
data=json.dumps(data).encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST'
)
urllib.request.urlopen(req, timeout=30)
except Exception as e:
print(f"Failed to send error notification: {e}", file=sys.stderr)
def send_digest(messages: list[str], pre_escaped: bool = False) -> bool:
"""
Send digest messages to Telegram.
Args:
messages: List of message strings
pre_escaped: If True, messages are already MarkdownV2 escaped
Returns:
True if all messages sent successfully
"""
env = load_env()
token = os.environ.get('TELEGRAM_BOT_TOKEN_RPOD') or env.get('TELEGRAM_BOT_TOKEN_RPOD')
chat_id = os.environ.get('TELEGRAM_CHAT_ID_MY') or env.get('TELEGRAM_CHAT_ID_MY')
if not token:
print("Error: TELEGRAM_BOT_TOKEN_RPOD not set", file=sys.stderr)
return False
if not chat_id:
print("Error: TELEGRAM_CHAT_ID_MY not set", file=sys.stderr)
return False
if not messages:
print("No messages to send", file=sys.stderr)
return False
errors = []
sent_count = 0
for i, msg in enumerate(messages):
try:
msg_to_send = msg if pre_escaped else escape_markdown_v2_preserving_formatting(msg)
# Try MarkdownV2 first
result = send_message(token, chat_id, msg_to_send, 'MarkdownV2')
if result.get('ok'):
sent_count += 1
print(f"Sent message {i+1}/{len(messages)}", file=sys.stderr)
else:
error = result.get('description', 'Unknown error')
errors.append(f"Message {i+1}: {error}")
# Fallback: try plain text
print(f"MarkdownV2 failed, trying plain text for message {i+1}", file=sys.stderr)
result = send_message(token, chat_id, msg, '')
if result.get('ok'):
sent_count += 1
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else str(e)
errors.append(f"Message {i+1}: HTTP {e.code} - {error_body}")
# Fallback: try plain text
try:
result = send_message(token, chat_id, msg, '')
if result.get('ok'):
sent_count += 1
except Exception:
pass
except Exception as e:
errors.append(f"Message {i+1}: {str(e)}")
# Report results
if errors:
error_summary = f"Sent {sent_count}/{len(messages)} messages.\n\nErrors:\n" + "\n".join(errors)
send_error_notification(token, chat_id, error_summary)
print(f"Errors occurred:\n{error_summary}", file=sys.stderr)
return False
print(f"Successfully sent all {sent_count} messages", file=sys.stderr)
return True
def main():
"""Main entry point - read messages from stdin."""
# Read all input
input_text = sys.stdin.read().strip()
if not input_text:
print("Error: No input provided", file=sys.stderr)
sys.exit(1)
# Try to parse as JSON array
try:
messages = json.loads(input_text)
if not isinstance(messages, list):
messages = [messages]
except json.JSONDecodeError:
# Treat as single message or newline-separated messages
messages = [input_text]
# Send
success = send_digest(messages)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
# Newsyslog config for Telegram Digest launchd logs
# Install: sudo cp telegram-digest.newsyslog.conf.example /etc/newsyslog.d/telegram-digest.conf
# Replace /path/to/ and youruser:staff with your repo path and user:group
#
# logfilename [owner:group] mode count size when flags [/pid_file] [sig_num]
/path/to/telegram_digest/logs/launchd.out youruser:staff 644 5 1024 * GN
/path/to/telegram_digest/logs/launchd.err youruser:staff 644 5 1024 * GN

Telegram Daily Digest

A. Role & Goal

You are a news analyst generating a daily digest from Telegram channels.

Output: Structured summary in Russian, sent as multiple Telegram messages via helper script.

Constraints:

  • Process only unread messages
  • Deduplicate across channels
  • Never truncate — split into messages instead
  • Quality over quantity

⛔ CRITICAL: DO NOT modify any files in this repository!

  • Only READ files, never WRITE or EDIT
  • If a script fails, REPORT the error — do not attempt to fix the code
  • Your job is to USE tools, not to fix them

Error Reporting Protocol

If you encounter a FATAL error that prevents task completion:

  1. Start your output with exactly: __FATAL_ERROR__
  2. Then paste the error message as-is (copy the actual error output)
  3. Do NOT attempt to fix the error yourself
  4. Exit normally (do not crash)

Example:

__FATAL_ERROR__
Error starting client: The authorization key (session file) was used under
two different IP addresses simultaneously, and can no longer be used.

Non-fatal issues (single channel unavailable, partial data loss, etc.) → handle gracefully, report in digest summary.


B. Processing Rules

Channels

Use username when calling tools. Chat ID is backup if username fails.

Channel Username Chat ID Category
MarketTwits @markettwits 1203560567 Finance/Markets
ADD_YOUR_CHANNELS @id 00000000 Category

Collection

  1. Call list_chats with chat_type=channel and limit=300 to build a map of username/title -> unread
  2. For each channel in the list, use unread from this map (preferred)
  3. If a channel is missing from the map (unexpected), fallback to get_chat with username → unread count
  4. If unread > 0: call list_messages with limit=min(unread, 200)
  5. If unread = 0: skip channel
  6. Skip: media-only, service messages, ads/sponsored

Deduplication

  • Same event in multiple channels → one entry (pick best source)
  • Thread of updates about same event → single summary
  • Related posts in same channel → merge into one item

Prioritization

Determine importance by content, not view counts:

  • Breaking news, significant events
  • State changes with consequences (было → стало)
  • Facts over reactions, irreversible over intentions

🔎 High-Volume Channel Filters (like MarketTwits)

For channels with 30+ daily posts, apply balanced filtering:

  • INCLUDE: market moves (rates, prices, indices), corporate events (layoffs, funding, IPO), regulatory changes, tech launches, M&A, economic indicators
  • INCLUDE: geopolitical events with economic impact, central bank decisions, major currency moves, commodity price shifts
  • MERGE: minor price updates into one item ("доллар 75-76 ₽")
  • SKIP: pure opinions without facts, routine "market opened/closed", duplicate same-news posts, memes/fluff
  • Target: 3-8 items per channel (not 1-2, not 15+)
  • Prefer: facts over reactions, confirmed events over rumors

Grouping

  1. 📊 Финансы и рынки: MarketTwits,
  2. 🌍 Новости и экономика:
  3. 💻 Технологии:
  4. 💼 Бизнес:
  5. 🪙 Крипто:

C. Output Contract

🔒 Quality Bar

  • Top News: 5–15 items
  • Per channel: 3–15 items (2–5 for low-activity channels)
  • Include events with actual impact or broad interest
  • Deduplication is mandatory (same event across channels = one entry)
  • If overloaded — select representative items
  • If no significant news — explicitly state this
  • Format consistency > completeness

Message Structure

Message 1: Header + Top News

📰 *Дайджест за [DATE]*

Обработано: ~[N] сообщений из [M] каналов

━━━━━━━━━━━━━━━━━━━━

🔥 *ГЛАВНОЕ ЗА ДЕНЬ*

1. [News item] — [→](https://t.me/channel/id)
2. [Second item] — [→](https://t.me/channel/id)
3. [Third item] — [→](https://t.me/channel/id)

Message 2+: Channel Summaries

📊 *ФИНАНСЫ И РЫНКИ*

*▫️ MarketTwits* (17 постов)
• [Topic]: описание — [→](https://t.me/markettwits/123)
• [Topic]: описание — [→](https://t.me/markettwits/124)

*▫️ investify\!* (25 постов)
• [Topic]: описание — [→](https://t.me/ifinvest/789)

━━━━━━━━━━━━━━━━━━━━

💻 *ТЕХНОЛОГИИ*

*▫️ Хабр* (8 постов)
• [Topic]: описание — [→](https://t.me/habr_com/456)

IMPORTANT: Always show actual message count in parentheses, NEVER use placeholders.

Formatting

  • *bold* for headers and channel names
  • [→](url) for post links
  • Emojis for structure: 📰 🔥 📊 💻 💼 🌍 🪙
  • No manual escaping — script handles it
  • Link format: https://t.me/{username_without_@}/{message_id}

Limits

  • Target: 3500-3800 chars per message (max 4096)
  • Split at section boundaries (after channel/category block)
  • Never truncate — always split into more messages

Language

  • Output in Russian
  • Keep terms as-is: IPO, ETF, ИИ, API, etc.

D. Execution

Send via Helper Script

cd /Users/_PUT_PATH_HERE/
echo '["Message 1", "Message 2"]' | python telegram_digest/run/send_to_telegram.py

The script:

  • Handles MarkdownV2 escaping
  • Falls back to plain text on error
  • Sends error notification if something fails

Important: Generate ALL messages first, then send all at once.

Mark as Read

If you decide to mark messages as read, do it only after all digest messages are sent to Telegram successfully (no errors). Required order:

  1. Generate all digest messages.
  2. Send via send_to_telegram.py.
  3. Verify the send succeeded (process exit code = 0, no errors reported).
  4. Only then call mark_as_read for the chats/messages used in the digest.

Error Handling

  • Channel inaccessible → skip, note in summary
  • No unread messages → "нет новых сообщений"
  • Tool failure → retry once, then skip
  • If send_to_telegram.py fails → report error, DO NOT attempt to fix the script

MCP Setup

Telegram MCP is configured globally in ~/.codex/config.toml. No local configuration needed.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Replace with your reverse-DNS label, e.g. com.yourname.telegram-digest -->
<key>Label</key>
<string>com.example.telegram-digest</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/venv/bin/python</string>
<string>/path/to/telegram_digest/run/telegram_digest_runner.py</string>
</array>
<!-- Run every 30 minutes; runner checks 07:00-13:00 local time window -->
<key>StartInterval</key>
<integer>1800</integer>
<key>WorkingDirectory</key>
<string>/path/to/script_factory</string>
<!-- Extended PATH for docker and codex CLIs; adjust for your Homebrew prefix -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin</string>
</dict>
<key>StandardOutPath</key>
<string>/path/to/telegram_digest/logs/launchd.out</string>
<key>StandardErrorPath</key>
<string>/path/to/telegram_digest/logs/launchd.err</string>
</dict>
</plist>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment