Spaces:
Paused
Paused
github-actions[bot] commited on
Commit Β·
61d6e2a
1
Parent(s): d7a1de8
Automated sync from GitHub
Browse files- .env.example +26 -0
- .gitattributes +8 -36
- .gitignore +7 -2
- Dockerfile +7 -0
- app/__init__.py +0 -0
- app/buttons.py +144 -0
- app/common.py +214 -0
- app/config.py +87 -0
- app/db.py +375 -0
- app/handlers/__init__.py +0 -0
- app/handlers/basic.py +96 -0
- app/handlers/channels.py +135 -0
- app/handlers/create.py +179 -0
- app/handlers/panel.py +68 -0
- app/handlers/posts.py +409 -0
- app/handlers/replace.py +436 -0
- app/health.py +22 -0
- app/linkswap.py +211 -0
- app/replace_engine.py +306 -0
- app/suggest.py +124 -0
- app/tgutil.py +370 -0
- app/ui.py +107 -0
- assets/rasengan.mp4 +0 -3
- bot.py +88 -0
- data/.gitkeep +0 -0
- index.html +0 -190
- make_session.py +17 -0
- requirements-dev.txt +3 -0
- requirements.txt +5 -0
- tests/__init__.py +0 -0
- tests/dbutil.py +16 -0
- tests/fakes.py +65 -0
- tests/harness.py +172 -0
- tests/test_engine.py +146 -0
- tests/test_flows.py +147 -0
- tests/test_units.py +248 -0
- tests/test_wire.py +166 -0
.env.example
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Telegram -------------------------------------------------------------
|
| 2 |
+
API_ID=
|
| 3 |
+
API_HASH=
|
| 4 |
+
BOT_TOKEN=
|
| 5 |
+
# Numeric Telegram ids (ask @userinfobot). Owners can do everything, admins can only create/edit posts.
|
| 6 |
+
OWNER_IDS=
|
| 7 |
+
ADMIN_IDS=
|
| 8 |
+
|
| 9 |
+
# --- Database -------------------------------------------------------------
|
| 10 |
+
# PostgreSQL, e.g. a free Neon database. Leave empty to use a local SQLite file (data/bot.db).
|
| 11 |
+
DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
|
| 12 |
+
# "null" = short-lived connections (lets serverless Postgres sleep); "queue" = normal pool for an always-on DB
|
| 13 |
+
DB_POOL=null
|
| 14 |
+
|
| 15 |
+
# --- Optional -------------------------------------------------------------
|
| 16 |
+
# Where the login session is kept. On hosts that wipe files on restart, run `python make_session.py`
|
| 17 |
+
# once and put the printed text in SESSION_STRING instead.
|
| 18 |
+
SESSION_PATH=data/bot
|
| 19 |
+
SESSION_STRING=
|
| 20 |
+
# Seconds between edits during /replace (Telegram rate limits: don't go much lower)
|
| 21 |
+
EDIT_DELAY=1.2
|
| 22 |
+
# Also swap links that are typed as plain text in a post (not only hyperlinks and buttons)
|
| 23 |
+
REPLACE_TYPED_LINKS=true
|
| 24 |
+
# Some free hosts need an open port: set it and the bot answers "ok" on /
|
| 25 |
+
PORT=
|
| 26 |
+
LOG_LEVEL=INFO
|
.gitattributes
CHANGED
|
@@ -1,36 +1,8 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.
|
| 3 |
-
*.
|
| 4 |
-
*.
|
| 5 |
-
*.
|
| 6 |
-
*.
|
| 7 |
-
*.
|
| 8 |
-
*.
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
rasengan.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -1,2 +1,7 @@
|
|
| 1 |
-
.
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
data/*
|
| 3 |
+
!data/.gitkeep
|
| 4 |
+
*.session
|
| 5 |
+
*.session-journal
|
| 6 |
+
__pycache__/
|
| 7 |
+
.pytest_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
COPY requirements.txt .
|
| 5 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 6 |
+
COPY . .
|
| 7 |
+
CMD ["python", "bot.py"]
|
app/__init__.py
ADDED
|
File without changes
|
app/buttons.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Button model: rows of {'t': text, 'u': url} (URL buttons) or {'t', 'raw'} (other bots' buttons, kept as-is)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
MAX_ROW = 8
|
| 8 |
+
MAX_TOTAL = 100
|
| 9 |
+
_URL_RX = re.compile(r"(?:https?://|tg://|t\.me/|telegram\.me/)\S+", re.I)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def norm_url(u: str) -> Optional[str]:
|
| 13 |
+
u = (u or "").strip()
|
| 14 |
+
if re.match(r"(?i)^(t\.me|telegram\.me)/", u):
|
| 15 |
+
u = "https://" + u
|
| 16 |
+
if not re.match(r"(?i)^(https?://|tg://)\S+$", u) or len(u) > 2000:
|
| 17 |
+
return None
|
| 18 |
+
return u
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def parse_buttons_text(text: str) -> list:
|
| 22 |
+
"""One row per line, 'Text - link'. Several buttons in a row: separate them with ' | '."""
|
| 23 |
+
rows: list = []
|
| 24 |
+
total = 0
|
| 25 |
+
for line in (text or "").strip().splitlines():
|
| 26 |
+
line = line.strip()
|
| 27 |
+
if not line:
|
| 28 |
+
continue
|
| 29 |
+
row = []
|
| 30 |
+
for part in re.split(r"\s+\|\s+|\s*&&\s*", line):
|
| 31 |
+
part = part.strip()
|
| 32 |
+
if not part:
|
| 33 |
+
continue
|
| 34 |
+
found = list(_URL_RX.finditer(part))
|
| 35 |
+
if not found:
|
| 36 |
+
raise ValueError(f"No link found in: {part}")
|
| 37 |
+
m = found[-1]
|
| 38 |
+
label = part[: m.start()].strip().rstrip("-ββ:|=").strip()
|
| 39 |
+
if not label:
|
| 40 |
+
raise ValueError(f"No button text before the link in: {part}")
|
| 41 |
+
url = norm_url(m.group(0))
|
| 42 |
+
if not url:
|
| 43 |
+
raise ValueError(f"That link doesn't look valid: {m.group(0)}")
|
| 44 |
+
row.append({"t": label, "u": url})
|
| 45 |
+
if len(row) > MAX_ROW:
|
| 46 |
+
raise ValueError(f"At most {MAX_ROW} buttons fit in one row.")
|
| 47 |
+
if row:
|
| 48 |
+
rows.append(row)
|
| 49 |
+
total += len(row)
|
| 50 |
+
if total > MAX_TOTAL:
|
| 51 |
+
raise ValueError(f"At most {MAX_TOTAL} buttons per post.")
|
| 52 |
+
return rows
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def copy_rows(rows: list) -> list:
|
| 56 |
+
return [[dict(b) for b in row] for row in (rows or [])]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def count(rows: list) -> int:
|
| 60 |
+
return sum(len(r) for r in rows or [])
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def missing_links(rows: list) -> int:
|
| 64 |
+
return sum(1 for r in rows or [] for b in r if not b.get("u") and not b.get("raw"))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def valid(rows: list, r: int, c: int) -> bool:
|
| 68 |
+
return 0 <= r < len(rows) and 0 <= c < len(rows[r])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def add_button(rows: list, btn: dict, new_row: bool = True) -> list:
|
| 72 |
+
rows = copy_rows(rows)
|
| 73 |
+
if new_row or not rows or len(rows[-1]) >= MAX_ROW:
|
| 74 |
+
rows.append([btn])
|
| 75 |
+
else:
|
| 76 |
+
rows[-1].append(btn)
|
| 77 |
+
return rows
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def set_field(rows: list, r: int, c: int, **fields) -> list:
|
| 81 |
+
rows = copy_rows(rows)
|
| 82 |
+
if valid(rows, r, c):
|
| 83 |
+
rows[r][c].update(fields)
|
| 84 |
+
return rows
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def delete_button(rows: list, r: int, c: int) -> list:
|
| 88 |
+
rows = copy_rows(rows)
|
| 89 |
+
if valid(rows, r, c):
|
| 90 |
+
rows[r].pop(c)
|
| 91 |
+
rows = [row for row in rows if row]
|
| 92 |
+
return rows
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def move_button(rows: list, r: int, c: int, direction: str) -> tuple:
|
| 96 |
+
"""direction: l | r | u | d. Returns (rows, new_r, new_c)."""
|
| 97 |
+
rows = copy_rows(rows)
|
| 98 |
+
if not valid(rows, r, c):
|
| 99 |
+
return rows, r, c
|
| 100 |
+
b = rows[r][c]
|
| 101 |
+
if direction == "l":
|
| 102 |
+
if c > 0:
|
| 103 |
+
rows[r][c - 1], rows[r][c] = rows[r][c], rows[r][c - 1]
|
| 104 |
+
c -= 1
|
| 105 |
+
elif direction == "r":
|
| 106 |
+
if c < len(rows[r]) - 1:
|
| 107 |
+
rows[r][c + 1], rows[r][c] = rows[r][c], rows[r][c + 1]
|
| 108 |
+
c += 1
|
| 109 |
+
elif direction == "u":
|
| 110 |
+
if r > 0 and len(rows[r - 1]) < MAX_ROW:
|
| 111 |
+
rows[r].pop(c)
|
| 112 |
+
pos = min(c, len(rows[r - 1]))
|
| 113 |
+
rows[r - 1].insert(pos, b)
|
| 114 |
+
nr, nc = r - 1, pos
|
| 115 |
+
if not rows[r]:
|
| 116 |
+
rows.pop(r)
|
| 117 |
+
r, c = nr, nc
|
| 118 |
+
elif direction == "d":
|
| 119 |
+
if r < len(rows) - 1:
|
| 120 |
+
if len(rows[r + 1]) < MAX_ROW:
|
| 121 |
+
rows[r].pop(c)
|
| 122 |
+
pos = min(c, len(rows[r + 1]))
|
| 123 |
+
rows[r + 1].insert(pos, b)
|
| 124 |
+
nr, nc = r + 1, pos
|
| 125 |
+
if not rows[r]:
|
| 126 |
+
rows.pop(r)
|
| 127 |
+
nr -= 1
|
| 128 |
+
r, c = nr, nc
|
| 129 |
+
elif len(rows[r]) > 1: # last row with company: split off into a new row below
|
| 130 |
+
rows[r].pop(c)
|
| 131 |
+
rows.append([b])
|
| 132 |
+
r, c = len(rows) - 1, 0
|
| 133 |
+
return rows, r, c
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def grid_label(b: dict) -> str:
|
| 137 |
+
t = (b.get("t") or "").strip() or "β¦"
|
| 138 |
+
if len(t) > 26:
|
| 139 |
+
t = t[:25] + "β¦"
|
| 140 |
+
if b.get("raw"):
|
| 141 |
+
return "π " + t
|
| 142 |
+
if not b.get("u"):
|
| 143 |
+
return "β οΈ " + t
|
| 144 |
+
return t
|
app/common.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared context, permission guard, message helpers and post send/edit operations."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import functools
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from types import SimpleNamespace
|
| 8 |
+
from typing import Any, Callable, Optional
|
| 9 |
+
|
| 10 |
+
from telethon import TelegramClient, errors, events
|
| 11 |
+
|
| 12 |
+
from .config import Config
|
| 13 |
+
from .db import Database
|
| 14 |
+
from .tgutil import build_markup, de_entities, edit_raw, esc, explain_rpc, peer_of, with_media
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class UserError(Exception):
|
| 20 |
+
"""An error whose message is safe and useful to show to the user."""
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class Ctx:
|
| 25 |
+
cfg: Config
|
| 26 |
+
db: Database
|
| 27 |
+
client: TelegramClient
|
| 28 |
+
state: dict = field(default_factory=dict) # user id -> {"mode": ..., ...}
|
| 29 |
+
pending: dict = field(default_factory=dict) # tokens for confirm dialogs (replace, test edit)
|
| 30 |
+
seen_channels: dict = field(default_factory=dict) # channel id -> Channel entity seen in updates
|
| 31 |
+
text_handlers: dict = field(default_factory=dict) # mode -> async fn(event, state)
|
| 32 |
+
callbacks: dict = field(default_factory=dict) # prefix -> async fn(event, parts)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def is_cb(event) -> bool:
|
| 36 |
+
return isinstance(event, events.CallbackQuery.Event) or getattr(event, "_is_callback", False)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def guard(ctx: Ctx, owner: bool = False) -> Callable:
|
| 40 |
+
"""Only allowed users may use a handler; errors are turned into friendly messages."""
|
| 41 |
+
|
| 42 |
+
def deco(fn):
|
| 43 |
+
@functools.wraps(fn)
|
| 44 |
+
async def wrapper(event):
|
| 45 |
+
uid = event.sender_id
|
| 46 |
+
ok = ctx.cfg.is_owner(uid) if owner else ctx.cfg.is_allowed(uid)
|
| 47 |
+
if not ok:
|
| 48 |
+
try:
|
| 49 |
+
if is_cb(event):
|
| 50 |
+
await event.answer("Not allowed.", alert=True)
|
| 51 |
+
elif getattr(event, "is_private", False):
|
| 52 |
+
await event.respond("β This bot is private.")
|
| 53 |
+
except Exception:
|
| 54 |
+
pass
|
| 55 |
+
return
|
| 56 |
+
try:
|
| 57 |
+
await fn(event)
|
| 58 |
+
except events.StopPropagation:
|
| 59 |
+
raise
|
| 60 |
+
except errors.MessageNotModifiedError:
|
| 61 |
+
if is_cb(event):
|
| 62 |
+
try:
|
| 63 |
+
await event.answer()
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
except UserError as e:
|
| 67 |
+
await notify_error(event, str(e))
|
| 68 |
+
except errors.RPCError as e:
|
| 69 |
+
await notify_error(event, "β " + explain_rpc(e))
|
| 70 |
+
except Exception as e:
|
| 71 |
+
log.exception("handler %s failed", fn.__name__)
|
| 72 |
+
await notify_error(event, f"β οΈ Something went wrong: {type(e).__name__}: {str(e)[:200]}")
|
| 73 |
+
|
| 74 |
+
return wrapper
|
| 75 |
+
|
| 76 |
+
return deco
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def notify_error(event, text: str) -> None:
|
| 80 |
+
try:
|
| 81 |
+
if is_cb(event):
|
| 82 |
+
try:
|
| 83 |
+
await event.answer("That didn't work - see the message below.", alert=False)
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
await event.respond(esc(text), link_preview=False)
|
| 87 |
+
except Exception: # pragma: no cover
|
| 88 |
+
log.exception("could not report an error")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def say(event, text: str, buttons=None):
|
| 92 |
+
return await event.respond(text, buttons=buttons, link_preview=False)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def edit_callback_message(event, text: str, buttons=None):
|
| 96 |
+
"""Edit the message a button was pressed on.
|
| 97 |
+
|
| 98 |
+
Telethon's CallbackQuery.edit() quietly starts a second `answer` task; the dispatcher has already
|
| 99 |
+
answered the query, so we call edit_message directly instead.
|
| 100 |
+
"""
|
| 101 |
+
query = getattr(event, "query", None)
|
| 102 |
+
if query is not None and hasattr(event, "client"):
|
| 103 |
+
return await event.client.edit_message(
|
| 104 |
+
await event.get_input_chat(), query.msg_id, text, buttons=buttons, link_preview=False
|
| 105 |
+
)
|
| 106 |
+
return await event.edit(text, buttons=buttons, link_preview=False) # test doubles
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def show(event, text: str, buttons=None):
|
| 110 |
+
"""Edit the message a button was pressed on; for normal messages send a new one."""
|
| 111 |
+
if is_cb(event):
|
| 112 |
+
try:
|
| 113 |
+
return await edit_callback_message(event, text, buttons)
|
| 114 |
+
except errors.MessageNotModifiedError:
|
| 115 |
+
return None
|
| 116 |
+
return await say(event, text, buttons)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def require_owner(ctx: Ctx, event) -> None:
|
| 120 |
+
if not ctx.cfg.is_owner(event.sender_id):
|
| 121 |
+
raise UserError("β Only the bot owner can do that.")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ------------------------------------------------------------------ sending and editing
|
| 125 |
+
async def send_post(client, peer, post):
|
| 126 |
+
ents = de_entities(post.entities)
|
| 127 |
+
markup = build_markup(post.buttons)
|
| 128 |
+
if post.media_file_id:
|
| 129 |
+
return await with_media(
|
| 130 |
+
post.media_file_id,
|
| 131 |
+
lambda media: client.send_file(
|
| 132 |
+
peer, media, caption=post.text or "", formatting_entities=ents, buttons=markup
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
return await client.send_message(
|
| 136 |
+
peer, post.text, formatting_entities=ents, buttons=markup, link_preview=bool(post.link_preview)
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def push_edit(client, ch, p, *, replace_media: bool = False) -> None:
|
| 141 |
+
"""Make the live channel message look like `p` (any object with the Post fields)."""
|
| 142 |
+
peer = peer_of(ch)
|
| 143 |
+
ents = de_entities(p.entities)
|
| 144 |
+
markup = build_markup(p.buttons)
|
| 145 |
+
if replace_media and p.media_file_id:
|
| 146 |
+
await with_media(
|
| 147 |
+
p.media_file_id,
|
| 148 |
+
lambda media: client.edit_message(
|
| 149 |
+
peer, p.message_id, text=p.text or "", formatting_entities=ents, buttons=markup, file=media
|
| 150 |
+
),
|
| 151 |
+
)
|
| 152 |
+
else:
|
| 153 |
+
await edit_raw(
|
| 154 |
+
client,
|
| 155 |
+
peer,
|
| 156 |
+
p.message_id,
|
| 157 |
+
text=p.text or "",
|
| 158 |
+
entities=ents,
|
| 159 |
+
markup=markup,
|
| 160 |
+
preview=None if p.media_kind else bool(p.link_preview),
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def apply_edit(ctx: Ctx, post, ch, *, replace_media: bool = False, **changes):
|
| 165 |
+
"""Edit the channel message first (if the post is published), and only then save the change."""
|
| 166 |
+
if post.status == "sent" and post.message_id:
|
| 167 |
+
new = SimpleNamespace(**{**post.as_dict(), **changes})
|
| 168 |
+
try:
|
| 169 |
+
await push_edit(ctx.client, ch, new, replace_media=replace_media)
|
| 170 |
+
except errors.MessageNotModifiedError:
|
| 171 |
+
pass
|
| 172 |
+
except errors.RPCError as e:
|
| 173 |
+
raise UserError("β " + explain_rpc(e) + "\nNothing was changed.")
|
| 174 |
+
return await ctx.db.update_post(post.id, **changes)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def on_cb(ctx: Ctx, prefix: str):
|
| 178 |
+
def deco(fn):
|
| 179 |
+
ctx.callbacks[prefix] = fn
|
| 180 |
+
return fn
|
| 181 |
+
|
| 182 |
+
return deco
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def on_text(ctx: Ctx, mode: str):
|
| 186 |
+
def deco(fn):
|
| 187 |
+
ctx.text_handlers[mode] = fn
|
| 188 |
+
return fn
|
| 189 |
+
|
| 190 |
+
return deco
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def set_state(ctx: Ctx, uid: int, mode: str, **data: Any) -> dict:
|
| 194 |
+
ctx.state[uid] = {"mode": mode, **data}
|
| 195 |
+
return ctx.state[uid]
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def clear_state(ctx: Ctx, uid: int) -> Optional[dict]:
|
| 199 |
+
return ctx.state.pop(uid, None)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def cmd(name: str, args: bool = False):
|
| 203 |
+
"""Event builder for a private-chat command such as /new or /replace <args>."""
|
| 204 |
+
pat = rf"(?is)^/{name}(?:@\w+)?" + (r"(?:\s+(.*))?$" if args else r"\s*$")
|
| 205 |
+
return events.NewMessage(incoming=True, pattern=pat, func=lambda e: e.is_private)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
async def discard_temp(ctx: Ctx, uid: int) -> None:
|
| 209 |
+
"""Drop a half-made draft when the user abandons the flow that was creating it."""
|
| 210 |
+
st = ctx.state.get(uid)
|
| 211 |
+
if st and st.get("temp") and st.get("pid"):
|
| 212 |
+
p = await ctx.db.get_post(st["pid"])
|
| 213 |
+
if p is not None and p.status == "draft":
|
| 214 |
+
await ctx.db.delete_post(p.id)
|
app/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Settings, read from environment variables (or a .env file)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
try: # python-dotenv is optional; real env vars always win
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
except Exception: # pragma: no cover
|
| 12 |
+
pass
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _ints(name: str) -> frozenset:
|
| 16 |
+
raw = os.getenv(name, "") or ""
|
| 17 |
+
out = set()
|
| 18 |
+
for part in raw.replace(";", ",").replace(" ", ",").split(","):
|
| 19 |
+
part = part.strip()
|
| 20 |
+
if part.lstrip("-").isdigit():
|
| 21 |
+
out.add(int(part))
|
| 22 |
+
return frozenset(out)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _bool(name: str, default: bool) -> bool:
|
| 26 |
+
raw = os.getenv(name)
|
| 27 |
+
if raw is None or raw.strip() == "":
|
| 28 |
+
return default
|
| 29 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _float(name: str, default: float) -> float:
|
| 33 |
+
try:
|
| 34 |
+
return float(os.getenv(name, "") or default)
|
| 35 |
+
except ValueError:
|
| 36 |
+
return default
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class Config:
|
| 41 |
+
api_id: int
|
| 42 |
+
api_hash: str
|
| 43 |
+
bot_token: str
|
| 44 |
+
owners: frozenset
|
| 45 |
+
admins: frozenset
|
| 46 |
+
database_url: str
|
| 47 |
+
db_pool: str
|
| 48 |
+
session_path: str
|
| 49 |
+
session_string: str
|
| 50 |
+
edit_delay: float
|
| 51 |
+
replace_typed_links: bool
|
| 52 |
+
port: int
|
| 53 |
+
log_level: str
|
| 54 |
+
|
| 55 |
+
def is_owner(self, uid) -> bool:
|
| 56 |
+
return uid in self.owners
|
| 57 |
+
|
| 58 |
+
def is_allowed(self, uid) -> bool:
|
| 59 |
+
return uid in self.owners or uid in self.admins
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def load_config() -> Config:
|
| 63 |
+
missing = [k for k in ("API_ID", "API_HASH", "BOT_TOKEN") if not os.getenv(k)]
|
| 64 |
+
owners = _ints("OWNER_IDS")
|
| 65 |
+
if not owners:
|
| 66 |
+
missing.append("OWNER_IDS")
|
| 67 |
+
if missing:
|
| 68 |
+
raise SystemExit("Missing required settings: " + ", ".join(missing) + " (see .env.example)")
|
| 69 |
+
try:
|
| 70 |
+
api_id = int(os.environ["API_ID"])
|
| 71 |
+
except ValueError:
|
| 72 |
+
raise SystemExit("API_ID must be a number")
|
| 73 |
+
return Config(
|
| 74 |
+
api_id=api_id,
|
| 75 |
+
api_hash=os.environ["API_HASH"].strip(),
|
| 76 |
+
bot_token=os.environ["BOT_TOKEN"].strip(),
|
| 77 |
+
owners=owners,
|
| 78 |
+
admins=_ints("ADMIN_IDS"),
|
| 79 |
+
database_url=(os.getenv("DATABASE_URL") or "sqlite+aiosqlite:///data/bot.db").strip(),
|
| 80 |
+
db_pool=(os.getenv("DB_POOL") or "null").strip().lower(),
|
| 81 |
+
session_path=(os.getenv("SESSION_PATH") or "data/bot").strip(),
|
| 82 |
+
session_string=(os.getenv("SESSION_STRING") or "").strip(),
|
| 83 |
+
edit_delay=max(0.3, _float("EDIT_DELAY", 1.2)),
|
| 84 |
+
replace_typed_links=_bool("REPLACE_TYPED_LINKS", True),
|
| 85 |
+
port=int(os.getenv("PORT") or 0),
|
| 86 |
+
log_level=(os.getenv("LOG_LEVEL") or "INFO").upper(),
|
| 87 |
+
)
|
app/db.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database layer (SQLAlchemy 2.0 async). PostgreSQL in production, SQLite for local tests."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
| 10 |
+
|
| 11 |
+
from sqlalchemy import (
|
| 12 |
+
JSON,
|
| 13 |
+
BigInteger,
|
| 14 |
+
Boolean,
|
| 15 |
+
DateTime,
|
| 16 |
+
ForeignKey,
|
| 17 |
+
Integer,
|
| 18 |
+
String,
|
| 19 |
+
Text,
|
| 20 |
+
UniqueConstraint,
|
| 21 |
+
desc,
|
| 22 |
+
func,
|
| 23 |
+
select,
|
| 24 |
+
)
|
| 25 |
+
from sqlalchemy.dialects.postgresql import JSONB
|
| 26 |
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
| 27 |
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 28 |
+
from sqlalchemy.pool import NullPool
|
| 29 |
+
|
| 30 |
+
log = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
JSONType = JSON().with_variant(JSONB(), "postgresql")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def utcnow() -> datetime:
|
| 36 |
+
return datetime.now(timezone.utc)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Base(DeclarativeBase):
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class Channel(Base):
|
| 44 |
+
__tablename__ = "channels"
|
| 45 |
+
|
| 46 |
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
| 47 |
+
access_hash: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 48 |
+
title: Mapped[str] = mapped_column(String(256), default="")
|
| 49 |
+
username: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
| 50 |
+
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 51 |
+
added_by: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 52 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class Post(Base):
|
| 56 |
+
__tablename__ = "posts"
|
| 57 |
+
__table_args__ = (UniqueConstraint("channel_id", "message_id", name="uq_post_channel_msg"),)
|
| 58 |
+
|
| 59 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 60 |
+
channel_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("channels.id"), index=True)
|
| 61 |
+
message_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
|
| 62 |
+
status: Mapped[str] = mapped_column(String(16), default="draft", index=True) # draft | sent
|
| 63 |
+
source: Mapped[str] = mapped_column(String(16), default="bot") # bot | adopted
|
| 64 |
+
text: Mapped[str] = mapped_column(Text, default="")
|
| 65 |
+
entities: Mapped[list] = mapped_column(JSONType, default=list)
|
| 66 |
+
media_kind: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
|
| 67 |
+
media_file_id: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
| 68 |
+
buttons: Mapped[list] = mapped_column(JSONType, default=list)
|
| 69 |
+
link_preview: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 70 |
+
created_by: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 71 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 72 |
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 73 |
+
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 74 |
+
|
| 75 |
+
def as_dict(self) -> dict:
|
| 76 |
+
return {c.name: getattr(self, c.name) for c in self.__table__.columns}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class Batch(Base):
|
| 80 |
+
"""One /replace run (so it can be undone)."""
|
| 81 |
+
|
| 82 |
+
__tablename__ = "batches"
|
| 83 |
+
|
| 84 |
+
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
| 85 |
+
old_username: Mapped[str] = mapped_column(String(64))
|
| 86 |
+
new_username: Mapped[str] = mapped_column(String(64))
|
| 87 |
+
created_by: Mapped[int] = mapped_column(BigInteger, default=0)
|
| 88 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 89 |
+
undone: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class Change(Base):
|
| 93 |
+
__tablename__ = "changes"
|
| 94 |
+
|
| 95 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 96 |
+
batch_id: Mapped[str] = mapped_column(String(32), ForeignKey("batches.id"), index=True)
|
| 97 |
+
channel_id: Mapped[int] = mapped_column(BigInteger)
|
| 98 |
+
message_id: Mapped[int] = mapped_column(BigInteger)
|
| 99 |
+
old_state: Mapped[dict] = mapped_column(JSONType, default=dict)
|
| 100 |
+
new_state: Mapped[dict] = mapped_column(JSONType, default=dict)
|
| 101 |
+
reverted: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 102 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def normalize_db_url(url: str) -> tuple:
|
| 106 |
+
"""Turn a plain DATABASE_URL into a SQLAlchemy async URL + connect args."""
|
| 107 |
+
connect_args: dict = {}
|
| 108 |
+
if url.startswith("postgres://"):
|
| 109 |
+
url = "postgresql://" + url[len("postgres://"):]
|
| 110 |
+
if url.startswith("postgresql://"):
|
| 111 |
+
url = "postgresql+asyncpg://" + url[len("postgresql://"):]
|
| 112 |
+
if url.startswith("postgresql+asyncpg://"):
|
| 113 |
+
parts = urlsplit(url)
|
| 114 |
+
keep = []
|
| 115 |
+
for k, v in parse_qsl(parts.query, keep_blank_values=True):
|
| 116 |
+
if k == "sslmode":
|
| 117 |
+
if v in ("require", "verify-ca", "verify-full"):
|
| 118 |
+
connect_args["ssl"] = True
|
| 119 |
+
elif k in ("channel_binding", "options"):
|
| 120 |
+
continue
|
| 121 |
+
else:
|
| 122 |
+
keep.append((k, v))
|
| 123 |
+
host = parts.hostname or ""
|
| 124 |
+
if "-pooler" in host or "pgbouncer" in host:
|
| 125 |
+
# PgBouncer in transaction mode does not support prepared statements
|
| 126 |
+
connect_args["statement_cache_size"] = 0
|
| 127 |
+
keep.append(("prepared_statement_cache_size", "0"))
|
| 128 |
+
url = urlunsplit(parts._replace(query=urlencode(keep)))
|
| 129 |
+
elif url.startswith("sqlite:///"):
|
| 130 |
+
url = "sqlite+aiosqlite:///" + url[len("sqlite:///"):]
|
| 131 |
+
return url, connect_args
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class Database:
|
| 135 |
+
def __init__(self, url: str, pool: str = "null"):
|
| 136 |
+
self.url, connect_args = normalize_db_url(url)
|
| 137 |
+
kwargs: dict = {"connect_args": connect_args}
|
| 138 |
+
if self.url.startswith("sqlite"):
|
| 139 |
+
path = self.url.split(":///", 1)[-1]
|
| 140 |
+
if path and path != ":memory:":
|
| 141 |
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
| 142 |
+
elif pool == "queue":
|
| 143 |
+
kwargs.update(pool_size=5, max_overflow=5, pool_pre_ping=True, pool_recycle=1800)
|
| 144 |
+
else:
|
| 145 |
+
# short-lived connections: lets serverless Postgres (e.g. Neon) go to sleep when idle
|
| 146 |
+
kwargs["poolclass"] = NullPool
|
| 147 |
+
self.engine = create_async_engine(self.url, **kwargs)
|
| 148 |
+
self.Session = async_sessionmaker(self.engine, expire_on_commit=False)
|
| 149 |
+
|
| 150 |
+
async def init(self, retries: int = 6) -> None:
|
| 151 |
+
last: Optional[BaseException] = None
|
| 152 |
+
for i in range(retries):
|
| 153 |
+
try:
|
| 154 |
+
async with self.engine.begin() as conn:
|
| 155 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 156 |
+
return
|
| 157 |
+
except Exception as e: # cold start of serverless DBs can fail once
|
| 158 |
+
last = e
|
| 159 |
+
log.warning("database not ready (%s) - retry %s/%s", e, i + 1, retries)
|
| 160 |
+
await asyncio.sleep(min(2 ** i, 15))
|
| 161 |
+
assert last is not None
|
| 162 |
+
raise last
|
| 163 |
+
|
| 164 |
+
async def close(self) -> None:
|
| 165 |
+
await self.engine.dispose()
|
| 166 |
+
|
| 167 |
+
# ------------------------------------------------------------------ channels
|
| 168 |
+
async def list_channels(self, active_only: bool = True) -> list:
|
| 169 |
+
async with self.Session() as s:
|
| 170 |
+
q = select(Channel).order_by(Channel.title)
|
| 171 |
+
if active_only:
|
| 172 |
+
q = q.where(Channel.active.is_(True))
|
| 173 |
+
return list((await s.execute(q)).scalars())
|
| 174 |
+
|
| 175 |
+
async def get_channel(self, cid: int) -> Optional[Channel]:
|
| 176 |
+
async with self.Session() as s:
|
| 177 |
+
return await s.get(Channel, cid)
|
| 178 |
+
|
| 179 |
+
async def save_channel(self, cid: int, access_hash: int, title: str, username: Optional[str], added_by: int) -> Channel:
|
| 180 |
+
async with self.Session() as s:
|
| 181 |
+
ch = await s.get(Channel, cid)
|
| 182 |
+
if ch is None:
|
| 183 |
+
ch = Channel(id=cid, access_hash=access_hash, title=title, username=username, added_by=added_by, active=True)
|
| 184 |
+
s.add(ch)
|
| 185 |
+
else:
|
| 186 |
+
ch.access_hash = access_hash or ch.access_hash
|
| 187 |
+
ch.title = title or ch.title
|
| 188 |
+
ch.username = username
|
| 189 |
+
ch.active = True
|
| 190 |
+
await s.commit()
|
| 191 |
+
return ch
|
| 192 |
+
|
| 193 |
+
async def touch_channel_hash(self, cid: int, access_hash: int) -> None:
|
| 194 |
+
async with self.Session() as s:
|
| 195 |
+
ch = await s.get(Channel, cid)
|
| 196 |
+
if ch is not None and access_hash and ch.access_hash != access_hash:
|
| 197 |
+
ch.access_hash = access_hash
|
| 198 |
+
await s.commit()
|
| 199 |
+
|
| 200 |
+
async def set_channel_active(self, cid: int, active: bool) -> None:
|
| 201 |
+
async with self.Session() as s:
|
| 202 |
+
ch = await s.get(Channel, cid)
|
| 203 |
+
if ch is not None:
|
| 204 |
+
ch.active = active
|
| 205 |
+
await s.commit()
|
| 206 |
+
|
| 207 |
+
# --------------------------------------------------------------------- posts
|
| 208 |
+
async def create_post(self, **fields: Any) -> Post:
|
| 209 |
+
async with self.Session() as s:
|
| 210 |
+
p = Post(**fields)
|
| 211 |
+
s.add(p)
|
| 212 |
+
await s.commit()
|
| 213 |
+
return p
|
| 214 |
+
|
| 215 |
+
async def get_post(self, pid: int) -> Optional[Post]:
|
| 216 |
+
async with self.Session() as s:
|
| 217 |
+
return await s.get(Post, pid)
|
| 218 |
+
|
| 219 |
+
async def update_post(self, pid: int, **fields: Any) -> Optional[Post]:
|
| 220 |
+
async with self.Session() as s:
|
| 221 |
+
p = await s.get(Post, pid)
|
| 222 |
+
if p is None:
|
| 223 |
+
return None
|
| 224 |
+
for k, v in fields.items():
|
| 225 |
+
setattr(p, k, v)
|
| 226 |
+
p.updated_at = utcnow()
|
| 227 |
+
await s.commit()
|
| 228 |
+
return p
|
| 229 |
+
|
| 230 |
+
async def delete_post(self, pid: int) -> None:
|
| 231 |
+
async with self.Session() as s:
|
| 232 |
+
p = await s.get(Post, pid)
|
| 233 |
+
if p is not None:
|
| 234 |
+
await s.delete(p)
|
| 235 |
+
await s.commit()
|
| 236 |
+
|
| 237 |
+
async def list_posts(self, channel_id: Optional[int], status: Optional[str], offset: int, limit: int) -> tuple:
|
| 238 |
+
cond = []
|
| 239 |
+
if channel_id is not None:
|
| 240 |
+
cond.append(Post.channel_id == channel_id)
|
| 241 |
+
if status:
|
| 242 |
+
cond.append(Post.status == status)
|
| 243 |
+
async with self.Session() as s:
|
| 244 |
+
total = (await s.execute(select(func.count()).select_from(Post).where(*cond))).scalar_one()
|
| 245 |
+
q = (
|
| 246 |
+
select(Post)
|
| 247 |
+
.where(*cond)
|
| 248 |
+
.order_by(Post.status.asc(), desc(Post.message_id), desc(Post.id))
|
| 249 |
+
.offset(offset)
|
| 250 |
+
.limit(limit)
|
| 251 |
+
)
|
| 252 |
+
rows = list((await s.execute(q)).scalars())
|
| 253 |
+
return rows, total
|
| 254 |
+
|
| 255 |
+
async def latest_sent(self, channel_id: int, limit: int = 30) -> list:
|
| 256 |
+
async with self.Session() as s:
|
| 257 |
+
q = (
|
| 258 |
+
select(Post)
|
| 259 |
+
.where(Post.channel_id == channel_id, Post.status == "sent", Post.message_id.is_not(None))
|
| 260 |
+
.order_by(desc(Post.message_id))
|
| 261 |
+
.limit(limit)
|
| 262 |
+
)
|
| 263 |
+
return list((await s.execute(q)).scalars())
|
| 264 |
+
|
| 265 |
+
async def mine_message_ids(self, channel_id: int) -> set:
|
| 266 |
+
async with self.Session() as s:
|
| 267 |
+
q = select(Post.message_id).where(
|
| 268 |
+
Post.channel_id == channel_id, Post.source == "bot", Post.message_id.is_not(None)
|
| 269 |
+
)
|
| 270 |
+
return {r for r in (await s.execute(q)).scalars()}
|
| 271 |
+
|
| 272 |
+
async def find_by_message(self, channel_id: int, message_id: int) -> Optional[Post]:
|
| 273 |
+
async with self.Session() as s:
|
| 274 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 275 |
+
return (await s.execute(q)).scalar_one_or_none()
|
| 276 |
+
|
| 277 |
+
# ------------------------------------------------------------- replace history
|
| 278 |
+
async def create_batch(self, batch_id: str, old: str, new: str, user_id: int) -> None:
|
| 279 |
+
async with self.Session() as s:
|
| 280 |
+
s.add(Batch(id=batch_id, old_username=old, new_username=new, created_by=user_id))
|
| 281 |
+
await s.commit()
|
| 282 |
+
|
| 283 |
+
async def _adopt(self, s, channel_id: int, message_id: int, fields: dict, user_id: int) -> None:
|
| 284 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 285 |
+
p = (await s.execute(q)).scalar_one_or_none()
|
| 286 |
+
if p is not None:
|
| 287 |
+
for k, v in fields.items():
|
| 288 |
+
setattr(p, k, v)
|
| 289 |
+
p.updated_at = utcnow()
|
| 290 |
+
else:
|
| 291 |
+
s.add(
|
| 292 |
+
Post(
|
| 293 |
+
channel_id=channel_id,
|
| 294 |
+
message_id=message_id,
|
| 295 |
+
status="sent",
|
| 296 |
+
source="adopted",
|
| 297 |
+
created_by=user_id,
|
| 298 |
+
sent_at=utcnow(),
|
| 299 |
+
**fields,
|
| 300 |
+
)
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
async def record_changes(self, batch_id: str, channel_id: int, records: list, user_id: int) -> None:
|
| 304 |
+
"""records: [{message_id, old_state, new_state, adopt: {post fields}}]"""
|
| 305 |
+
if not records:
|
| 306 |
+
return
|
| 307 |
+
async with self.Session() as s:
|
| 308 |
+
for r in records:
|
| 309 |
+
s.add(
|
| 310 |
+
Change(
|
| 311 |
+
batch_id=batch_id,
|
| 312 |
+
channel_id=channel_id,
|
| 313 |
+
message_id=r["message_id"],
|
| 314 |
+
old_state=r["old_state"],
|
| 315 |
+
new_state=r["new_state"],
|
| 316 |
+
)
|
| 317 |
+
)
|
| 318 |
+
await self._adopt(s, channel_id, r["message_id"], r["adopt"], user_id)
|
| 319 |
+
await s.commit()
|
| 320 |
+
|
| 321 |
+
async def last_batch(self) -> Optional[Batch]:
|
| 322 |
+
async with self.Session() as s:
|
| 323 |
+
q = select(Batch).where(Batch.undone.is_(False)).order_by(desc(Batch.created_at)).limit(1)
|
| 324 |
+
return (await s.execute(q)).scalar_one_or_none()
|
| 325 |
+
|
| 326 |
+
async def get_batch(self, batch_id: str) -> Optional[Batch]:
|
| 327 |
+
async with self.Session() as s:
|
| 328 |
+
return await s.get(Batch, batch_id)
|
| 329 |
+
|
| 330 |
+
async def batch_changes(self, batch_id: str) -> list:
|
| 331 |
+
async with self.Session() as s:
|
| 332 |
+
q = select(Change).where(Change.batch_id == batch_id, Change.reverted.is_(False)).order_by(Change.id)
|
| 333 |
+
return list((await s.execute(q)).scalars())
|
| 334 |
+
|
| 335 |
+
async def count_batch_changes(self, batch_id: str) -> int:
|
| 336 |
+
async with self.Session() as s:
|
| 337 |
+
q = select(func.count()).select_from(Change).where(Change.batch_id == batch_id, Change.reverted.is_(False))
|
| 338 |
+
return (await s.execute(q)).scalar_one()
|
| 339 |
+
|
| 340 |
+
async def finish_undo(self, batch_id: str, change_ids: list, restored: list, all_done: bool) -> None:
|
| 341 |
+
"""restored: [(channel_id, message_id, state)] -> refresh the saved post copies."""
|
| 342 |
+
async with self.Session() as s:
|
| 343 |
+
for cid in change_ids:
|
| 344 |
+
c = await s.get(Change, cid)
|
| 345 |
+
if c is not None:
|
| 346 |
+
c.reverted = True
|
| 347 |
+
for channel_id, message_id, state in restored:
|
| 348 |
+
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 349 |
+
p = (await s.execute(q)).scalar_one_or_none()
|
| 350 |
+
if p is not None:
|
| 351 |
+
p.text = state.get("text", "")
|
| 352 |
+
p.entities = state.get("entities", [])
|
| 353 |
+
p.buttons = state.get("buttons", [])
|
| 354 |
+
p.updated_at = utcnow()
|
| 355 |
+
if all_done:
|
| 356 |
+
b = await s.get(Batch, batch_id)
|
| 357 |
+
if b is not None:
|
| 358 |
+
b.undone = True
|
| 359 |
+
await s.commit()
|
| 360 |
+
|
| 361 |
+
# -------------------------------------------------------------------- export
|
| 362 |
+
async def export_all(self) -> dict:
|
| 363 |
+
def clean(d: dict) -> dict:
|
| 364 |
+
return {k: (v.isoformat() if isinstance(v, datetime) else v) for k, v in d.items()}
|
| 365 |
+
|
| 366 |
+
async with self.Session() as s:
|
| 367 |
+
chans = list((await s.execute(select(Channel))).scalars())
|
| 368 |
+
posts = list((await s.execute(select(Post).order_by(Post.id))).scalars())
|
| 369 |
+
return {
|
| 370 |
+
"exported_at": utcnow().isoformat(),
|
| 371 |
+
"channels": [
|
| 372 |
+
clean({c.name: getattr(ch, c.name) for c in ch.__table__.columns}) for ch in chans
|
| 373 |
+
],
|
| 374 |
+
"posts": [clean(p.as_dict()) for p in posts],
|
| 375 |
+
}
|
app/handlers/__init__.py
ADDED
|
File without changes
|
app/handlers/basic.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/start /help /cancel /export, plus the routers for free text and inline-button presses."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
from telethon import events
|
| 9 |
+
|
| 10 |
+
from ..common import Ctx, cmd, clear_state, discard_temp, guard, say, show
|
| 11 |
+
|
| 12 |
+
log = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
HELP = (
|
| 15 |
+
"<b>Channel Post Bot</b>\n\n"
|
| 16 |
+
"<b>Posts</b>\n"
|
| 17 |
+
"/new - create a post (text or media, with buttons)\n"
|
| 18 |
+
"/posts - your posts and drafts: edit text, caption, media and buttons, or delete them\n"
|
| 19 |
+
"/cancel - stop what you're doing\n\n"
|
| 20 |
+
"<b>Channels</b>\n"
|
| 21 |
+
"/addchannel - register a channel (the bot must be an admin there)\n"
|
| 22 |
+
"/channels - list or remove channels\n\n"
|
| 23 |
+
"<b>Owner tools</b>\n"
|
| 24 |
+
"/replace @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
|
| 25 |
+
"/undo - undo the last /replace\n"
|
| 26 |
+
"/testedit <post link> - check that the bot can edit the buttons of one specific post\n"
|
| 27 |
+
"/export - download a JSON backup of your posts"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def register(ctx: Ctx) -> None:
|
| 32 |
+
client, db = ctx.client, ctx.db
|
| 33 |
+
|
| 34 |
+
@client.on(cmd("start"))
|
| 35 |
+
@client.on(cmd("help"))
|
| 36 |
+
@guard(ctx)
|
| 37 |
+
async def h_help(event):
|
| 38 |
+
await say(event, HELP)
|
| 39 |
+
|
| 40 |
+
@client.on(cmd("cancel"))
|
| 41 |
+
@guard(ctx)
|
| 42 |
+
async def h_cancel(event):
|
| 43 |
+
await discard_temp(ctx, event.sender_id)
|
| 44 |
+
clear_state(ctx, event.sender_id)
|
| 45 |
+
await say(event, "Cancelled.")
|
| 46 |
+
|
| 47 |
+
@client.on(cmd("export"))
|
| 48 |
+
@guard(ctx, owner=True)
|
| 49 |
+
async def h_export(event):
|
| 50 |
+
data = await db.export_all()
|
| 51 |
+
buf = io.BytesIO(json.dumps(data, ensure_ascii=False, indent=1).encode("utf-8"))
|
| 52 |
+
buf.name = "channel-posts-export.json"
|
| 53 |
+
await event.respond(f"π¦ Backup: {len(data['channels'])} channel(s), {len(data['posts'])} post(s).", file=buf)
|
| 54 |
+
|
| 55 |
+
@client.on(
|
| 56 |
+
events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
|
| 57 |
+
)
|
| 58 |
+
@guard(ctx)
|
| 59 |
+
async def h_free_text(event):
|
| 60 |
+
st = ctx.state.get(event.sender_id)
|
| 61 |
+
if not st:
|
| 62 |
+
await say(event, "Use /new to create a post or /posts to manage your posts. /help lists everything.")
|
| 63 |
+
return
|
| 64 |
+
fn = ctx.text_handlers.get(st["mode"])
|
| 65 |
+
if fn is None:
|
| 66 |
+
clear_state(ctx, event.sender_id)
|
| 67 |
+
await say(event, "That step is no longer active. Start again with /new.")
|
| 68 |
+
return
|
| 69 |
+
await fn(event, st)
|
| 70 |
+
|
| 71 |
+
@client.on(events.CallbackQuery())
|
| 72 |
+
@guard(ctx)
|
| 73 |
+
async def h_callback(event):
|
| 74 |
+
try:
|
| 75 |
+
await event.answer() # stop the loading spinner right away
|
| 76 |
+
except Exception:
|
| 77 |
+
pass
|
| 78 |
+
data = event.data.decode("utf-8", "replace")
|
| 79 |
+
prefix, *parts = data.split(":")
|
| 80 |
+
fn = ctx.callbacks.get(prefix)
|
| 81 |
+
if fn is None:
|
| 82 |
+
await event.respond("This button is no longer active. Use /new or /posts.")
|
| 83 |
+
return
|
| 84 |
+
await fn(event, parts)
|
| 85 |
+
|
| 86 |
+
async def cb_noop(event, parts):
|
| 87 |
+
await event.answer()
|
| 88 |
+
|
| 89 |
+
async def cb_cancel(event, parts):
|
| 90 |
+
uid = event.sender_id
|
| 91 |
+
await discard_temp(ctx, uid)
|
| 92 |
+
clear_state(ctx, uid)
|
| 93 |
+
await show(event, "Cancelled.")
|
| 94 |
+
|
| 95 |
+
ctx.callbacks["noop"] = cb_noop
|
| 96 |
+
ctx.callbacks["cx"] = cb_cancel
|
app/handlers/channels.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Channel registration: /addchannel, /channels."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
from telethon import Button, events, types
|
| 8 |
+
|
| 9 |
+
from ..common import Ctx, UserError, clear_state, cmd, guard, on_cb, on_text, require_owner, say, set_state, show
|
| 10 |
+
from ..tgutil import esc, get_rights, short
|
| 11 |
+
from ..ui import CANCEL
|
| 12 |
+
|
| 13 |
+
log = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
ADD_HELP = (
|
| 16 |
+
"π’ <b>Add a channel</b>\n"
|
| 17 |
+
"1. Make the bot an admin of the channel with the rights <i>Post messages</i>, <i>Edit messages of others</i> "
|
| 18 |
+
"and <i>Delete messages</i>.\n"
|
| 19 |
+
"2. Then <b>forward any post from that channel to me</b>, or send its @username (public channels).\n\n"
|
| 20 |
+
"Tip for private channels: if forwarding doesn't work, post something in the channel while the bot is "
|
| 21 |
+
"running, then forward that post."
|
| 22 |
+
)
|
| 23 |
+
NEEDS_ACCESS = (
|
| 24 |
+
"I can see the channel, but Telegram hasn't given me its access details yet. Post something in the "
|
| 25 |
+
"channel while the bot is running (or send the @username if it's public), then forward a post again."
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
_REF_RX = re.compile(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(\d+)|([A-Za-z0-9_]{3,32}))(?:/\d+)?/?$")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def parse_channel_ref(text: str):
|
| 32 |
+
"""@name / t.me link / numeric id -> username (str) or channel id (int); None if unrecognised."""
|
| 33 |
+
t = (text or "").strip()
|
| 34 |
+
m = _REF_RX.match(t)
|
| 35 |
+
if m:
|
| 36 |
+
return int(m.group(1)) if m.group(1) else m.group(2)
|
| 37 |
+
t = t.lstrip("@")
|
| 38 |
+
if re.fullmatch(r"-?\d+", t):
|
| 39 |
+
s = str(abs(int(t)))
|
| 40 |
+
return int(s[3:]) if s.startswith("100") and len(s) > 10 else int(s)
|
| 41 |
+
if re.fullmatch(r"[A-Za-z0-9_]{3,32}", t):
|
| 42 |
+
return t
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def rights_line(r) -> str:
|
| 47 |
+
if r is None:
|
| 48 |
+
return "rights unknown"
|
| 49 |
+
if not r.admin:
|
| 50 |
+
return "β οΈ not an admin there"
|
| 51 |
+
ok = lambda v: "β
" if v else "β" # noqa: E731
|
| 52 |
+
return f"post {ok(r.post)} Β· edit others' posts {ok(r.edit)} Β· delete {ok(r.delete)}"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def register(ctx: Ctx) -> None:
|
| 56 |
+
client, db = ctx.client, ctx.db
|
| 57 |
+
|
| 58 |
+
# remember channels whose full details (access hash) we have seen in updates
|
| 59 |
+
@client.on(events.NewMessage(func=lambda e: bool(e.is_channel) and not e.is_group))
|
| 60 |
+
async def watch_channels(event):
|
| 61 |
+
try:
|
| 62 |
+
chat = await event.get_chat()
|
| 63 |
+
if isinstance(chat, types.Channel) and chat.access_hash and not getattr(chat, "min", False):
|
| 64 |
+
prev = ctx.seen_channels.get(chat.id)
|
| 65 |
+
ctx.seen_channels[chat.id] = chat
|
| 66 |
+
if prev is None or prev.access_hash != chat.access_hash:
|
| 67 |
+
await db.touch_channel_hash(chat.id, chat.access_hash)
|
| 68 |
+
except Exception: # pragma: no cover
|
| 69 |
+
log.debug("channel watcher failed", exc_info=True)
|
| 70 |
+
|
| 71 |
+
@client.on(cmd("addchannel"))
|
| 72 |
+
@guard(ctx, owner=True)
|
| 73 |
+
async def h_addchannel(event):
|
| 74 |
+
set_state(ctx, event.sender_id, "add_channel")
|
| 75 |
+
await say(event, ADD_HELP, CANCEL)
|
| 76 |
+
|
| 77 |
+
@on_text(ctx, "add_channel")
|
| 78 |
+
async def h_add_channel(event, st):
|
| 79 |
+
m = event.message
|
| 80 |
+
ent = None
|
| 81 |
+
fwd = m.forward
|
| 82 |
+
if fwd is not None and isinstance(getattr(fwd, "chat", None), types.Channel):
|
| 83 |
+
ent = fwd.chat
|
| 84 |
+
if getattr(ent, "min", False) or not getattr(ent, "access_hash", None):
|
| 85 |
+
ent = ctx.seen_channels.get(ent.id, ent)
|
| 86 |
+
else:
|
| 87 |
+
ref = parse_channel_ref(m.raw_text or "")
|
| 88 |
+
if ref is None:
|
| 89 |
+
raise UserError("Forward a post from the channel to me, or send its @username.")
|
| 90 |
+
if isinstance(ref, int):
|
| 91 |
+
ent = ctx.seen_channels.get(ref)
|
| 92 |
+
if ent is None:
|
| 93 |
+
try:
|
| 94 |
+
ent = await client.get_entity(ref if isinstance(ref, str) else types.PeerChannel(ref))
|
| 95 |
+
except Exception:
|
| 96 |
+
ent = None
|
| 97 |
+
if not isinstance(ent, types.Channel):
|
| 98 |
+
raise UserError("I couldn't find that channel.\n\n" + re.sub(r"<[^>]+>", "", ADD_HELP))
|
| 99 |
+
if not ent.broadcast:
|
| 100 |
+
raise UserError("That is a group, not a channel. Only channels are supported.")
|
| 101 |
+
if not ent.access_hash or getattr(ent, "min", False):
|
| 102 |
+
raise UserError(NEEDS_ACCESS)
|
| 103 |
+
rights = await get_rights(client, ent)
|
| 104 |
+
if rights is not None and not rights.admin:
|
| 105 |
+
raise UserError("The bot is not an admin of that channel yet. Add it as an admin, then try again.")
|
| 106 |
+
await db.save_channel(ent.id, ent.access_hash, ent.title or "", ent.username, event.sender_id)
|
| 107 |
+
clear_state(ctx, event.sender_id)
|
| 108 |
+
warn = ""
|
| 109 |
+
if rights is not None and not (rights.post and rights.edit and rights.delete):
|
| 110 |
+
warn = "\n\nβ οΈ Missing rights: give the bot <i>Post</i>, <i>Edit messages of others</i> and <i>Delete</i> so everything works."
|
| 111 |
+
await say(
|
| 112 |
+
event,
|
| 113 |
+
f"β
Added <b>{esc(ent.title or '')}</b>\n{rights_line(rights)}{warn}\n\nCreate a post with /new.",
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
@client.on(cmd("channels"))
|
| 117 |
+
@guard(ctx)
|
| 118 |
+
async def h_channels(event):
|
| 119 |
+
chans = await db.list_channels()
|
| 120 |
+
if not chans:
|
| 121 |
+
raise UserError("No channels yet. Use /addchannel.")
|
| 122 |
+
lines, kb = [], []
|
| 123 |
+
for ch in chans:
|
| 124 |
+
r = await get_rights(client, ch)
|
| 125 |
+
name = esc(ch.title) + (f" (@{esc(ch.username)})" if ch.username else "")
|
| 126 |
+
lines.append(f"β’ <b>{name}</b>\n {rights_line(r)}")
|
| 127 |
+
if ctx.cfg.is_owner(event.sender_id):
|
| 128 |
+
kb.append([Button.inline(f"β Remove {short(ch.title, 22)}", f"chd:{ch.id}")])
|
| 129 |
+
await say(event, "π’ <b>Your channels</b>\n\n" + "\n".join(lines), kb or None)
|
| 130 |
+
|
| 131 |
+
@on_cb(ctx, "chd")
|
| 132 |
+
async def cb_remove_channel(event, parts):
|
| 133 |
+
require_owner(ctx, event)
|
| 134 |
+
await db.set_channel_active(int(parts[0]), False)
|
| 135 |
+
await show(event, "Channel removed from the bot. Its saved posts are kept; add it again with /addchannel.")
|
app/handlers/create.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/new: create a post, with the episode-range auto-suggestion."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from telethon import Button
|
| 7 |
+
|
| 8 |
+
from ..buttons import norm_url, parse_buttons_text, set_field
|
| 9 |
+
from ..common import Ctx, UserError, clear_state, cmd, discard_temp, guard, on_cb, on_text, say, set_state, show
|
| 10 |
+
from ..suggest import make_suggestion
|
| 11 |
+
from ..tgutil import esc, short
|
| 12 |
+
from ..ui import CANCEL, KIND_NAME
|
| 13 |
+
from .panel import extract_content, show_panel
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
BUTTON_HELP = (
|
| 18 |
+
"β <b>Now the buttons</b>\n"
|
| 19 |
+
"Send one row per line as <code>Button text - link</code>.\n"
|
| 20 |
+
"Several buttons in one row: separate them with <code> | </code>.\n\n"
|
| 21 |
+
"Example:\n<code>Download Episodes 01 to 20 - https://t.me/yourbot?start=abc</code>\n\n"
|
| 22 |
+
"Or send /skip to post without buttons."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def register(ctx: Ctx) -> None:
|
| 27 |
+
client, db = ctx.client, ctx.db
|
| 28 |
+
|
| 29 |
+
async def ask_content(event, cid: int) -> None:
|
| 30 |
+
set_state(ctx, event.sender_id, "new_content", cid=cid)
|
| 31 |
+
await show(
|
| 32 |
+
event,
|
| 33 |
+
"βοΈ <b>Send the post now.</b>\nText, or a photo / video / GIF / file with a caption. "
|
| 34 |
+
"Formatting (bold, links, spoilers...) is kept.",
|
| 35 |
+
CANCEL,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
async def start_for_channel(event, cid: int) -> None:
|
| 39 |
+
ch = await db.get_channel(cid)
|
| 40 |
+
if ch is None:
|
| 41 |
+
raise UserError("That channel is not registered. Use /addchannel.")
|
| 42 |
+
sug = await make_suggestion(db, cid)
|
| 43 |
+
if sug is None:
|
| 44 |
+
await ask_content(event, cid)
|
| 45 |
+
return
|
| 46 |
+
lines = [
|
| 47 |
+
f"π‘ <b>Suggestion for {esc(ch.title)}</b>",
|
| 48 |
+
f"Your last button post: <code>{esc(sug.prev_label)}</code>",
|
| 49 |
+
"The next one would be:",
|
| 50 |
+
]
|
| 51 |
+
if (sug.text or "").strip():
|
| 52 |
+
lines.append(f"β’ Message: <i>{esc(short(sug.text, 120))}</i>")
|
| 53 |
+
lines.append(f"β’ Button: <code>{esc(sug.new_label)}</code>")
|
| 54 |
+
if sug.media_kind:
|
| 55 |
+
lines.append(f"β’ Media: same {KIND_NAME.get(sug.media_kind, 'file')} as that post (you can change it before publishing)")
|
| 56 |
+
lines.append("\nUse it and I'll only ask for the button link.")
|
| 57 |
+
kb = [
|
| 58 |
+
[Button.inline("β
Use suggestion", f"ns:{cid}"), Button.inline("βοΈ Customize", f"nn:{cid}")],
|
| 59 |
+
CANCEL[0],
|
| 60 |
+
]
|
| 61 |
+
await show(event, "\n".join(lines), kb)
|
| 62 |
+
|
| 63 |
+
async def ask_link(event, post, pos) -> None:
|
| 64 |
+
r, c = pos
|
| 65 |
+
label = post.buttons[r][c]["t"]
|
| 66 |
+
await say(
|
| 67 |
+
event,
|
| 68 |
+
f"π Send the link for the button <code>{esc(label)}</code>\n"
|
| 69 |
+
"(for example <code>https://t.me/yourbot?start=abc</code>)",
|
| 70 |
+
CANCEL,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
@client.on(cmd("new"))
|
| 74 |
+
@guard(ctx)
|
| 75 |
+
async def h_new(event):
|
| 76 |
+
uid = event.sender_id
|
| 77 |
+
await discard_temp(ctx, uid)
|
| 78 |
+
clear_state(ctx, uid)
|
| 79 |
+
chans = await db.list_channels()
|
| 80 |
+
if not chans:
|
| 81 |
+
raise UserError("No channels yet. Register one with /addchannel first.")
|
| 82 |
+
if len(chans) == 1:
|
| 83 |
+
await start_for_channel(event, chans[0].id)
|
| 84 |
+
return
|
| 85 |
+
kb = [[Button.inline(f"π’ {short(c.title, 40)}", f"nc:{c.id}")] for c in chans] + CANCEL
|
| 86 |
+
await say(event, "Which channel is this post for?", kb)
|
| 87 |
+
|
| 88 |
+
@on_cb(ctx, "nc")
|
| 89 |
+
async def cb_channel(event, parts):
|
| 90 |
+
await discard_temp(ctx, event.sender_id)
|
| 91 |
+
await start_for_channel(event, int(parts[0]))
|
| 92 |
+
|
| 93 |
+
@on_cb(ctx, "nn")
|
| 94 |
+
async def cb_customize(event, parts):
|
| 95 |
+
await ask_content(event, int(parts[0]))
|
| 96 |
+
|
| 97 |
+
@on_cb(ctx, "ns")
|
| 98 |
+
async def cb_use_suggestion(event, parts):
|
| 99 |
+
uid, cid = event.sender_id, int(parts[0])
|
| 100 |
+
sug = await make_suggestion(db, cid)
|
| 101 |
+
if sug is None:
|
| 102 |
+
raise UserError("There is nothing to suggest any more - send the post yourself with /new.")
|
| 103 |
+
await discard_temp(ctx, uid)
|
| 104 |
+
post = await db.create_post(
|
| 105 |
+
channel_id=cid,
|
| 106 |
+
created_by=uid,
|
| 107 |
+
status="draft",
|
| 108 |
+
source="bot",
|
| 109 |
+
text=sug.text,
|
| 110 |
+
entities=sug.entities,
|
| 111 |
+
buttons=sug.buttons,
|
| 112 |
+
media_kind=sug.media_kind,
|
| 113 |
+
media_file_id=sug.media_file_id,
|
| 114 |
+
link_preview=False,
|
| 115 |
+
)
|
| 116 |
+
set_state(ctx, uid, "new_link", pid=post.id, pending=list(sug.pending), temp=True)
|
| 117 |
+
await show(event, f"β
Using the suggestion: <code>{esc(sug.new_label)}</code>")
|
| 118 |
+
await ask_link(event, post, sug.pending[0])
|
| 119 |
+
|
| 120 |
+
@on_text(ctx, "new_link")
|
| 121 |
+
async def h_new_link(event, st):
|
| 122 |
+
url = norm_url(event.raw_text or "")
|
| 123 |
+
if not url:
|
| 124 |
+
raise UserError("That doesn't look like a link. Send something like https://t.me/yourbot?start=abc")
|
| 125 |
+
post = await db.get_post(st["pid"])
|
| 126 |
+
if post is None:
|
| 127 |
+
clear_state(ctx, event.sender_id)
|
| 128 |
+
raise UserError("That draft is gone. Start again with /new.")
|
| 129 |
+
r, c = st["pending"][0]
|
| 130 |
+
post = await db.update_post(post.id, buttons=set_field(post.buttons, r, c, u=url))
|
| 131 |
+
st["pending"] = st["pending"][1:]
|
| 132 |
+
if st["pending"]:
|
| 133 |
+
await ask_link(event, post, st["pending"][0])
|
| 134 |
+
return
|
| 135 |
+
clear_state(ctx, event.sender_id)
|
| 136 |
+
await show_panel(ctx, event, post.id, note="β
Ready. Preview it, or tap <b>Publish</b>.", new=True)
|
| 137 |
+
|
| 138 |
+
@on_text(ctx, "new_content")
|
| 139 |
+
async def h_new_content(event, st):
|
| 140 |
+
m = event.message
|
| 141 |
+
if m.grouped_id and st.get("group") == m.grouped_id:
|
| 142 |
+
return # the other items of an album: only the first one is used
|
| 143 |
+
if m.grouped_id:
|
| 144 |
+
st["group"] = m.grouped_id
|
| 145 |
+
content = extract_content(m)
|
| 146 |
+
post = await db.create_post(
|
| 147 |
+
channel_id=st["cid"],
|
| 148 |
+
created_by=event.sender_id,
|
| 149 |
+
status="draft",
|
| 150 |
+
source="bot",
|
| 151 |
+
buttons=[],
|
| 152 |
+
link_preview=False,
|
| 153 |
+
**content,
|
| 154 |
+
)
|
| 155 |
+
set_state(ctx, event.sender_id, "new_buttons", pid=post.id, temp=True)
|
| 156 |
+
await say(event, BUTTON_HELP, CANCEL)
|
| 157 |
+
|
| 158 |
+
@on_text(ctx, "new_buttons")
|
| 159 |
+
async def h_new_buttons(event, st):
|
| 160 |
+
if event.message.grouped_id:
|
| 161 |
+
return
|
| 162 |
+
try:
|
| 163 |
+
rows = parse_buttons_text(event.raw_text or "")
|
| 164 |
+
except ValueError as e:
|
| 165 |
+
raise UserError(f"β {e}\n\nTry again, or send /skip.")
|
| 166 |
+
if not rows:
|
| 167 |
+
raise UserError("Send at least one button as βText - linkβ, or /skip.")
|
| 168 |
+
await db.update_post(st["pid"], buttons=rows)
|
| 169 |
+
clear_state(ctx, event.sender_id)
|
| 170 |
+
await show_panel(ctx, event, st["pid"], note="β
Ready. Preview it, or tap <b>Publish</b>.", new=True)
|
| 171 |
+
|
| 172 |
+
@client.on(cmd("skip"))
|
| 173 |
+
@guard(ctx)
|
| 174 |
+
async def h_skip(event):
|
| 175 |
+
st = ctx.state.get(event.sender_id)
|
| 176 |
+
if not st or st["mode"] != "new_buttons":
|
| 177 |
+
raise UserError("There is nothing to skip right now.")
|
| 178 |
+
clear_state(ctx, event.sender_id)
|
| 179 |
+
await show_panel(ctx, event, st["pid"], note="β
Ready (no buttons). Preview it, or tap <b>Publish</b>.", new=True)
|
app/handlers/panel.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers shared by the create and post-management handlers (not a handler module itself)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from telethon import types
|
| 5 |
+
|
| 6 |
+
from ..buttons import valid
|
| 7 |
+
from ..common import Ctx, UserError, is_cb, say, show
|
| 8 |
+
from ..tgutil import classify_media, pack_file_id, ser_entities, utf16_len
|
| 9 |
+
from ..ui import button_keyboard, button_text, panel_keyboard, panel_text
|
| 10 |
+
|
| 11 |
+
CAPTION_MAX = 1024
|
| 12 |
+
TEXT_MAX = 4096
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def load_post(ctx: Ctx, pid):
|
| 16 |
+
post = await ctx.db.get_post(int(pid))
|
| 17 |
+
if post is None:
|
| 18 |
+
raise UserError("That post no longer exists.")
|
| 19 |
+
ch = await ctx.db.get_channel(post.channel_id)
|
| 20 |
+
if ch is None:
|
| 21 |
+
raise UserError("Its channel was removed from the bot. Add it again with /addchannel.")
|
| 22 |
+
return post, ch
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def show_panel(ctx: Ctx, event, pid, note=None, new: bool = False) -> None:
|
| 26 |
+
post, ch = await load_post(ctx, pid)
|
| 27 |
+
text, kb = panel_text(post, ch, note), panel_keyboard(post)
|
| 28 |
+
if new or not is_cb(event):
|
| 29 |
+
await say(event, text, kb)
|
| 30 |
+
else:
|
| 31 |
+
await show(event, text, kb)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def show_button_screen(ctx: Ctx, event, pid, r: int, c: int, new: bool = False) -> None:
|
| 35 |
+
post, _ = await load_post(ctx, pid)
|
| 36 |
+
if not valid(post.buttons or [], r, c):
|
| 37 |
+
await show_panel(ctx, event, pid, new=new)
|
| 38 |
+
return
|
| 39 |
+
text, kb = button_text(post, r, c), button_keyboard(post, r, c)
|
| 40 |
+
if new or not is_cb(event):
|
| 41 |
+
await say(event, text, kb)
|
| 42 |
+
else:
|
| 43 |
+
await show(event, text, kb)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def check_lengths(text: str, has_media: bool) -> None:
|
| 47 |
+
n = utf16_len(text or "")
|
| 48 |
+
if has_media and n > CAPTION_MAX:
|
| 49 |
+
raise UserError(f"A caption can have at most {CAPTION_MAX} characters (yours has {n}).")
|
| 50 |
+
if not has_media and n > TEXT_MAX:
|
| 51 |
+
raise UserError(f"A text post can have at most {TEXT_MAX} characters (yours has {n}).")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def extract_content(m) -> dict:
|
| 55 |
+
"""A message from the user -> post fields (text, entities, media)."""
|
| 56 |
+
kind = classify_media(m)
|
| 57 |
+
if m.media and kind is None and not isinstance(m.media, types.MessageMediaWebPage):
|
| 58 |
+
raise UserError("That kind of message can't be used in a post. Send text, a photo, video, GIF, audio or a file.")
|
| 59 |
+
text = m.raw_text or ""
|
| 60 |
+
if not kind and not text.strip():
|
| 61 |
+
raise UserError("Send some text, or a photo / video / GIF / file.")
|
| 62 |
+
check_lengths(text, bool(kind))
|
| 63 |
+
return {
|
| 64 |
+
"text": text,
|
| 65 |
+
"entities": ser_entities(m.entities),
|
| 66 |
+
"media_kind": kind,
|
| 67 |
+
"media_file_id": pack_file_id(m) if kind else None,
|
| 68 |
+
}
|
app/handlers/posts.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/posts: list, open and edit posts (text, caption, media, buttons), publish drafts, delete."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
from telethon import Button, errors
|
| 7 |
+
|
| 8 |
+
from ..buttons import (
|
| 9 |
+
MAX_ROW,
|
| 10 |
+
add_button,
|
| 11 |
+
count,
|
| 12 |
+
delete_button,
|
| 13 |
+
missing_links,
|
| 14 |
+
move_button,
|
| 15 |
+
norm_url,
|
| 16 |
+
parse_buttons_text,
|
| 17 |
+
set_field,
|
| 18 |
+
valid,
|
| 19 |
+
)
|
| 20 |
+
from ..common import (
|
| 21 |
+
Ctx,
|
| 22 |
+
UserError,
|
| 23 |
+
apply_edit,
|
| 24 |
+
clear_state,
|
| 25 |
+
cmd,
|
| 26 |
+
guard,
|
| 27 |
+
on_cb,
|
| 28 |
+
on_text,
|
| 29 |
+
say,
|
| 30 |
+
send_post,
|
| 31 |
+
set_state,
|
| 32 |
+
show,
|
| 33 |
+
)
|
| 34 |
+
from ..db import utcnow
|
| 35 |
+
from ..tgutil import classify_media, esc, explain_rpc, pack_file_id, peer_of, post_link, ser_entities, short
|
| 36 |
+
from ..ui import CANCEL, post_label
|
| 37 |
+
from .panel import (
|
| 38 |
+
check_lengths,
|
| 39 |
+
load_post,
|
| 40 |
+
show_button_screen,
|
| 41 |
+
show_panel,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
log = logging.getLogger(__name__)
|
| 45 |
+
PAGE = 8
|
| 46 |
+
UPDATED = "β
Updated in the channel."
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def register(ctx: Ctx) -> None:
|
| 50 |
+
client, db = ctx.client, ctx.db
|
| 51 |
+
|
| 52 |
+
def note_for(post) -> str:
|
| 53 |
+
return UPDATED if post.status == "sent" else "β
Saved."
|
| 54 |
+
|
| 55 |
+
# ------------------------------------------------------------------ lists
|
| 56 |
+
async def channel_chooser(event) -> None:
|
| 57 |
+
chans = await db.list_channels()
|
| 58 |
+
kb = [[Button.inline(f"π’ {short(c.title, 40)}", f"pc:{c.id}:0")] for c in chans]
|
| 59 |
+
kb.append([Button.inline("πΎ Drafts (all channels)", "pc:d:0")])
|
| 60 |
+
await show(event, "π <b>My posts</b> - pick a channel:", kb)
|
| 61 |
+
|
| 62 |
+
@client.on(cmd("posts"))
|
| 63 |
+
@guard(ctx)
|
| 64 |
+
async def h_posts(event):
|
| 65 |
+
await channel_chooser(event)
|
| 66 |
+
|
| 67 |
+
@on_cb(ctx, "pl")
|
| 68 |
+
async def cb_channel_chooser(event, parts):
|
| 69 |
+
await channel_chooser(event)
|
| 70 |
+
|
| 71 |
+
@on_cb(ctx, "pc")
|
| 72 |
+
async def cb_list(event, parts):
|
| 73 |
+
key, page = parts[0], int(parts[1])
|
| 74 |
+
if key == "d":
|
| 75 |
+
cid, status, title = None, "draft", "Drafts"
|
| 76 |
+
else:
|
| 77 |
+
cid, status = int(key), None
|
| 78 |
+
ch = await db.get_channel(cid)
|
| 79 |
+
title = ch.title if ch else "Channel"
|
| 80 |
+
rows, total = await db.list_posts(cid, status, page * PAGE, PAGE)
|
| 81 |
+
pages = max(1, -(-total // PAGE))
|
| 82 |
+
if page >= pages:
|
| 83 |
+
page = pages - 1
|
| 84 |
+
rows, total = await db.list_posts(cid, status, page * PAGE, PAGE)
|
| 85 |
+
kb = [[Button.inline(post_label(p), f"po:{p.id}")] for p in rows]
|
| 86 |
+
if pages > 1:
|
| 87 |
+
nav = []
|
| 88 |
+
if page > 0:
|
| 89 |
+
nav.append(Button.inline("β¬
οΈ Prev", f"pc:{key}:{page - 1}"))
|
| 90 |
+
nav.append(Button.inline(f"{page + 1}/{pages}", "noop"))
|
| 91 |
+
if page < pages - 1:
|
| 92 |
+
nav.append(Button.inline("Next β‘οΈ", f"pc:{key}:{page + 1}"))
|
| 93 |
+
kb.append(nav)
|
| 94 |
+
kb.append([Button.inline("π Channels", "pl")])
|
| 95 |
+
head = f"π <b>{esc(title)}</b> - {total} post(s)"
|
| 96 |
+
if not rows:
|
| 97 |
+
head += "\n\nNothing here yet. Create a post with /new."
|
| 98 |
+
await show(event, head, kb)
|
| 99 |
+
|
| 100 |
+
@on_cb(ctx, "po")
|
| 101 |
+
async def cb_open(event, parts):
|
| 102 |
+
clear_state(ctx, event.sender_id)
|
| 103 |
+
await show_panel(ctx, event, parts[0])
|
| 104 |
+
|
| 105 |
+
# ------------------------------------------------------------ text / caption
|
| 106 |
+
@on_cb(ctx, "et")
|
| 107 |
+
async def cb_edit_text(event, parts):
|
| 108 |
+
post, _ = await load_post(ctx, parts[0])
|
| 109 |
+
set_state(ctx, event.sender_id, "edit_text", pid=post.id)
|
| 110 |
+
what = "caption" if post.media_kind else "text"
|
| 111 |
+
await say(event, f"βοΈ Send the new {what}. Formatting is kept.", CANCEL)
|
| 112 |
+
|
| 113 |
+
@on_text(ctx, "edit_text")
|
| 114 |
+
async def h_edit_text(event, st):
|
| 115 |
+
m = event.message
|
| 116 |
+
if classify_media(m):
|
| 117 |
+
raise UserError("Send text only here. To change the media, use πΌ Media.")
|
| 118 |
+
post, ch = await load_post(ctx, st["pid"])
|
| 119 |
+
text = m.raw_text or ""
|
| 120 |
+
if not post.media_kind and not text.strip():
|
| 121 |
+
raise UserError("A text post can't be empty.")
|
| 122 |
+
check_lengths(text, bool(post.media_kind))
|
| 123 |
+
post = await apply_edit(ctx, post, ch, text=text, entities=ser_entities(m.entities))
|
| 124 |
+
clear_state(ctx, event.sender_id)
|
| 125 |
+
await show_panel(ctx, event, post.id, note=note_for(post), new=True)
|
| 126 |
+
|
| 127 |
+
# ------------------------------------------------------------------- media
|
| 128 |
+
@on_cb(ctx, "em")
|
| 129 |
+
async def cb_edit_media(event, parts):
|
| 130 |
+
post, _ = await load_post(ctx, parts[0])
|
| 131 |
+
set_state(ctx, event.sender_id, "edit_media", pid=post.id)
|
| 132 |
+
extra = "" if post.media_kind else "\nThe current text becomes its caption."
|
| 133 |
+
await say(event, f"πΌ Send the new photo / video / GIF / audio / file.{extra}", CANCEL)
|
| 134 |
+
|
| 135 |
+
@on_text(ctx, "edit_media")
|
| 136 |
+
async def h_edit_media(event, st):
|
| 137 |
+
m = event.message
|
| 138 |
+
if m.grouped_id and st.get("group") == m.grouped_id:
|
| 139 |
+
return
|
| 140 |
+
if m.grouped_id:
|
| 141 |
+
st["group"] = m.grouped_id
|
| 142 |
+
kind = classify_media(m)
|
| 143 |
+
if not kind:
|
| 144 |
+
raise UserError("Send a photo, video, GIF, audio file or document.")
|
| 145 |
+
post, ch = await load_post(ctx, st["pid"])
|
| 146 |
+
check_lengths(post.text or "", True)
|
| 147 |
+
post = await apply_edit(ctx, post, ch, replace_media=True, media_kind=kind, media_file_id=pack_file_id(m))
|
| 148 |
+
clear_state(ctx, event.sender_id)
|
| 149 |
+
await show_panel(ctx, event, post.id, note=note_for(post), new=True)
|
| 150 |
+
|
| 151 |
+
@on_cb(ctx, "rm")
|
| 152 |
+
async def cb_remove_media(event, parts):
|
| 153 |
+
post, ch = await load_post(ctx, parts[0])
|
| 154 |
+
if post.status != "draft":
|
| 155 |
+
raise UserError("Media can't be removed from a published post - Telegram doesn't allow it.")
|
| 156 |
+
if not (post.text or "").strip():
|
| 157 |
+
raise UserError("Add some text first, then remove the media (a post needs text or media).")
|
| 158 |
+
await db.update_post(post.id, media_kind=None, media_file_id=None)
|
| 159 |
+
await show_panel(ctx, event, post.id, note="β
Media removed.")
|
| 160 |
+
|
| 161 |
+
@on_cb(ctx, "lp")
|
| 162 |
+
async def cb_link_preview(event, parts):
|
| 163 |
+
post, ch = await load_post(ctx, parts[0])
|
| 164 |
+
post = await apply_edit(ctx, post, ch, link_preview=not post.link_preview)
|
| 165 |
+
await show_panel(ctx, event, post.id)
|
| 166 |
+
|
| 167 |
+
# ----------------------------------------------------------------- buttons
|
| 168 |
+
async def commit_buttons(event, post, ch, rows, *, new_msg: bool = True, note: str = None):
|
| 169 |
+
post = await apply_edit(ctx, post, ch, buttons=rows)
|
| 170 |
+
await show_panel(ctx, event, post.id, note=note or note_for(post), new=new_msg)
|
| 171 |
+
return post
|
| 172 |
+
|
| 173 |
+
@on_cb(ctx, "ba")
|
| 174 |
+
async def cb_add_button(event, parts):
|
| 175 |
+
post, _ = await load_post(ctx, parts[0])
|
| 176 |
+
if count(post.buttons) >= 100:
|
| 177 |
+
raise UserError("A post can have at most 100 buttons.")
|
| 178 |
+
set_state(ctx, event.sender_id, "btn_add_text", pid=post.id)
|
| 179 |
+
await say(
|
| 180 |
+
event,
|
| 181 |
+
"β Send the button as <code>Button text - link</code>, or just the button text and I'll ask for the link.",
|
| 182 |
+
CANCEL,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
async def place_button(event, pid, btn: dict) -> None:
|
| 186 |
+
post, ch = await load_post(ctx, pid)
|
| 187 |
+
if post.buttons and len(post.buttons[-1]) < MAX_ROW:
|
| 188 |
+
set_state(ctx, event.sender_id, "btn_place", pid=post.id, btn=btn)
|
| 189 |
+
await say(
|
| 190 |
+
event,
|
| 191 |
+
f"Where should <code>{esc(btn['t'])}</code> go?",
|
| 192 |
+
[
|
| 193 |
+
[Button.inline("β¬οΈ New row", f"bk:{post.id}:n"), Button.inline("β‘οΈ Same row", f"bk:{post.id}:s")],
|
| 194 |
+
CANCEL[0],
|
| 195 |
+
],
|
| 196 |
+
)
|
| 197 |
+
return
|
| 198 |
+
clear_state(ctx, event.sender_id)
|
| 199 |
+
await commit_buttons(event, post, ch, add_button(post.buttons, btn, True))
|
| 200 |
+
|
| 201 |
+
@on_text(ctx, "btn_add_text")
|
| 202 |
+
async def h_btn_add_text(event, st):
|
| 203 |
+
txt = (event.raw_text or "").strip()
|
| 204 |
+
if not txt:
|
| 205 |
+
raise UserError("Send the button text.")
|
| 206 |
+
try:
|
| 207 |
+
rows = parse_buttons_text(txt)
|
| 208 |
+
except ValueError:
|
| 209 |
+
rows = []
|
| 210 |
+
if rows:
|
| 211 |
+
if len(rows) == 1 and len(rows[0]) == 1:
|
| 212 |
+
await place_button(event, st["pid"], rows[0][0])
|
| 213 |
+
return
|
| 214 |
+
raise UserError("That's several buttons. Send one at a time, or use π Set buttons to replace them all.")
|
| 215 |
+
if len(txt) > 64:
|
| 216 |
+
raise UserError("Keep the button text under 64 characters.")
|
| 217 |
+
set_state(ctx, event.sender_id, "btn_add_url", pid=st["pid"], text=txt)
|
| 218 |
+
await say(event, f"π Now send the link for <code>{esc(txt)}</code>.", CANCEL)
|
| 219 |
+
|
| 220 |
+
@on_text(ctx, "btn_add_url")
|
| 221 |
+
async def h_btn_add_url(event, st):
|
| 222 |
+
url = norm_url(event.raw_text or "")
|
| 223 |
+
if not url:
|
| 224 |
+
raise UserError("That doesn't look like a link. Send something like https://t.me/yourbot?start=abc")
|
| 225 |
+
await place_button(event, st["pid"], {"t": st["text"], "u": url})
|
| 226 |
+
|
| 227 |
+
@on_cb(ctx, "bk")
|
| 228 |
+
async def cb_place(event, parts):
|
| 229 |
+
st = ctx.state.get(event.sender_id)
|
| 230 |
+
if not st or st.get("mode") != "btn_place" or str(st.get("pid")) != parts[0]:
|
| 231 |
+
raise UserError("That step expired. Tap β Button again.")
|
| 232 |
+
post, ch = await load_post(ctx, parts[0])
|
| 233 |
+
rows = add_button(post.buttons, st["btn"], new_row=(parts[1] == "n"))
|
| 234 |
+
clear_state(ctx, event.sender_id)
|
| 235 |
+
await commit_buttons(event, post, ch, rows, new_msg=False)
|
| 236 |
+
|
| 237 |
+
@on_cb(ctx, "bp")
|
| 238 |
+
async def cb_paste(event, parts):
|
| 239 |
+
post, _ = await load_post(ctx, parts[0])
|
| 240 |
+
set_state(ctx, event.sender_id, "btn_paste", pid=post.id)
|
| 241 |
+
warn = ""
|
| 242 |
+
if any(b.get("raw") for r in post.buttons for b in r):
|
| 243 |
+
warn = "\nβ οΈ This also removes buttons made by other bots (like reactions)."
|
| 244 |
+
await say(
|
| 245 |
+
event,
|
| 246 |
+
"π Send <b>all</b> the buttons - one row per line as <code>Text - link</code>, "
|
| 247 |
+
"with <code> | </code> between buttons in one row. This replaces the current buttons." + warn,
|
| 248 |
+
CANCEL,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
@on_text(ctx, "btn_paste")
|
| 252 |
+
async def h_btn_paste(event, st):
|
| 253 |
+
try:
|
| 254 |
+
rows = parse_buttons_text(event.raw_text or "")
|
| 255 |
+
except ValueError as e:
|
| 256 |
+
raise UserError(f"β {e}\n\nTry again.")
|
| 257 |
+
if not rows:
|
| 258 |
+
raise UserError("Send at least one button as βText - linkβ.")
|
| 259 |
+
post, ch = await load_post(ctx, st["pid"])
|
| 260 |
+
clear_state(ctx, event.sender_id)
|
| 261 |
+
await commit_buttons(event, post, ch, rows)
|
| 262 |
+
|
| 263 |
+
@on_cb(ctx, "bc")
|
| 264 |
+
async def cb_clear(event, parts):
|
| 265 |
+
await show(
|
| 266 |
+
event,
|
| 267 |
+
"Remove <b>all</b> buttons from this post?",
|
| 268 |
+
[[Button.inline("π§Ή Yes, remove", f"bcy:{parts[0]}"), Button.inline("β©οΈ Back", f"po:{parts[0]}")]],
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
@on_cb(ctx, "bcy")
|
| 272 |
+
async def cb_clear_yes(event, parts):
|
| 273 |
+
post, ch = await load_post(ctx, parts[0])
|
| 274 |
+
await commit_buttons(event, post, ch, [], new_msg=False)
|
| 275 |
+
|
| 276 |
+
@on_cb(ctx, "bs")
|
| 277 |
+
async def cb_select(event, parts):
|
| 278 |
+
clear_state(ctx, event.sender_id)
|
| 279 |
+
await show_button_screen(ctx, event, parts[0], int(parts[1]), int(parts[2]))
|
| 280 |
+
|
| 281 |
+
@on_cb(ctx, "bt")
|
| 282 |
+
async def cb_btn_text(event, parts):
|
| 283 |
+
set_state(ctx, event.sender_id, "btn_text", pid=parts[0], r=int(parts[1]), c=int(parts[2]))
|
| 284 |
+
await say(event, "βοΈ Send the new button text.", CANCEL)
|
| 285 |
+
|
| 286 |
+
@on_text(ctx, "btn_text")
|
| 287 |
+
async def h_btn_text(event, st):
|
| 288 |
+
txt = (event.raw_text or "").strip()
|
| 289 |
+
if not txt:
|
| 290 |
+
raise UserError("Send the new button text.")
|
| 291 |
+
if len(txt) > 64:
|
| 292 |
+
raise UserError("Keep the button text under 64 characters.")
|
| 293 |
+
post, ch = await load_post(ctx, st["pid"])
|
| 294 |
+
if not valid(post.buttons, st["r"], st["c"]):
|
| 295 |
+
raise UserError("That button no longer exists.")
|
| 296 |
+
post = await apply_edit(ctx, post, ch, buttons=set_field(post.buttons, st["r"], st["c"], t=txt))
|
| 297 |
+
clear_state(ctx, event.sender_id)
|
| 298 |
+
await show_button_screen(ctx, event, post.id, st["r"], st["c"], new=True)
|
| 299 |
+
|
| 300 |
+
@on_cb(ctx, "bu")
|
| 301 |
+
async def cb_btn_url(event, parts):
|
| 302 |
+
set_state(ctx, event.sender_id, "btn_url", pid=parts[0], r=int(parts[1]), c=int(parts[2]))
|
| 303 |
+
await say(event, "π Send the new link for this button.", CANCEL)
|
| 304 |
+
|
| 305 |
+
@on_text(ctx, "btn_url")
|
| 306 |
+
async def h_btn_url(event, st):
|
| 307 |
+
url = norm_url(event.raw_text or "")
|
| 308 |
+
if not url:
|
| 309 |
+
raise UserError("That doesn't look like a link. Send something like https://t.me/yourbot?start=abc")
|
| 310 |
+
post, ch = await load_post(ctx, st["pid"])
|
| 311 |
+
if not valid(post.buttons, st["r"], st["c"]):
|
| 312 |
+
raise UserError("That button no longer exists.")
|
| 313 |
+
post = await apply_edit(ctx, post, ch, buttons=set_field(post.buttons, st["r"], st["c"], u=url))
|
| 314 |
+
clear_state(ctx, event.sender_id)
|
| 315 |
+
await show_button_screen(ctx, event, post.id, st["r"], st["c"], new=True)
|
| 316 |
+
|
| 317 |
+
@on_cb(ctx, "bm")
|
| 318 |
+
async def cb_move(event, parts):
|
| 319 |
+
pid, r, c, d = parts[0], int(parts[1]), int(parts[2]), parts[3]
|
| 320 |
+
post, ch = await load_post(ctx, pid)
|
| 321 |
+
rows, nr, nc = move_button(post.buttons, r, c, d)
|
| 322 |
+
if rows == post.buttons:
|
| 323 |
+
return # already at the edge
|
| 324 |
+
post = await apply_edit(ctx, post, ch, buttons=rows)
|
| 325 |
+
await show_button_screen(ctx, event, post.id, nr, nc)
|
| 326 |
+
|
| 327 |
+
@on_cb(ctx, "bd")
|
| 328 |
+
async def cb_btn_delete(event, parts):
|
| 329 |
+
post, ch = await load_post(ctx, parts[0])
|
| 330 |
+
rows = delete_button(post.buttons, int(parts[1]), int(parts[2]))
|
| 331 |
+
await commit_buttons(event, post, ch, rows, new_msg=False)
|
| 332 |
+
|
| 333 |
+
# ------------------------------------------------------- preview / publish
|
| 334 |
+
@on_cb(ctx, "pv")
|
| 335 |
+
async def cb_preview(event, parts):
|
| 336 |
+
post, _ = await load_post(ctx, parts[0])
|
| 337 |
+
if not (post.text or "").strip() and not post.media_file_id:
|
| 338 |
+
raise UserError("The post is empty.")
|
| 339 |
+
await send_post(client, await event.get_input_chat(), post)
|
| 340 |
+
miss = missing_links(post.buttons)
|
| 341 |
+
if miss:
|
| 342 |
+
await say(event, f"π Preview. β οΈ {miss} button(s) without a link are not shown.")
|
| 343 |
+
|
| 344 |
+
@on_cb(ctx, "pb")
|
| 345 |
+
async def cb_publish(event, parts):
|
| 346 |
+
post, ch = await load_post(ctx, parts[0])
|
| 347 |
+
if post.status != "draft":
|
| 348 |
+
raise UserError("This post is already published.")
|
| 349 |
+
if missing_links(post.buttons):
|
| 350 |
+
raise UserError("Some buttons (β οΈ) have no link yet. Open them and add a link first.")
|
| 351 |
+
if not (post.text or "").strip() and not post.media_file_id:
|
| 352 |
+
raise UserError("The post is empty.")
|
| 353 |
+
await show(
|
| 354 |
+
event,
|
| 355 |
+
f"Publish this post to <b>{esc(ch.title)}</b>?",
|
| 356 |
+
[[Button.inline("π Yes, publish", f"pby:{post.id}"), Button.inline("β©οΈ Back", f"po:{post.id}")]],
|
| 357 |
+
)
|
| 358 |
+
|
| 359 |
+
@on_cb(ctx, "pby")
|
| 360 |
+
async def cb_publish_yes(event, parts):
|
| 361 |
+
post, ch = await load_post(ctx, parts[0])
|
| 362 |
+
if post.status != "draft":
|
| 363 |
+
raise UserError("This post is already published.")
|
| 364 |
+
try:
|
| 365 |
+
sent = await send_post(client, peer_of(ch), post)
|
| 366 |
+
except errors.RPCError as e:
|
| 367 |
+
raise UserError("β " + explain_rpc(e) + "\nThe post was NOT published.")
|
| 368 |
+
post = await db.update_post(post.id, status="sent", message_id=sent.id, sent_at=utcnow())
|
| 369 |
+
await show(
|
| 370 |
+
event,
|
| 371 |
+
f"β
<b>Published</b> in <b>{esc(ch.title)}</b>\n<a href=\"{post_link(ch, sent.id)}\">Open the post</a>",
|
| 372 |
+
[
|
| 373 |
+
[Button.inline("π Manage this post", f"po:{post.id}")],
|
| 374 |
+
[Button.inline("β New post", f"nc:{ch.id}")],
|
| 375 |
+
],
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
# ----------------------------------------------------------------- delete
|
| 379 |
+
@on_cb(ctx, "dl")
|
| 380 |
+
async def cb_delete(event, parts):
|
| 381 |
+
post, ch = await load_post(ctx, parts[0])
|
| 382 |
+
back = Button.inline("β©οΈ Back", f"po:{post.id}")
|
| 383 |
+
if post.status == "draft":
|
| 384 |
+
await show(event, "Discard this draft?", [[Button.inline("π Yes, discard", f"dly:{post.id}:b"), back]])
|
| 385 |
+
return
|
| 386 |
+
await show(
|
| 387 |
+
event,
|
| 388 |
+
f"Delete this post from <b>{esc(ch.title)}</b>?\n\n"
|
| 389 |
+
"Telegram only lets a bot delete posts that are less than 48 hours old. For older posts, delete them "
|
| 390 |
+
"by hand in the channel and then use βOnly forget itβ.",
|
| 391 |
+
[
|
| 392 |
+
[Button.inline("π Delete from channel", f"dly:{post.id}:a")],
|
| 393 |
+
[Button.inline("π§Ή Only forget it in the bot", f"dly:{post.id}:b")],
|
| 394 |
+
[back],
|
| 395 |
+
],
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
@on_cb(ctx, "dly")
|
| 399 |
+
async def cb_delete_yes(event, parts):
|
| 400 |
+
post, ch = await load_post(ctx, parts[0])
|
| 401 |
+
if post.status == "sent" and parts[1] == "a":
|
| 402 |
+
res = await client.delete_messages(peer_of(ch), [post.message_id])
|
| 403 |
+
if not sum(getattr(r, "pts_count", 0) or 0 for r in res):
|
| 404 |
+
raise UserError(
|
| 405 |
+
"Telegram did not delete that message - bots can only delete posts younger than 48 hours. "
|
| 406 |
+
"Delete it by hand in the channel, then use βOnly forget it in the botβ."
|
| 407 |
+
)
|
| 408 |
+
await db.delete_post(post.id)
|
| 409 |
+
await show(event, "π Done.", [[Button.inline("π My posts", "pl")]])
|
app/handlers/replace.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/replace, /undo and /testedit (owner only)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
import secrets
|
| 8 |
+
import time
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
from telethon import Button, errors, types
|
| 12 |
+
|
| 13 |
+
from ..common import Ctx, UserError, cmd, edit_callback_message, guard, on_cb, require_owner, say, show
|
| 14 |
+
from ..linkswap import normalize_username
|
| 15 |
+
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 16 |
+
from ..tgutil import button_url, edit_raw, esc, explain_rpc, get_rights, peer_of, with_url
|
| 17 |
+
from .channels import parse_channel_ref
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
USAGE = (
|
| 22 |
+
"Usage: <code>/replace @old @new</code>\n"
|
| 23 |
+
"Options: <code>--channel @name</code> (only that channel), <code>--last 200</code> (only the newest 200 "
|
| 24 |
+
"post ids), <code>--posts</code> (also change t.me/old/123 post links), <code>--no-typed</code> (don't touch "
|
| 25 |
+
"links typed in the text)."
|
| 26 |
+
)
|
| 27 |
+
TOKEN_TTL = 3600
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class Job:
|
| 32 |
+
user: int
|
| 33 |
+
opts: ScanOptions
|
| 34 |
+
scans: list
|
| 35 |
+
running: bool = False
|
| 36 |
+
created: float = field(default_factory=time.monotonic)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def parse_replace_args(raw: str) -> tuple:
|
| 40 |
+
toks = (raw or "").split()
|
| 41 |
+
flags = {"channel": None, "last": None, "posts": False, "typed": None}
|
| 42 |
+
pos: list = []
|
| 43 |
+
i = 0
|
| 44 |
+
while i < len(toks):
|
| 45 |
+
t = toks[i]
|
| 46 |
+
if t == "--channel" and i + 1 < len(toks):
|
| 47 |
+
flags["channel"] = toks[i + 1]
|
| 48 |
+
i += 2
|
| 49 |
+
elif t == "--last" and i + 1 < len(toks):
|
| 50 |
+
if not toks[i + 1].isdigit() or int(toks[i + 1]) < 1:
|
| 51 |
+
raise ValueError("--last needs a number, for example --last 200")
|
| 52 |
+
flags["last"] = int(toks[i + 1])
|
| 53 |
+
i += 2
|
| 54 |
+
elif t == "--posts":
|
| 55 |
+
flags["posts"] = True
|
| 56 |
+
i += 1
|
| 57 |
+
elif t == "--no-typed":
|
| 58 |
+
flags["typed"] = False
|
| 59 |
+
i += 1
|
| 60 |
+
elif t.startswith("--"):
|
| 61 |
+
raise ValueError(f"Unknown option {t}")
|
| 62 |
+
else:
|
| 63 |
+
pos.append(t)
|
| 64 |
+
i += 1
|
| 65 |
+
if len(pos) != 2:
|
| 66 |
+
raise ValueError("Give the old and the new username.")
|
| 67 |
+
old, new = normalize_username(pos[0]), normalize_username(pos[1])
|
| 68 |
+
if not old or not new:
|
| 69 |
+
raise ValueError("Usernames look like @name (letters, digits and underscores).")
|
| 70 |
+
if old.lower() == new.lower():
|
| 71 |
+
raise ValueError("The old and the new username are the same.")
|
| 72 |
+
return old, new, flags
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def pick_channels(chans: list, ref) -> list:
|
| 76 |
+
if not ref:
|
| 77 |
+
return chans
|
| 78 |
+
r = parse_channel_ref(ref)
|
| 79 |
+
out = [c for c in chans if (isinstance(r, int) and c.id == r) or (isinstance(r, str) and (c.username or "").lower() == r.lower())]
|
| 80 |
+
if not out:
|
| 81 |
+
raise ValueError("That channel isn't registered (see /channels).")
|
| 82 |
+
return out
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def summary_text(job: Job) -> str:
|
| 86 |
+
o = job.opts
|
| 87 |
+
lines = [f"π <b>Scan finished</b> - replace <code>@{esc(o.old)}</code> β <code>@{esc(o.new)}</code>"]
|
| 88 |
+
total = 0
|
| 89 |
+
for s in job.scans:
|
| 90 |
+
head = f"\n<b>{esc(s.channel.title)}</b>"
|
| 91 |
+
if s.error:
|
| 92 |
+
lines.append(f"{head}\nβ οΈ {esc(s.error)}")
|
| 93 |
+
continue
|
| 94 |
+
block = f"{head} - looked at post ids {s.first}-{s.scanned}"
|
| 95 |
+
if s.total == 0:
|
| 96 |
+
block += "\nNothing to change."
|
| 97 |
+
else:
|
| 98 |
+
b = sum(i.buttons for i in s.infos.values())
|
| 99 |
+
h = sum(i.links for i in s.infos.values())
|
| 100 |
+
t = sum(i.typed for i in s.infos.values())
|
| 101 |
+
block += f"\n{s.total} post(s) to change: {b} button link(s), {h} hyperlink(s), {t} typed link(s)"
|
| 102 |
+
if s.foreign:
|
| 103 |
+
block += f"\nβ’ {s.foreign} of them were not posted by this bot"
|
| 104 |
+
cb = sum(1 for i in s.infos.values() if i.callbacks)
|
| 105 |
+
if cb:
|
| 106 |
+
block += f"\nβ’ {cb} carry other bots' buttons (e.g. reactions) - those stay as they are"
|
| 107 |
+
if s.skipped_post_links:
|
| 108 |
+
block += f"\nβ’ skipped {s.skipped_post_links} link(s) to channel posts (t.me/{o.old}/123) - use --posts to change them too"
|
| 109 |
+
if s.not_editable:
|
| 110 |
+
block += f"\nβ οΈ {s.not_editable} post(s) by others can't be edited: the bot lacks βEdit messages of othersβ here"
|
| 111 |
+
total += s.total
|
| 112 |
+
lines.append(block)
|
| 113 |
+
lines.append(f"\n<b>Total: {total} post(s).</b>")
|
| 114 |
+
if total:
|
| 115 |
+
lines.append("Nothing has been changed yet. Every edit is logged, so /undo can restore the old versions.")
|
| 116 |
+
return "\n".join(lines)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def register(ctx: Ctx) -> None:
|
| 120 |
+
client, db, cfg = ctx.client, ctx.db, ctx.cfg
|
| 121 |
+
lock = asyncio.Lock()
|
| 122 |
+
|
| 123 |
+
def purge_tokens() -> None:
|
| 124 |
+
now = time.monotonic()
|
| 125 |
+
for k in [k for k, v in ctx.pending.items() if now - getattr(v, "created", now) > TOKEN_TTL]:
|
| 126 |
+
ctx.pending.pop(k, None)
|
| 127 |
+
|
| 128 |
+
def make_updater(event):
|
| 129 |
+
last = [0.0]
|
| 130 |
+
|
| 131 |
+
async def upd(text: str, force: bool = False) -> None:
|
| 132 |
+
now = time.monotonic()
|
| 133 |
+
if not force and now - last[0] < 3:
|
| 134 |
+
return
|
| 135 |
+
last[0] = now
|
| 136 |
+
try:
|
| 137 |
+
await edit_callback_message(event, text)
|
| 138 |
+
except errors.MessageNotModifiedError:
|
| 139 |
+
pass
|
| 140 |
+
except Exception:
|
| 141 |
+
log.debug("progress update failed", exc_info=True)
|
| 142 |
+
|
| 143 |
+
return upd
|
| 144 |
+
|
| 145 |
+
# ------------------------------------------------------------------ /replace
|
| 146 |
+
@client.on(cmd("replace", args=True))
|
| 147 |
+
@guard(ctx, owner=True)
|
| 148 |
+
async def h_replace(event):
|
| 149 |
+
purge_tokens()
|
| 150 |
+
raw = (event.pattern_match.group(1) or "").strip()
|
| 151 |
+
if not raw:
|
| 152 |
+
await say(event, USAGE)
|
| 153 |
+
return
|
| 154 |
+
try:
|
| 155 |
+
old, new, flags = parse_replace_args(raw)
|
| 156 |
+
chans = pick_channels(await db.list_channels(), flags["channel"])
|
| 157 |
+
except ValueError as e:
|
| 158 |
+
raise UserError(f"{e}\n\n" + re.sub(r"<[^>]+>", "", USAGE))
|
| 159 |
+
if not chans:
|
| 160 |
+
raise UserError("No channels registered yet. Use /addchannel.")
|
| 161 |
+
if lock.locked():
|
| 162 |
+
raise UserError("Another /replace or /undo is still running - wait for it to finish.")
|
| 163 |
+
opts = ScanOptions(
|
| 164 |
+
old=old,
|
| 165 |
+
new=new,
|
| 166 |
+
include_typed=cfg.replace_typed_links if flags["typed"] is None else flags["typed"],
|
| 167 |
+
include_posts=flags["posts"],
|
| 168 |
+
last=flags["last"],
|
| 169 |
+
)
|
| 170 |
+
async with lock:
|
| 171 |
+
status = await say(event, f"π Scanning {len(chans)} channel(s)β¦")
|
| 172 |
+
last_edit = [0.0]
|
| 173 |
+
scans = []
|
| 174 |
+
for ch in chans:
|
| 175 |
+
rights = await get_rights(client, ch)
|
| 176 |
+
if rights is not None and not rights.admin:
|
| 177 |
+
s = await _empty_scan(ch, "The bot is not an admin here any more.")
|
| 178 |
+
scans.append(s)
|
| 179 |
+
continue
|
| 180 |
+
can_edit = True if rights is None else rights.edit
|
| 181 |
+
|
| 182 |
+
async def prog(scan, title=ch.title):
|
| 183 |
+
now = time.monotonic()
|
| 184 |
+
if now - last_edit[0] < 3:
|
| 185 |
+
return
|
| 186 |
+
last_edit[0] = now
|
| 187 |
+
try:
|
| 188 |
+
await status.edit(f"π Scanning <b>{esc(title)}</b>: post id {scan.scanned} of {scan.top or '?'}β¦")
|
| 189 |
+
except Exception:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
scan = await scan_channel(
|
| 194 |
+
client, ch, opts, can_edit_others=can_edit, mine_ids=await db.mine_message_ids(ch.id), progress=prog
|
| 195 |
+
)
|
| 196 |
+
except errors.RPCError as e:
|
| 197 |
+
scan = await _empty_scan(ch, explain_rpc(e))
|
| 198 |
+
scan.rights = rights
|
| 199 |
+
scans.append(scan)
|
| 200 |
+
job = Job(user=event.sender_id, opts=opts, scans=scans)
|
| 201 |
+
token = secrets.token_hex(4)
|
| 202 |
+
ctx.pending[token] = job
|
| 203 |
+
total = sum(s.total for s in scans)
|
| 204 |
+
kb = None
|
| 205 |
+
if total:
|
| 206 |
+
kb = [[Button.inline(f"β
Apply to {total} post(s)", f"rp:{token}:y"), Button.inline("βοΈ Cancel", f"rp:{token}:n")]]
|
| 207 |
+
try:
|
| 208 |
+
await status.edit(summary_text(job), buttons=kb, link_preview=False)
|
| 209 |
+
except Exception:
|
| 210 |
+
await say(event, summary_text(job), kb)
|
| 211 |
+
|
| 212 |
+
async def _empty_scan(ch, error: str):
|
| 213 |
+
return ChannelScan(channel=ch, error=error)
|
| 214 |
+
|
| 215 |
+
@on_cb(ctx, "rp")
|
| 216 |
+
async def cb_replace(event, parts):
|
| 217 |
+
require_owner(ctx, event)
|
| 218 |
+
token, action = parts[0], parts[1]
|
| 219 |
+
job = ctx.pending.get(token)
|
| 220 |
+
if not isinstance(job, Job) or job.user != event.sender_id:
|
| 221 |
+
raise UserError("That scan has expired. Run /replace again.")
|
| 222 |
+
if action == "n":
|
| 223 |
+
ctx.pending.pop(token, None)
|
| 224 |
+
await show(event, "Cancelled - nothing was changed.")
|
| 225 |
+
return
|
| 226 |
+
if job.running or lock.locked():
|
| 227 |
+
raise UserError("It is already running.")
|
| 228 |
+
job.running = True
|
| 229 |
+
upd = make_updater(event)
|
| 230 |
+
batch_id = secrets.token_hex(6)
|
| 231 |
+
o = job.opts
|
| 232 |
+
totals = ApplyResult()
|
| 233 |
+
async with lock:
|
| 234 |
+
await db.create_batch(batch_id, o.old, o.new, event.sender_id)
|
| 235 |
+
todo = sum(s.total for s in job.scans)
|
| 236 |
+
done_before = 0
|
| 237 |
+
for s in job.scans:
|
| 238 |
+
if not s.infos:
|
| 239 |
+
continue
|
| 240 |
+
|
| 241 |
+
async def prog(res, s=s, base=done_before):
|
| 242 |
+
await upd(f"β³ <b>{esc(s.channel.title)}</b> - {base + res.edited}/{todo} editedβ¦")
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
res = await apply_channel(
|
| 246 |
+
client, db, s, o, batch_id, event.sender_id, edit_delay=cfg.edit_delay, progress=prog
|
| 247 |
+
)
|
| 248 |
+
except Exception as e:
|
| 249 |
+
log.exception("apply failed in %s", s.channel.id)
|
| 250 |
+
totals.failed.append((0, f"{s.channel.title}: {type(e).__name__}"))
|
| 251 |
+
continue
|
| 252 |
+
done_before += res.edited
|
| 253 |
+
totals.edited += res.edited
|
| 254 |
+
totals.unchanged += res.unchanged
|
| 255 |
+
totals.missing += res.missing
|
| 256 |
+
totals.no_perm += res.no_perm
|
| 257 |
+
totals.failed += res.failed
|
| 258 |
+
ctx.pending.pop(token, None)
|
| 259 |
+
lines = [f"β
<b>Done</b> - <code>@{esc(o.old)}</code> β <code>@{esc(o.new)}</code>", f"β’ edited: <b>{totals.edited}</b>"]
|
| 260 |
+
if totals.unchanged:
|
| 261 |
+
lines.append(f"β’ already fine / changed meanwhile: {totals.unchanged}")
|
| 262 |
+
if totals.missing:
|
| 263 |
+
lines.append(f"β’ deleted meanwhile: {totals.missing}")
|
| 264 |
+
if totals.no_perm:
|
| 265 |
+
lines.append(f"β’ skipped (no permission): {totals.no_perm}")
|
| 266 |
+
if totals.failed:
|
| 267 |
+
sample = ", ".join(f"#{m} {esc(n)}" for m, n in totals.failed[:8])
|
| 268 |
+
lines.append(f"β’ <b>failed: {len(totals.failed)}</b> ({sample}{'β¦' if len(totals.failed) > 8 else ''})")
|
| 269 |
+
names = {n for _, n in totals.failed}
|
| 270 |
+
if names & {"MessageAuthorRequiredError", "InlineBotRequiredError", "ChatAdminRequiredError"}:
|
| 271 |
+
lines.append(" Telegram doesn't let this bot edit those posts (see /testedit).")
|
| 272 |
+
kb = [[Button.inline("β©οΈ Undo this replace", f"ruc:{batch_id}")]] if totals.edited else None
|
| 273 |
+
if kb:
|
| 274 |
+
try:
|
| 275 |
+
await edit_callback_message(event, "\n".join(lines), kb)
|
| 276 |
+
except errors.MessageNotModifiedError:
|
| 277 |
+
pass
|
| 278 |
+
else:
|
| 279 |
+
await upd("\n".join(lines), force=True)
|
| 280 |
+
|
| 281 |
+
# -------------------------------------------------------------------- /undo
|
| 282 |
+
async def ask_undo(event, batch) -> None:
|
| 283 |
+
n = await db.count_batch_changes(batch.id)
|
| 284 |
+
await say(
|
| 285 |
+
event,
|
| 286 |
+
f"β©οΈ Undo the replace <code>@{esc(batch.old_username)}</code> β <code>@{esc(batch.new_username)}</code>?\n"
|
| 287 |
+
f"{n} post(s) will be put back exactly as they were before.",
|
| 288 |
+
[[Button.inline("β
Yes, undo", f"ru:{batch.id}"), Button.inline("βοΈ No", "cx")]],
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
@client.on(cmd("undo"))
|
| 292 |
+
@guard(ctx, owner=True)
|
| 293 |
+
async def h_undo(event):
|
| 294 |
+
batch = await db.last_batch()
|
| 295 |
+
if batch is None:
|
| 296 |
+
raise UserError("There is no /replace run to undo.")
|
| 297 |
+
await ask_undo(event, batch)
|
| 298 |
+
|
| 299 |
+
@on_cb(ctx, "ruc")
|
| 300 |
+
async def cb_undo_ask(event, parts):
|
| 301 |
+
require_owner(ctx, event)
|
| 302 |
+
batch = await db.get_batch(parts[0])
|
| 303 |
+
if batch is None or batch.undone:
|
| 304 |
+
raise UserError("That replace was already undone.")
|
| 305 |
+
await ask_undo(event, batch)
|
| 306 |
+
|
| 307 |
+
@on_cb(ctx, "ru")
|
| 308 |
+
async def cb_undo(event, parts):
|
| 309 |
+
require_owner(ctx, event)
|
| 310 |
+
if lock.locked():
|
| 311 |
+
raise UserError("Another /replace or /undo is still running.")
|
| 312 |
+
upd = make_updater(event)
|
| 313 |
+
async with lock:
|
| 314 |
+
await upd("β³ Restoringβ¦", force=True)
|
| 315 |
+
|
| 316 |
+
async def prog(res):
|
| 317 |
+
await upd(f"ⳠRestoring⦠{res.restored} done")
|
| 318 |
+
|
| 319 |
+
res = await undo_batch(client, db, parts[0], edit_delay=cfg.edit_delay, progress=prog)
|
| 320 |
+
text = f"β©οΈ Restored <b>{res.restored}</b> post(s)."
|
| 321 |
+
if res.failed:
|
| 322 |
+
text += f"\nβ οΈ {len(res.failed)} could not be restored (" + ", ".join(f"#{m} {esc(n)}" for m, n in res.failed[:6]) + ")."
|
| 323 |
+
await upd(text, force=True)
|
| 324 |
+
|
| 325 |
+
# ---------------------------------------------------------------- /testedit
|
| 326 |
+
link_rx = re.compile(
|
| 327 |
+
r"(?i)(?:https?://)?(?:t\.me|telegram\.me)/(?:c/(?P<cid>\d+)|(?P<user>[A-Za-z0-9_]{3,32}))/(?P<mid>\d+)"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
@client.on(cmd("testedit", args=True))
|
| 331 |
+
@guard(ctx, owner=True)
|
| 332 |
+
async def h_testedit(event):
|
| 333 |
+
purge_tokens()
|
| 334 |
+
m = link_rx.search(event.pattern_match.group(1) or "")
|
| 335 |
+
if not m:
|
| 336 |
+
raise UserError("Usage: /testedit https://t.me/yourchannel/123 (a link to one post that has a link button)")
|
| 337 |
+
chans = await db.list_channels()
|
| 338 |
+
ch = None
|
| 339 |
+
for c in chans:
|
| 340 |
+
if (m.group("cid") and c.id == int(m.group("cid"))) or (
|
| 341 |
+
m.group("user") and (c.username or "").lower() == m.group("user").lower()
|
| 342 |
+
):
|
| 343 |
+
ch = c
|
| 344 |
+
if ch is None:
|
| 345 |
+
raise UserError("That channel isn't registered. Use /addchannel first.")
|
| 346 |
+
mid = int(m.group("mid"))
|
| 347 |
+
msgs = await client.get_messages(peer_of(ch), ids=[mid])
|
| 348 |
+
msg = msgs[0] if msgs else None
|
| 349 |
+
if msg is None or isinstance(msg, (types.MessageEmpty, types.MessageService)):
|
| 350 |
+
raise UserError("I can't read that message - was it deleted?")
|
| 351 |
+
urls = []
|
| 352 |
+
if isinstance(msg.reply_markup, types.ReplyInlineMarkup):
|
| 353 |
+
urls = [button_url(b) for row in msg.reply_markup.rows for b in row.buttons if button_url(b)]
|
| 354 |
+
if not urls:
|
| 355 |
+
raise UserError("That post has no link buttons. Pick one that has a button with a link.")
|
| 356 |
+
rights = await get_rights(client, ch)
|
| 357 |
+
rtxt = "unknown" if rights is None else ("can edit others' posts" if rights.edit else "β οΈ NO βedit messages of othersβ right")
|
| 358 |
+
token = secrets.token_hex(4)
|
| 359 |
+
ctx.pending[token] = _TestJob(user=event.sender_id, ch=ch, mid=mid)
|
| 360 |
+
await say(
|
| 361 |
+
event,
|
| 362 |
+
f"π§ͺ <b>Edit test</b> for post {mid} in <b>{esc(ch.title)}</b>\n"
|
| 363 |
+
f"Posted by this bot: {'yes' if msg.out else 'no (someone else / another bot)'}\n"
|
| 364 |
+
f"Bot rights: {rtxt}\n"
|
| 365 |
+
f"First button link: <code>{esc(urls[0][:80])}</code>\n\n"
|
| 366 |
+
"The test adds <code>x=1</code> to that first link and immediately changes it back. "
|
| 367 |
+
"Visitors won't notice.",
|
| 368 |
+
[[Button.inline("π§ͺ Run the test", f"te:{token}"), Button.inline("βοΈ Cancel", "cx")]],
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
@on_cb(ctx, "te")
|
| 372 |
+
async def cb_testedit(event, parts):
|
| 373 |
+
require_owner(ctx, event)
|
| 374 |
+
job = ctx.pending.pop(parts[0], None)
|
| 375 |
+
if not isinstance(job, _TestJob) or job.user != event.sender_id:
|
| 376 |
+
raise UserError("That test has expired. Run /testedit again.")
|
| 377 |
+
peer = peer_of(job.ch)
|
| 378 |
+
msgs = await client.get_messages(peer, ids=[job.mid])
|
| 379 |
+
msg = msgs[0]
|
| 380 |
+
if msg is None or not isinstance(msg.reply_markup, types.ReplyInlineMarkup):
|
| 381 |
+
raise UserError("The post changed or was deleted. Run /testedit again.")
|
| 382 |
+
original = msg.reply_markup
|
| 383 |
+
changed = False
|
| 384 |
+
rows = []
|
| 385 |
+
for row in original.rows:
|
| 386 |
+
btns = []
|
| 387 |
+
for b in row.buttons:
|
| 388 |
+
u = button_url(b)
|
| 389 |
+
if u and not changed:
|
| 390 |
+
btns.append(with_url(b, u + ("&" if "?" in u else "?") + "x=1"))
|
| 391 |
+
changed = True
|
| 392 |
+
else:
|
| 393 |
+
btns.append(b)
|
| 394 |
+
rows.append(types.KeyboardButtonRow(btns))
|
| 395 |
+
try:
|
| 396 |
+
await edit_raw(client, peer, job.mid, markup=types.ReplyInlineMarkup(rows))
|
| 397 |
+
except errors.RPCError as e:
|
| 398 |
+
await show(
|
| 399 |
+
event,
|
| 400 |
+
f"β <b>Telegram refused the edit</b>\n{esc(explain_rpc(e))}\n\n"
|
| 401 |
+
"So /replace can NOT change the buttons of this post with this bot.",
|
| 402 |
+
)
|
| 403 |
+
return
|
| 404 |
+
restored, last_err = False, None
|
| 405 |
+
for _ in range(3):
|
| 406 |
+
try:
|
| 407 |
+
await edit_raw(client, peer, job.mid, markup=original)
|
| 408 |
+
restored = True
|
| 409 |
+
break
|
| 410 |
+
except errors.MessageNotModifiedError:
|
| 411 |
+
restored = True
|
| 412 |
+
break
|
| 413 |
+
except errors.RPCError as e:
|
| 414 |
+
last_err = e
|
| 415 |
+
await asyncio.sleep(1.5)
|
| 416 |
+
if restored:
|
| 417 |
+
await show(
|
| 418 |
+
event,
|
| 419 |
+
"β
<b>Test passed.</b> The bot can edit the buttons of that post, and the link was put back.\n"
|
| 420 |
+
"/replace will work on posts like this one.",
|
| 421 |
+
)
|
| 422 |
+
else:
|
| 423 |
+
orig_urls = "\n".join(esc(button_url(b)) for row in original.rows for b in row.buttons if button_url(b))
|
| 424 |
+
await show(
|
| 425 |
+
event,
|
| 426 |
+
f"β οΈ The edit worked, but putting the link back failed ({esc(type(last_err).__name__)}).\n"
|
| 427 |
+
"The first link still ends in x=1 - please fix it by hand. Original links:\n" + orig_urls,
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
@dataclass
|
| 432 |
+
class _TestJob:
|
| 433 |
+
user: int
|
| 434 |
+
ch: object
|
| 435 |
+
mid: int
|
| 436 |
+
created: float = field(default_factory=time.monotonic)
|
app/health.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional tiny HTTP endpoint for hosts that insist on an open port (set PORT)."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
log = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def start_health_server(port: int):
|
| 9 |
+
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
| 10 |
+
try:
|
| 11 |
+
await asyncio.wait_for(reader.read(1024), timeout=5)
|
| 12 |
+
except Exception:
|
| 13 |
+
pass
|
| 14 |
+
try:
|
| 15 |
+
writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
| 16 |
+
await writer.drain()
|
| 17 |
+
finally:
|
| 18 |
+
writer.close()
|
| 19 |
+
|
| 20 |
+
server = await asyncio.start_server(handle, "0.0.0.0", port)
|
| 21 |
+
log.info("health endpoint listening on :%s", port)
|
| 22 |
+
return server
|
app/linkswap.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Swap a username inside t.me links: button links, hyperlinks and typed links in message text.
|
| 2 |
+
|
| 3 |
+
Only the username part of a link changes; everything after it (?start=...) is left untouched.
|
| 4 |
+
Offsets of message entities are UTF-16 based, so text is handled in Telethon's 'surrogate' form.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
import re
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from telethon import types
|
| 14 |
+
from telethon.helpers import add_surrogate, del_surrogate
|
| 15 |
+
|
| 16 |
+
from .tgutil import button_url, is_callback_button, with_url
|
| 17 |
+
|
| 18 |
+
USERNAME_RX = re.compile(r"^[A-Za-z0-9_]{1,32}$")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def normalize_username(s: str) -> Optional[str]:
|
| 22 |
+
s = (s or "").strip()
|
| 23 |
+
m = re.match(r"(?i)^(?:https?://)?(?:t\.me|telegram\.me)/([A-Za-z0-9_]+)/?$", s)
|
| 24 |
+
if m:
|
| 25 |
+
s = m.group(1)
|
| 26 |
+
s = s.lstrip("@")
|
| 27 |
+
return s if USERNAME_RX.match(s) else None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ------------------------------------------------------------------------------- urls
|
| 31 |
+
_HTTP_RX = re.compile(
|
| 32 |
+
r"^(?P<pre>(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me|telegram\.dog)/)(?P<name>[A-Za-z0-9_]+)(?P<rest>[/?#].*)?$",
|
| 33 |
+
re.I | re.S,
|
| 34 |
+
)
|
| 35 |
+
_TG_RX = re.compile(r"^(?P<pre>tg://resolve\?(?:[^#]*&)?domain=)(?P<name>[A-Za-z0-9_]+)(?P<rest>[&#].*)?$", re.I | re.S)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _split(url: str):
|
| 39 |
+
for rx in (_HTTP_RX, _TG_RX):
|
| 40 |
+
m = rx.match(url)
|
| 41 |
+
if m:
|
| 42 |
+
return m
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _is_post_tail(tail: str) -> bool:
|
| 47 |
+
return bool(re.match(r"/\d", tail)) or bool(re.match(r"&post=\d", tail))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def classify_url(url: str, old: str) -> Optional[str]:
|
| 51 |
+
"""'link' (t.me/old?start=..), 'post' (t.me/old/123 - a channel post) or None."""
|
| 52 |
+
m = _split(url or "")
|
| 53 |
+
if not m or m.group("name").lower() != old.lower():
|
| 54 |
+
return None
|
| 55 |
+
return "post" if _is_post_tail(m.group("rest") or "") else "link"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def swap_url(url: str, old: str, new: str) -> str:
|
| 59 |
+
m = _split(url or "")
|
| 60 |
+
if not m or m.group("name").lower() != old.lower():
|
| 61 |
+
return url
|
| 62 |
+
return url[: m.start("name")] + new + url[m.end("name"):]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ------------------------------------------------------------------- typed links in text
|
| 66 |
+
def _typed_regexes(old: str) -> list:
|
| 67 |
+
n = re.escape(old)
|
| 68 |
+
return [
|
| 69 |
+
re.compile(
|
| 70 |
+
r"(?<![\w@./-])(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me|telegram\.dog)/(?P<name>" + n + r")(?![A-Za-z0-9_])",
|
| 71 |
+
re.I,
|
| 72 |
+
),
|
| 73 |
+
re.compile(r"(?<![\w@./-])tg://resolve\?(?:[^\s&#]*&)?domain=(?P<name>" + n + r")(?![A-Za-z0-9_])", re.I),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def find_typed_spans(sur: str, old: str, include_posts: bool) -> tuple:
|
| 78 |
+
spans: list = []
|
| 79 |
+
skipped = 0
|
| 80 |
+
for rx in _typed_regexes(old):
|
| 81 |
+
for m in rx.finditer(sur):
|
| 82 |
+
s, e = m.span("name")
|
| 83 |
+
if _is_post_tail(sur[e:e + 8]) and not include_posts:
|
| 84 |
+
skipped += 1
|
| 85 |
+
continue
|
| 86 |
+
spans.append((s, e))
|
| 87 |
+
spans.sort()
|
| 88 |
+
return spans, skipped
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def apply_span_replacements(sur: str, entities: list, repls: list) -> tuple:
|
| 92 |
+
"""repls: sorted, non-overlapping (start, end, new_text) in UTF-16 units.
|
| 93 |
+
Returns (new_text, new_entities) with every entity offset/length kept consistent."""
|
| 94 |
+
parts: list = []
|
| 95 |
+
last = 0
|
| 96 |
+
for s, e, nt in repls:
|
| 97 |
+
parts.append(sur[last:s])
|
| 98 |
+
parts.append(nt)
|
| 99 |
+
last = e
|
| 100 |
+
parts.append(sur[last:])
|
| 101 |
+
new_text = "".join(parts)
|
| 102 |
+
|
| 103 |
+
def mp(x: int) -> int:
|
| 104 |
+
acc = 0
|
| 105 |
+
for s, e, nt in repls:
|
| 106 |
+
if x >= e:
|
| 107 |
+
acc += len(nt) - (e - s)
|
| 108 |
+
elif x > s:
|
| 109 |
+
return s + acc + min(x - s, len(nt))
|
| 110 |
+
else:
|
| 111 |
+
break
|
| 112 |
+
return x + acc
|
| 113 |
+
|
| 114 |
+
out = []
|
| 115 |
+
for ent in entities:
|
| 116 |
+
cp = copy.copy(ent)
|
| 117 |
+
o = mp(ent.offset)
|
| 118 |
+
end = mp(ent.offset + ent.length)
|
| 119 |
+
cp.offset = o
|
| 120 |
+
cp.length = max(0, end - o)
|
| 121 |
+
out.append(cp)
|
| 122 |
+
return new_text, out
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# --------------------------------------------------------------------- whole message
|
| 126 |
+
@dataclass
|
| 127 |
+
class MsgChange:
|
| 128 |
+
message_id: int
|
| 129 |
+
new_text: str
|
| 130 |
+
new_entities: list
|
| 131 |
+
new_markup: Optional[types.ReplyInlineMarkup]
|
| 132 |
+
text_changed: bool
|
| 133 |
+
entities_changed: bool
|
| 134 |
+
n_buttons: int
|
| 135 |
+
n_links: int
|
| 136 |
+
n_typed: int
|
| 137 |
+
mine: bool
|
| 138 |
+
callbacks: bool
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def compute_message_changes(msg, old: str, new: str, *, include_typed: bool = True, include_posts: bool = False) -> tuple:
|
| 142 |
+
"""Returns (MsgChange or None, number_of_skipped_post_links)."""
|
| 143 |
+
text = msg.message or ""
|
| 144 |
+
ents = list(msg.entities or [])
|
| 145 |
+
skipped = 0
|
| 146 |
+
n_typed = n_links = n_btn = 0
|
| 147 |
+
|
| 148 |
+
# 1) links typed as plain text (this changes the text itself)
|
| 149 |
+
sur = add_surrogate(text)
|
| 150 |
+
new_sur = sur
|
| 151 |
+
new_ents = [copy.copy(e) for e in ents]
|
| 152 |
+
if include_typed and text:
|
| 153 |
+
spans, sk = find_typed_spans(sur, old, include_posts)
|
| 154 |
+
skipped += sk
|
| 155 |
+
if spans:
|
| 156 |
+
new_sur, new_ents = apply_span_replacements(sur, ents, [(s, e, new) for s, e in spans])
|
| 157 |
+
n_typed = len(spans)
|
| 158 |
+
|
| 159 |
+
# 2) hyperlinks (text with a link behind it)
|
| 160 |
+
for e in new_ents:
|
| 161 |
+
if isinstance(e, types.MessageEntityTextUrl):
|
| 162 |
+
kind = classify_url(e.url, old)
|
| 163 |
+
if kind == "link" or (kind == "post" and include_posts):
|
| 164 |
+
e.url = swap_url(e.url, old, new)
|
| 165 |
+
n_links += 1
|
| 166 |
+
elif kind == "post":
|
| 167 |
+
skipped += 1
|
| 168 |
+
|
| 169 |
+
# 3) buttons
|
| 170 |
+
new_markup = None
|
| 171 |
+
has_cb = False
|
| 172 |
+
mk = msg.reply_markup
|
| 173 |
+
if isinstance(mk, types.ReplyInlineMarkup):
|
| 174 |
+
rows = []
|
| 175 |
+
for row in mk.rows:
|
| 176 |
+
btns = []
|
| 177 |
+
for b in row.buttons:
|
| 178 |
+
url = button_url(b)
|
| 179 |
+
if url is not None:
|
| 180 |
+
kind = classify_url(url, old)
|
| 181 |
+
if kind == "link" or (kind == "post" and include_posts):
|
| 182 |
+
btns.append(with_url(b, swap_url(url, old, new)))
|
| 183 |
+
n_btn += 1
|
| 184 |
+
continue
|
| 185 |
+
if kind == "post":
|
| 186 |
+
skipped += 1
|
| 187 |
+
elif is_callback_button(b):
|
| 188 |
+
has_cb = True
|
| 189 |
+
btns.append(b)
|
| 190 |
+
rows.append(types.KeyboardButtonRow(btns))
|
| 191 |
+
if n_btn:
|
| 192 |
+
new_markup = types.ReplyInlineMarkup(rows)
|
| 193 |
+
|
| 194 |
+
if not (n_typed or n_links or n_btn):
|
| 195 |
+
return None, skipped
|
| 196 |
+
return (
|
| 197 |
+
MsgChange(
|
| 198 |
+
message_id=msg.id,
|
| 199 |
+
new_text=del_surrogate(new_sur),
|
| 200 |
+
new_entities=new_ents,
|
| 201 |
+
new_markup=new_markup,
|
| 202 |
+
text_changed=bool(n_typed),
|
| 203 |
+
entities_changed=bool(n_typed or n_links),
|
| 204 |
+
n_buttons=n_btn,
|
| 205 |
+
n_links=n_links,
|
| 206 |
+
n_typed=n_typed,
|
| 207 |
+
mine=bool(getattr(msg, "out", False)),
|
| 208 |
+
callbacks=has_cb,
|
| 209 |
+
),
|
| 210 |
+
skipped,
|
| 211 |
+
)
|
app/replace_engine.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""/replace engine: scan channels message by message, rewrite links, log every change, undo."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any, Awaitable, Callable, Optional
|
| 8 |
+
|
| 9 |
+
from telethon import errors, functions, types
|
| 10 |
+
|
| 11 |
+
from .linkswap import compute_message_changes
|
| 12 |
+
from .tgutil import (
|
| 13 |
+
build_markup,
|
| 14 |
+
de_entities,
|
| 15 |
+
edit_raw,
|
| 16 |
+
flood_retry,
|
| 17 |
+
media_info,
|
| 18 |
+
peer_of,
|
| 19 |
+
ser_entities,
|
| 20 |
+
ser_markup,
|
| 21 |
+
state_of,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
log = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
CHUNK = 100 # Telegram returns at most 100 messages per request
|
| 27 |
+
FALLBACK_EMPTY_BATCHES = 15 # used only when the newest message id can't be determined
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class ScanOptions:
|
| 32 |
+
old: str
|
| 33 |
+
new: str
|
| 34 |
+
include_typed: bool = True
|
| 35 |
+
include_posts: bool = False
|
| 36 |
+
last: Optional[int] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class ChangeInfo:
|
| 41 |
+
message_id: int
|
| 42 |
+
buttons: int = 0
|
| 43 |
+
links: int = 0
|
| 44 |
+
typed: int = 0
|
| 45 |
+
mine: bool = False
|
| 46 |
+
callbacks: bool = False
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class ChannelScan:
|
| 51 |
+
channel: Any
|
| 52 |
+
can_edit_others: bool = True
|
| 53 |
+
top: int = 0
|
| 54 |
+
first: int = 1
|
| 55 |
+
scanned: int = 0
|
| 56 |
+
infos: dict = field(default_factory=dict)
|
| 57 |
+
skipped_post_links: int = 0
|
| 58 |
+
not_editable: int = 0
|
| 59 |
+
error: Optional[str] = None
|
| 60 |
+
rights: Any = None
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def total(self) -> int:
|
| 64 |
+
return len(self.infos)
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def foreign(self) -> int:
|
| 68 |
+
return sum(1 for i in self.infos.values() if not i.mine)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass
|
| 72 |
+
class ApplyResult:
|
| 73 |
+
edited: int = 0
|
| 74 |
+
unchanged: int = 0
|
| 75 |
+
missing: int = 0
|
| 76 |
+
no_perm: int = 0
|
| 77 |
+
failed: list = field(default_factory=list)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class UndoResult:
|
| 82 |
+
restored: int = 0
|
| 83 |
+
failed: list = field(default_factory=list)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def _fetch(client, peer, ids: list) -> list:
|
| 87 |
+
res = await flood_retry(lambda: client.get_messages(peer, ids=ids))
|
| 88 |
+
return list(res)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def get_top_id(client, peer) -> Optional[int]:
|
| 92 |
+
"""Upper bound for the newest message id: the channel's event counter (pts) is never lower."""
|
| 93 |
+
try:
|
| 94 |
+
full = await flood_retry(lambda: client(functions.channels.GetFullChannelRequest(peer)))
|
| 95 |
+
pts = getattr(full.full_chat, "pts", None)
|
| 96 |
+
if pts:
|
| 97 |
+
return int(pts)
|
| 98 |
+
except Exception as e:
|
| 99 |
+
log.info("could not read channel counter: %s", e)
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _skip(m) -> bool:
|
| 104 |
+
return m is None or isinstance(m, (types.MessageEmpty, types.MessageService))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def scan_channel(
|
| 108 |
+
client,
|
| 109 |
+
ch,
|
| 110 |
+
opts: ScanOptions,
|
| 111 |
+
*,
|
| 112 |
+
can_edit_others: bool = True,
|
| 113 |
+
mine_ids: Optional[set] = None,
|
| 114 |
+
progress: Optional[Callable[[ChannelScan], Awaitable[None]]] = None,
|
| 115 |
+
) -> ChannelScan:
|
| 116 |
+
scan = ChannelScan(channel=ch, can_edit_others=can_edit_others)
|
| 117 |
+
peer = peer_of(ch)
|
| 118 |
+
mine_ids = mine_ids or set()
|
| 119 |
+
top = await get_top_id(client, peer)
|
| 120 |
+
first = 1
|
| 121 |
+
if opts.last and top:
|
| 122 |
+
first = max(1, top - opts.last + 1)
|
| 123 |
+
scan.first, scan.top = first, top or 0
|
| 124 |
+
a, empty_run = first, 0
|
| 125 |
+
while True:
|
| 126 |
+
if top is not None and a > top:
|
| 127 |
+
break
|
| 128 |
+
hi = a + CHUNK - 1 if top is None else min(a + CHUNK - 1, top)
|
| 129 |
+
msgs = await _fetch(client, peer, list(range(a, hi + 1)))
|
| 130 |
+
found = False
|
| 131 |
+
for m in msgs:
|
| 132 |
+
if m is None or isinstance(m, types.MessageEmpty):
|
| 133 |
+
continue
|
| 134 |
+
found = True
|
| 135 |
+
if isinstance(m, types.MessageService):
|
| 136 |
+
continue
|
| 137 |
+
change, skipped = compute_message_changes(
|
| 138 |
+
m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 139 |
+
)
|
| 140 |
+
scan.skipped_post_links += skipped
|
| 141 |
+
if not change:
|
| 142 |
+
continue
|
| 143 |
+
mine = change.mine or m.id in mine_ids
|
| 144 |
+
if not mine and not can_edit_others:
|
| 145 |
+
scan.not_editable += 1
|
| 146 |
+
continue
|
| 147 |
+
scan.infos[m.id] = ChangeInfo(
|
| 148 |
+
m.id, change.n_buttons, change.n_links, change.n_typed, mine, change.callbacks
|
| 149 |
+
)
|
| 150 |
+
scan.scanned = hi
|
| 151 |
+
if top is None:
|
| 152 |
+
empty_run = 0 if found else empty_run + 1
|
| 153 |
+
if empty_run >= FALLBACK_EMPTY_BATCHES:
|
| 154 |
+
break
|
| 155 |
+
if progress:
|
| 156 |
+
await progress(scan)
|
| 157 |
+
a = hi + 1
|
| 158 |
+
if top is None:
|
| 159 |
+
scan.top = scan.scanned
|
| 160 |
+
return scan
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def preview_flag(m):
|
| 164 |
+
if isinstance(m.media, types.MessageMediaWebPage):
|
| 165 |
+
return True
|
| 166 |
+
return False if m.media is None else None
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
async def _do_edit(client, peer, m, change, markup, preview) -> None:
|
| 170 |
+
if change.text_changed or change.entities_changed:
|
| 171 |
+
await edit_raw(
|
| 172 |
+
client, peer, m.id, text=change.new_text, entities=change.new_entities, markup=markup, preview=preview
|
| 173 |
+
)
|
| 174 |
+
else: # only button links changed: leave the text untouched
|
| 175 |
+
await edit_raw(client, peer, m.id, markup=markup)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
async def apply_channel(
|
| 179 |
+
client,
|
| 180 |
+
db,
|
| 181 |
+
scan: ChannelScan,
|
| 182 |
+
opts: ScanOptions,
|
| 183 |
+
batch_id: str,
|
| 184 |
+
user_id: int,
|
| 185 |
+
*,
|
| 186 |
+
edit_delay: float = 1.2,
|
| 187 |
+
progress: Optional[Callable[[ApplyResult], Awaitable[None]]] = None,
|
| 188 |
+
) -> ApplyResult:
|
| 189 |
+
ch = scan.channel
|
| 190 |
+
peer = peer_of(ch)
|
| 191 |
+
mine_ids = await db.mine_message_ids(ch.id)
|
| 192 |
+
res = ApplyResult()
|
| 193 |
+
ids = sorted(scan.infos)
|
| 194 |
+
buf: list = []
|
| 195 |
+
|
| 196 |
+
async def flush() -> None:
|
| 197 |
+
nonlocal buf
|
| 198 |
+
if buf:
|
| 199 |
+
batch, buf = buf, []
|
| 200 |
+
await db.record_changes(batch_id, ch.id, batch, user_id)
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
for i in range(0, len(ids), CHUNK):
|
| 204 |
+
chunk = ids[i:i + CHUNK]
|
| 205 |
+
msgs = await _fetch(client, peer, chunk)
|
| 206 |
+
for mid, m in zip(chunk, msgs):
|
| 207 |
+
if _skip(m):
|
| 208 |
+
res.missing += 1
|
| 209 |
+
continue
|
| 210 |
+
# re-check against the live message: it may have been edited since the scan
|
| 211 |
+
change, _ = compute_message_changes(
|
| 212 |
+
m, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 213 |
+
)
|
| 214 |
+
if not change:
|
| 215 |
+
res.unchanged += 1
|
| 216 |
+
continue
|
| 217 |
+
if not (change.mine or m.id in mine_ids) and not scan.can_edit_others:
|
| 218 |
+
res.no_perm += 1
|
| 219 |
+
continue
|
| 220 |
+
preview = preview_flag(m)
|
| 221 |
+
markup = change.new_markup if change.new_markup is not None else m.reply_markup
|
| 222 |
+
old_state = state_of(m.message, m.entities, m.reply_markup, preview)
|
| 223 |
+
try:
|
| 224 |
+
await flood_retry(lambda: _do_edit(client, peer, m, change, markup, preview))
|
| 225 |
+
except errors.MessageNotModifiedError:
|
| 226 |
+
res.unchanged += 1
|
| 227 |
+
continue
|
| 228 |
+
except errors.RPCError as e:
|
| 229 |
+
log.warning("edit of %s/%s failed: %s", ch.id, m.id, e)
|
| 230 |
+
res.failed.append((m.id, type(e).__name__))
|
| 231 |
+
continue
|
| 232 |
+
res.edited += 1
|
| 233 |
+
kind, fid = media_info(m)
|
| 234 |
+
adopt = {
|
| 235 |
+
"text": change.new_text,
|
| 236 |
+
"entities": ser_entities(change.new_entities),
|
| 237 |
+
"buttons": ser_markup(markup),
|
| 238 |
+
"link_preview": bool(preview),
|
| 239 |
+
}
|
| 240 |
+
if kind:
|
| 241 |
+
adopt["media_kind"], adopt["media_file_id"] = kind, fid
|
| 242 |
+
buf.append(
|
| 243 |
+
{
|
| 244 |
+
"message_id": m.id,
|
| 245 |
+
"old_state": old_state,
|
| 246 |
+
"new_state": state_of(change.new_text, change.new_entities, markup, preview),
|
| 247 |
+
"adopt": adopt,
|
| 248 |
+
}
|
| 249 |
+
)
|
| 250 |
+
if len(buf) >= 10:
|
| 251 |
+
await flush()
|
| 252 |
+
if progress:
|
| 253 |
+
await progress(res)
|
| 254 |
+
await asyncio.sleep(edit_delay)
|
| 255 |
+
finally:
|
| 256 |
+
await flush()
|
| 257 |
+
return res
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
async def undo_batch(
|
| 261 |
+
client,
|
| 262 |
+
db,
|
| 263 |
+
batch_id: str,
|
| 264 |
+
*,
|
| 265 |
+
edit_delay: float = 1.2,
|
| 266 |
+
progress: Optional[Callable[[UndoResult], Awaitable[None]]] = None,
|
| 267 |
+
) -> UndoResult:
|
| 268 |
+
"""Put every post touched by a /replace run back exactly as it was."""
|
| 269 |
+
res = UndoResult()
|
| 270 |
+
channels: dict = {}
|
| 271 |
+
done_ids: list = []
|
| 272 |
+
restored: list = []
|
| 273 |
+
for c in await db.batch_changes(batch_id):
|
| 274 |
+
if c.channel_id not in channels:
|
| 275 |
+
channels[c.channel_id] = await db.get_channel(c.channel_id)
|
| 276 |
+
ch = channels[c.channel_id]
|
| 277 |
+
if ch is None:
|
| 278 |
+
res.failed.append((c.message_id, "ChannelRemoved"))
|
| 279 |
+
continue
|
| 280 |
+
old = c.old_state or {}
|
| 281 |
+
try:
|
| 282 |
+
await flood_retry(
|
| 283 |
+
lambda: edit_raw(
|
| 284 |
+
client,
|
| 285 |
+
peer_of(ch),
|
| 286 |
+
c.message_id,
|
| 287 |
+
text=old.get("text", ""),
|
| 288 |
+
entities=de_entities(old.get("entities")),
|
| 289 |
+
markup=build_markup(old.get("buttons")),
|
| 290 |
+
preview=old.get("preview"),
|
| 291 |
+
)
|
| 292 |
+
)
|
| 293 |
+
except errors.MessageNotModifiedError:
|
| 294 |
+
pass
|
| 295 |
+
except errors.RPCError as e:
|
| 296 |
+
log.warning("undo of %s/%s failed: %s", c.channel_id, c.message_id, e)
|
| 297 |
+
res.failed.append((c.message_id, type(e).__name__))
|
| 298 |
+
continue
|
| 299 |
+
res.restored += 1
|
| 300 |
+
done_ids.append(c.id)
|
| 301 |
+
restored.append((c.channel_id, c.message_id, old))
|
| 302 |
+
if progress:
|
| 303 |
+
await progress(res)
|
| 304 |
+
await asyncio.sleep(edit_delay)
|
| 305 |
+
await db.finish_undo(batch_id, done_ids, restored, all_done=not res.failed)
|
| 306 |
+
return res
|
app/suggest.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auto-suggest the next episode batch: 'Download Episodes 01 to 20' -> 'Download Episodes 21 to 40'."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import copy
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from telethon.helpers import add_surrogate, del_surrogate
|
| 10 |
+
|
| 11 |
+
from .linkswap import apply_span_replacements
|
| 12 |
+
from .tgutil import de_entities, ser_entities
|
| 13 |
+
|
| 14 |
+
RANGE_RX = re.compile(r"(?P<a>\d+)(?P<sep>\s*(?:to|[-ββ~&])\s*)(?P<pre>(?:ep?\.?\s*)?)(?P<b>\d+)", re.I)
|
| 15 |
+
SINGLE_RX = re.compile(r"(?<![\w.])(?P<label>episodes?|eps?\.?|e)(?P<gap>\s*[-:#]?\s*)(?P<n>\d+)(?!\d)", re.I)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class Advance:
|
| 20 |
+
new_text: str # the whole string with the numbers moved on
|
| 21 |
+
old_span: str # e.g. "01 to 20"
|
| 22 |
+
new_span: str # e.g. "21 to 40"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def advance_plain(s: str) -> Optional[Advance]:
|
| 26 |
+
"""Move the first episode range (or single episode) in `s` on to the next batch."""
|
| 27 |
+
for m in RANGE_RX.finditer(s or ""):
|
| 28 |
+
a_s, b_s = m.group("a"), m.group("b")
|
| 29 |
+
a, b = int(a_s), int(b_s)
|
| 30 |
+
if a > b or b - a > 5000:
|
| 31 |
+
continue
|
| 32 |
+
step = b - a + 1
|
| 33 |
+
width = max(len(a_s), len(b_s)) if (a_s.startswith("0") or b_s.startswith("0")) else 0
|
| 34 |
+
na, nb = str(b + 1).zfill(width), str(b + step).zfill(width)
|
| 35 |
+
new_span = na + s[m.end("a"):m.start("b")] + nb
|
| 36 |
+
return Advance(
|
| 37 |
+
new_text=s[: m.start("a")] + new_span + s[m.end("b"):],
|
| 38 |
+
old_span=s[m.start("a"):m.end("b")],
|
| 39 |
+
new_span=new_span,
|
| 40 |
+
)
|
| 41 |
+
for m in SINGLE_RX.finditer(s or ""):
|
| 42 |
+
n_s = m.group("n")
|
| 43 |
+
width = len(n_s) if n_s.startswith("0") else 0
|
| 44 |
+
nn = str(int(n_s) + 1).zfill(width)
|
| 45 |
+
return Advance(
|
| 46 |
+
new_text=s[: m.start("n")] + nn + s[m.end("n"):],
|
| 47 |
+
old_span=s[m.start("label"):m.end("n")],
|
| 48 |
+
new_span=s[m.start("label"):m.start("n")] + nn,
|
| 49 |
+
)
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class Suggestion:
|
| 55 |
+
text: str
|
| 56 |
+
entities: list
|
| 57 |
+
buttons: list
|
| 58 |
+
media_kind: Optional[str]
|
| 59 |
+
media_file_id: Optional[str]
|
| 60 |
+
pending: list = field(default_factory=list) # [(row, col)] of buttons that still need a link
|
| 61 |
+
prev_label: str = ""
|
| 62 |
+
new_label: str = ""
|
| 63 |
+
source_post_id: int = 0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def build_suggestion(post) -> Optional[Suggestion]:
|
| 67 |
+
rows: list = []
|
| 68 |
+
pending: list = []
|
| 69 |
+
first: Optional[Advance] = None
|
| 70 |
+
prev_label = ""
|
| 71 |
+
for row in post.buttons or []:
|
| 72 |
+
new_row: list = []
|
| 73 |
+
for b in row:
|
| 74 |
+
if b.get("raw"): # other bots' buttons (e.g. reactions) are not carried over
|
| 75 |
+
continue
|
| 76 |
+
b = dict(b)
|
| 77 |
+
b.pop("s", None)
|
| 78 |
+
adv = advance_plain(b.get("t", "")) if b.get("u") else None
|
| 79 |
+
if adv:
|
| 80 |
+
if first is None:
|
| 81 |
+
first, prev_label = adv, b["t"]
|
| 82 |
+
b["t"] = adv.new_text
|
| 83 |
+
b["u"] = None
|
| 84 |
+
pending.append((len(rows), len(new_row)))
|
| 85 |
+
new_row.append(b)
|
| 86 |
+
if new_row:
|
| 87 |
+
rows.append(new_row)
|
| 88 |
+
if not pending or first is None:
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
text = post.text or ""
|
| 92 |
+
entities = copy.deepcopy(post.entities or [])
|
| 93 |
+
sur = add_surrogate(text)
|
| 94 |
+
repls: list = []
|
| 95 |
+
pos = 0
|
| 96 |
+
while first.old_span:
|
| 97 |
+
i = sur.find(first.old_span, pos)
|
| 98 |
+
if i < 0:
|
| 99 |
+
break
|
| 100 |
+
repls.append((i, i + len(first.old_span), first.new_span))
|
| 101 |
+
pos = i + len(first.old_span)
|
| 102 |
+
if repls:
|
| 103 |
+
new_sur, new_ents = apply_span_replacements(sur, de_entities(entities), repls)
|
| 104 |
+
text, entities = del_surrogate(new_sur), ser_entities(new_ents)
|
| 105 |
+
|
| 106 |
+
return Suggestion(
|
| 107 |
+
text=text,
|
| 108 |
+
entities=entities,
|
| 109 |
+
buttons=rows,
|
| 110 |
+
media_kind=post.media_kind,
|
| 111 |
+
media_file_id=post.media_file_id,
|
| 112 |
+
pending=pending,
|
| 113 |
+
prev_label=prev_label,
|
| 114 |
+
new_label=rows[pending[0][0]][pending[0][1]]["t"],
|
| 115 |
+
source_post_id=post.id,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
async def make_suggestion(db, channel_id: int) -> Optional[Suggestion]:
|
| 120 |
+
for p in await db.latest_sent(channel_id, 30):
|
| 121 |
+
s = build_suggestion(p)
|
| 122 |
+
if s:
|
| 123 |
+
return s
|
| 124 |
+
return None
|
app/tgutil.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers around Telethon types: entity/button (de)serialisation, media info, raw edits, rights."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import base64
|
| 6 |
+
import copy
|
| 7 |
+
import html
|
| 8 |
+
import logging
|
| 9 |
+
from types import SimpleNamespace
|
| 10 |
+
from typing import Any, Awaitable, Callable, Optional
|
| 11 |
+
|
| 12 |
+
from telethon import errors, functions, types
|
| 13 |
+
from telethon.extensions import BinaryReader
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger(__name__)
|
| 16 |
+
esc = html.escape
|
| 17 |
+
|
| 18 |
+
# ----------------------------------------------------------------------------- entities
|
| 19 |
+
_SIMPLE = {
|
| 20 |
+
"bold": types.MessageEntityBold,
|
| 21 |
+
"italic": types.MessageEntityItalic,
|
| 22 |
+
"underline": types.MessageEntityUnderline,
|
| 23 |
+
"strike": types.MessageEntityStrike,
|
| 24 |
+
"code": types.MessageEntityCode,
|
| 25 |
+
}
|
| 26 |
+
if hasattr(types, "MessageEntitySpoiler"):
|
| 27 |
+
_SIMPLE["spoiler"] = types.MessageEntitySpoiler
|
| 28 |
+
|
| 29 |
+
# entities Telegram works out by itself from the text - never stored
|
| 30 |
+
_AUTO = tuple(
|
| 31 |
+
getattr(types, n)
|
| 32 |
+
for n in (
|
| 33 |
+
"MessageEntityUrl",
|
| 34 |
+
"MessageEntityMention",
|
| 35 |
+
"MessageEntityHashtag",
|
| 36 |
+
"MessageEntityCashtag",
|
| 37 |
+
"MessageEntityBotCommand",
|
| 38 |
+
"MessageEntityEmail",
|
| 39 |
+
"MessageEntityPhone",
|
| 40 |
+
"MessageEntityBankCard",
|
| 41 |
+
)
|
| 42 |
+
if hasattr(types, n)
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _b64(obj: Any) -> str:
|
| 47 |
+
return base64.b64encode(bytes(obj)).decode()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _unb64(data: str) -> Any:
|
| 51 |
+
return BinaryReader(base64.b64decode(data)).tgread_object()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def ser_entities(entities) -> list:
|
| 55 |
+
"""Message entities -> JSON-friendly list (UTF-16 offsets, as Telegram uses)."""
|
| 56 |
+
out: list = []
|
| 57 |
+
for e in entities or []:
|
| 58 |
+
if isinstance(e, _AUTO):
|
| 59 |
+
continue
|
| 60 |
+
done = False
|
| 61 |
+
for key, cls in _SIMPLE.items():
|
| 62 |
+
if isinstance(e, cls):
|
| 63 |
+
out.append({"k": key, "o": e.offset, "l": e.length})
|
| 64 |
+
done = True
|
| 65 |
+
break
|
| 66 |
+
if done:
|
| 67 |
+
continue
|
| 68 |
+
if isinstance(e, types.MessageEntityPre):
|
| 69 |
+
out.append({"k": "pre", "o": e.offset, "l": e.length, "g": e.language or ""})
|
| 70 |
+
elif isinstance(e, types.MessageEntityTextUrl):
|
| 71 |
+
out.append({"k": "url", "o": e.offset, "l": e.length, "u": e.url})
|
| 72 |
+
elif isinstance(e, types.MessageEntityBlockquote):
|
| 73 |
+
out.append({"k": "quote", "o": e.offset, "l": e.length, "c": bool(getattr(e, "collapsed", False))})
|
| 74 |
+
elif isinstance(e, types.MessageEntityCustomEmoji):
|
| 75 |
+
out.append({"k": "emoji", "o": e.offset, "l": e.length, "d": e.document_id})
|
| 76 |
+
else:
|
| 77 |
+
try: # anything else is kept byte-for-byte
|
| 78 |
+
out.append({"k": "raw", "b": _b64(e)})
|
| 79 |
+
except Exception: # pragma: no cover
|
| 80 |
+
log.warning("cannot store entity %r", e)
|
| 81 |
+
return out
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def de_entities(data) -> list:
|
| 85 |
+
out: list = []
|
| 86 |
+
for d in data or []:
|
| 87 |
+
k = d.get("k")
|
| 88 |
+
try:
|
| 89 |
+
if k in _SIMPLE:
|
| 90 |
+
out.append(_SIMPLE[k](d["o"], d["l"]))
|
| 91 |
+
elif k == "pre":
|
| 92 |
+
out.append(types.MessageEntityPre(d["o"], d["l"], d.get("g", "")))
|
| 93 |
+
elif k == "url":
|
| 94 |
+
out.append(types.MessageEntityTextUrl(d["o"], d["l"], d["u"]))
|
| 95 |
+
elif k == "quote":
|
| 96 |
+
out.append(types.MessageEntityBlockquote(d["o"], d["l"], True if d.get("c") else None))
|
| 97 |
+
elif k == "emoji":
|
| 98 |
+
out.append(types.MessageEntityCustomEmoji(d["o"], d["l"], d["d"]))
|
| 99 |
+
elif k == "raw":
|
| 100 |
+
out.append(_unb64(d["b"]))
|
| 101 |
+
except Exception: # pragma: no cover
|
| 102 |
+
log.warning("bad stored entity %r", d)
|
| 103 |
+
return out
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ------------------------------------------------------------------------------ buttons
|
| 107 |
+
_LEGACY = hasattr(types, "KeyboardButtonUrl") # Telethon < 1.45 used one class per button kind
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def button_url(b) -> Optional[str]:
|
| 111 |
+
"""The link of a URL button, else None."""
|
| 112 |
+
if _LEGACY:
|
| 113 |
+
return b.url if isinstance(b, types.KeyboardButtonUrl) else None
|
| 114 |
+
t = getattr(b, "type", None)
|
| 115 |
+
return t.url if isinstance(t, types.InlineButtonTypeUrl) else None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def is_callback_button(b) -> bool:
|
| 119 |
+
if _LEGACY:
|
| 120 |
+
return isinstance(b, types.KeyboardButtonCallback)
|
| 121 |
+
return isinstance(getattr(b, "type", None), types.InlineButtonTypeCallback)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def make_url_button(text: str, url: str, style=None):
|
| 125 |
+
if _LEGACY:
|
| 126 |
+
return types.KeyboardButtonUrl(text, url)
|
| 127 |
+
return types.KeyboardButton(text=text, type=types.InlineButtonTypeUrl(url), style=style)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def with_url(b, new_url: str):
|
| 131 |
+
"""Copy of URL button `b` pointing to `new_url`; every other attribute (colour, ...) is kept."""
|
| 132 |
+
nb = copy.copy(b)
|
| 133 |
+
if _LEGACY:
|
| 134 |
+
nb.url = new_url
|
| 135 |
+
else:
|
| 136 |
+
nb.type = copy.copy(b.type)
|
| 137 |
+
nb.type.url = new_url
|
| 138 |
+
return nb
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _style_dump(b) -> Optional[str]:
|
| 142 |
+
st = getattr(b, "style", None)
|
| 143 |
+
if st is None:
|
| 144 |
+
return None
|
| 145 |
+
if not any(getattr(st, f, None) for f in ("bg_primary", "bg_danger", "bg_success", "icon")):
|
| 146 |
+
return None
|
| 147 |
+
try:
|
| 148 |
+
return _b64(st)
|
| 149 |
+
except Exception: # pragma: no cover
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def ser_markup(markup) -> list:
|
| 154 |
+
"""Inline keyboard -> rows of {'t': text, 'u': url} (URL buttons) or {'t', 'raw'} (any other kind)."""
|
| 155 |
+
rows: list = []
|
| 156 |
+
if isinstance(markup, types.ReplyInlineMarkup):
|
| 157 |
+
for row in markup.rows:
|
| 158 |
+
r = []
|
| 159 |
+
for b in row.buttons:
|
| 160 |
+
url = button_url(b)
|
| 161 |
+
if url is not None:
|
| 162 |
+
item = {"t": b.text, "u": url}
|
| 163 |
+
s = _style_dump(b)
|
| 164 |
+
if s:
|
| 165 |
+
item["s"] = s
|
| 166 |
+
r.append(item)
|
| 167 |
+
else:
|
| 168 |
+
try:
|
| 169 |
+
r.append({"t": getattr(b, "text", ""), "raw": _b64(b)})
|
| 170 |
+
except Exception: # pragma: no cover
|
| 171 |
+
log.warning("cannot store button %r", b)
|
| 172 |
+
if r:
|
| 173 |
+
rows.append(r)
|
| 174 |
+
return rows
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def build_markup(rows) -> Optional[types.ReplyInlineMarkup]:
|
| 178 |
+
out = []
|
| 179 |
+
for row in rows or []:
|
| 180 |
+
btns = []
|
| 181 |
+
for b in row:
|
| 182 |
+
if b.get("raw"):
|
| 183 |
+
try:
|
| 184 |
+
btns.append(_unb64(b["raw"]))
|
| 185 |
+
except Exception: # pragma: no cover
|
| 186 |
+
continue
|
| 187 |
+
elif b.get("u"):
|
| 188 |
+
style = None
|
| 189 |
+
if b.get("s"):
|
| 190 |
+
try:
|
| 191 |
+
style = _unb64(b["s"])
|
| 192 |
+
except Exception: # pragma: no cover
|
| 193 |
+
style = None
|
| 194 |
+
btns.append(make_url_button(b["t"], b["u"], style))
|
| 195 |
+
if btns:
|
| 196 |
+
out.append(types.KeyboardButtonRow(btns))
|
| 197 |
+
return types.ReplyInlineMarkup(out) if out else None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def state_of(text, entities, markup, preview) -> dict:
|
| 201 |
+
"""Snapshot used for the undo log."""
|
| 202 |
+
return {
|
| 203 |
+
"text": text or "",
|
| 204 |
+
"entities": ser_entities(entities),
|
| 205 |
+
"buttons": ser_markup(markup),
|
| 206 |
+
"preview": preview, # True/False for text messages, None for media messages
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# --------------------------------------------------------------------------------- media
|
| 211 |
+
def classify_media(m) -> Optional[str]:
|
| 212 |
+
"""photo | video | animation | audio | document, or None (text / unsupported)."""
|
| 213 |
+
media = getattr(m, "media", None)
|
| 214 |
+
if isinstance(media, types.MessageMediaPhoto) and getattr(media, "photo", None):
|
| 215 |
+
return "photo"
|
| 216 |
+
if isinstance(media, types.MessageMediaDocument) and getattr(media, "document", None):
|
| 217 |
+
if m.gif:
|
| 218 |
+
return "animation"
|
| 219 |
+
if m.video:
|
| 220 |
+
return "video"
|
| 221 |
+
if m.audio:
|
| 222 |
+
return "audio"
|
| 223 |
+
if m.voice or m.video_note or m.sticker:
|
| 224 |
+
return None
|
| 225 |
+
return "document"
|
| 226 |
+
return None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# Media is stored as the serialised Telegram media object ("tl:<base64>"). Telethon's Bot-API style
|
| 230 |
+
# file_id helper cannot pack photos with the current Telegram schema, so we don't rely on it.
|
| 231 |
+
_REF_PREFIX = "tl:"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def media_ref(m) -> Optional[str]:
|
| 235 |
+
"""A storable reference to the photo / document attached to message `m` (None if it has none)."""
|
| 236 |
+
media = getattr(m, "media", None)
|
| 237 |
+
if isinstance(media, (types.MessageMediaPhoto, types.MessageMediaDocument)):
|
| 238 |
+
try:
|
| 239 |
+
return _REF_PREFIX + _b64(media)
|
| 240 |
+
except Exception: # pragma: no cover
|
| 241 |
+
log.warning("cannot store media reference", exc_info=True)
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
pack_file_id = media_ref # older name, kept for the callers
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def media_from_ref(ref: str, *, blank_reference: bool = False):
|
| 249 |
+
"""Rebuild the media object; `blank_reference` drops the (possibly expired) file reference."""
|
| 250 |
+
media = _unb64(ref[len(_REF_PREFIX):]) if ref.startswith(_REF_PREFIX) else None
|
| 251 |
+
if media is None:
|
| 252 |
+
raise ValueError("unknown media reference")
|
| 253 |
+
if blank_reference:
|
| 254 |
+
media = copy.copy(media)
|
| 255 |
+
inner = copy.copy(media.photo if isinstance(media, types.MessageMediaPhoto) else media.document)
|
| 256 |
+
inner.file_reference = b""
|
| 257 |
+
if isinstance(media, types.MessageMediaPhoto):
|
| 258 |
+
media.photo = inner
|
| 259 |
+
else:
|
| 260 |
+
media.document = inner
|
| 261 |
+
return media
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
async def with_media(ref: str, call):
|
| 265 |
+
"""call(media): first with the stored file reference, then (if Telegram says it expired) without it."""
|
| 266 |
+
try:
|
| 267 |
+
return await call(media_from_ref(ref))
|
| 268 |
+
except errors.RPCError as e:
|
| 269 |
+
if "FileReference" not in type(e).__name__ and type(e).__name__ != "MediaEmptyError":
|
| 270 |
+
raise
|
| 271 |
+
log.info("stored file reference rejected (%s) - retrying without it", type(e).__name__)
|
| 272 |
+
return await call(media_from_ref(ref, blank_reference=True))
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def media_info(m) -> tuple:
|
| 276 |
+
kind = classify_media(m)
|
| 277 |
+
return kind, (media_ref(m) if kind else None)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def utf16_len(s: str) -> int:
|
| 281 |
+
return len(s.encode("utf-16-le")) // 2
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# --------------------------------------------------------------------------------- misc
|
| 285 |
+
def peer_of(ch) -> types.InputPeerChannel:
|
| 286 |
+
return types.InputPeerChannel(ch.id, ch.access_hash)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def post_link(ch, message_id: int) -> str:
|
| 290 |
+
if ch.username:
|
| 291 |
+
return f"https://t.me/{ch.username}/{message_id}"
|
| 292 |
+
return f"https://t.me/c/{ch.id}/{message_id}"
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def short(s: str, n: int) -> str:
|
| 296 |
+
s = " ".join((s or "").split())
|
| 297 |
+
return s if len(s) <= n else s[: n - 1] + "β¦"
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
async def flood_retry(factory: Callable[[], Awaitable[Any]], tries: int = 4) -> Any:
|
| 301 |
+
for _ in range(tries):
|
| 302 |
+
try:
|
| 303 |
+
return await factory()
|
| 304 |
+
except errors.FloodWaitError as e:
|
| 305 |
+
wait = int(getattr(e, "seconds", 5)) + 1
|
| 306 |
+
log.warning("flood wait %ss", wait)
|
| 307 |
+
await asyncio.sleep(min(wait, 900))
|
| 308 |
+
return await factory()
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
async def edit_raw(client, peer, message_id: int, *, text: Optional[str] = None, entities=None, markup=None, preview=None):
|
| 312 |
+
"""messages.editMessage with every field explicit.
|
| 313 |
+
|
| 314 |
+
`markup=None` together with `text` removes the keyboard (that is how Telegram behaves);
|
| 315 |
+
a markup-only edit (text=None) leaves the text alone.
|
| 316 |
+
"""
|
| 317 |
+
kw: dict = {"peer": peer, "id": message_id}
|
| 318 |
+
if markup is not None:
|
| 319 |
+
kw["reply_markup"] = markup
|
| 320 |
+
if text is not None:
|
| 321 |
+
kw["message"] = text
|
| 322 |
+
kw["entities"] = list(entities or [])
|
| 323 |
+
if preview is not None:
|
| 324 |
+
kw["no_webpage"] = not preview
|
| 325 |
+
return await client(functions.messages.EditMessageRequest(**kw))
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
async def get_rights(client, ch):
|
| 329 |
+
"""The bot's own admin rights in a channel, or None if Telegram won't tell us."""
|
| 330 |
+
try:
|
| 331 |
+
res = await client(
|
| 332 |
+
functions.channels.GetParticipantRequest(channel=peer_of(ch), participant=types.InputPeerSelf())
|
| 333 |
+
)
|
| 334 |
+
except Exception as e:
|
| 335 |
+
log.info("could not read own rights in %s: %s", ch.id, e)
|
| 336 |
+
return None
|
| 337 |
+
p = res.participant
|
| 338 |
+
if isinstance(p, (types.ChannelParticipantCreator, types.ChannelParticipantAdmin)):
|
| 339 |
+
r = getattr(p, "admin_rights", None)
|
| 340 |
+
creator = isinstance(p, types.ChannelParticipantCreator)
|
| 341 |
+
|
| 342 |
+
def has(name: str) -> bool:
|
| 343 |
+
return True if (creator and r is None) else bool(getattr(r, name, False))
|
| 344 |
+
|
| 345 |
+
return SimpleNamespace(
|
| 346 |
+
admin=True, post=has("post_messages"), edit=has("edit_messages"), delete=has("delete_messages")
|
| 347 |
+
)
|
| 348 |
+
return SimpleNamespace(admin=False, post=False, edit=False, delete=False)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
_HINTS = {
|
| 352 |
+
"ChatAdminRequiredError": "The bot needs admin rights in that channel (post / edit / delete messages).",
|
| 353 |
+
"MessageAuthorRequiredError": "Telegram wants the message author. Give the bot the 'Edit messages of others' right.",
|
| 354 |
+
"InlineBotRequiredError": "That post was sent through another bot's inline mode, so only that bot can edit it.",
|
| 355 |
+
"MessageEditTimeExpiredError": "Telegram no longer allows editing that message.",
|
| 356 |
+
"MessageIdInvalidError": "That message no longer exists in the channel.",
|
| 357 |
+
"ButtonUrlInvalidError": "Telegram rejected one of the button links.",
|
| 358 |
+
"ReplyMarkupInvalidError": "Telegram rejected the buttons.",
|
| 359 |
+
"ChatWriteForbiddenError": "The bot can't post in that channel - check its admin rights.",
|
| 360 |
+
"ChannelPrivateError": "The bot can't access that channel any more (removed or banned?).",
|
| 361 |
+
"UserBannedInChannelError": "The bot is banned in that channel.",
|
| 362 |
+
"FileReferenceExpiredError": "The saved media expired - send the media again.",
|
| 363 |
+
"MediaEmptyError": "Telegram could not use that media - send it again.",
|
| 364 |
+
"EntityBoundsInvalidError": "The text formatting doesn't fit the text.",
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def explain_rpc(e: BaseException) -> str:
|
| 369 |
+
name = type(e).__name__
|
| 370 |
+
return f"Telegram refused ({name}). " + _HINTS.get(name, str(e)[:200])
|
app/ui.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Texts and inline keyboards for the post panel."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from telethon import Button
|
| 5 |
+
|
| 6 |
+
from .buttons import count, grid_label, missing_links
|
| 7 |
+
from .tgutil import esc, post_link, short
|
| 8 |
+
|
| 9 |
+
CANCEL = [[Button.inline("βοΈ Cancel", "cx")]]
|
| 10 |
+
KIND_ICON = {None: "π", "photo": "πΌ", "video": "π¬", "animation": "π", "audio": "π΅", "document": "π"}
|
| 11 |
+
KIND_NAME = {None: "text", "photo": "photo", "video": "video", "animation": "GIF", "audio": "audio", "document": "file"}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def post_title(p) -> str:
|
| 15 |
+
for row in p.buttons or []:
|
| 16 |
+
for b in row:
|
| 17 |
+
if b.get("t"):
|
| 18 |
+
return b["t"]
|
| 19 |
+
text = (p.text or "").strip()
|
| 20 |
+
return text.splitlines()[0] if text else (KIND_NAME.get(p.media_kind) or "post")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def post_label(p) -> str:
|
| 24 |
+
icon = "π" if p.status == "draft" else "β
"
|
| 25 |
+
return f"{icon} #{p.id} {short(post_title(p), 34)}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def panel_text(p, ch, note=None) -> str:
|
| 29 |
+
state = "draft (not published yet)" if p.status == "draft" else "published"
|
| 30 |
+
lines = [f"<b>Post #{p.id}</b> Β· {state}", f"Channel: <b>{esc(ch.title)}</b>"]
|
| 31 |
+
if p.status == "sent" and p.message_id:
|
| 32 |
+
lines.append(f'<a href="{post_link(ch, p.message_id)}">Open in the channel</a>')
|
| 33 |
+
lines.append(f"Type: {KIND_ICON.get(p.media_kind, 'π')} {KIND_NAME.get(p.media_kind, p.media_kind)}")
|
| 34 |
+
n, miss = count(p.buttons), missing_links(p.buttons)
|
| 35 |
+
lines.append(f"Buttons: {n}" + (f" (β οΈ {miss} without a link)" if miss else ""))
|
| 36 |
+
if p.source == "adopted":
|
| 37 |
+
lines.append("<i>Picked up from the channel by /replace.</i>")
|
| 38 |
+
snippet = short(p.text or "", 220) or "(no text)"
|
| 39 |
+
lines += ["", f"<i>{esc(snippet)}</i>"]
|
| 40 |
+
if note:
|
| 41 |
+
lines += ["", note]
|
| 42 |
+
return "\n".join(lines)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def panel_keyboard(p) -> list:
|
| 46 |
+
pid = p.id
|
| 47 |
+
kb = []
|
| 48 |
+
for r, row in enumerate(p.buttons or []):
|
| 49 |
+
kb.append([Button.inline(grid_label(b), f"bs:{pid}:{r}:{c}") for c, b in enumerate(row)])
|
| 50 |
+
kb.append(
|
| 51 |
+
[
|
| 52 |
+
Button.inline("βοΈ Caption" if p.media_kind else "βοΈ Text", f"et:{pid}"),
|
| 53 |
+
Button.inline("πΌ Media", f"em:{pid}"),
|
| 54 |
+
]
|
| 55 |
+
)
|
| 56 |
+
row3 = [Button.inline("β Button", f"ba:{pid}"), Button.inline("π Set buttons", f"bp:{pid}")]
|
| 57 |
+
if p.buttons:
|
| 58 |
+
row3.append(Button.inline("π§Ή Clear", f"bc:{pid}"))
|
| 59 |
+
kb.append(row3)
|
| 60 |
+
extra = []
|
| 61 |
+
if not p.media_kind:
|
| 62 |
+
extra.append(Button.inline(f"π Link preview: {'on' if p.link_preview else 'off'}", f"lp:{pid}"))
|
| 63 |
+
if p.media_kind and p.status == "draft":
|
| 64 |
+
extra.append(Button.inline("π« Remove media", f"rm:{pid}"))
|
| 65 |
+
if extra:
|
| 66 |
+
kb.append(extra)
|
| 67 |
+
if p.status == "draft":
|
| 68 |
+
kb.append([Button.inline("π Preview", f"pv:{pid}"), Button.inline("π Publish", f"pb:{pid}")])
|
| 69 |
+
kb.append([Button.inline("π Discard", f"dl:{pid}"), Button.inline("π My posts", "pc:d:0")])
|
| 70 |
+
else:
|
| 71 |
+
kb.append([Button.inline("π Preview", f"pv:{pid}"), Button.inline("π Delete", f"dl:{pid}")])
|
| 72 |
+
kb.append([Button.inline("π My posts", f"pc:{p.channel_id}:0")])
|
| 73 |
+
return kb
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def button_text(p, r: int, c: int) -> str:
|
| 77 |
+
b = p.buttons[r][c]
|
| 78 |
+
if b.get("raw"):
|
| 79 |
+
return (
|
| 80 |
+
f"π <b>Button from another bot</b> (row {r + 1}, position {c + 1})\n"
|
| 81 |
+
f"Text: <code>{esc(b.get('t') or '')}</code>\n"
|
| 82 |
+
"Buttons like reactions only work with the bot that made them, so you can move or delete it, "
|
| 83 |
+
"but not edit it."
|
| 84 |
+
)
|
| 85 |
+
return (
|
| 86 |
+
f"π <b>Button</b> (row {r + 1}, position {c + 1})\n"
|
| 87 |
+
f"Text: <code>{esc(b.get('t') or '')}</code>\n"
|
| 88 |
+
f"Link: <code>{esc(b.get('u') or '- none yet -')}</code>"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def button_keyboard(p, r: int, c: int) -> list:
|
| 93 |
+
pid, at = p.id, f"{p.id}:{r}:{c}"
|
| 94 |
+
b = p.buttons[r][c]
|
| 95 |
+
kb = []
|
| 96 |
+
if not b.get("raw"):
|
| 97 |
+
kb.append([Button.inline("βοΈ Change text", f"bt:{at}"), Button.inline("π Change link", f"bu:{at}")])
|
| 98 |
+
kb.append(
|
| 99 |
+
[
|
| 100 |
+
Button.inline("β¬
οΈ", f"bm:{at}:l"),
|
| 101 |
+
Button.inline("β¬οΈ", f"bm:{at}:u"),
|
| 102 |
+
Button.inline("β¬οΈ", f"bm:{at}:d"),
|
| 103 |
+
Button.inline("β‘οΈ", f"bm:{at}:r"),
|
| 104 |
+
]
|
| 105 |
+
)
|
| 106 |
+
kb.append([Button.inline("π Delete", f"bd:{at}"), Button.inline("π Back", f"po:{pid}")])
|
| 107 |
+
return kb
|
assets/rasengan.mp4
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:b01eaeae25d0a50f6052d422aac6c14a6a2d848abe0cd20209b909e3b9f950b6
|
| 3 |
-
size 3470021
|
|
|
|
|
|
|
|
|
|
|
|
bot.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Channel Post Bot - entry point. Run: python bot.py"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
from telethon import TelegramClient, functions, types
|
| 9 |
+
from telethon.sessions import StringSession
|
| 10 |
+
|
| 11 |
+
from app.common import Ctx
|
| 12 |
+
from app.config import Config, load_config
|
| 13 |
+
from app.db import Database
|
| 14 |
+
from app.handlers import basic, channels, create, posts, replace
|
| 15 |
+
from app.health import start_health_server
|
| 16 |
+
|
| 17 |
+
log = logging.getLogger("bot")
|
| 18 |
+
|
| 19 |
+
COMMANDS = [
|
| 20 |
+
("new", "Create a post"),
|
| 21 |
+
("posts", "My posts and drafts"),
|
| 22 |
+
("addchannel", "Register a channel"),
|
| 23 |
+
("channels", "List channels"),
|
| 24 |
+
("replace", "Swap a username in links (owner)"),
|
| 25 |
+
("undo", "Undo the last replace (owner)"),
|
| 26 |
+
("testedit", "Test editing one post (owner)"),
|
| 27 |
+
("export", "Backup as JSON (owner)"),
|
| 28 |
+
("cancel", "Cancel what I'm doing"),
|
| 29 |
+
("help", "Show help"),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def build_client(cfg: Config) -> TelegramClient:
|
| 34 |
+
if cfg.session_string:
|
| 35 |
+
session = StringSession(cfg.session_string)
|
| 36 |
+
else:
|
| 37 |
+
os.makedirs(os.path.dirname(cfg.session_path) or ".", exist_ok=True)
|
| 38 |
+
session = cfg.session_path
|
| 39 |
+
client = TelegramClient(session, cfg.api_id, cfg.api_hash)
|
| 40 |
+
client.parse_mode = "html"
|
| 41 |
+
return client
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def register_all(ctx: Ctx) -> None:
|
| 45 |
+
for module in (basic, channels, create, posts, replace):
|
| 46 |
+
module.register(ctx)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def set_commands(client: TelegramClient) -> None:
|
| 50 |
+
try:
|
| 51 |
+
await client(
|
| 52 |
+
functions.bots.SetBotCommandsRequest(
|
| 53 |
+
scope=types.BotCommandScopeDefault(),
|
| 54 |
+
lang_code="",
|
| 55 |
+
commands=[types.BotCommand(command=c, description=d) for c, d in COMMANDS],
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
except Exception as e: # cosmetic only
|
| 59 |
+
log.warning("could not set the command menu: %s", e)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def main() -> None:
|
| 63 |
+
cfg = load_config()
|
| 64 |
+
logging.basicConfig(level=cfg.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
| 65 |
+
logging.getLogger("telethon").setLevel(logging.WARNING)
|
| 66 |
+
|
| 67 |
+
db = Database(cfg.database_url, cfg.db_pool)
|
| 68 |
+
await db.init()
|
| 69 |
+
client = build_client(cfg)
|
| 70 |
+
ctx = Ctx(cfg=cfg, db=db, client=client)
|
| 71 |
+
register_all(ctx)
|
| 72 |
+
try:
|
| 73 |
+
await client.start(bot_token=cfg.bot_token)
|
| 74 |
+
me = await client.get_me()
|
| 75 |
+
log.info("Logged in as @%s (owners: %s)", me.username, ", ".join(map(str, sorted(cfg.owners))))
|
| 76 |
+
await set_commands(client)
|
| 77 |
+
if cfg.port:
|
| 78 |
+
await start_health_server(cfg.port)
|
| 79 |
+
await client.run_until_disconnected()
|
| 80 |
+
finally:
|
| 81 |
+
await db.close()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
try:
|
| 86 |
+
asyncio.run(main())
|
| 87 |
+
except KeyboardInterrupt:
|
| 88 |
+
pass
|
data/.gitkeep
ADDED
|
File without changes
|
index.html
DELETED
|
@@ -1,190 +0,0 @@
|
|
| 1 |
-
<!DOCTYPE html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8">
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Rasengan</title>
|
| 7 |
-
|
| 8 |
-
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
|
| 9 |
-
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
|
| 10 |
-
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
|
| 11 |
-
|
| 12 |
-
<style>
|
| 13 |
-
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; font-family: system-ui, sans-serif; }
|
| 14 |
-
|
| 15 |
-
.layer {
|
| 16 |
-
position: fixed; inset: 0;
|
| 17 |
-
width: 100vw; height: 100vh;
|
| 18 |
-
object-fit: cover;
|
| 19 |
-
transform: scaleX(-1);
|
| 20 |
-
}
|
| 21 |
-
#cam { z-index: 0; }
|
| 22 |
-
#skeleton { z-index: 2; pointer-events: none; }
|
| 23 |
-
|
| 24 |
-
.vignette {
|
| 25 |
-
position: fixed; inset: 0;
|
| 26 |
-
background: radial-gradient(ellipse at center, transparent 35%, rgba(0,0,0,0.55) 100%);
|
| 27 |
-
pointer-events: none; z-index: 4;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
.rasengan {
|
| 31 |
-
position: fixed; top: 0; left: 0;
|
| 32 |
-
width: 1500px; height: auto;
|
| 33 |
-
transform: translate(-50%, -50%);
|
| 34 |
-
pointer-events: none;
|
| 35 |
-
mix-blend-mode: screen;
|
| 36 |
-
opacity: 0;
|
| 37 |
-
will-change: opacity, left, top;
|
| 38 |
-
z-index: 10;
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
#hint {
|
| 42 |
-
position: fixed; bottom: 16px; left: 50%;
|
| 43 |
-
transform: translateX(-50%);
|
| 44 |
-
color: #cfe9ff; font-size: 14px; letter-spacing: 0.04em;
|
| 45 |
-
background: rgba(0,0,0,0.45);
|
| 46 |
-
padding: 6px 12px; border-radius: 999px;
|
| 47 |
-
z-index: 6; pointer-events: none;
|
| 48 |
-
}
|
| 49 |
-
</style>
|
| 50 |
-
</head>
|
| 51 |
-
<body>
|
| 52 |
-
<video id="cam" class="layer" autoplay playsinline muted></video>
|
| 53 |
-
<canvas id="skeleton" class="layer"></canvas>
|
| 54 |
-
<div class="vignette"></div>
|
| 55 |
-
|
| 56 |
-
<video class="rasengan" data-slot="0" src="assets/rasengan.mp4" muted autoplay loop playsinline preload="auto"></video>
|
| 57 |
-
<video class="rasengan" data-slot="1" src="assets/rasengan.mp4" muted autoplay loop playsinline preload="auto"></video>
|
| 58 |
-
|
| 59 |
-
<script>
|
| 60 |
-
const camVideo = document.getElementById('cam');
|
| 61 |
-
const canvas = document.getElementById('skeleton');
|
| 62 |
-
const pen = canvas.getContext('2d');
|
| 63 |
-
const orbs = Array.from(document.querySelectorAll('.rasengan'));
|
| 64 |
-
|
| 65 |
-
// Two distinct palettes so each hand's skeleton is obviously different,
|
| 66 |
-
// and bones vs joints inside one hand are also different colors.
|
| 67 |
-
const PALETTES = {
|
| 68 |
-
Left: { bone: '#ff8a3d', joint: '#fff36a', glow: '#ff5e1a' }, // warm β Naruto orange
|
| 69 |
-
Right: { bone: '#62f0ff', joint: '#ff66e0', glow: '#1aa6ff' } // cool β chakra blue
|
| 70 |
-
};
|
| 71 |
-
|
| 72 |
-
// Per-hand fade-in / fade-out state for the orbs.
|
| 73 |
-
const orbState = [
|
| 74 |
-
{ charge: 0, wasOpen: false },
|
| 75 |
-
{ charge: 0, wasOpen: false }
|
| 76 |
-
];
|
| 77 |
-
|
| 78 |
-
const FINGER_TIPS = [8, 12, 16, 20];
|
| 79 |
-
const FINGER_KNUCKLES = [6, 10, 14, 18];
|
| 80 |
-
|
| 81 |
-
function isPalmOpen(lm) {
|
| 82 |
-
const wrist = lm[0];
|
| 83 |
-
let extended = 0;
|
| 84 |
-
for (let i = 0; i < FINGER_TIPS.length; i++) {
|
| 85 |
-
const tip = lm[FINGER_TIPS[i]];
|
| 86 |
-
const knk = lm[FINGER_KNUCKLES[i]];
|
| 87 |
-
const dTip = Math.hypot(tip.x - wrist.x, tip.y - wrist.y);
|
| 88 |
-
const dKnk = Math.hypot(knk.x - wrist.x, knk.y - wrist.y);
|
| 89 |
-
if (dTip > dKnk) extended++;
|
| 90 |
-
}
|
| 91 |
-
return extended >= 3;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
// Draw bones in one color, joints in another, with a colored glow,
|
| 95 |
-
// so the tracking sticks visibly follow each finger.
|
| 96 |
-
function paintSkeleton(lm, colors) {
|
| 97 |
-
pen.save();
|
| 98 |
-
pen.shadowBlur = 18;
|
| 99 |
-
pen.shadowColor = colors.glow;
|
| 100 |
-
drawConnectors(pen, lm, HAND_CONNECTIONS, { color: colors.bone, lineWidth: 3 });
|
| 101 |
-
pen.restore();
|
| 102 |
-
|
| 103 |
-
pen.save();
|
| 104 |
-
pen.shadowBlur = 10;
|
| 105 |
-
pen.shadowColor = colors.glow;
|
| 106 |
-
pen.fillStyle = colors.joint;
|
| 107 |
-
for (const p of lm) {
|
| 108 |
-
pen.beginPath();
|
| 109 |
-
pen.arc(p.x * canvas.width, p.y * canvas.height, 3.5, 0, Math.PI * 2);
|
| 110 |
-
pen.fill();
|
| 111 |
-
}
|
| 112 |
-
pen.restore();
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
function placeOrb(slot, lm, charge) {
|
| 116 |
-
const wrist = lm[0];
|
| 117 |
-
const midKnk = lm[9];
|
| 118 |
-
|
| 119 |
-
const cx = (wrist.x + midKnk.x) / 2;
|
| 120 |
-
const cy = (wrist.y + midKnk.y) / 2;
|
| 121 |
-
const handSize = Math.hypot(midKnk.x - wrist.x, midKnk.y - wrist.y);
|
| 122 |
-
const lift = handSize * 1.8;
|
| 123 |
-
|
| 124 |
-
const orb = orbs[slot];
|
| 125 |
-
orb.style.left = `${(1 - cx) * window.innerWidth}px`;
|
| 126 |
-
orb.style.top = `${(cy - lift) * window.innerHeight}px`;
|
| 127 |
-
orb.style.opacity = charge.toFixed(3);
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
function onResults(res) {
|
| 131 |
-
canvas.width = camVideo.videoWidth || canvas.clientWidth;
|
| 132 |
-
canvas.height = camVideo.videoHeight || canvas.clientHeight;
|
| 133 |
-
pen.clearRect(0, 0, canvas.width, canvas.height);
|
| 134 |
-
|
| 135 |
-
const seen = [false, false];
|
| 136 |
-
|
| 137 |
-
if (res.multiHandLandmarks && res.multiHandedness) {
|
| 138 |
-
res.multiHandLandmarks.forEach((lm, i) => {
|
| 139 |
-
const label = res.multiHandedness[i].label; // "Left" | "Right"
|
| 140 |
-
const slot = label === 'Right' ? 1 : 0;
|
| 141 |
-
const colors = PALETTES[label] || PALETTES.Left;
|
| 142 |
-
seen[slot] = true;
|
| 143 |
-
|
| 144 |
-
paintSkeleton(lm, colors);
|
| 145 |
-
|
| 146 |
-
const open = isPalmOpen(lm);
|
| 147 |
-
const st = orbState[slot];
|
| 148 |
-
st.charge = Math.max(0, Math.min(1, st.charge + (open ? 0.06 : -0.18)));
|
| 149 |
-
|
| 150 |
-
if (open && !st.wasOpen) {
|
| 151 |
-
const v = orbs[slot];
|
| 152 |
-
v.currentTime = 0;
|
| 153 |
-
v.play().catch(err => console.warn('rasengan play failed', err));
|
| 154 |
-
}
|
| 155 |
-
st.wasOpen = open;
|
| 156 |
-
|
| 157 |
-
if (st.charge > 0.01) placeOrb(slot, lm, st.charge);
|
| 158 |
-
else orbs[slot].style.opacity = 0;
|
| 159 |
-
});
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
// Decay any hand we didn't see this frame.
|
| 163 |
-
for (let i = 0; i < 2; i++) {
|
| 164 |
-
if (seen[i]) continue;
|
| 165 |
-
orbState[i].charge = Math.max(0, orbState[i].charge - 0.18);
|
| 166 |
-
orbState[i].wasOpen = false;
|
| 167 |
-
orbs[i].style.opacity = orbState[i].charge.toFixed(3);
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
const hands = new Hands({
|
| 172 |
-
locateFile: f => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${f}`
|
| 173 |
-
});
|
| 174 |
-
hands.setOptions({
|
| 175 |
-
maxNumHands: 2,
|
| 176 |
-
modelComplexity: 1,
|
| 177 |
-
minDetectionConfidence: 0.65,
|
| 178 |
-
minTrackingConfidence: 0.65
|
| 179 |
-
});
|
| 180 |
-
hands.onResults(onResults);
|
| 181 |
-
|
| 182 |
-
const camera = new Camera(camVideo, {
|
| 183 |
-
onFrame: async () => { await hands.send({ image: camVideo }); },
|
| 184 |
-
width: 1280,
|
| 185 |
-
height: 720
|
| 186 |
-
});
|
| 187 |
-
camera.start();
|
| 188 |
-
</script>
|
| 189 |
-
</body>
|
| 190 |
-
</html>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
make_session.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prints a SESSION_STRING for hosts that lose local files on restart: python make_session.py"""
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from telethon import TelegramClient
|
| 5 |
+
from telethon.sessions import StringSession
|
| 6 |
+
|
| 7 |
+
from app.config import load_config
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def main() -> None:
|
| 11 |
+
cfg = load_config()
|
| 12 |
+
async with TelegramClient(StringSession(), cfg.api_id, cfg.api_hash) as client:
|
| 13 |
+
await client.start(bot_token=cfg.bot_token)
|
| 14 |
+
print("\nSESSION_STRING=" + client.session.save())
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
asyncio.run(main())
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest>=8
|
| 3 |
+
pyflakes
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
telethon>=1.45,<1.46
|
| 2 |
+
SQLAlchemy[asyncio]>=2.0.30,<2.1
|
| 3 |
+
asyncpg>=0.29
|
| 4 |
+
aiosqlite>=0.20
|
| 5 |
+
python-dotenv>=1.0
|
tests/__init__.py
ADDED
|
File without changes
|
tests/dbutil.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests run on SQLite by default; set TEST_DATABASE_URL to run the very same tests on PostgreSQL."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
from app.db import Base, Database
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def db_url_for(tmp_path, name="t.db"):
|
| 8 |
+
return os.getenv("TEST_DATABASE_URL") or f"sqlite+aiosqlite:///{tmp_path}/{name}"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def fresh_db(url: str) -> Database:
|
| 12 |
+
db = Database(url)
|
| 13 |
+
async with db.engine.begin() as conn:
|
| 14 |
+
await conn.run_sync(Base.metadata.drop_all)
|
| 15 |
+
await db.init()
|
| 16 |
+
return db
|
tests/fakes.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny in-memory stand-ins for Telethon, good enough to exercise the engine offline."""
|
| 2 |
+
import datetime
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
from telethon import errors, functions, types
|
| 6 |
+
from telethon.tl.custom import Message
|
| 7 |
+
|
| 8 |
+
from app.tgutil import make_url_button
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def make_msg(i, text="", entities=None, markup=None, out=False, media=None):
|
| 12 |
+
return Message(
|
| 13 |
+
id=i,
|
| 14 |
+
peer_id=types.PeerChannel(1),
|
| 15 |
+
date=datetime.datetime.now(datetime.timezone.utc),
|
| 16 |
+
message=text,
|
| 17 |
+
out=out,
|
| 18 |
+
entities=entities,
|
| 19 |
+
reply_markup=markup,
|
| 20 |
+
media=media,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def url_btn(text, url):
|
| 25 |
+
return make_url_button(text, url)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def cb_btn(text, data):
|
| 29 |
+
return types.KeyboardButton(text=text, type=types.InlineButtonTypeCallback(data))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def markup(*rows):
|
| 33 |
+
return types.ReplyInlineMarkup([types.KeyboardButtonRow(list(r)) for r in rows])
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class FakeClient:
|
| 37 |
+
def __init__(self, msgs, pts=None):
|
| 38 |
+
self.msgs = dict(msgs)
|
| 39 |
+
self.pts = pts
|
| 40 |
+
self.edits = []
|
| 41 |
+
self.fail = {} # message id -> exception to raise on edit
|
| 42 |
+
|
| 43 |
+
async def __call__(self, req):
|
| 44 |
+
if isinstance(req, functions.channels.GetFullChannelRequest):
|
| 45 |
+
return SimpleNamespace(full_chat=SimpleNamespace(pts=self.pts))
|
| 46 |
+
if isinstance(req, functions.messages.EditMessageRequest):
|
| 47 |
+
if req.id in self.fail:
|
| 48 |
+
raise self.fail[req.id]
|
| 49 |
+
m = self.msgs[req.id]
|
| 50 |
+
self.edits.append(req)
|
| 51 |
+
if req.message is not None:
|
| 52 |
+
m.message = req.message
|
| 53 |
+
m.entities = list(req.entities or [])
|
| 54 |
+
m.reply_markup = req.reply_markup # like Telegram: no markup sent -> keyboard removed
|
| 55 |
+
elif req.reply_markup is not None:
|
| 56 |
+
m.reply_markup = req.reply_markup
|
| 57 |
+
return None
|
| 58 |
+
raise NotImplementedError(type(req))
|
| 59 |
+
|
| 60 |
+
async def get_messages(self, peer, ids=None):
|
| 61 |
+
return [self.msgs.get(i) for i in ids]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def rpc(name):
|
| 65 |
+
return getattr(errors, name)(request=None)
|
tests/harness.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Drive the real handlers with fake events and a fake Telegram client (no network)."""
|
| 2 |
+
import datetime
|
| 3 |
+
import itertools
|
| 4 |
+
import re
|
| 5 |
+
from types import SimpleNamespace
|
| 6 |
+
|
| 7 |
+
from telethon import events, functions, types
|
| 8 |
+
|
| 9 |
+
from app.common import Ctx
|
| 10 |
+
from app.config import Config
|
| 11 |
+
from app.db import Database
|
| 12 |
+
|
| 13 |
+
from bot import register_all
|
| 14 |
+
|
| 15 |
+
from .dbutil import db_url_for
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FakeSent:
|
| 19 |
+
"""A message the bot sent to the user (so `.edit()` works on it)."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, out, text, buttons):
|
| 22 |
+
self.out, self.text, self.buttons = out, text, buttons
|
| 23 |
+
|
| 24 |
+
async def edit(self, text=None, buttons=None, **kw):
|
| 25 |
+
if text is not None:
|
| 26 |
+
self.text = text
|
| 27 |
+
self.buttons = buttons
|
| 28 |
+
self.out.log.append(("edit", self.text, buttons))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FakeEvent:
|
| 32 |
+
def __init__(self, uid, out, text="", data=None, message=None):
|
| 33 |
+
self.sender_id, self.out = uid, out
|
| 34 |
+
self.is_private = True
|
| 35 |
+
self.is_channel = False
|
| 36 |
+
self.is_group = False
|
| 37 |
+
self.raw_text = text
|
| 38 |
+
self.text = text
|
| 39 |
+
self.message = message or SimpleNamespace(
|
| 40 |
+
raw_text=text, entities=[], media=None, grouped_id=None, forward=None
|
| 41 |
+
)
|
| 42 |
+
self.pattern_match = None
|
| 43 |
+
self._is_callback = data is not None
|
| 44 |
+
if data is not None:
|
| 45 |
+
self.data = data.encode()
|
| 46 |
+
|
| 47 |
+
async def respond(self, text=None, buttons=None, file=None, **kw):
|
| 48 |
+
self.out.log.append(("say", text, buttons))
|
| 49 |
+
return FakeSent(self.out, text, buttons)
|
| 50 |
+
|
| 51 |
+
async def edit(self, text=None, buttons=None, **kw):
|
| 52 |
+
self.out.log.append(("edit", text, buttons))
|
| 53 |
+
|
| 54 |
+
async def answer(self, *a, **kw):
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
async def get_input_chat(self):
|
| 58 |
+
return types.InputPeerUser(self.sender_id, 0)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class Outbox:
|
| 62 |
+
def __init__(self):
|
| 63 |
+
self.log = []
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def last_text(self):
|
| 67 |
+
return self.log[-1][1] if self.log else None
|
| 68 |
+
|
| 69 |
+
def last_buttons(self):
|
| 70 |
+
b = self.log[-1][2]
|
| 71 |
+
return [[getattr(x, "text", None) for x in row] for row in (b or [])]
|
| 72 |
+
|
| 73 |
+
def callback_data(self, label_part):
|
| 74 |
+
"""Data of the first inline button (in the last message) whose label contains `label_part`."""
|
| 75 |
+
for row in self.log[-1][2] or []:
|
| 76 |
+
for btn in row:
|
| 77 |
+
if label_part in btn.text:
|
| 78 |
+
t = btn.type if hasattr(btn, "type") else btn
|
| 79 |
+
return t.data.decode()
|
| 80 |
+
raise AssertionError(f"no button {label_part!r} in {self.last_buttons()}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class FakeTG:
|
| 84 |
+
def __init__(self):
|
| 85 |
+
self.handlers = []
|
| 86 |
+
self.sent = []
|
| 87 |
+
self.edits = []
|
| 88 |
+
self.deleted = []
|
| 89 |
+
self.ids = itertools.count(100)
|
| 90 |
+
self.parse_mode = "html"
|
| 91 |
+
|
| 92 |
+
def on(self, builder):
|
| 93 |
+
def deco(fn):
|
| 94 |
+
self.handlers.append((builder, fn))
|
| 95 |
+
return fn
|
| 96 |
+
|
| 97 |
+
return deco
|
| 98 |
+
|
| 99 |
+
async def send_message(self, peer, message="", **kw):
|
| 100 |
+
m = SimpleNamespace(id=next(self.ids))
|
| 101 |
+
self.sent.append(("text", peer, message, kw))
|
| 102 |
+
return m
|
| 103 |
+
|
| 104 |
+
async def send_file(self, peer, file, **kw):
|
| 105 |
+
m = SimpleNamespace(id=next(self.ids))
|
| 106 |
+
self.sent.append(("file", peer, file, kw))
|
| 107 |
+
return m
|
| 108 |
+
|
| 109 |
+
async def edit_message(self, peer, message, **kw):
|
| 110 |
+
self.edits.append(("edit_message", message, kw))
|
| 111 |
+
|
| 112 |
+
async def delete_messages(self, peer, ids, **kw):
|
| 113 |
+
self.deleted.append(list(ids))
|
| 114 |
+
return [SimpleNamespace(pts_count=len(ids))]
|
| 115 |
+
|
| 116 |
+
async def __call__(self, req):
|
| 117 |
+
if isinstance(req, functions.messages.EditMessageRequest):
|
| 118 |
+
self.edits.append(("raw", req.id, req))
|
| 119 |
+
return None
|
| 120 |
+
if isinstance(req, functions.channels.GetParticipantRequest):
|
| 121 |
+
rights = types.ChatAdminRights(post_messages=True, edit_messages=True, delete_messages=True)
|
| 122 |
+
return SimpleNamespace(participant=types.ChannelParticipantAdmin(
|
| 123 |
+
user_id=1, promoted_by=1, date=datetime.datetime.now(datetime.timezone.utc), admin_rights=rights))
|
| 124 |
+
raise NotImplementedError(type(req))
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class App:
|
| 128 |
+
def __init__(self, tmp_path, owner=1):
|
| 129 |
+
cfg = Config(api_id=1, api_hash="x", bot_token="1:x", owners=frozenset({owner}), admins=frozenset(),
|
| 130 |
+
database_url=db_url_for(tmp_path, "h.db"), db_pool="null", session_path="x", session_string="",
|
| 131 |
+
edit_delay=0.3, replace_typed_links=True, port=0, log_level="INFO")
|
| 132 |
+
self.tg = FakeTG()
|
| 133 |
+
self.db = Database(cfg.database_url)
|
| 134 |
+
self.ctx = Ctx(cfg=cfg, db=self.db, client=self.tg)
|
| 135 |
+
self.uid = owner
|
| 136 |
+
self.out = Outbox()
|
| 137 |
+
|
| 138 |
+
async def start(self):
|
| 139 |
+
from app.db import Base
|
| 140 |
+
|
| 141 |
+
async with self.db.engine.begin() as conn:
|
| 142 |
+
await conn.run_sync(Base.metadata.drop_all)
|
| 143 |
+
await self.db.init()
|
| 144 |
+
register_all(self.ctx)
|
| 145 |
+
|
| 146 |
+
async def text(self, s, message=None):
|
| 147 |
+
ev = FakeEvent(self.uid, self.out, s, message=message)
|
| 148 |
+
for builder, cb in self.tg.handlers:
|
| 149 |
+
if not isinstance(builder, events.NewMessage):
|
| 150 |
+
continue
|
| 151 |
+
if builder.pattern:
|
| 152 |
+
m = builder.pattern(s)
|
| 153 |
+
if not m:
|
| 154 |
+
continue
|
| 155 |
+
ev.pattern_match = m
|
| 156 |
+
if builder.func and not builder.func(ev):
|
| 157 |
+
continue
|
| 158 |
+
await cb(ev)
|
| 159 |
+
return ev
|
| 160 |
+
|
| 161 |
+
async def press(self, data):
|
| 162 |
+
ev = FakeEvent(self.uid, self.out, data=data)
|
| 163 |
+
for builder, cb in self.tg.handlers:
|
| 164 |
+
if isinstance(builder, events.CallbackQuery):
|
| 165 |
+
await cb(ev)
|
| 166 |
+
return ev
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def norm(s):
|
| 170 |
+
import html
|
| 171 |
+
|
| 172 |
+
return html.unescape(re.sub(r"<[^>]+>", "", s or ""))
|
tests/test_engine.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from telethon import types
|
| 3 |
+
|
| 4 |
+
from app.linkswap import compute_message_changes
|
| 5 |
+
from app.replace_engine import ScanOptions, apply_channel, scan_channel, undo_batch
|
| 6 |
+
from app.tgutil import button_url, is_callback_button
|
| 7 |
+
|
| 8 |
+
from .dbutil import fresh_db, db_url_for
|
| 9 |
+
from .fakes import FakeClient, cb_btn, make_msg, markup, rpc, url_btn
|
| 10 |
+
|
| 11 |
+
OLD = "https://t.me/bharath?start=BBB"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build():
|
| 15 |
+
text3 = "Hi π https://t.me/bharath?start=CCC done"
|
| 16 |
+
msgs = {
|
| 17 |
+
1: make_msg(1, "Get it now", [types.MessageEntityTextUrl(0, 3, "https://t.me/Bharath?start=AAA")]),
|
| 18 |
+
2: make_msg(
|
| 19 |
+
2,
|
| 20 |
+
"Episodes",
|
| 21 |
+
markup=markup([url_btn("Ep 1-20", OLD), cb_btn("π 3", b"r1")], [url_btn("Join", "https://t.me/other")]),
|
| 22 |
+
out=True,
|
| 23 |
+
),
|
| 24 |
+
3: make_msg(3, text3, [types.MessageEntityBold(37, 4)]),
|
| 25 |
+
4: make_msg(4, "see https://t.me/bharath_bot?start=1"),
|
| 26 |
+
5: make_msg(5, "https://t.me/bharath/123"),
|
| 27 |
+
7: types.MessageEmpty(id=7, peer_id=types.PeerChannel(1)),
|
| 28 |
+
}
|
| 29 |
+
return msgs, text3
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def setup(tmp_path, msgs, pts=8):
|
| 33 |
+
db = await fresh_db(db_url_for(tmp_path))
|
| 34 |
+
await db.save_channel(1, 5, "Test", None, 99)
|
| 35 |
+
ch = await db.get_channel(1)
|
| 36 |
+
return db, ch, FakeClient(msgs, pts=pts)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_scan_apply_undo(tmp_path):
|
| 40 |
+
async def run():
|
| 41 |
+
msgs, text3 = build()
|
| 42 |
+
db, ch, client = await setup(tmp_path, msgs)
|
| 43 |
+
opts = ScanOptions("bharath", "goku")
|
| 44 |
+
scan = await scan_channel(client, ch, opts, can_edit_others=True)
|
| 45 |
+
assert set(scan.infos) == {1, 2, 3}
|
| 46 |
+
assert scan.skipped_post_links == 1 # t.me/bharath/123 is a channel post, not a bot link
|
| 47 |
+
assert scan.foreign == 2 and scan.infos[2].mine and scan.infos[2].callbacks
|
| 48 |
+
|
| 49 |
+
await db.create_batch("b1", "bharath", "goku", 99)
|
| 50 |
+
res = await apply_channel(client, db, scan, opts, "b1", 99, edit_delay=0)
|
| 51 |
+
assert (res.edited, res.failed) == (3, [])
|
| 52 |
+
|
| 53 |
+
# hyperlink: only the username changed
|
| 54 |
+
assert msgs[1].entities[0].url == "https://t.me/goku?start=AAA"
|
| 55 |
+
# buttons: URL swapped, reaction button + other link untouched, text untouched
|
| 56 |
+
rows = msgs[2].reply_markup.rows
|
| 57 |
+
assert button_url(rows[0].buttons[0]) == "https://t.me/goku?start=BBB"
|
| 58 |
+
assert is_callback_button(rows[0].buttons[1])
|
| 59 |
+
assert button_url(rows[1].buttons[0]) == "https://t.me/other"
|
| 60 |
+
assert msgs[2].message == "Episodes"
|
| 61 |
+
# typed link after an emoji: text swapped and the following entity moved with it
|
| 62 |
+
assert msgs[3].message == "Hi π https://t.me/goku?start=CCC done"
|
| 63 |
+
assert (msgs[3].entities[0].offset, msgs[3].entities[0].length) == (34, 4)
|
| 64 |
+
# untouched
|
| 65 |
+
assert msgs[4].message == "see https://t.me/bharath_bot?start=1"
|
| 66 |
+
assert msgs[5].message == "https://t.me/bharath/123"
|
| 67 |
+
|
| 68 |
+
# adopted into the database
|
| 69 |
+
p = await db.find_by_message(1, 3)
|
| 70 |
+
assert p.source == "adopted" and "goku" in p.text
|
| 71 |
+
assert (await db.find_by_message(1, 2)).buttons[0][0]["u"].endswith("goku?start=BBB")
|
| 72 |
+
|
| 73 |
+
# undo restores everything
|
| 74 |
+
ures = await undo_batch(client, db, "b1", edit_delay=0)
|
| 75 |
+
assert ures.restored == 3 and not ures.failed
|
| 76 |
+
assert msgs[1].entities[0].url == "https://t.me/Bharath?start=AAA"
|
| 77 |
+
assert msgs[3].message == text3 and msgs[3].entities[0].offset == 37
|
| 78 |
+
assert button_url(msgs[2].reply_markup.rows[0].buttons[0]) == OLD
|
| 79 |
+
assert is_callback_button(msgs[2].reply_markup.rows[0].buttons[1])
|
| 80 |
+
assert (await db.last_batch()) is None # batch marked as undone
|
| 81 |
+
await db.close()
|
| 82 |
+
|
| 83 |
+
asyncio.run(run())
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_only_own_posts_without_edit_right(tmp_path):
|
| 87 |
+
async def run():
|
| 88 |
+
msgs, _ = build()
|
| 89 |
+
db, ch, client = await setup(tmp_path, msgs)
|
| 90 |
+
opts = ScanOptions("bharath", "goku")
|
| 91 |
+
scan = await scan_channel(client, ch, opts, can_edit_others=False)
|
| 92 |
+
assert set(scan.infos) == {2} and scan.not_editable == 2
|
| 93 |
+
await db.create_batch("b2", "bharath", "goku", 99)
|
| 94 |
+
res = await apply_channel(client, db, scan, opts, "b2", 99, edit_delay=0)
|
| 95 |
+
assert res.edited == 1 and len(client.edits) == 1
|
| 96 |
+
await db.close()
|
| 97 |
+
|
| 98 |
+
asyncio.run(run())
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_failures_are_reported_not_fatal(tmp_path):
|
| 102 |
+
async def run():
|
| 103 |
+
msgs, _ = build()
|
| 104 |
+
db, ch, client = await setup(tmp_path, msgs)
|
| 105 |
+
client.fail[1] = rpc("MessageAuthorRequiredError")
|
| 106 |
+
opts = ScanOptions("bharath", "goku")
|
| 107 |
+
scan = await scan_channel(client, ch, opts)
|
| 108 |
+
await db.create_batch("b3", "bharath", "goku", 99)
|
| 109 |
+
res = await apply_channel(client, db, scan, opts, "b3", 99, edit_delay=0)
|
| 110 |
+
assert res.edited == 2 and res.failed == [(1, "MessageAuthorRequiredError")]
|
| 111 |
+
await db.close()
|
| 112 |
+
|
| 113 |
+
asyncio.run(run())
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_scan_without_counter_falls_back_to_probing(tmp_path):
|
| 117 |
+
async def run():
|
| 118 |
+
msgs, _ = build()
|
| 119 |
+
db, ch, client = await setup(tmp_path, msgs, pts=None)
|
| 120 |
+
scan = await scan_channel(client, ch, ScanOptions("bharath", "goku"))
|
| 121 |
+
assert set(scan.infos) == {1, 2, 3}
|
| 122 |
+
await db.close()
|
| 123 |
+
|
| 124 |
+
asyncio.run(run())
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_post_links_can_be_included():
|
| 128 |
+
m = make_msg(5, "https://t.me/bharath/123")
|
| 129 |
+
assert compute_message_changes(m, "bharath", "goku")[0] is None
|
| 130 |
+
ch, skipped = compute_message_changes(m, "bharath", "goku", include_posts=True)
|
| 131 |
+
assert ch.new_text == "https://t.me/goku/123"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_tg_scheme_and_hyperlink_and_text_flags():
|
| 135 |
+
m = make_msg(
|
| 136 |
+
6,
|
| 137 |
+
"open tg://resolve?domain=bharath&start=x",
|
| 138 |
+
[types.MessageEntityTextUrl(0, 4, "tg://resolve?domain=Bharath&start=y")],
|
| 139 |
+
)
|
| 140 |
+
ch, _ = compute_message_changes(m, "bharath", "goku")
|
| 141 |
+
assert ch.new_text == "open tg://resolve?domain=goku&start=x"
|
| 142 |
+
assert ch.new_entities[0].url == "tg://resolve?domain=goku&start=y"
|
| 143 |
+
assert ch.n_typed == 1 and ch.n_links == 1
|
| 144 |
+
|
| 145 |
+
ch2, _ = compute_message_changes(m, "bharath", "goku", include_typed=False)
|
| 146 |
+
assert ch2.new_text == m.message and ch2.n_typed == 0
|
tests/test_flows.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
|
| 3 |
+
from app.tgutil import button_url
|
| 4 |
+
from .harness import App, norm
|
| 5 |
+
|
| 6 |
+
SEEDED = [[{"t": "Download Episodes 01 to 20", "u": "https://t.me/oldbot?start=aaa"}]]
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def make_app(tmp_path):
|
| 10 |
+
app = App(tmp_path)
|
| 11 |
+
await app.start()
|
| 12 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 13 |
+
return app
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def sent_markup(app, i=-1):
|
| 17 |
+
return app.tg.sent[i][3]["buttons"]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_suggestion_flow_end_to_end(tmp_path):
|
| 21 |
+
async def run():
|
| 22 |
+
app = await make_app(tmp_path)
|
| 23 |
+
await app.db.create_post(channel_id=1, message_id=10, status="sent", source="bot", text="Click the button below to download π", entities=[], buttons=SEEDED)
|
| 24 |
+
|
| 25 |
+
await app.text("/new")
|
| 26 |
+
assert "Suggestion" in app.out.last_text and "Download Episodes 21 to 40" in app.out.last_text
|
| 27 |
+
await app.press(app.out.callback_data("Use suggestion"))
|
| 28 |
+
assert "Send the link" in app.out.last_text
|
| 29 |
+
await app.text("https://t.me/newbot?start=zzz")
|
| 30 |
+
assert "Ready" in app.out.last_text and "Publish" in str(app.out.last_buttons())
|
| 31 |
+
|
| 32 |
+
pid = int(app.out.callback_data("Publish").split(":")[1])
|
| 33 |
+
await app.press(f"pb:{pid}")
|
| 34 |
+
assert "Publish this post" in app.out.last_text
|
| 35 |
+
await app.press(f"pby:{pid}")
|
| 36 |
+
assert "Published" in app.out.last_text
|
| 37 |
+
kind, peer, msg, kw = app.tg.sent[-1]
|
| 38 |
+
assert msg == "Click the button below to download π"
|
| 39 |
+
btn = sent_markup(app).rows[0].buttons[0]
|
| 40 |
+
assert btn.text == "Download Episodes 21 to 40" and button_url(btn) == "https://t.me/newbot?start=zzz"
|
| 41 |
+
p = await app.db.get_post(pid)
|
| 42 |
+
assert p.status == "sent" and p.message_id == 100
|
| 43 |
+
|
| 44 |
+
# the next suggestion moves on again
|
| 45 |
+
await app.text("/new")
|
| 46 |
+
assert "Download Episodes 41 to 60" in app.out.last_text
|
| 47 |
+
await app.db.close()
|
| 48 |
+
|
| 49 |
+
asyncio.run(run())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_customize_new_post_with_buttons_and_edit_live(tmp_path):
|
| 53 |
+
async def run():
|
| 54 |
+
app = await make_app(tmp_path)
|
| 55 |
+
await app.text("/new") # nothing to suggest yet -> asks for the post
|
| 56 |
+
assert "Send the post now" in app.out.last_text
|
| 57 |
+
await app.text("Hello *world*") # stored literally (no markdown parsing)
|
| 58 |
+
assert "buttons" in app.out.last_text.lower()
|
| 59 |
+
await app.text("Get it - https://t.me/bot?start=1 | Join - t.me/animech\nMirror - https://example.com/x")
|
| 60 |
+
pid = int(app.out.callback_data("Publish").split(":")[1])
|
| 61 |
+
post = await app.db.get_post(pid)
|
| 62 |
+
assert post.text == "Hello *world*" and [len(r) for r in post.buttons] == [2, 1]
|
| 63 |
+
await app.press(f"pb:{pid}")
|
| 64 |
+
await app.press(f"pby:{pid}")
|
| 65 |
+
assert app.tg.sent[-1][2] == "Hello *world*"
|
| 66 |
+
|
| 67 |
+
# edit the live post: change a button's text, then its link
|
| 68 |
+
await app.press(f"bs:{pid}:0:0")
|
| 69 |
+
assert "Get it" in app.out.last_text
|
| 70 |
+
await app.press(f"bt:{pid}:0:0")
|
| 71 |
+
await app.text("Download now")
|
| 72 |
+
await app.press(f"bu:{pid}:0:0")
|
| 73 |
+
await app.text("https://t.me/goku?start=1")
|
| 74 |
+
last_edit = app.tg.edits[-1][2]
|
| 75 |
+
assert last_edit.message == "Hello *world*"
|
| 76 |
+
b = last_edit.reply_markup.rows[0].buttons[0]
|
| 77 |
+
assert b.text == "Download now" and button_url(b) == "https://t.me/goku?start=1"
|
| 78 |
+
|
| 79 |
+
# reorder: move the first button right, then down into its own row
|
| 80 |
+
await app.press(f"bm:{pid}:0:0:r")
|
| 81 |
+
rows = (await app.db.get_post(pid)).buttons
|
| 82 |
+
assert [b["t"] for b in rows[0]] == ["Join", "Download now"]
|
| 83 |
+
await app.press(f"bm:{pid}:0:1:d")
|
| 84 |
+
rows = (await app.db.get_post(pid)).buttons
|
| 85 |
+
assert [[b["t"] for b in r] for r in rows] == [["Join"], ["Mirror", "Download now"]]
|
| 86 |
+
|
| 87 |
+
# delete a button
|
| 88 |
+
await app.press(f"bd:{pid}:0:0")
|
| 89 |
+
assert [[b["t"] for b in r] for r in (await app.db.get_post(pid)).buttons] == [["Mirror", "Download now"]]
|
| 90 |
+
live = app.tg.edits[-1][2].reply_markup
|
| 91 |
+
assert [b.text for b in live.rows[0].buttons] == ["Mirror", "Download now"]
|
| 92 |
+
|
| 93 |
+
# edit text of the live post keeps the buttons
|
| 94 |
+
await app.press(f"et:{pid}")
|
| 95 |
+
await app.text("New text")
|
| 96 |
+
req = app.tg.edits[-1][2]
|
| 97 |
+
assert req.message == "New text" and len(req.reply_markup.rows[0].buttons) == 2
|
| 98 |
+
|
| 99 |
+
# delete from the channel
|
| 100 |
+
await app.press(f"dly:{pid}:a")
|
| 101 |
+
assert app.tg.deleted == [[100]] and await app.db.get_post(pid) is None
|
| 102 |
+
await app.db.close()
|
| 103 |
+
|
| 104 |
+
asyncio.run(run())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_bad_input_and_permissions(tmp_path):
|
| 108 |
+
async def run():
|
| 109 |
+
app = await make_app(tmp_path)
|
| 110 |
+
await app.text("/new")
|
| 111 |
+
await app.text("A post")
|
| 112 |
+
await app.text("this has no link")
|
| 113 |
+
assert "No link found" in norm(app.out.last_text)
|
| 114 |
+
await app.text("/skip")
|
| 115 |
+
pid = int(app.out.callback_data("Publish").split(":")[1])
|
| 116 |
+
await app.press(f"ba:{pid}")
|
| 117 |
+
await app.text("Only text") # asks for the link next
|
| 118 |
+
assert "send the link" in app.out.last_text.lower()
|
| 119 |
+
await app.text("not a link")
|
| 120 |
+
assert "doesn't look like a link" in norm(app.out.last_text)
|
| 121 |
+
await app.text("https://t.me/bot?start=q")
|
| 122 |
+
assert (await app.db.get_post(pid)).buttons[0][0]["u"] == "https://t.me/bot?start=q"
|
| 123 |
+
|
| 124 |
+
# a non-owner is rejected everywhere
|
| 125 |
+
app.uid = 999
|
| 126 |
+
await app.text("/replace @a @b")
|
| 127 |
+
assert "private" in app.out.last_text.lower()
|
| 128 |
+
await app.db.close()
|
| 129 |
+
|
| 130 |
+
asyncio.run(run())
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_drafts_list_and_discard(tmp_path):
|
| 134 |
+
async def run():
|
| 135 |
+
app = await make_app(tmp_path)
|
| 136 |
+
await app.text("/new")
|
| 137 |
+
await app.text("draft one")
|
| 138 |
+
await app.text("/skip")
|
| 139 |
+
await app.text("/posts")
|
| 140 |
+
await app.press("pc:d:0")
|
| 141 |
+
assert "Drafts" in app.out.last_text and "draft one" in str(app.out.last_buttons())
|
| 142 |
+
pid = int(app.out.callback_data("draft one").split(":")[1])
|
| 143 |
+
await app.press(f"dly:{pid}:b")
|
| 144 |
+
assert await app.db.get_post(pid) is None
|
| 145 |
+
await app.db.close()
|
| 146 |
+
|
| 147 |
+
asyncio.run(run())
|
tests/test_units.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import datetime
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from telethon import TelegramClient, types
|
| 7 |
+
from telethon.sessions import MemorySession
|
| 8 |
+
|
| 9 |
+
from app import buttons as B
|
| 10 |
+
from app.common import UserError
|
| 11 |
+
from app.db import normalize_db_url
|
| 12 |
+
from app.handlers.channels import parse_channel_ref
|
| 13 |
+
from app.handlers.panel import extract_content
|
| 14 |
+
from app.handlers.replace import parse_replace_args
|
| 15 |
+
from app.linkswap import apply_span_replacements, classify_url, normalize_username, swap_url
|
| 16 |
+
from app.suggest import advance_plain, build_suggestion
|
| 17 |
+
from app.tgutil import (
|
| 18 |
+
build_markup,
|
| 19 |
+
button_url,
|
| 20 |
+
classify_media,
|
| 21 |
+
de_entities,
|
| 22 |
+
is_callback_button,
|
| 23 |
+
media_from_ref,
|
| 24 |
+
pack_file_id,
|
| 25 |
+
ser_entities,
|
| 26 |
+
ser_markup,
|
| 27 |
+
utf16_len,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
from .fakes import cb_btn, make_msg, markup, url_btn
|
| 31 |
+
from .harness import App
|
| 32 |
+
|
| 33 |
+
NOW = datetime.datetime.now(datetime.timezone.utc)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def photo_msg(caption="cap", entities=None):
|
| 37 |
+
photo = types.Photo(1, 2, b"ref", NOW, [types.PhotoSize("x", 100, 100, 999)], 2)
|
| 38 |
+
return make_msg(5, caption, entities, media=types.MessageMediaPhoto(photo=photo))
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def video_msg(gif=False):
|
| 42 |
+
attrs = [types.DocumentAttributeVideo(3.0, 100, 100)]
|
| 43 |
+
if gif:
|
| 44 |
+
attrs.append(types.DocumentAttributeAnimated())
|
| 45 |
+
doc = types.Document(9, 8, b"ref", NOW, "video/mp4", 1000, 2, attrs)
|
| 46 |
+
return make_msg(6, "v", media=types.MessageMediaDocument(document=doc))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ------------------------------------------------------------------ entities / markup
|
| 50 |
+
def test_entity_roundtrip_keeps_formatting_and_unknown_types():
|
| 51 |
+
ents = [
|
| 52 |
+
types.MessageEntityBold(0, 3),
|
| 53 |
+
types.MessageEntityTextUrl(4, 2, "https://t.me/x?start=1"),
|
| 54 |
+
types.MessageEntityPre(7, 3, "python"),
|
| 55 |
+
types.MessageEntitySpoiler(11, 2),
|
| 56 |
+
types.MessageEntityUrl(14, 5), # auto entity: not stored
|
| 57 |
+
types.MessageEntityFormattedDate(20, 4, NOW), # unknown to us: kept byte-for-byte
|
| 58 |
+
]
|
| 59 |
+
data = ser_entities(ents)
|
| 60 |
+
assert [d["k"] for d in data] == ["bold", "url", "pre", "spoiler", "raw"]
|
| 61 |
+
back = de_entities(data)
|
| 62 |
+
assert [type(e).__name__ for e in back] == [
|
| 63 |
+
"MessageEntityBold", "MessageEntityTextUrl", "MessageEntityPre", "MessageEntitySpoiler", "MessageEntityFormattedDate"
|
| 64 |
+
]
|
| 65 |
+
assert back[1].url == "https://t.me/x?start=1" and back[2].language == "python"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_markup_roundtrip_keeps_other_bots_buttons_and_colours():
|
| 69 |
+
style = types.KeyboardButtonStyle(bg_success=True)
|
| 70 |
+
coloured = types.KeyboardButton(text="Go", type=types.InlineButtonTypeUrl("https://t.me/x"), style=style)
|
| 71 |
+
mk = markup([coloured, cb_btn("π 3", b"react:1")], [url_btn("Two", "https://t.me/y")])
|
| 72 |
+
rows = ser_markup(mk)
|
| 73 |
+
assert rows[0][0]["u"] == "https://t.me/x" and "s" in rows[0][0]
|
| 74 |
+
assert rows[0][1]["raw"] and rows[1][0]["u"] == "https://t.me/y"
|
| 75 |
+
rebuilt = build_markup(rows)
|
| 76 |
+
b0, b1 = rebuilt.rows[0].buttons
|
| 77 |
+
assert button_url(b0) == "https://t.me/x" and b0.style.bg_success
|
| 78 |
+
assert is_callback_button(b1) and b1.type.data == b"react:1"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_utf16_len_counts_emoji_as_two():
|
| 82 |
+
assert utf16_len("aπ") == 3
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_apply_span_replacements_shifts_entities():
|
| 86 |
+
ents = [types.MessageEntityBold(14, 3), types.MessageEntityItalic(0, 4), types.MessageEntityCode(5, 13)]
|
| 87 |
+
text, out = apply_span_replacements("open t.me/abc now", ents, [(10, 13, "longer")])
|
| 88 |
+
assert text == "open t.me/longer now"
|
| 89 |
+
assert (out[0].offset, out[0].length) == (17, 3) # after the change: moved by +3
|
| 90 |
+
assert (out[1].offset, out[1].length) == (0, 4) # before it: untouched
|
| 91 |
+
assert (out[2].offset, out[2].length) == (5, 16) # spanning it: grows
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# -------------------------------------------------------------------- media handling
|
| 95 |
+
def test_extract_content_photo_video_gif_and_text():
|
| 96 |
+
c = extract_content(photo_msg("hello", [types.MessageEntityBold(0, 5)]))
|
| 97 |
+
assert c["media_kind"] == "photo" and c["text"] == "hello" and c["entities"][0]["k"] == "bold"
|
| 98 |
+
assert c["media_file_id"]
|
| 99 |
+
assert classify_media(video_msg()) == "video" and classify_media(video_msg(gif=True)) == "animation"
|
| 100 |
+
t = extract_content(make_msg(1, "plain"))
|
| 101 |
+
assert t["media_kind"] is None and t["media_file_id"] is None
|
| 102 |
+
with pytest.raises(UserError):
|
| 103 |
+
extract_content(make_msg(1, " "))
|
| 104 |
+
with pytest.raises(UserError):
|
| 105 |
+
extract_content(photo_msg("x" * 1025))
|
| 106 |
+
with pytest.raises(UserError): # a poll can't be used in a post
|
| 107 |
+
extract_content(make_msg(1, "q", media=types.MessageMediaPoll(types.Poll(1, [], types.TextWithEntities("q", []), None), types.PollResults())))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_link_preview_photo_is_not_mistaken_for_a_photo_post():
|
| 111 |
+
page = types.WebPage(id=1, url="https://x.com", display_url="x.com", hash=0, photo=types.Photo(1, 2, b"r", NOW, [], 2))
|
| 112 |
+
m = make_msg(2, "look https://x.com", media=types.MessageMediaWebPage(webpage=page))
|
| 113 |
+
assert classify_media(m) is None
|
| 114 |
+
assert extract_content(m)["media_kind"] is None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_bot_file_id_can_be_reused_when_sending():
|
| 118 |
+
m = photo_msg()
|
| 119 |
+
fid = pack_file_id(m)
|
| 120 |
+
assert fid.startswith("tl:")
|
| 121 |
+
|
| 122 |
+
async def go(blank):
|
| 123 |
+
client = TelegramClient(MemorySession(), 1, "h")
|
| 124 |
+
# what send_file / edit_message do with the media object
|
| 125 |
+
_, media, _ = await client._file_to_media(media_from_ref(fid, blank_reference=blank))
|
| 126 |
+
return media
|
| 127 |
+
|
| 128 |
+
assert isinstance(asyncio.run(go(False)), types.InputMediaPhoto)
|
| 129 |
+
blank = asyncio.run(go(True))
|
| 130 |
+
assert blank.id.file_reference == b"" and blank.id.id == 1 and blank.id.access_hash == 2
|
| 131 |
+
doc_ref = pack_file_id(video_msg())
|
| 132 |
+
assert media_from_ref(doc_ref).document.id == 9
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# --------------------------------------------------------------------------- parsing
|
| 136 |
+
def test_button_parsing_and_limits():
|
| 137 |
+
assert B.parse_buttons_text("A - t.me/x | B - https://y.com/?a=1\nC β https://z.com") == [
|
| 138 |
+
[{"t": "A", "u": "https://t.me/x"}, {"t": "B", "u": "https://y.com/?a=1"}],
|
| 139 |
+
[{"t": "C", "u": "https://z.com"}],
|
| 140 |
+
]
|
| 141 |
+
for bad in ("no link here", "- https://x.com", "A - htp://x"):
|
| 142 |
+
with pytest.raises(ValueError):
|
| 143 |
+
B.parse_buttons_text(bad)
|
| 144 |
+
with pytest.raises(ValueError):
|
| 145 |
+
B.parse_buttons_text(" | ".join(f"b{i} - https://x.com/{i}" for i in range(9)))
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_move_button_edges():
|
| 149 |
+
rows = [[{"t": "a", "u": "u"}, {"t": "b", "u": "u"}], [{"t": "c", "u": "u"}]]
|
| 150 |
+
assert B.move_button(rows, 0, 0, "l")[0] == rows and B.move_button(rows, 0, 0, "u")[0] == rows
|
| 151 |
+
r, nr, nc = B.move_button(rows, 1, 0, "u") # keeps its column when it can
|
| 152 |
+
assert [[b["t"] for b in x] for x in r] == [["c", "a", "b"]] and (nr, nc) == (0, 0)
|
| 153 |
+
r, nr, nc = B.move_button(rows, 0, 1, "d")
|
| 154 |
+
assert [[b["t"] for b in x] for x in r] == [["a"], ["c", "b"]] and (nr, nc) == (1, 1)
|
| 155 |
+
r, nr, nc = B.move_button([[{"t": "a"}, {"t": "b"}]], 0, 1, "d") # last row with company -> new row
|
| 156 |
+
assert [[b["t"] for b in x] for x in r] == [["a"], ["b"]] and (nr, nc) == (1, 0)
|
| 157 |
+
assert B.delete_button([[{"t": "a"}]], 0, 0) == []
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_replace_args_and_refs():
|
| 161 |
+
old, new, f = parse_replace_args("@Bharath goku --last 50 --posts --channel @ch")
|
| 162 |
+
assert (old, new, f["last"], f["posts"], f["channel"]) == ("Bharath", "goku", 50, True, "@ch")
|
| 163 |
+
for bad in ("@a", "@a @a", "@a @b --wat", "@a @b --last x", "@a b!d"):
|
| 164 |
+
with pytest.raises(ValueError):
|
| 165 |
+
parse_replace_args(bad)
|
| 166 |
+
assert normalize_username("https://t.me/foo_bar/") == "foo_bar" and normalize_username("@x y") is None
|
| 167 |
+
assert parse_channel_ref("@animech") == "animech" and parse_channel_ref("https://t.me/c/123456/7") == 123456
|
| 168 |
+
assert parse_channel_ref("-1001234567890") == 1234567890 and parse_channel_ref("???") is None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_url_helpers():
|
| 172 |
+
assert classify_url("https://T.me/Bharath?start=x", "bharath") == "link"
|
| 173 |
+
assert classify_url("https://t.me/bharath/12", "bharath") == "post"
|
| 174 |
+
assert classify_url("https://t.me/bharath_bot", "bharath") is None
|
| 175 |
+
assert classify_url("https://example.com/bharath", "bharath") is None
|
| 176 |
+
assert swap_url("http://telegram.me/Bharath?start=A_b-C", "bharath", "goku") == "http://telegram.me/goku?start=A_b-C"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_db_url_normalisation():
|
| 180 |
+
url, args = normalize_db_url("postgres://u:p@ep-x-pooler.eu.neon.tech/db?sslmode=require&channel_binding=require")
|
| 181 |
+
assert url.startswith("postgresql+asyncpg://") and "sslmode" not in url and "channel_binding" not in url
|
| 182 |
+
assert args == {"ssl": True, "statement_cache_size": 0} and "prepared_statement_cache_size=0" in url
|
| 183 |
+
assert normalize_db_url("sqlite:///data/x.db")[0] == "sqlite+aiosqlite:///data/x.db"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
# ------------------------------------------------------------------------ suggestions
|
| 187 |
+
def test_suggestion_details():
|
| 188 |
+
post = SimpleNamespace(
|
| 189 |
+
id=1, text="Episodes 01 to 20 are here π", entities=[{"k": "bold", "o": 0, "l": 8}],
|
| 190 |
+
buttons=[[{"t": "Download Episodes 01 to 20", "u": "https://x"}, {"t": "Join", "u": "https://j"}],
|
| 191 |
+
[{"t": "π 2", "raw": "AAAA"}]],
|
| 192 |
+
media_kind="photo", media_file_id="FID",
|
| 193 |
+
)
|
| 194 |
+
s = build_suggestion(post)
|
| 195 |
+
assert s.text == "Episodes 21 to 40 are here π" and s.entities[0]["l"] == 8
|
| 196 |
+
assert s.buttons == [[{"t": "Download Episodes 21 to 40", "u": None}, {"t": "Join", "u": "https://j"}]]
|
| 197 |
+
assert s.pending == [(0, 0)] and s.media_file_id == "FID" # reactions from another bot are dropped
|
| 198 |
+
assert advance_plain("no numbers") is None
|
| 199 |
+
assert build_suggestion(SimpleNamespace(id=2, text="", entities=[], buttons=[[{"t": "Join", "u": "u"}]], media_kind=None, media_file_id=None)) is None
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ------------------------------------------------------- channel registration handler
|
| 203 |
+
def test_add_channel_by_forward_and_rights(tmp_path):
|
| 204 |
+
async def run():
|
| 205 |
+
app = App(tmp_path)
|
| 206 |
+
await app.start()
|
| 207 |
+
ch = types.Channel(id=77, title="Fwd Channel", photo=types.ChatPhotoEmpty(), date=NOW, broadcast=True,
|
| 208 |
+
access_hash=1234, username="fwdch")
|
| 209 |
+
msg = SimpleNamespace(raw_text="", entities=[], media=None, grouped_id=None, forward=SimpleNamespace(chat=ch))
|
| 210 |
+
await app.text("/addchannel")
|
| 211 |
+
await app.text("", message=msg)
|
| 212 |
+
assert "Added" in app.out.last_text
|
| 213 |
+
saved = await app.db.get_channel(77)
|
| 214 |
+
assert (saved.access_hash, saved.username, saved.active) == (1234, "fwdch", True)
|
| 215 |
+
|
| 216 |
+
# a "min" channel without access details is refused with an explanation
|
| 217 |
+
mini = types.Channel(id=78, title="Min", photo=types.ChatPhotoEmpty(), date=NOW, broadcast=True, min=True)
|
| 218 |
+
await app.text("/addchannel")
|
| 219 |
+
await app.text("", message=SimpleNamespace(raw_text="", entities=[], media=None, grouped_id=None, forward=SimpleNamespace(chat=mini)))
|
| 220 |
+
assert "access details" in app.out.last_text
|
| 221 |
+
assert await app.db.get_channel(78) is None
|
| 222 |
+
await app.db.close()
|
| 223 |
+
|
| 224 |
+
asyncio.run(run())
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def test_media_post_publish_uses_send_file_and_caption_edit(tmp_path):
|
| 228 |
+
async def run():
|
| 229 |
+
app = App(tmp_path)
|
| 230 |
+
await app.start()
|
| 231 |
+
await app.db.save_channel(1, 5, "C", None, 1)
|
| 232 |
+
p = await app.db.create_post(channel_id=1, status="draft", source="bot", text="cap", entities=[], media_kind="photo",
|
| 233 |
+
media_file_id=pack_file_id(photo_msg()), buttons=[[{"t": "Go", "u": "https://x.com"}]], created_by=1)
|
| 234 |
+
await app.press(f"pby:{p.id}")
|
| 235 |
+
kind, peer, file, kw = app.tg.sent[-1]
|
| 236 |
+
assert kind == "file" and isinstance(file, types.MessageMediaPhoto) and kw["caption"] == "cap"
|
| 237 |
+
# editing the caption of the live media post goes through messages.editMessage
|
| 238 |
+
await app.press(f"et:{p.id}")
|
| 239 |
+
await app.text("new caption")
|
| 240 |
+
req = app.tg.edits[-1][2]
|
| 241 |
+
assert req.message == "new caption" and len(req.reply_markup.rows[0].buttons) == 1
|
| 242 |
+
# replacing the media uses Telegram's file-edit path
|
| 243 |
+
await app.press(f"em:{p.id}")
|
| 244 |
+
await app.text("nope")
|
| 245 |
+
assert "Send a photo" in app.out.last_text
|
| 246 |
+
await app.db.close()
|
| 247 |
+
|
| 248 |
+
asyncio.run(run())
|
tests/test_wire.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Check that every Telegram call the bot makes builds a valid, serialisable MTProto request.
|
| 2 |
+
|
| 3 |
+
Telethon's network layer is replaced by a function that captures the request (after Telethon has
|
| 4 |
+
resolved it) and serialises it, so wrong argument types or shapes fail here instead of in production.
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
import datetime
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
from telethon import TelegramClient, functions, types, utils
|
| 11 |
+
from telethon.sessions import MemorySession
|
| 12 |
+
|
| 13 |
+
from app.common import push_edit, send_post
|
| 14 |
+
from app.tgutil import (
|
| 15 |
+
build_markup,
|
| 16 |
+
de_entities,
|
| 17 |
+
edit_raw,
|
| 18 |
+
media_from_ref,
|
| 19 |
+
media_ref,
|
| 20 |
+
peer_of,
|
| 21 |
+
ser_entities,
|
| 22 |
+
)
|
| 23 |
+
from app.ui import button_keyboard, panel_keyboard
|
| 24 |
+
|
| 25 |
+
from .fakes import make_msg
|
| 26 |
+
|
| 27 |
+
NOW = datetime.datetime.now(datetime.timezone.utc)
|
| 28 |
+
CH = type("Ch", (), {"id": 1234, "access_hash": 5678})()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Captured(Exception):
|
| 32 |
+
def __init__(self, req):
|
| 33 |
+
self.req = req
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.fixture()
|
| 37 |
+
def client(monkeypatch):
|
| 38 |
+
async def fake_call(self, request, ordered=False, flood_sleep_threshold=None):
|
| 39 |
+
reqs = list(request) if isinstance(request, (list, tuple)) else [request]
|
| 40 |
+
for r in reqs:
|
| 41 |
+
if hasattr(r, "resolve"):
|
| 42 |
+
await r.resolve(self, utils)
|
| 43 |
+
assert isinstance(bytes(r), bytes) # must serialise
|
| 44 |
+
raise Captured(reqs[0])
|
| 45 |
+
|
| 46 |
+
monkeypatch.setattr(TelegramClient, "__call__", fake_call)
|
| 47 |
+
return TelegramClient(MemorySession(), 1, "h")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def capture(coro):
|
| 51 |
+
async def go():
|
| 52 |
+
try:
|
| 53 |
+
await coro
|
| 54 |
+
except Captured as c:
|
| 55 |
+
return c.req
|
| 56 |
+
raise AssertionError("no request was made")
|
| 57 |
+
|
| 58 |
+
return asyncio.run(go())
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def post(**kw):
|
| 62 |
+
base = dict(
|
| 63 |
+
id=1, message_id=50, text="Hello π", entities=[{"k": "bold", "o": 0, "l": 5}, {"k": "url", "o": 6, "l": 2, "u": "https://x.com"}],
|
| 64 |
+
media_kind=None, media_file_id=None, link_preview=False,
|
| 65 |
+
buttons=[[{"t": "Go", "u": "https://t.me/bot?start=a"}, {"t": "Two", "u": "https://t.me/bot?start=b"}]],
|
| 66 |
+
)
|
| 67 |
+
base.update(kw)
|
| 68 |
+
return type("P", (), base)()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def photo_ref():
|
| 72 |
+
photo = types.Photo(11, 22, b"ref", NOW, [types.PhotoSize("x", 100, 100, 999)], 2)
|
| 73 |
+
return media_ref(make_msg(1, "", media=types.MessageMediaPhoto(photo=photo)))
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_send_text_post(client):
|
| 77 |
+
req = capture(send_post(client, peer_of(CH), post()))
|
| 78 |
+
assert isinstance(req, functions.messages.SendMessageRequest)
|
| 79 |
+
assert req.message == "Hello π" and req.no_webpage is True
|
| 80 |
+
assert [type(e).__name__ for e in req.entities] == ["MessageEntityBold", "MessageEntityTextUrl"]
|
| 81 |
+
assert req.reply_markup.rows[0].buttons[1].text == "Two"
|
| 82 |
+
assert isinstance(req.peer, types.InputPeerChannel)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_send_media_post_with_and_without_file_reference(client):
|
| 86 |
+
ref = photo_ref()
|
| 87 |
+
req = capture(send_post(client, peer_of(CH), post(media_kind="photo", media_file_id=ref)))
|
| 88 |
+
assert isinstance(req, functions.messages.SendMediaRequest)
|
| 89 |
+
assert isinstance(req.media, types.InputMediaPhoto) and req.media.id.id == 11 and req.media.id.file_reference == b"ref"
|
| 90 |
+
assert req.message == "Hello π" and req.reply_markup is not None
|
| 91 |
+
|
| 92 |
+
async def blank():
|
| 93 |
+
c = client
|
| 94 |
+
_, media, _ = await c._file_to_media(media_from_ref(ref, blank_reference=True))
|
| 95 |
+
return media
|
| 96 |
+
|
| 97 |
+
assert asyncio.run(blank()).id.file_reference == b""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_raw_edits(client):
|
| 101 |
+
p = post()
|
| 102 |
+
req = capture(push_edit(client, CH, p))
|
| 103 |
+
assert isinstance(req, functions.messages.EditMessageRequest)
|
| 104 |
+
assert req.id == 50 and req.message == "Hello π" and req.no_webpage is True and req.reply_markup is not None
|
| 105 |
+
# no buttons -> no reply_markup (Telegram then removes the keyboard)
|
| 106 |
+
req = capture(push_edit(client, CH, post(buttons=[])))
|
| 107 |
+
assert req.reply_markup is None and req.message == "Hello π"
|
| 108 |
+
# markup-only edit leaves the text alone
|
| 109 |
+
req = capture(edit_raw(client, peer_of(CH), 9, markup=build_markup([[{"t": "A", "u": "https://a.com"}]])))
|
| 110 |
+
assert req.message is None and req.entities is None and req.reply_markup.rows[0].buttons[0].text == "A"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def test_media_replacement_edit(client):
|
| 114 |
+
ref = photo_ref()
|
| 115 |
+
req = capture(push_edit(client, CH, post(media_kind="photo", media_file_id=ref), replace_media=True))
|
| 116 |
+
assert isinstance(req, functions.messages.EditMessageRequest)
|
| 117 |
+
assert isinstance(req.media, types.InputMediaPhoto) and req.message == "Hello π"
|
| 118 |
+
assert req.reply_markup is not None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_reads_deletes_and_rights_requests(client):
|
| 122 |
+
peer = peer_of(CH)
|
| 123 |
+
req = capture(client.get_messages(peer, ids=[1, 2, 3]))
|
| 124 |
+
assert isinstance(req, functions.channels.GetMessagesRequest)
|
| 125 |
+
assert isinstance(req.channel, types.InputChannel) and [i.id for i in req.id] == [1, 2, 3]
|
| 126 |
+
req = capture(client.delete_messages(peer, [7]))
|
| 127 |
+
assert isinstance(req, functions.channels.DeleteMessagesRequest) and req.id == [7]
|
| 128 |
+
req = capture(client(functions.channels.GetFullChannelRequest(peer)))
|
| 129 |
+
assert isinstance(req.channel, types.InputChannel)
|
| 130 |
+
req = capture(client(functions.channels.GetParticipantRequest(channel=peer, participant=types.InputPeerSelf())))
|
| 131 |
+
assert isinstance(req.participant, types.InputPeerSelf)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_entities_with_every_stored_kind_serialise():
|
| 135 |
+
ents = [
|
| 136 |
+
types.MessageEntityBold(0, 1), types.MessageEntityItalic(1, 1), types.MessageEntityUnderline(2, 1),
|
| 137 |
+
types.MessageEntityStrike(3, 1), types.MessageEntitySpoiler(4, 1), types.MessageEntityCode(5, 1),
|
| 138 |
+
types.MessageEntityPre(6, 1, "py"), types.MessageEntityTextUrl(7, 1, "https://x.com"),
|
| 139 |
+
types.MessageEntityBlockquote(8, 1, True), types.MessageEntityCustomEmoji(9, 2, 123456789),
|
| 140 |
+
]
|
| 141 |
+
back = de_entities(ser_entities(ents))
|
| 142 |
+
assert [type(e) for e in back] == [type(e) for e in ents]
|
| 143 |
+
assert back[8].collapsed is True
|
| 144 |
+
for e in back:
|
| 145 |
+
bytes(e)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_ui_keyboards_serialise(client):
|
| 149 |
+
p = post()
|
| 150 |
+
p.status, p.channel_id, p.source = "draft", 1, "bot"
|
| 151 |
+
p.buttons = [[{"t": "A", "u": "https://a.com"}, {"t": "β οΈ no link", "u": None}], [{"t": "π", "raw": ""}]]
|
| 152 |
+
for kb in (panel_keyboard(p), button_keyboard(p, 0, 0), button_keyboard(p, 1, 0)):
|
| 153 |
+
mk = client.build_reply_markup(kb)
|
| 154 |
+
assert isinstance(bytes(mk), bytes)
|
| 155 |
+
for label_row in panel_keyboard(p):
|
| 156 |
+
for b in label_row:
|
| 157 |
+
assert len(b.type.data) <= 64 # Telegram's limit for callback data
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_button_callback_data_fits_for_large_ids():
|
| 161 |
+
p = post(id=2_000_000_000)
|
| 162 |
+
p.status, p.channel_id, p.source = "sent", -1, "bot"
|
| 163 |
+
p.buttons = [[{"t": "x", "u": "https://a.com"}] * 8] * 12
|
| 164 |
+
for row in panel_keyboard(p):
|
| 165 |
+
for b in row:
|
| 166 |
+
assert len(b.type.data) <= 64
|