requests, встроенный модуль sqlite3, бот-токен из @BotFather, права администратора в канале, публичный юзернейм канала вида @my_channel для проверки по t.meretry_after
Идемпотентность — свойство операции, при котором повторный вызов с теми же входными данными не создаёт нового результата. Здесь результат — сообщение в канале, входные данные — текст, канал и слот публикации.
python3 --version → Python 3.11.x)pip install requests (версию фиксируйте в requirements.txt, остальное — stdlib)/mybots → ваш бот → API Token@my_channel), иначе проверка по t.me/<канал>/<id> не сработает/opt/tg-publisher с файлами publish.py, dedup.py, reconcile.py, outbox.dbДальше — важная часть: одна и та же логика используется и для первого запуска, и для повторного. Поэтому схема БД создаётся до первой отправки. Если вы только начинаете с ботами, базовый каркас проекта разобран в гайде Telegram-бот с ИИ на Python: от нуля до деплоя.
Что сделать: создайте файл dedup.py со схемой таблицы outbox и функцией генерации ключа идемпотентности.
Зачем: ключ — это «отпечаток» поста. Одинаковый ключ даёт конфликт первичного ключа, и второй INSERT не проходит. На этом конфликте и строится защита от дубля.
# dedup.py
import sqlite3, hashlib, json, time
DB = "outbox.db"
def connect():
conn = sqlite3.connect(DB, timeout=30)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("""
CREATE TABLE IF NOT EXISTS outbox (
idempotency_key TEXT PRIMARY KEY,
channel TEXT NOT NULL,
payload_hash TEXT NOT NULL,
status TEXT NOT NULL, -- sending | sent | failed
message_id INTEGER,
created_at INTEGER NOT NULL
)
""")
conn.commit()
return conn
def make_key(channel: str, text: str, slot: str) -> str:
raw = json.dumps({"channel": channel, "text": text, "slot": slot},
ensure_ascii=False, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
slot — окно публикации, например 2026-02-11T10:00. Повторный запуск в том же окне даёт тот же ключ, а запуск в 11:00 — уже новый.
Как проверить: создайте БД и убедитесь, что таблица появилась.
cd /opt/tg-publisher
python3 -c "import dedup;c=dedup.connect();print(c.execute('PRAGMA journal_mode').fetchone())"
python3 -c "import sqlite3;print(sqlite3.connect('outbox.db').execute(\"select name from sqlite_master where type='table'\").fetchall())"
Ожидаемый вывод:
('wal',)
[('outbox',)]
Проверьте доступ бота к каналу — до всей логики:
curl -s "https://api.telegram.org/bot<BOT_TOKEN>/getMe"
curl -s "https://api.telegram.org/bot<BOT_TOKEN>/getChat?chat_id=@my_channel"
Первая команда возвращает {"ok":true,"result":{"id":...,"is_bot":true,...}}, вторая — объект чата с "type":"channel". Если вместо этого {"ok":false,"error_code":403,...} — бот не админ, дальше идти смысла нет.
requests установленыoutbox.db создан, режим walgetMe вернул ok:truegetChat вернул type:channel
Что сделать: напишите publish.py, который занимает ключ в БД, отправляет сообщение и записывает message_id. Плюс поставьте поверх отправки token bucket — ограничитель частоты (механизм, который выдаёт не больше N запросов за интервал).
Зачем: два независимых риска. Первый — повторный запуск (cron сработал дважды, оператор нажал «ещё раз»). Второй — flood-лимит Telegram: слишком частые запросы бот получает как 429. Ключ лечит первый риск, ограничитель — второй.
# publish.py
import argparse, time, requests
from dedup import connect, make_key
from dedup import hashlib # см. импорт ниже
BOT_TOKEN = "<BOT_TOKEN>"
API = f"https://api.telegram.org/bot{BOT_TOKEN}"
class TokenBucket:
def __init__(self, rate_per_sec: float, burst: int):
self.rate, self.capacity = rate_per_sec, burst
self.tokens, self.ts = burst, time.monotonic()
def take(self):
while True:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= 1:
self.tokens -= 1
return
time.sleep((1 - self.tokens) / self.rate)
bucket = TokenBucket(rate_per_sec=0.33, burst=1) # ~20 сообщений/минуту
def send_with_retry(payload, attempts=5):
for i in range(attempts):
bucket.take()
r = requests.post(f"{API}/sendMessage", json=payload, timeout=15)
data = r.json()
if data.get("ok"):
return data["result"]
if data.get("error_code") == 429:
wait = data.get("parameters", {}).get("retry_after", 2 ** i)
print(f"429: sleep {wait}s")
time.sleep(wait + 1)
continue
raise RuntimeError(data.get("description"))
raise RuntimeError("rate limit retries exhausted")
def publish(channel: str, text: str, slot: str):
conn = connect()
key = make_key(channel, text, slot)
cur = conn.execute(
"INSERT OR IGNORE INTO outbox(idempotency_key, channel, payload_hash, status, created_at)"
" VALUES (?,?,?,?,?)",
(key, channel, hashlib.sha256(text.encode("utf-8")).hexdigest(),
"sending", int(time.time())))
conn.commit()
if cur.rowcount == 0:
print(f"skip: duplicate key {key[:12]}")
return None
try:
result = send_with_retry({"chat_id": channel, "text": text,
"disable_web_page_preview": False})
except Exception as e:
conn.execute("UPDATE outbox SET status='failed' WHERE idempotency_key=?", (key,))
conn.commit()
raise
mid = result["message_id"]
conn.execute("UPDATE outbox SET status='sent', message_id=? WHERE idempotency_key=?",
(mid, key))
conn.commit()
print(f"sent message_id={mid}")
return mid
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--slot", required=True)
p.add_argument("--channel", default="@my_channel")
p.add_argument("--text-file", default="post.txt")
a = p.parse_args()
publish(a.channel, open(a.text_file, encoding="utf-8").read(), a.slot)
Строку from dedup import hashlib замените на обычный import hashlib в начале файла — модуль стандартный.
Почему это работает: INSERT OR IGNORE при конфликте по первичному ключу возвращает rowcount == 0, и до сетевого вызова дело не доходит. status='sending' фиксируется до запроса — значит, факт «начали отправлять» не потеряется при падении процесса.
Как проверить: запустите публикацию дважды с одним и тем же слотом.
echo "Тестовый пост: проверка дедупликации" > post.txt
python3 publish.py --slot 2026-02-11T10:00
python3 publish.py --slot 2026-02-11T10:00
Ожидаемый экран (значения message_id зависят от канала):
sent message_id=4127
skip: duplicate key 9f2c1a7b4d10
В канале — одно сообщение. Проверьте содержимое БД:
sqlite3 outbox.db "select substr(idempotency_key,1,12), status, message_id from outbox;"
9f2c1a7b4d10|sent|4127

Что сделать: добавьте reconcile.py — сверку зависших записей со статусом sending через публичную страницу https://t.me/<канал>/<message_id>.
Зачем: остаётся узкое место — процесс упал между sendMessage и UPDATE status='sent'. Пост в канале есть, а в БД sending. При следующем запуске появится дубль. Проверка по t.me отвечает на вопрос «пост реально существует?» без риска повторной отправки.
# reconcile.py
import re, time, requests
from dedup import connect
def exists_on_tme(username: str, message_id: int) -> bool:
url = f"https://t.me/{username.lstrip('@')}/{message_id}"
html = requests.get(url, timeout=10,
headers={"User-Agent": "Mozilla/5.0"}).text
if "Post not found" in html or "tgme_widget_message_error" in html:
return False
return "tgme_widget_message" in html
def find_message_id(username: str, text_head: str):
# запасной путь: последние посты канала через веб-превью
html = requests.get(f"https://t.me/s/{username.lstrip('@')}", timeout=10,
headers={"User-Agent": "Mozilla/5.0"}).text
ids = re.findall(r'data-post="[^/]+/(\d+)"', html)
if text_head[:40] in html and ids:
return int(ids[-1])
return None
conn = connect()
rows = conn.execute(
"SELECT idempotency_key, channel FROM outbox"
" WHERE status='sending' AND created_at < ?", (int(time.time()) - 300,)).fetchall()
for key, channel in rows:
mid = find_message_id(channel, "")
if mid and exists_on_tme(channel, mid):
conn.execute("UPDATE outbox SET status='sent', message_id=? WHERE idempotency_key=?",
(mid, key))
print(f"orphan key={key[:12]} -> found on t.me/{channel.lstrip('@')}/{mid}, marked sent")
else:
conn.execute("UPDATE outbox SET status='failed' WHERE idempotency_key=?", (key,))
print(f"orphan key={key[:12]} -> not found, marked failed (safe to resend)")
conn.commit()
Разметка веб-превью t.me не является публичным API и может меняться — после обновления Telegram сверьтесь с документацией и проверьте вручную: откройте https://t.me/my_channel/4127 в браузере. Если пост удалён, страница содержит Post not found.
Как проверить: сымитируйте сбой — вручную поставьте статус sending и запустите сверку.
sqlite3 outbox.db "update outbox set status='sending', message_id=NULL where message_id=4127;"
python3 reconcile.py
sqlite3 outbox.db "select status, message_id from outbox;"
Ожидаемый вывод:
orphan key=9f2c1a7b4d10 -> found on t.me/my_channel/4127, marked sent
sent|4127
Сверьте итог с каналом: откройте https://t.me/my_channel — на экране должен быть ровно один пост с текстом Тестовый пост: проверка дедупликации. Дальше поставьте publish.py в cron с шагом слота (например, 0 * * * *) и запускайте reconcile.py раз в 15 минут. Если публикацию собирает агент, который сам решает, когда и что постить, схему «агент → ключ → отправка» разберите по гайду ИИ-агенты для автоматизации задач: гид по инструментам.
Лимиты, которые заложены в примере (сверьтесь с актуальной версией документации Telegram Bot API, значения могут меняться):
| Ограничение | Ориентир | Что делает код |
|---|---|---|
| Сообщений в минуту в один канал | ~20 | `TokenBucket(0.33, 1)` — пауза ~3 с |
| Общая пропускная способность бота | 30 сообщений/сек | тот же bucket на все вызовы |
| Ответ 429 | `parameters.retry_after` в секундах | сон `retry_after + 1` и повтор |
Проблема. В логах 429, публикация пачки постов растягивается на минуты
{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 35","parameters":{"retry_after":35}}sendMessage без паузы, лимит на канал исчерпан.attempts вслепую, а снизьте скорость и уважайте retry_after. В примере это TokenBucket(rate_per_sec=0.33, burst=1) и ветка error_code == 429. Проверка: python3 -c "from publish import bucket; [bucket.take() for _ in range(3)]" — три вызова занимают около 6 секунд, а не мгновение.Проблема. Дубль в канале после падения контейнера
message_id (например, 4127 и 4128), в outbox одна запись со статусом sending.RuntimeError: rate limit retries exhausted или пустой traceback после KeyboardInterrupt — процесс умер между sendMessage и UPDATE.status='sending' не был сверен с реальным состоянием канала.python3 reconcile.py до следующей попытки отправки, удалить лишний пост руками в клиенте, убедиться, что message_id в БД указывает на оставшийся. Профилактика: reconcile.py в cron каждые 15 минут и порог created_at < now - 300.Проблема. Второй воркер падает на запись в БД
sqlite3.OperationalError: database is lockedoutbox.db одновременно, WAL и busy_timeout не включены в этом процессе.connect() из dedup.py, где уже есть PRAGMA journal_mode=WAL и PRAGMA busy_timeout=5000. Проверка: sqlite3 outbox.db "PRAGMA journal_mode; PRAGMA busy_timeout;" → wal и 5000.Три механизма закрывают три разных источника дублей: первичный ключ по хешу поста — повторный запуск, TokenBucket с обработкой retry_after — flood-лимит, сверка по t.me — падение процесса на середине отправки. Дальше вынесите BOT_TOKEN и @my_channel в переменные окружения, добавьте reconcile.py в cron и прогоните тест с намеренно убитым процессом во время отправки.
Почему пост дублируется, если скрипт запускается по cron каждую минуту?
Потому что ключ идемпотентности не включает слот или включает время запуска. Ключ должен строиться из канала, текста и окна публикации (make_key(channel, text, slot)), тогда все запуски внутри окна схлопываются в один.
Зачем проверять t.me, если в ответе API уже есть message_id?
message_id появляется только после успешного ответа. Если процесс умер до записи в БД, message_id потерян, а пост в канале уже есть — веб-превью t.me/<канал>/<id> позволяет найти его без повторной отправки.
Какой лимит отправки в Telegram-канал?
Ориентир — около 20 сообщений в минуту в один чат и 30 сообщений в секунду суммарно для бота; точные значения и формат ответа 429 сверьте с документацией Telegram Bot API. В коде лимит задан параметрами TokenBucket(0.33, 1).
Что делать, если getChat возвращает 403?
Добавьте бота в администраторы канала с правом публикации сообщений и проверьте, что chat_id указан как @my_channel, а не как числовой id без префикса -100. После правки повторите curl -s "https://api.telegram.org/bot<BOT_TOKEN>/getChat?chat_id=@my_channel" — должен вернуться объект с "type":"channel".