github-actions[bot] commited on
Commit
61d6e2a
Β·
1 Parent(s): d7a1de8

Automated sync from GitHub

Browse files
.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
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
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
- .DS_Store
2
- **/.DS_Store
 
 
 
 
 
 
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 &lt;post link&gt; - 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