Spaces:
Paused
Paused
github-actions[bot] commited on
Commit ·
53e3df0
1
Parent(s): 1dfae7a
Automated sync from GitHub
Browse files- .gitattributes +4 -4
- app/common.py +12 -1
- app/db.py +3 -2
- app/handlers/basic.py +8 -0
- app/handlers/broadcast.py +3 -3
- app/handlers/create.py +4 -3
- app/handlers/panel.py +64 -8
- app/handlers/posts.py +24 -8
- app/handlers/replace.py +3 -0
- app/handleswap.py +4 -2
- app/linkswap.py +4 -2
- app/postlinks.py +5 -1
- app/replace_engine.py +29 -11
- app/repost_engine.py +52 -13
- app/richmsg.py +537 -0
- app/richtext.py +3 -1
- app/suggest.py +3 -0
- app/sync_engine.py +33 -9
- app/tgutil.py +15 -3
- app/ui.py +11 -6
- app/userbot.py +13 -5
- tests/fakes.py +64 -2
- tests/harness.py +8 -0
- tests/test_rich.py +483 -0
- tests/test_rich_flows.py +759 -0
.gitattributes
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
app/common.py
CHANGED
|
@@ -17,6 +17,7 @@ from .buttons_sync import ensure_markup, link_urls
|
|
| 17 |
from .config import Config
|
| 18 |
from .db import Database
|
| 19 |
from .replace_engine import fetch_messages, is_skippable
|
|
|
|
| 20 |
from .tgutil import build_markup, de_entities, edit_raw, esc, explain_rpc, fetch_media, media_info, peer_of, with_media
|
| 21 |
from .userbot import UserbotError, UserbotRefused, edit_problem_text
|
| 22 |
|
|
@@ -141,6 +142,9 @@ def require_owner(ctx: Ctx, event) -> None:
|
|
| 141 |
async def send_post(client, peer, post):
|
| 142 |
ents = de_entities(post.entities)
|
| 143 |
markup = build_markup(post.buttons)
|
|
|
|
|
|
|
|
|
|
| 144 |
if post.media_file_id:
|
| 145 |
return await with_media(
|
| 146 |
post.media_file_id,
|
|
@@ -158,6 +162,10 @@ async def push_edit(client, ch, p, *, replace_media: bool = False) -> None:
|
|
| 158 |
peer = peer_of(ch)
|
| 159 |
ents = de_entities(p.entities)
|
| 160 |
markup = build_markup(p.buttons)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
if replace_media and p.media_file_id:
|
| 162 |
await with_media(
|
| 163 |
p.media_file_id,
|
|
@@ -264,7 +272,10 @@ async def edit_by_userbot(ctx: Ctx, ch, p, *, replace_media: bool, bot_error: st
|
|
| 264 |
await notify(text)
|
| 265 |
|
| 266 |
fields = dict(
|
| 267 |
-
text=p.text or "",
|
|
|
|
|
|
|
|
|
|
| 268 |
)
|
| 269 |
try:
|
| 270 |
await tell("⏳ Telegram refused the bot, so the userbot is making the edit.")
|
|
|
|
| 17 |
from .config import Config
|
| 18 |
from .db import Database
|
| 19 |
from .replace_engine import fetch_messages, is_skippable
|
| 20 |
+
from .richmsg import rich_of, send_rich, with_rich
|
| 21 |
from .tgutil import build_markup, de_entities, edit_raw, esc, explain_rpc, fetch_media, media_info, peer_of, with_media
|
| 22 |
from .userbot import UserbotError, UserbotRefused, edit_problem_text
|
| 23 |
|
|
|
|
| 142 |
async def send_post(client, peer, post):
|
| 143 |
ents = de_entities(post.entities)
|
| 144 |
markup = build_markup(post.buttons)
|
| 145 |
+
rich = rich_of(post)
|
| 146 |
+
if rich is not None and not post.media_file_id: # a rich message: Telegram builds its blocks from what was kept
|
| 147 |
+
return await send_rich(client, peer, rich, markup=markup)
|
| 148 |
if post.media_file_id:
|
| 149 |
return await with_media(
|
| 150 |
post.media_file_id,
|
|
|
|
| 162 |
peer = peer_of(ch)
|
| 163 |
ents = de_entities(p.entities)
|
| 164 |
markup = build_markup(p.buttons)
|
| 165 |
+
rich = rich_of(p)
|
| 166 |
+
if rich is not None and not p.media_kind:
|
| 167 |
+
await with_rich(rich, lambda inp: edit_raw(client, peer, p.message_id, rich_message=inp, markup=markup))
|
| 168 |
+
return
|
| 169 |
if replace_media and p.media_file_id:
|
| 170 |
await with_media(
|
| 171 |
p.media_file_id,
|
|
|
|
| 272 |
await notify(text)
|
| 273 |
|
| 274 |
fields = dict(
|
| 275 |
+
text=p.text or "",
|
| 276 |
+
entities=de_entities(p.entities),
|
| 277 |
+
preview=None if p.media_kind or rich_of(p) else bool(p.link_preview),
|
| 278 |
+
rich=None if p.media_kind else rich_of(p),
|
| 279 |
)
|
| 280 |
try:
|
| 281 |
await tell("⏳ Telegram refused the bot, so the userbot is making the edit.")
|
app/db.py
CHANGED
|
@@ -826,8 +826,9 @@ class Database:
|
|
| 826 |
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 827 |
p = (await s.execute(q)).scalar_one_or_none()
|
| 828 |
if p is not None:
|
| 829 |
-
|
| 830 |
-
|
|
|
|
| 831 |
p.buttons = state.get("buttons", [])
|
| 832 |
p.updated_at = utcnow()
|
| 833 |
if all_done:
|
|
|
|
| 826 |
q = select(Post).where(Post.channel_id == channel_id, Post.message_id == message_id)
|
| 827 |
p = (await s.execute(q)).scalar_one_or_none()
|
| 828 |
if p is not None:
|
| 829 |
+
if not state.get("rich"): # a rich message's text was never changed: it stays as saved
|
| 830 |
+
p.text = state.get("text", "")
|
| 831 |
+
p.entities = state.get("entities", [])
|
| 832 |
p.buttons = state.get("buttons", [])
|
| 833 |
p.updated_at = utcnow()
|
| 834 |
if all_done:
|
app/handlers/basic.py
CHANGED
|
@@ -9,6 +9,7 @@ from telethon import events
|
|
| 9 |
|
| 10 |
from ..common import Ctx, UserError, cmd, clear_state, discard_temp, guard, say, show
|
| 11 |
from ..picker import PAGE_CB
|
|
|
|
| 12 |
from ..richtext import CHEAT_SHEET
|
| 13 |
from ..tgutil import esc
|
| 14 |
|
|
@@ -21,6 +22,7 @@ HELP = (
|
|
| 21 |
"/posts - your posts and drafts: edit text, caption, media and buttons, or delete them - a published post is deleted in the channel and in My posts (a button post also has “Mass replace links”)\n"
|
| 22 |
"/autopost 114 20 - post “Episodes 01 to 20”, “21 to 40” ... up to 114, each with a download button, then fill in the links\n"
|
| 23 |
"/formatting - bold, italic, links, quotes ...: Telegram's own formatting is kept, or type HTML / Markdown instead\n"
|
|
|
|
| 24 |
"/cancel - stop what you're doing\n\n"
|
| 25 |
"<b>Channels</b>\n"
|
| 26 |
"/addchannel - register a channel (the bot must be an admin there)\n"
|
|
@@ -92,6 +94,12 @@ def register(ctx: Ctx) -> None:
|
|
| 92 |
async def h_formatting(event):
|
| 93 |
await say(event, CHEAT_SHEET)
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
@client.on(cmd("cancel"))
|
| 96 |
@guard(ctx)
|
| 97 |
async def h_cancel(event):
|
|
|
|
| 9 |
|
| 10 |
from ..common import Ctx, UserError, cmd, clear_state, discard_temp, guard, say, show
|
| 11 |
from ..picker import PAGE_CB
|
| 12 |
+
from ..richmsg import RICH_HELP, RICH_HELP_HTML
|
| 13 |
from ..richtext import CHEAT_SHEET
|
| 14 |
from ..tgutil import esc
|
| 15 |
|
|
|
|
| 22 |
"/posts - your posts and drafts: edit text, caption, media and buttons, or delete them - a published post is deleted in the channel and in My posts (a button post also has “Mass replace links”)\n"
|
| 23 |
"/autopost 114 20 - post “Episodes 01 to 20”, “21 to 40” ... up to 114, each with a download button, then fill in the links\n"
|
| 24 |
"/formatting - bold, italic, links, quotes ...: Telegram's own formatting is kept, or type HTML / Markdown instead\n"
|
| 25 |
+
"/rich - rich messages: headings, lists, tables, expandable sections, math, maps, pictures, buttons ... typed as HTML / Markdown, or sent as a Telegram rich message\n"
|
| 26 |
"/cancel - stop what you're doing\n\n"
|
| 27 |
"<b>Channels</b>\n"
|
| 28 |
"/addchannel - register a channel (the bot must be an admin there)\n"
|
|
|
|
| 94 |
async def h_formatting(event):
|
| 95 |
await say(event, CHEAT_SHEET)
|
| 96 |
|
| 97 |
+
@client.on(cmd("rich"))
|
| 98 |
+
@guard(ctx)
|
| 99 |
+
async def h_rich(event):
|
| 100 |
+
await say(event, RICH_HELP)
|
| 101 |
+
await say(event, RICH_HELP_HTML)
|
| 102 |
+
|
| 103 |
@client.on(cmd("cancel"))
|
| 104 |
@guard(ctx)
|
| 105 |
async def h_cancel(event):
|
app/handlers/broadcast.py
CHANGED
|
@@ -38,7 +38,7 @@ from ..db import utcnow
|
|
| 38 |
from ..expiry import human, parse_duration, stamp
|
| 39 |
from ..tgutil import esc, explain_rpc, flood_retry, peer_of
|
| 40 |
from ..ui import CANCEL
|
| 41 |
-
from .panel import
|
| 42 |
|
| 43 |
log = logging.getLogger(__name__)
|
| 44 |
|
|
@@ -165,7 +165,7 @@ def register(ctx: Ctx) -> None:
|
|
| 165 |
event,
|
| 166 |
f"📣 <b>Broadcast</b> to {len(chans)} channel(s)\n{when}\n\n"
|
| 167 |
"✍️ <b>Send the message now.</b> Text, or a photo / video / GIF / file with a caption. Formatting is kept "
|
| 168 |
-
"(or type HTML / Markdown - /formatting shows how).",
|
| 169 |
CANCEL,
|
| 170 |
)
|
| 171 |
|
|
@@ -176,7 +176,7 @@ def register(ctx: Ctx) -> None:
|
|
| 176 |
return # the other items of an album: only the first one is used
|
| 177 |
if m.grouped_id:
|
| 178 |
st["group"] = m.grouped_id
|
| 179 |
-
content =
|
| 180 |
set_state(ctx, event.sender_id, "bc_buttons", ttl=st["ttl"], content=content)
|
| 181 |
await say(event, markup_note(content.how) + BUTTON_HELP, [[Button.inline("⏭ No buttons", "brs")], CANCEL[0]])
|
| 182 |
|
|
|
|
| 38 |
from ..expiry import human, parse_duration, stamp
|
| 39 |
from ..tgutil import esc, explain_rpc, flood_retry, peer_of
|
| 40 |
from ..ui import CANCEL
|
| 41 |
+
from .panel import markup_note, read_content
|
| 42 |
|
| 43 |
log = logging.getLogger(__name__)
|
| 44 |
|
|
|
|
| 165 |
event,
|
| 166 |
f"📣 <b>Broadcast</b> to {len(chans)} channel(s)\n{when}\n\n"
|
| 167 |
"✍️ <b>Send the message now.</b> Text, or a photo / video / GIF / file with a caption. Formatting is kept "
|
| 168 |
+
"(or type HTML / Markdown - /formatting shows how; rich blocks: /rich).",
|
| 169 |
CANCEL,
|
| 170 |
)
|
| 171 |
|
|
|
|
| 176 |
return # the other items of an album: only the first one is used
|
| 177 |
if m.grouped_id:
|
| 178 |
st["group"] = m.grouped_id
|
| 179 |
+
content = await read_content(ctx, event)
|
| 180 |
set_state(ctx, event.sender_id, "bc_buttons", ttl=st["ttl"], content=content)
|
| 181 |
await say(event, markup_note(content.how) + BUTTON_HELP, [[Button.inline("⏭ No buttons", "brs")], CANCEL[0]])
|
| 182 |
|
app/handlers/create.py
CHANGED
|
@@ -11,7 +11,7 @@ from ..common import Ctx, UserError, clear_state, cmd, discard_temp, guard, on_c
|
|
| 11 |
from ..suggest import make_suggestion
|
| 12 |
from ..tgutil import esc, short
|
| 13 |
from ..ui import CANCEL, KIND_NAME
|
| 14 |
-
from .panel import
|
| 15 |
|
| 16 |
log = logging.getLogger(__name__)
|
| 17 |
|
|
@@ -32,7 +32,8 @@ def register(ctx: Ctx) -> None:
|
|
| 32 |
await show(
|
| 33 |
event,
|
| 34 |
"✍️ <b>Send the post now.</b>\nText, or a photo / video / GIF / file with a caption. "
|
| 35 |
-
"Formatting (bold, links, spoilers...) is kept. No Telegram editor at hand? Type HTML or Markdown - /formatting shows how
|
|
|
|
| 36 |
CANCEL,
|
| 37 |
)
|
| 38 |
|
|
@@ -153,7 +154,7 @@ def register(ctx: Ctx) -> None:
|
|
| 153 |
return # the other items of an album: only the first one is used
|
| 154 |
if m.grouped_id:
|
| 155 |
st["group"] = m.grouped_id
|
| 156 |
-
content =
|
| 157 |
post = await db.create_post(
|
| 158 |
channel_id=st["cid"],
|
| 159 |
created_by=event.sender_id,
|
|
|
|
| 11 |
from ..suggest import make_suggestion
|
| 12 |
from ..tgutil import esc, short
|
| 13 |
from ..ui import CANCEL, KIND_NAME
|
| 14 |
+
from .panel import markup_note, read_content, show_panel
|
| 15 |
|
| 16 |
log = logging.getLogger(__name__)
|
| 17 |
|
|
|
|
| 32 |
await show(
|
| 33 |
event,
|
| 34 |
"✍️ <b>Send the post now.</b>\nText, or a photo / video / GIF / file with a caption. "
|
| 35 |
+
"Formatting (bold, links, spoilers...) is kept. No Telegram editor at hand? Type HTML or Markdown - /formatting shows how; "
|
| 36 |
+
"headings, lists, tables, math and the other rich blocks: /rich.",
|
| 37 |
CANCEL,
|
| 38 |
)
|
| 39 |
|
|
|
|
| 154 |
return # the other items of an album: only the first one is used
|
| 155 |
if m.grouped_id:
|
| 156 |
st["group"] = m.grouped_id
|
| 157 |
+
content = await read_content(ctx, event)
|
| 158 |
post = await db.create_post(
|
| 159 |
channel_id=st["cid"],
|
| 160 |
created_by=event.sender_id,
|
app/handlers/panel.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
| 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 ..richtext import message_text
|
| 9 |
-
from ..tgutil import classify_media, pack_file_id, utf16_len
|
| 10 |
from ..ui import button_keyboard, button_text, panel_keyboard, panel_text
|
| 11 |
|
| 12 |
CAPTION_MAX = 1024
|
|
@@ -53,27 +56,80 @@ def check_lengths(text: str, has_media: bool) -> None:
|
|
| 53 |
|
| 54 |
|
| 55 |
class Content(dict):
|
| 56 |
-
"""Post fields (text, entities, media_kind, media_file_id)
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
how = None
|
| 59 |
|
| 60 |
|
| 61 |
def markup_note(how, sep: str = "\n\n") -> str:
|
| 62 |
"""A line for the owner when their text was read as HTML or Markdown, followed by `sep` (empty otherwise)."""
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
"""A message from the user -> post fields (text, entities, media).
|
| 68 |
|
| 69 |
-
Telegram's own formatting is kept as it is; without any, `markup` lets HTML or Markdown in the text become formatting
|
|
|
|
| 70 |
kind = classify_media(m)
|
| 71 |
if m.media and kind is None and not isinstance(m.media, types.MessageMediaWebPage):
|
| 72 |
raise UserError("That kind of message can't be used in a post. Send text, a photo, video, GIF, audio or a file.")
|
| 73 |
-
text, entities, how =
|
| 74 |
-
if not kind and not text.strip():
|
| 75 |
raise UserError("Send some text, or a photo / video / GIF / file.")
|
| 76 |
-
|
|
|
|
| 77 |
content = Content(text=text, entities=entities, media_kind=kind, media_file_id=pack_file_id(m) if kind else None)
|
| 78 |
content.how = how
|
| 79 |
return content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Helpers shared by the create and post-management handlers (not a handler module itself)."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
from telethon import types
|
| 7 |
|
| 8 |
from ..buttons import valid
|
| 9 |
from ..common import Ctx, UserError, is_cb, say, show
|
| 10 |
+
from ..richmsg import Rich, RichError, check as check_rich, entry as rich_entry, plain_of, read_typed, received
|
| 11 |
from ..richtext import message_text
|
| 12 |
+
from ..tgutil import classify_media, pack_file_id, ser_entities, utf16_len
|
| 13 |
from ..ui import button_keyboard, button_text, panel_keyboard, panel_text
|
| 14 |
|
| 15 |
CAPTION_MAX = 1024
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
class Content(dict):
|
| 59 |
+
"""Post fields (text, entities, media_kind, media_file_id).
|
| 60 |
+
|
| 61 |
+
`how` says what the text was read as: "html" or "markdown" (turned into formatting), "rich" (a rich message sent
|
| 62 |
+
natively, kept as it is), "rich-html" / "rich-markdown" (typed, and Telegram builds the blocks), or None."""
|
| 63 |
|
| 64 |
how = None
|
| 65 |
|
| 66 |
|
| 67 |
def markup_note(how, sep: str = "\n\n") -> str:
|
| 68 |
"""A line for the owner when their text was read as HTML or Markdown, followed by `sep` (empty otherwise)."""
|
| 69 |
+
if not how:
|
| 70 |
+
return ""
|
| 71 |
+
if how == "rich":
|
| 72 |
+
return f"🧩 Rich message kept as it is - 👁 Preview shows it.{sep}"
|
| 73 |
+
if how.startswith("rich-"):
|
| 74 |
+
lang = "HTML" if how == "rich-html" else "Markdown"
|
| 75 |
+
return f"🧩 Rich message read from {lang} - 👁 Preview shows what Telegram makes of it (/rich).{sep}"
|
| 76 |
+
return f"🎨 Formatting read from {'HTML' if how == 'html' else 'Markdown'} - see /formatting.{sep}"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def is_rich_how(how) -> bool:
|
| 80 |
+
return bool(how) and how.startswith("rich")
|
| 81 |
|
| 82 |
|
| 83 |
+
def read_text(m, markup: bool = True, rich: Optional[Rich] = None, allow_rich: bool = True) -> tuple:
|
| 84 |
+
"""(text, entities, how) of what the owner sent - see Content.how.
|
| 85 |
+
|
| 86 |
+
A rich message (`rich`: sent natively, kept block for block) or text typed as HTML / Markdown that needs blocks
|
| 87 |
+
becomes a rich post: `text` is its plain-text version and its only entity is the rich entry. Otherwise it is an
|
| 88 |
+
ordinary text with Telegram's own formatting, or HTML / Markdown turned into formatting. A message that has
|
| 89 |
+
Telegram formatting of its own is used as it is. `allow_rich` is False where a rich message can't be (a caption)."""
|
| 90 |
+
how = None
|
| 91 |
+
if rich is not None:
|
| 92 |
+
if not allow_rich:
|
| 93 |
+
raise UserError("A caption can't be a rich message. Rich messages are for text posts.")
|
| 94 |
+
how = "rich"
|
| 95 |
+
elif allow_rich and markup and not ser_entities(m.entities):
|
| 96 |
+
rich = read_typed(m.raw_text or "")
|
| 97 |
+
how = f"rich-{rich.fmt}" if rich is not None else None
|
| 98 |
+
if rich is None:
|
| 99 |
+
return message_text(m, markup)
|
| 100 |
+
try:
|
| 101 |
+
check_rich(rich)
|
| 102 |
+
except RichError as e:
|
| 103 |
+
raise UserError(str(e))
|
| 104 |
+
return plain_of(rich), [rich_entry(rich)], how
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def read_native(ctx: Ctx, event) -> Optional[Rich]:
|
| 108 |
+
"""The rich message the owner sent natively (or forwarded), or None for an ordinary message."""
|
| 109 |
+
try:
|
| 110 |
+
return await received(ctx.client, event)
|
| 111 |
+
except RichError as e:
|
| 112 |
+
raise UserError(str(e))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def extract_content(m, markup: bool = True, rich: Optional[Rich] = None) -> Content:
|
| 116 |
"""A message from the user -> post fields (text, entities, media).
|
| 117 |
|
| 118 |
+
Telegram's own formatting is kept as it is; without any, `markup` lets HTML or Markdown in the text become formatting
|
| 119 |
+
(or a rich message, when it has blocks only those have). `rich`: the message is a rich message sent natively."""
|
| 120 |
kind = classify_media(m)
|
| 121 |
if m.media and kind is None and not isinstance(m.media, types.MessageMediaWebPage):
|
| 122 |
raise UserError("That kind of message can't be used in a post. Send text, a photo, video, GIF, audio or a file.")
|
| 123 |
+
text, entities, how = read_text(m, markup, rich if not kind else None, allow_rich=not kind)
|
| 124 |
+
if not kind and not text.strip() and not is_rich_how(how):
|
| 125 |
raise UserError("Send some text, or a photo / video / GIF / file.")
|
| 126 |
+
if not is_rich_how(how):
|
| 127 |
+
check_lengths(text, bool(kind))
|
| 128 |
content = Content(text=text, entities=entities, media_kind=kind, media_file_id=pack_file_id(m) if kind else None)
|
| 129 |
content.how = how
|
| 130 |
return content
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def read_content(ctx: Ctx, event) -> Content:
|
| 134 |
+
"""extract_content for a message that just arrived (a rich message sent natively is read, in full, first)."""
|
| 135 |
+
return extract_content(event.message, ctx.cfg.markup_input, await read_native(ctx, event))
|
app/handlers/posts.py
CHANGED
|
@@ -36,14 +36,17 @@ from ..common import (
|
|
| 36 |
)
|
| 37 |
from ..db import utcnow
|
| 38 |
from ..repost_engine import delete_batch
|
| 39 |
-
from ..
|
| 40 |
from ..tgutil import classify_media, esc, explain_rpc, pack_file_id, peer_of, post_link
|
| 41 |
from ..ui import CANCEL, post_label
|
| 42 |
from ..userbot import delete_problem_text
|
| 43 |
from .panel import (
|
| 44 |
check_lengths,
|
|
|
|
| 45 |
load_post,
|
| 46 |
markup_note,
|
|
|
|
|
|
|
| 47 |
show_button_screen,
|
| 48 |
show_panel,
|
| 49 |
)
|
|
@@ -123,7 +126,8 @@ def register(ctx: Ctx) -> None:
|
|
| 123 |
post, _ = await load_post(ctx, parts[0])
|
| 124 |
set_state(ctx, event.sender_id, "edit_text", pid=post.id)
|
| 125 |
what = "caption" if post.media_kind else "text"
|
| 126 |
-
|
|
|
|
| 127 |
|
| 128 |
@on_text(ctx, "edit_text")
|
| 129 |
async def h_edit_text(event, st):
|
|
@@ -131,10 +135,12 @@ def register(ctx: Ctx) -> None:
|
|
| 131 |
if classify_media(m):
|
| 132 |
raise UserError("Send text only here. To change the media, use 🖼 Media.")
|
| 133 |
post, ch = await load_post(ctx, st["pid"])
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
raise UserError("A text post can't be empty.")
|
| 137 |
-
|
|
|
|
| 138 |
post, report = await apply_edit_reported(ctx, post, ch, text=text, entities=entities)
|
| 139 |
clear_state(ctx, event.sender_id)
|
| 140 |
await show_panel(ctx, event, post.id, note=markup_note(how, "\n") + note_for(post, report), new=True)
|
|
@@ -143,6 +149,11 @@ def register(ctx: Ctx) -> None:
|
|
| 143 |
@on_cb(ctx, "em")
|
| 144 |
async def cb_edit_media(event, parts):
|
| 145 |
post, _ = await load_post(ctx, parts[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
set_state(ctx, event.sender_id, "edit_media", pid=post.id)
|
| 147 |
extra = "" if post.media_kind else "\nThe current text becomes its caption."
|
| 148 |
await say(event, f"🖼 Send the new photo / video / GIF / audio / file.{extra}", CANCEL)
|
|
@@ -355,9 +366,14 @@ def register(ctx: Ctx) -> None:
|
|
| 355 |
@on_cb(ctx, "pv")
|
| 356 |
async def cb_preview(event, parts):
|
| 357 |
post, _ = await load_post(ctx, parts[0])
|
| 358 |
-
if not (post.text or "").strip() and not post.media_file_id:
|
| 359 |
raise UserError("The post is empty.")
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
miss = missing_links(post.buttons)
|
| 362 |
if miss:
|
| 363 |
await say(event, f"👆 Preview. ⚠️ {miss} button(s) without a link are not shown.")
|
|
@@ -369,7 +385,7 @@ def register(ctx: Ctx) -> None:
|
|
| 369 |
raise UserError("This post is already published.")
|
| 370 |
if missing_links(post.buttons):
|
| 371 |
raise UserError("Some buttons (⚠️) have no link yet. Open them and add a link first.")
|
| 372 |
-
if not (post.text or "").strip() and not post.media_file_id:
|
| 373 |
raise UserError("The post is empty.")
|
| 374 |
await show(
|
| 375 |
event,
|
|
|
|
| 36 |
)
|
| 37 |
from ..db import utcnow
|
| 38 |
from ..repost_engine import delete_batch
|
| 39 |
+
from ..richmsg import is_rich
|
| 40 |
from ..tgutil import classify_media, esc, explain_rpc, pack_file_id, peer_of, post_link
|
| 41 |
from ..ui import CANCEL, post_label
|
| 42 |
from ..userbot import delete_problem_text
|
| 43 |
from .panel import (
|
| 44 |
check_lengths,
|
| 45 |
+
is_rich_how,
|
| 46 |
load_post,
|
| 47 |
markup_note,
|
| 48 |
+
read_native,
|
| 49 |
+
read_text,
|
| 50 |
show_button_screen,
|
| 51 |
show_panel,
|
| 52 |
)
|
|
|
|
| 126 |
post, _ = await load_post(ctx, parts[0])
|
| 127 |
set_state(ctx, event.sender_id, "edit_text", pid=post.id)
|
| 128 |
what = "caption" if post.media_kind else "text"
|
| 129 |
+
more = "" if post.media_kind else " Rich messages (headings, lists, tables ...) too: /rich."
|
| 130 |
+
await say(event, f"✏️ Send the new {what}. Formatting is kept - or type HTML / Markdown (/formatting shows how).{more}", CANCEL)
|
| 131 |
|
| 132 |
@on_text(ctx, "edit_text")
|
| 133 |
async def h_edit_text(event, st):
|
|
|
|
| 135 |
if classify_media(m):
|
| 136 |
raise UserError("Send text only here. To change the media, use 🖼 Media.")
|
| 137 |
post, ch = await load_post(ctx, st["pid"])
|
| 138 |
+
native = await read_native(ctx, event)
|
| 139 |
+
text, entities, how = read_text(m, ctx.cfg.markup_input, native, allow_rich=not post.media_kind)
|
| 140 |
+
if not post.media_kind and not text.strip() and not is_rich_how(how):
|
| 141 |
raise UserError("A text post can't be empty.")
|
| 142 |
+
if not is_rich_how(how):
|
| 143 |
+
check_lengths(text, bool(post.media_kind))
|
| 144 |
post, report = await apply_edit_reported(ctx, post, ch, text=text, entities=entities)
|
| 145 |
clear_state(ctx, event.sender_id)
|
| 146 |
await show_panel(ctx, event, post.id, note=markup_note(how, "\n") + note_for(post, report), new=True)
|
|
|
|
| 149 |
@on_cb(ctx, "em")
|
| 150 |
async def cb_edit_media(event, parts):
|
| 151 |
post, _ = await load_post(ctx, parts[0])
|
| 152 |
+
if is_rich(post):
|
| 153 |
+
raise UserError(
|
| 154 |
+
"A rich message has no media slot. Put pictures into the text itself (see /rich), "
|
| 155 |
+
"or send new text without rich blocks first."
|
| 156 |
+
)
|
| 157 |
set_state(ctx, event.sender_id, "edit_media", pid=post.id)
|
| 158 |
extra = "" if post.media_kind else "\nThe current text becomes its caption."
|
| 159 |
await say(event, f"🖼 Send the new photo / video / GIF / audio / file.{extra}", CANCEL)
|
|
|
|
| 366 |
@on_cb(ctx, "pv")
|
| 367 |
async def cb_preview(event, parts):
|
| 368 |
post, _ = await load_post(ctx, parts[0])
|
| 369 |
+
if not (post.text or "").strip() and not post.media_file_id and not is_rich(post):
|
| 370 |
raise UserError("The post is empty.")
|
| 371 |
+
try:
|
| 372 |
+
await send_post(client, await event.get_input_chat(), post)
|
| 373 |
+
except errors.RPCError as e:
|
| 374 |
+
if not is_rich(post):
|
| 375 |
+
raise
|
| 376 |
+
raise UserError("❌ " + explain_rpc(e) + "\nTelegram could not make that rich message - see /rich for the syntax.")
|
| 377 |
miss = missing_links(post.buttons)
|
| 378 |
if miss:
|
| 379 |
await say(event, f"👆 Preview. ⚠️ {miss} button(s) without a link are not shown.")
|
|
|
|
| 385 |
raise UserError("This post is already published.")
|
| 386 |
if missing_links(post.buttons):
|
| 387 |
raise UserError("Some buttons (⚠️) have no link yet. Open them and add a link first.")
|
| 388 |
+
if not (post.text or "").strip() and not post.media_file_id and not is_rich(post):
|
| 389 |
raise UserError("The post is empty.")
|
| 390 |
await show(
|
| 391 |
event,
|
app/handlers/replace.py
CHANGED
|
@@ -26,6 +26,7 @@ from ..common import (
|
|
| 26 |
)
|
| 27 |
from ..linkswap import normalize_username
|
| 28 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
|
|
|
| 29 |
from ..richtext import message_text
|
| 30 |
from ..sync_engine import SyncOptions, sync_ids
|
| 31 |
from ..tgutil import classify_media, esc, explain_rpc, get_rights, pack_file_id
|
|
@@ -265,6 +266,8 @@ def register(ctx: Ctx) -> None:
|
|
| 265 |
raise UserError("That post no longer exists in the channel, or I can't add it to My posts.")
|
| 266 |
if post.status != "sent":
|
| 267 |
raise UserError("That post is not published yet.")
|
|
|
|
|
|
|
| 268 |
caption, entities, how = message_text(reply, cfg.markup_input) # Telegram's formatting, or HTML / Markdown
|
| 269 |
check_lengths(caption, True)
|
| 270 |
|
|
|
|
| 26 |
)
|
| 27 |
from ..linkswap import normalize_username
|
| 28 |
from ..replace_engine import ApplyResult, ChannelScan, ScanOptions, apply_channel, scan_channel, undo_batch
|
| 29 |
+
from ..richmsg import is_rich
|
| 30 |
from ..richtext import message_text
|
| 31 |
from ..sync_engine import SyncOptions, sync_ids
|
| 32 |
from ..tgutil import classify_media, esc, explain_rpc, get_rights, pack_file_id
|
|
|
|
| 266 |
raise UserError("That post no longer exists in the channel, or I can't add it to My posts.")
|
| 267 |
if post.status != "sent":
|
| 268 |
raise UserError("That post is not published yet.")
|
| 269 |
+
if is_rich(post):
|
| 270 |
+
raise UserError("That post is a rich message - it has no media or caption to replace. Edit its text in My posts.")
|
| 271 |
caption, entities, how = message_text(reply, cfg.markup_input) # Telegram's formatting, or HTML / Markdown
|
| 272 |
check_lengths(caption, True)
|
| 273 |
|
app/handleswap.py
CHANGED
|
@@ -13,6 +13,7 @@ from telethon import types
|
|
| 13 |
from telethon.helpers import add_surrogate, del_surrogate
|
| 14 |
|
| 15 |
from .linkswap import MsgChange, apply_span_replacements
|
|
|
|
| 16 |
from .tgutil import button_url, is_callback_button, with_text
|
| 17 |
|
| 18 |
# a "@name" that follows one of these is part of something else: an e-mail (a@bro), a link (t.me/x@bro, ?u=@bro) ...
|
|
@@ -44,8 +45,9 @@ def find_mention_spans(sur: str, entities, name: str) -> tuple:
|
|
| 44 |
def compute_handle_changes(msg, old: str, new: str) -> tuple:
|
| 45 |
"""(MsgChange or None, number of mentions skipped because they are part of a link) - same shape as
|
| 46 |
linkswap.compute_message_changes, so the /swap engine can apply it."""
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 49 |
sur = add_surrogate(text)
|
| 50 |
spans, skipped = find_mention_spans(sur, ents, old) if text else ([], 0)
|
| 51 |
new_sur, new_ents = sur, [copy.copy(e) for e in ents]
|
|
|
|
| 13 |
from telethon.helpers import add_surrogate, del_surrogate
|
| 14 |
|
| 15 |
from .linkswap import MsgChange, apply_span_replacements
|
| 16 |
+
from .richmsg import is_rich_message
|
| 17 |
from .tgutil import button_url, is_callback_button, with_text
|
| 18 |
|
| 19 |
# a "@name" that follows one of these is part of something else: an e-mail (a@bro), a link (t.me/x@bro, ?u=@bro) ...
|
|
|
|
| 45 |
def compute_handle_changes(msg, old: str, new: str) -> tuple:
|
| 46 |
"""(MsgChange or None, number of mentions skipped because they are part of a link) - same shape as
|
| 47 |
linkswap.compute_message_changes, so the /swap engine can apply it."""
|
| 48 |
+
rich = is_rich_message(msg) # the text of a rich message is blocks: only its buttons are changed
|
| 49 |
+
text = "" if rich else (msg.message or "")
|
| 50 |
+
ents = [] if rich else list(msg.entities or [])
|
| 51 |
sur = add_surrogate(text)
|
| 52 |
spans, skipped = find_mention_spans(sur, ents, old) if text else ([], 0)
|
| 53 |
new_sur, new_ents = sur, [copy.copy(e) for e in ents]
|
app/linkswap.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import Optional
|
|
| 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}$")
|
|
@@ -140,8 +141,9 @@ class MsgChange:
|
|
| 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 |
-
|
| 144 |
-
|
|
|
|
| 145 |
skipped = 0
|
| 146 |
n_typed = n_links = n_btn = 0
|
| 147 |
|
|
|
|
| 13 |
from telethon import types
|
| 14 |
from telethon.helpers import add_surrogate, del_surrogate
|
| 15 |
|
| 16 |
+
from .richmsg import is_rich_message
|
| 17 |
from .tgutil import button_url, is_callback_button, with_url
|
| 18 |
|
| 19 |
USERNAME_RX = re.compile(r"^[A-Za-z0-9_]{1,32}$")
|
|
|
|
| 141 |
|
| 142 |
def compute_message_changes(msg, old: str, new: str, *, include_typed: bool = True, include_posts: bool = False) -> tuple:
|
| 143 |
"""Returns (MsgChange or None, number_of_skipped_post_links)."""
|
| 144 |
+
rich = is_rich_message(msg) # the text of a rich message is blocks: only its buttons are changed
|
| 145 |
+
text = "" if rich else (msg.message or "")
|
| 146 |
+
ents = [] if rich else list(msg.entities or [])
|
| 147 |
skipped = 0
|
| 148 |
n_typed = n_links = n_btn = 0
|
| 149 |
|
app/postlinks.py
CHANGED
|
@@ -15,6 +15,7 @@ from telethon import types
|
|
| 15 |
from telethon.helpers import add_surrogate, del_surrogate
|
| 16 |
|
| 17 |
from .linkswap import apply_span_replacements
|
|
|
|
| 18 |
from .tgutil import button_url, with_url
|
| 19 |
|
| 20 |
_HOST = r"(?:t\.me|telegram\.me|telegram\.dog)"
|
|
@@ -195,7 +196,10 @@ class Relinked:
|
|
| 195 |
|
| 196 |
def relink_message(msg, links: PostLinks) -> Optional[Relinked]:
|
| 197 |
"""The new text / entities / buttons of `msg` once its links are remapped; None when nothing changes."""
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
| 199 |
markup, n_buttons = links.remap_markup(msg.reply_markup)
|
| 200 |
if not (n_typed or n_hyper or n_buttons):
|
| 201 |
return None
|
|
|
|
| 15 |
from telethon.helpers import add_surrogate, del_surrogate
|
| 16 |
|
| 17 |
from .linkswap import apply_span_replacements
|
| 18 |
+
from .richmsg import is_rich_message
|
| 19 |
from .tgutil import button_url, with_url
|
| 20 |
|
| 21 |
_HOST = r"(?:t\.me|telegram\.me|telegram\.dog)"
|
|
|
|
| 196 |
|
| 197 |
def relink_message(msg, links: PostLinks) -> Optional[Relinked]:
|
| 198 |
"""The new text / entities / buttons of `msg` once its links are remapped; None when nothing changes."""
|
| 199 |
+
if is_rich_message(msg): # the text of a rich message is blocks: only its buttons are changed
|
| 200 |
+
text, entities, n_typed, n_hyper = "", [], 0, 0
|
| 201 |
+
else:
|
| 202 |
+
text, entities, n_typed, n_hyper = links.remap_text(msg.message or "", msg.entities)
|
| 203 |
markup, n_buttons = links.remap_markup(msg.reply_markup)
|
| 204 |
if not (n_typed or n_hyper or n_buttons):
|
| 205 |
return None
|
app/replace_engine.py
CHANGED
|
@@ -10,6 +10,7 @@ from telethon import errors, functions, types
|
|
| 10 |
|
| 11 |
from .handleswap import compute_handle_changes
|
| 12 |
from .linkswap import compute_message_changes
|
|
|
|
| 13 |
from .tgutil import (
|
| 14 |
build_markup,
|
| 15 |
de_entities,
|
|
@@ -230,6 +231,9 @@ async def apply_channel(
|
|
| 230 |
preview = preview_flag(m)
|
| 231 |
markup = change.new_markup if change.new_markup is not None else m.reply_markup
|
| 232 |
old_state = state_of(m.message, m.entities, m.reply_markup, preview)
|
|
|
|
|
|
|
|
|
|
| 233 |
try:
|
| 234 |
await flood_retry(lambda: _do_edit(client, peer, m, change, markup, preview))
|
| 235 |
except errors.MessageNotModifiedError:
|
|
@@ -256,11 +260,20 @@ async def apply_channel(
|
|
| 256 |
}
|
| 257 |
if kind:
|
| 258 |
adopt["media_kind"], adopt["media_file_id"] = kind, fid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
buf.append(
|
| 260 |
{
|
| 261 |
"message_id": m.id,
|
| 262 |
"old_state": old_state,
|
| 263 |
-
"new_state":
|
| 264 |
"adopt": adopt,
|
| 265 |
}
|
| 266 |
)
|
|
@@ -298,17 +311,22 @@ async def undo_batch(
|
|
| 298 |
continue
|
| 299 |
old = c.old_state or {}
|
| 300 |
try:
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
peer_of(ch),
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
)
|
| 311 |
-
)
|
| 312 |
except errors.MessageNotModifiedError:
|
| 313 |
pass
|
| 314 |
except errors.RPCError as e:
|
|
|
|
| 10 |
|
| 11 |
from .handleswap import compute_handle_changes
|
| 12 |
from .linkswap import compute_message_changes
|
| 13 |
+
from .richmsg import entry as rich_entry, is_rich_message, plain_of_message, rich_from_received, rich_of
|
| 14 |
from .tgutil import (
|
| 15 |
build_markup,
|
| 16 |
de_entities,
|
|
|
|
| 231 |
preview = preview_flag(m)
|
| 232 |
markup = change.new_markup if change.new_markup is not None else m.reply_markup
|
| 233 |
old_state = state_of(m.message, m.entities, m.reply_markup, preview)
|
| 234 |
+
rich = is_rich_message(m)
|
| 235 |
+
if rich: # its text is blocks this run never touches: undo puts the buttons back and nothing else
|
| 236 |
+
old_state["rich"] = True
|
| 237 |
try:
|
| 238 |
await flood_retry(lambda: _do_edit(client, peer, m, change, markup, preview))
|
| 239 |
except errors.MessageNotModifiedError:
|
|
|
|
| 260 |
}
|
| 261 |
if kind:
|
| 262 |
adopt["media_kind"], adopt["media_file_id"] = kind, fid
|
| 263 |
+
new_state = state_of(change.new_text, change.new_entities, markup, preview)
|
| 264 |
+
if rich:
|
| 265 |
+
new_state["rich"] = True
|
| 266 |
+
saved = await db.find_by_message(ch.id, m.id)
|
| 267 |
+
if saved is not None and rich_of(saved) is not None:
|
| 268 |
+
del adopt["text"], adopt["entities"] # the saved copy keeps what it was sent from
|
| 269 |
+
else: # a rich message nobody saved yet: it is taken over as its blocks
|
| 270 |
+
adopt["text"] = plain_of_message(m)
|
| 271 |
+
adopt["entities"] = [rich_entry(rich_from_received(m.rich_message))]
|
| 272 |
buf.append(
|
| 273 |
{
|
| 274 |
"message_id": m.id,
|
| 275 |
"old_state": old_state,
|
| 276 |
+
"new_state": new_state,
|
| 277 |
"adopt": adopt,
|
| 278 |
}
|
| 279 |
)
|
|
|
|
| 311 |
continue
|
| 312 |
old = c.old_state or {}
|
| 313 |
try:
|
| 314 |
+
if old.get("rich"): # a rich message: only its buttons were changed, so only they are put back
|
| 315 |
+
markup = build_markup(old.get("buttons"))
|
| 316 |
+
if markup is not None:
|
| 317 |
+
await flood_retry(lambda: edit_raw(client, peer_of(ch), c.message_id, markup=markup))
|
| 318 |
+
else:
|
| 319 |
+
await flood_retry(
|
| 320 |
+
lambda: edit_raw(
|
| 321 |
+
client,
|
| 322 |
+
peer_of(ch),
|
| 323 |
+
c.message_id,
|
| 324 |
+
text=old.get("text", ""),
|
| 325 |
+
entities=de_entities(old.get("entities")),
|
| 326 |
+
markup=build_markup(old.get("buttons")),
|
| 327 |
+
preview=old.get("preview"),
|
| 328 |
+
)
|
| 329 |
)
|
|
|
|
| 330 |
except errors.MessageNotModifiedError:
|
| 331 |
pass
|
| 332 |
except errors.RPCError as e:
|
app/repost_engine.py
CHANGED
|
@@ -42,6 +42,7 @@ from .linkswap import compute_message_changes, url_only_markup
|
|
| 42 |
from .postlinks import PostLinks, relink_message
|
| 43 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, is_skippable, preview_flag
|
| 44 |
from .restrictions import PLACEHOLDER_NOTE, is_placeholder_text, message_restriction, probe_channel
|
|
|
|
| 45 |
from .tgutil import (
|
| 46 |
FATAL,
|
| 47 |
build_markup,
|
|
@@ -89,6 +90,16 @@ class Final:
|
|
| 89 |
dropped: int = 0 # other bots' buttons that are not copied
|
| 90 |
n_post_links: int = 0 # links to other posts of the same channel (whatever their new id will be)
|
| 91 |
n_relinked: int = 0 # of those, how many were already pointed at a copy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
def final_state(src, opts: RepostOptions, links: Optional[PostLinks] = None) -> Final:
|
|
@@ -99,8 +110,11 @@ def final_state(src, opts: RepostOptions, links: Optional[PostLinks] = None) ->
|
|
| 99 |
change, _ = compute_message_changes(
|
| 100 |
src, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 101 |
)
|
|
|
|
| 102 |
text = change.new_text if change else (src.message or "")
|
| 103 |
entities = list(change.new_entities) if change else list(src.entities or [])
|
|
|
|
|
|
|
| 104 |
# text mentions need an access hash to be re-sent; they are rare in channel posts
|
| 105 |
entities = [e for e in entities if not isinstance(e, types.MessageEntityMentionName)]
|
| 106 |
markup, dropped = url_only_markup(
|
|
@@ -130,6 +144,7 @@ def final_state(src, opts: RepostOptions, links: Optional[PostLinks] = None) ->
|
|
| 130 |
dropped=dropped,
|
| 131 |
n_post_links=n_post_links,
|
| 132 |
n_relinked=n_relinked,
|
|
|
|
| 133 |
)
|
| 134 |
|
| 135 |
|
|
@@ -212,7 +227,9 @@ def copy_source(unit: list, rows: dict, *, restricted: Optional[str] = None, use
|
|
| 212 |
return "saved", why
|
| 213 |
for m in unit:
|
| 214 |
live = m.message or ""
|
| 215 |
-
if is_placeholder_text(live) or (
|
|
|
|
|
|
|
| 216 |
return "skip", why
|
| 217 |
return "live", why
|
| 218 |
|
|
@@ -223,6 +240,8 @@ def stand_in(m, row):
|
|
| 223 |
s = copy.copy(m)
|
| 224 |
s.message = row.text or ""
|
| 225 |
s.entities = de_entities(row.entities)
|
|
|
|
|
|
|
| 226 |
s.reply_markup = build_markup(row.buttons)
|
| 227 |
s.media = None
|
| 228 |
s.saved_ref = None # the saved media reference (resend_saved may need it again without its file reference)
|
|
@@ -267,7 +286,7 @@ def without_files(unit: list) -> tuple:
|
|
| 267 |
c = copy.copy(m)
|
| 268 |
c.media, c.saved_ref, c.invert_media = None, None, None
|
| 269 |
lost += 1
|
| 270 |
-
if (c.message or "").strip():
|
| 271 |
out.append(c)
|
| 272 |
return out, lost
|
| 273 |
|
|
@@ -592,7 +611,22 @@ async def resend_unit(client, peer, unit: list, finals: list, reply_to: Optional
|
|
| 592 |
"""Rebuild a post from its parts (used when copying is not allowed, or the copy must differ from the original).
|
| 593 |
`reply_to`: message id (in the channel `peer`) the new post answers."""
|
| 594 |
reply = reply_header(reply_to)
|
| 595 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
singles = [
|
| 597 |
types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
|
| 598 |
for m, f in zip(unit, finals)
|
|
@@ -634,7 +668,7 @@ async def resend_unit(client, peer, unit: list, finals: list, reply_to: Optional
|
|
| 634 |
reply_to=reply,
|
| 635 |
)
|
| 636 |
|
| 637 |
-
result = await flood_retry(lambda: client(make()))
|
| 638 |
news = new_messages_from(result)
|
| 639 |
if len(news) != len(unit):
|
| 640 |
await delete_ids(client, peer, [n.id for n in news])
|
|
@@ -847,8 +881,8 @@ def _rows(unit: list, finals: list, news: list) -> list:
|
|
| 847 |
"old_id": src.id,
|
| 848 |
"new_id": new.id,
|
| 849 |
"post": {
|
| 850 |
-
"text": f.text,
|
| 851 |
-
"entities": ser_entities(f.entities),
|
| 852 |
"buttons": ser_markup(f.markup),
|
| 853 |
"media_kind": kind,
|
| 854 |
"media_file_id": ref,
|
|
@@ -1131,12 +1165,16 @@ async def relink_copies(
|
|
| 1131 |
if should_stop and should_stop():
|
| 1132 |
res.stopped = True
|
| 1133 |
return res
|
|
|
|
| 1134 |
try:
|
| 1135 |
-
|
| 1136 |
-
lambda: edit_raw(
|
| 1137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1138 |
)
|
| 1139 |
-
)
|
| 1140 |
if link_urls(rl.markup):
|
| 1141 |
await ensure_markup(client, peer, m.id, rl.markup, pause=settle_pause)
|
| 1142 |
except errors.MessageNotModifiedError:
|
|
@@ -1155,9 +1193,10 @@ async def relink_copies(
|
|
| 1155 |
res.relinked += 1
|
| 1156 |
res.links += rl.links
|
| 1157 |
try:
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
|
|
|
| 1161 |
except Exception: # the channel is right; the saved copy will be corrected by the next sync
|
| 1162 |
log.warning("could not save the new links of %s", m.id, exc_info=True)
|
| 1163 |
if progress:
|
|
|
|
| 42 |
from .postlinks import PostLinks, relink_message
|
| 43 |
from .replace_engine import CHUNK, FALLBACK_EMPTY_BATCHES, fetch_messages, is_skippable, preview_flag
|
| 44 |
from .restrictions import PLACEHOLDER_NOTE, is_placeholder_text, message_restriction, probe_channel
|
| 45 |
+
from .richmsg import Rich, entry as rich_entry, is_rich_message, plain_of, rich_from_received, rich_of, with_rich
|
| 46 |
from .tgutil import (
|
| 47 |
FATAL,
|
| 48 |
build_markup,
|
|
|
|
| 90 |
dropped: int = 0 # other bots' buttons that are not copied
|
| 91 |
n_post_links: int = 0 # links to other posts of the same channel (whatever their new id will be)
|
| 92 |
n_relinked: int = 0 # of those, how many were already pointed at a copy
|
| 93 |
+
rich: Optional[Rich] = None # the post is a rich message: this is what the copy is made from (text / entities are empty)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def rich_for(src) -> Optional[Rich]:
|
| 97 |
+
"""The rich message that `src` is - a live post, or a stand-in made from a saved post - or None for an ordinary post."""
|
| 98 |
+
saved = getattr(src, "rich_saved", None)
|
| 99 |
+
if saved is not None:
|
| 100 |
+
return saved
|
| 101 |
+
rm = getattr(src, "rich_message", None)
|
| 102 |
+
return rich_from_received(rm) if rm is not None else None
|
| 103 |
|
| 104 |
|
| 105 |
def final_state(src, opts: RepostOptions, links: Optional[PostLinks] = None) -> Final:
|
|
|
|
| 110 |
change, _ = compute_message_changes(
|
| 111 |
src, opts.old, opts.new, include_typed=opts.include_typed, include_posts=opts.include_posts
|
| 112 |
)
|
| 113 |
+
rich = rich_for(src)
|
| 114 |
text = change.new_text if change else (src.message or "")
|
| 115 |
entities = list(change.new_entities) if change else list(src.entities or [])
|
| 116 |
+
if rich is not None: # a rich message's text is blocks: nothing of it is changed here, only its buttons
|
| 117 |
+
text, entities = "", []
|
| 118 |
# text mentions need an access hash to be re-sent; they are rare in channel posts
|
| 119 |
entities = [e for e in entities if not isinstance(e, types.MessageEntityMentionName)]
|
| 120 |
markup, dropped = url_only_markup(
|
|
|
|
| 144 |
dropped=dropped,
|
| 145 |
n_post_links=n_post_links,
|
| 146 |
n_relinked=n_relinked,
|
| 147 |
+
rich=rich,
|
| 148 |
)
|
| 149 |
|
| 150 |
|
|
|
|
| 227 |
return "saved", why
|
| 228 |
for m in unit:
|
| 229 |
live = m.message or ""
|
| 230 |
+
if is_placeholder_text(live) or (
|
| 231 |
+
not live.strip() and getattr(m, "media", None) is None and rich_for(m) is None
|
| 232 |
+
):
|
| 233 |
return "skip", why
|
| 234 |
return "live", why
|
| 235 |
|
|
|
|
| 240 |
s = copy.copy(m)
|
| 241 |
s.message = row.text or ""
|
| 242 |
s.entities = de_entities(row.entities)
|
| 243 |
+
s.rich_message = None # what it shows is what My posts saved ...
|
| 244 |
+
s.rich_saved = rich_of(row) # ... which is a rich message when the saved post is one
|
| 245 |
s.reply_markup = build_markup(row.buttons)
|
| 246 |
s.media = None
|
| 247 |
s.saved_ref = None # the saved media reference (resend_saved may need it again without its file reference)
|
|
|
|
| 286 |
c = copy.copy(m)
|
| 287 |
c.media, c.saved_ref, c.invert_media = None, None, None
|
| 288 |
lost += 1
|
| 289 |
+
if (c.message or "").strip() or rich_for(c) is not None:
|
| 290 |
out.append(c)
|
| 291 |
return out, lost
|
| 292 |
|
|
|
|
| 611 |
"""Rebuild a post from its parts (used when copying is not allowed, or the copy must differ from the original).
|
| 612 |
`reply_to`: message id (in the channel `peer`) the new post answers."""
|
| 613 |
reply = reply_header(reply_to)
|
| 614 |
+
sender = None
|
| 615 |
+
if len(unit) == 1 and getattr(finals[0], "rich", None) is not None:
|
| 616 |
+
rich_final = finals[0] # a rich message: Telegram builds it again from its blocks (or its HTML / Markdown)
|
| 617 |
+
|
| 618 |
+
async def send_rich_final():
|
| 619 |
+
return await with_rich(
|
| 620 |
+
rich_final.rich,
|
| 621 |
+
lambda inp: client(
|
| 622 |
+
functions.messages.SendMessageRequest(
|
| 623 |
+
peer=peer, message="", rich_message=inp, reply_markup=rich_final.markup, silent=True, reply_to=reply
|
| 624 |
+
)
|
| 625 |
+
),
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
sender = send_rich_final
|
| 629 |
+
elif len(unit) > 1:
|
| 630 |
singles = [
|
| 631 |
types.InputSingleMedia(media=utils.get_input_media(m.media), message=f.text, entities=f.entities or None)
|
| 632 |
for m, f in zip(unit, finals)
|
|
|
|
| 668 |
reply_to=reply,
|
| 669 |
)
|
| 670 |
|
| 671 |
+
result = await flood_retry(sender if sender is not None else (lambda: client(make())))
|
| 672 |
news = new_messages_from(result)
|
| 673 |
if len(news) != len(unit):
|
| 674 |
await delete_ids(client, peer, [n.id for n in news])
|
|
|
|
| 881 |
"old_id": src.id,
|
| 882 |
"new_id": new.id,
|
| 883 |
"post": {
|
| 884 |
+
"text": plain_of(f.rich) if f.rich is not None else f.text,
|
| 885 |
+
"entities": [rich_entry(f.rich)] if f.rich is not None else ser_entities(f.entities),
|
| 886 |
"buttons": ser_markup(f.markup),
|
| 887 |
"media_kind": kind,
|
| 888 |
"media_file_id": ref,
|
|
|
|
| 1165 |
if should_stop and should_stop():
|
| 1166 |
res.stopped = True
|
| 1167 |
return res
|
| 1168 |
+
rich = is_rich_message(m) # a rich message's text is blocks: only its buttons change, the text is not sent again
|
| 1169 |
try:
|
| 1170 |
+
if rich:
|
| 1171 |
+
await flood_retry(lambda: edit_raw(client, peer, m.id, markup=rl.markup))
|
| 1172 |
+
else:
|
| 1173 |
+
await flood_retry(
|
| 1174 |
+
lambda: edit_raw(
|
| 1175 |
+
client, peer, m.id, text=rl.text, entities=rl.entities, markup=rl.markup, preview=preview_flag(m)
|
| 1176 |
+
)
|
| 1177 |
)
|
|
|
|
| 1178 |
if link_urls(rl.markup):
|
| 1179 |
await ensure_markup(client, peer, m.id, rl.markup, pause=settle_pause)
|
| 1180 |
except errors.MessageNotModifiedError:
|
|
|
|
| 1193 |
res.relinked += 1
|
| 1194 |
res.links += rl.links
|
| 1195 |
try:
|
| 1196 |
+
saved = dict(buttons=ser_markup(rl.markup))
|
| 1197 |
+
if not rich:
|
| 1198 |
+
saved.update(text=rl.text, entities=ser_entities(rl.entities))
|
| 1199 |
+
await db.update_post_by_message(ch.id, m.id, **saved)
|
| 1200 |
except Exception: # the channel is right; the saved copy will be corrected by the next sync
|
| 1201 |
log.warning("could not save the new links of %s", m.id, exc_info=True)
|
| 1202 |
if progress:
|
app/richmsg.py
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Telegram rich messages: headings, lists, tables, details, math, maps, media blocks, buttons ...
|
| 2 |
+
|
| 3 |
+
A rich message is not text plus entities: it is a tree of blocks. Telegram builds that tree itself from HTML or Markdown
|
| 4 |
+
(InputRichMessageHTML / InputRichMessageMarkdown), so text typed in those languages is handed over unchanged - every block
|
| 5 |
+
and every formatting Telegram knows is supported without this bot having to know it. A rich message that is sent to the
|
| 6 |
+
bot natively (or forwarded to it) is kept block for block (InputRichMessage).
|
| 7 |
+
|
| 8 |
+
How the bot keeps one: as a single entry in the post's `entities` list - ``{"k": "rich", "f": "html" | "markdown" |
|
| 9 |
+
"blocks", "s": source, ...}`` - next to a plain-text version in `text` (for lists, panels and the like). Whatever sends or
|
| 10 |
+
edits a post looks for that entry with `rich_of`. No database change is needed, and exports, backups and the undo log carry
|
| 11 |
+
it like any other formatting.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import copy
|
| 16 |
+
import hashlib
|
| 17 |
+
import logging
|
| 18 |
+
import re
|
| 19 |
+
from dataclasses import dataclass, replace
|
| 20 |
+
from html.parser import HTMLParser
|
| 21 |
+
from types import SimpleNamespace
|
| 22 |
+
from typing import List, Optional
|
| 23 |
+
|
| 24 |
+
from telethon import errors, functions, types, utils
|
| 25 |
+
|
| 26 |
+
from .tgutil import _b64, _unb64
|
| 27 |
+
|
| 28 |
+
log = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
KIND = "rich" # the "k" of the entry in a post's entities
|
| 31 |
+
MAX_SOURCE = 32768 # characters Telegram allows in a rich message (500 blocks, 16 levels and 50 media as well)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class RichError(ValueError):
|
| 35 |
+
"""A rich message that can't be used; the text is safe to show to the owner."""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class Rich:
|
| 40 |
+
"""A rich message as the bot keeps it."""
|
| 41 |
+
|
| 42 |
+
fmt: str # "html" | "markdown" (typed: Telegram reads it) | "blocks" (received: base64 of an InputRichMessage)
|
| 43 |
+
source: str
|
| 44 |
+
rtl: bool = False
|
| 45 |
+
noautolink: bool = False # links, @mentions, #tags ... in the text are not made clickable by Telegram
|
| 46 |
+
fp: Optional[str] = None # fingerprint of the blocks Telegram showed last time (what the sync compares)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def entry(r: Rich) -> dict:
|
| 50 |
+
"""The entry that stands for `r` in a post's entities."""
|
| 51 |
+
e: dict = {"k": KIND, "f": r.fmt, "s": r.source}
|
| 52 |
+
if r.rtl:
|
| 53 |
+
e["r"] = 1
|
| 54 |
+
if r.noautolink:
|
| 55 |
+
e["n"] = 1
|
| 56 |
+
if r.fp:
|
| 57 |
+
e["p"] = r.fp
|
| 58 |
+
return e
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def rich_of(obj) -> Optional[Rich]:
|
| 62 |
+
"""The rich message a post (or anything with a post's `entities`) holds, or None."""
|
| 63 |
+
for d in getattr(obj, "entities", None) or []:
|
| 64 |
+
if isinstance(d, dict) and d.get("k") == KIND and d.get("f") in ("html", "markdown", "blocks") and d.get("s"):
|
| 65 |
+
return Rich(d["f"], d["s"], bool(d.get("r")), bool(d.get("n")), d.get("p") or None)
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def is_rich(obj) -> bool:
|
| 70 |
+
return rich_of(obj) is not None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def real_entities(entities) -> list:
|
| 74 |
+
"""The formatting entries of a stored entities list, without the rich message's."""
|
| 75 |
+
return [d for d in entities or [] if not (isinstance(d, dict) and d.get("k") == KIND)]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------------------------------ what Telegram takes
|
| 79 |
+
def to_input(r: Rich):
|
| 80 |
+
"""The InputRichMessage* object to send for `r`."""
|
| 81 |
+
flags = dict(rtl=True if r.rtl else None, noautolink=True if r.noautolink else None)
|
| 82 |
+
if r.fmt == "html":
|
| 83 |
+
return types.InputRichMessageHTML(html=r.source, **flags)
|
| 84 |
+
if r.fmt == "markdown":
|
| 85 |
+
return types.InputRichMessageMarkdown(markdown=r.source, **flags)
|
| 86 |
+
return _unb64(r.source)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def blank_references(inp):
|
| 90 |
+
"""The same InputRichMessage with the file references of its photos and files left out (they may have expired)."""
|
| 91 |
+
if not isinstance(inp, types.InputRichMessage):
|
| 92 |
+
return inp
|
| 93 |
+
out = copy.copy(inp)
|
| 94 |
+
out.photos = [types.InputPhoto(p.id, p.access_hash, b"") for p in inp.photos or []] or None
|
| 95 |
+
out.documents = [types.InputDocument(d.id, d.access_hash, b"") for d in inp.documents or []] or None
|
| 96 |
+
return out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def with_rich(r: Rich, call):
|
| 100 |
+
"""call(InputRichMessage*): first as saved, then (if Telegram says a saved file reference expired) without them."""
|
| 101 |
+
try:
|
| 102 |
+
return await call(to_input(r))
|
| 103 |
+
except errors.RPCError as e:
|
| 104 |
+
if r.fmt != "blocks" or "FileReference" not in type(e).__name__:
|
| 105 |
+
raise
|
| 106 |
+
log.info("stored file reference of a rich message rejected (%s) - retrying without it", type(e).__name__)
|
| 107 |
+
return await call(blank_references(to_input(r)))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def sent_message(result):
|
| 111 |
+
"""The message a send request created (something with an `id`)."""
|
| 112 |
+
for u in getattr(result, "updates", None) or []:
|
| 113 |
+
if isinstance(u, (types.UpdateNewChannelMessage, types.UpdateNewMessage)):
|
| 114 |
+
return u.message
|
| 115 |
+
if getattr(result, "id", None) is not None and not isinstance(result, (list, tuple)):
|
| 116 |
+
return SimpleNamespace(id=result.id) # UpdateShortSentMessage
|
| 117 |
+
raise RuntimeError("Telegram did not say which message it created")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def send_rich(client, peer, r: Rich, *, markup=None, silent=None):
|
| 121 |
+
"""Send `r` as a new message to `peer`; the message that was created comes back."""
|
| 122 |
+
result = await with_rich(
|
| 123 |
+
r,
|
| 124 |
+
lambda inp: client(
|
| 125 |
+
functions.messages.SendMessageRequest(
|
| 126 |
+
peer=peer, message="", rich_message=inp, reply_markup=markup, silent=True if silent else None
|
| 127 |
+
)
|
| 128 |
+
),
|
| 129 |
+
)
|
| 130 |
+
return sent_message(result)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# --------------------------------------------------------------------------------- rich messages that are received
|
| 134 |
+
def is_rich_message(m) -> bool:
|
| 135 |
+
"""The message carries a rich message (blocks) instead of plain text."""
|
| 136 |
+
return getattr(m, "rich_message", None) is not None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def fingerprint(rm) -> str:
|
| 140 |
+
"""Identifies what a rich message shows (its blocks; the file references of its pictures change from fetch to fetch)."""
|
| 141 |
+
h = hashlib.sha1()
|
| 142 |
+
for b in getattr(rm, "blocks", None) or []:
|
| 143 |
+
h.update(bytes(b))
|
| 144 |
+
h.update(b"r" if getattr(rm, "rtl", None) else b"-")
|
| 145 |
+
return h.hexdigest()[:20]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _input_files(items, getter, empty) -> list:
|
| 149 |
+
out = []
|
| 150 |
+
for item in items or []:
|
| 151 |
+
try:
|
| 152 |
+
inp = getter(item)
|
| 153 |
+
except (TypeError, ValueError):
|
| 154 |
+
continue
|
| 155 |
+
if not isinstance(inp, empty):
|
| 156 |
+
out.append(inp)
|
| 157 |
+
return out
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def input_of(rm, users: Optional[list] = None):
|
| 161 |
+
"""The InputRichMessage that sends a received RichMessage again: same blocks, same pictures and files."""
|
| 162 |
+
photos = _input_files(getattr(rm, "photos", None), utils.get_input_photo, types.InputPhotoEmpty)
|
| 163 |
+
docs = _input_files(getattr(rm, "documents", None), utils.get_input_document, types.InputDocumentEmpty)
|
| 164 |
+
return types.InputRichMessage(
|
| 165 |
+
blocks=list(rm.blocks),
|
| 166 |
+
rtl=True if getattr(rm, "rtl", None) else None,
|
| 167 |
+
photos=photos or None,
|
| 168 |
+
documents=docs or None,
|
| 169 |
+
users=users or None,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def rich_from_received(rm, users: Optional[list] = None) -> Rich:
|
| 174 |
+
return Rich("blocks", _b64(input_of(rm, users)), bool(getattr(rm, "rtl", None)), False, fingerprint(rm))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def mention_ids(blocks) -> list:
|
| 178 |
+
"""User ids that the blocks mention by link (a text mention needs the account's access data to be sent again)."""
|
| 179 |
+
found: list = []
|
| 180 |
+
|
| 181 |
+
def walk(o) -> None:
|
| 182 |
+
if isinstance(o, dict):
|
| 183 |
+
if o.get("_") == "TextMentionName" and isinstance(o.get("user_id"), int):
|
| 184 |
+
found.append(o["user_id"])
|
| 185 |
+
for v in o.values():
|
| 186 |
+
walk(v)
|
| 187 |
+
elif isinstance(o, list):
|
| 188 |
+
for v in o:
|
| 189 |
+
walk(v)
|
| 190 |
+
|
| 191 |
+
for b in blocks or []:
|
| 192 |
+
walk(b.to_dict())
|
| 193 |
+
return list(dict.fromkeys(found))
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
async def input_users(client, ids: list) -> list:
|
| 197 |
+
out = []
|
| 198 |
+
for uid in ids:
|
| 199 |
+
try:
|
| 200 |
+
peer = await client.get_input_entity(uid)
|
| 201 |
+
except Exception as e: # noqa: BLE001 - a mention that can't be resolved is sent as the plain name
|
| 202 |
+
log.info("could not resolve the mentioned user %s: %s", uid, type(e).__name__)
|
| 203 |
+
continue
|
| 204 |
+
if isinstance(peer, types.InputPeerUser):
|
| 205 |
+
out.append(types.InputUser(peer.user_id, peer.access_hash))
|
| 206 |
+
return out
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
async def received(client, event) -> Optional[Rich]:
|
| 210 |
+
"""The rich message that `event`'s message is (sent natively or forwarded to the bot), or None for an ordinary message."""
|
| 211 |
+
m = event.message
|
| 212 |
+
rm = getattr(m, "rich_message", None)
|
| 213 |
+
if rm is None:
|
| 214 |
+
return None
|
| 215 |
+
if getattr(rm, "part", None): # Telegram sent a part of it; the rest has to be asked for
|
| 216 |
+
full = None
|
| 217 |
+
try:
|
| 218 |
+
got = await client(functions.messages.GetRichMessageRequest(peer=await event.get_input_chat(), id=m.id))
|
| 219 |
+
full = got if isinstance(got, types.RichMessage) else getattr(got, "rich_message", None)
|
| 220 |
+
except errors.RPCError as e:
|
| 221 |
+
log.info("could not read the whole rich message: %s", type(e).__name__)
|
| 222 |
+
if full is None or getattr(full, "part", None):
|
| 223 |
+
raise RichError("Telegram only handed me a part of that rich message. Send it again, or type it as HTML / Markdown.")
|
| 224 |
+
rm = full
|
| 225 |
+
if not getattr(rm, "blocks", None):
|
| 226 |
+
raise RichError("That rich message has no content.")
|
| 227 |
+
return rich_from_received(rm, await input_users(client, mention_ids(rm.blocks)))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# ------------------------------------------------------------------------- plain text (lists, panels, previews)
|
| 231 |
+
_BLOCKY = ("h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "table", "tr", "details", "summary", "figure",
|
| 232 |
+
"figcaption", "aside", "blockquote", "pre", "footer", "hr", "br", "tg-math-block", "tg-button-row", "tg-collage",
|
| 233 |
+
"tg-slideshow", "tg-map", "img", "video", "audio", "tg-document", "caption", "cite")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class _Plain(HTMLParser):
|
| 237 |
+
def __init__(self) -> None:
|
| 238 |
+
super().__init__(convert_charrefs=True)
|
| 239 |
+
self.out: List[str] = []
|
| 240 |
+
self.skip = 0
|
| 241 |
+
|
| 242 |
+
def _break(self, tag: str) -> None:
|
| 243 |
+
"""A line break for a block; two blocks in a row are only one line apart (a <br> is always a break)."""
|
| 244 |
+
if tag == "br" or (self.out and not self.out[-1].endswith("\n")):
|
| 245 |
+
self.out.append("\n")
|
| 246 |
+
|
| 247 |
+
def handle_starttag(self, tag, attrs):
|
| 248 |
+
if tag in ("script", "style"):
|
| 249 |
+
self.skip += 1
|
| 250 |
+
if tag in _BLOCKY:
|
| 251 |
+
self._break(tag)
|
| 252 |
+
if tag == "li":
|
| 253 |
+
self.out.append("• ")
|
| 254 |
+
elif tag in ("td", "th"):
|
| 255 |
+
self.out.append(" | " if self.out and not self.out[-1].endswith(("\n", " | ")) else "")
|
| 256 |
+
elif tag == "input" and any(k == "type" and v == "checkbox" for k, v in attrs):
|
| 257 |
+
self.out.append("☑ " if any(k == "checked" for k, _ in attrs) else "☐ ")
|
| 258 |
+
|
| 259 |
+
def handle_startendtag(self, tag, attrs):
|
| 260 |
+
self.handle_starttag(tag, attrs)
|
| 261 |
+
|
| 262 |
+
def handle_endtag(self, tag):
|
| 263 |
+
if tag in ("script", "style") and self.skip:
|
| 264 |
+
self.skip -= 1
|
| 265 |
+
if tag in _BLOCKY:
|
| 266 |
+
self._break(tag)
|
| 267 |
+
|
| 268 |
+
def handle_data(self, data):
|
| 269 |
+
if not self.skip:
|
| 270 |
+
self.out.append(data)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _tidy(text: str) -> str:
|
| 274 |
+
lines = [" ".join(line.split()) for line in text.replace("\r", "").split("\n")]
|
| 275 |
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _plain_html(source: str) -> str:
|
| 279 |
+
p = _Plain()
|
| 280 |
+
try:
|
| 281 |
+
p.feed(source)
|
| 282 |
+
p.close()
|
| 283 |
+
except Exception: # noqa: BLE001 - a snippet is not worth failing for
|
| 284 |
+
return _tidy(re.sub(r"<[^>]*>", " ", source))
|
| 285 |
+
return _tidy("".join(p.out))
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
_MD_EMPHASIS = re.compile(r"(\*\*|__|~~|\|\||==)(?=\S)(.+?)(?<=\S)\1")
|
| 289 |
+
_MD_SINGLE = re.compile(r"(?<![\w*])([*_])(?=\S)([^*_\n]+?)(?<=\S)\1(?![\w*])")
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _plain_markdown(source: str) -> str:
|
| 293 |
+
out: List[str] = []
|
| 294 |
+
fenced = False
|
| 295 |
+
for line in source.replace("\r", "").split("\n"):
|
| 296 |
+
if re.match(r"^\s*(```|~~~)", line):
|
| 297 |
+
fenced = not fenced
|
| 298 |
+
continue
|
| 299 |
+
if fenced:
|
| 300 |
+
out.append(line)
|
| 301 |
+
continue
|
| 302 |
+
if re.match(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$", line): # a table's delimiter row
|
| 303 |
+
continue
|
| 304 |
+
if re.match(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$", line):
|
| 305 |
+
out.append("")
|
| 306 |
+
continue
|
| 307 |
+
line = re.sub(r"^\s{0,3}#{1,6}\s+", "", line)
|
| 308 |
+
line = re.sub(r"^(\s*)(?:>\s?)+", r"\1", line)
|
| 309 |
+
line = re.sub(r"^(\s*)[-*+]\s+\[[xX]\]\s+", r"\1☑ ", line)
|
| 310 |
+
line = re.sub(r"^(\s*)[-*+]\s+\[ \]\s+", r"\1☐ ", line)
|
| 311 |
+
line = re.sub(r"^(\s*)[-*+]\s+", r"\1• ", line)
|
| 312 |
+
if "|" in line and line.count("|") >= 2:
|
| 313 |
+
line = " | ".join(c.strip() for c in line.strip().strip("|").split("|"))
|
| 314 |
+
line = re.sub(r"!\[([^\]]*)\]\([^)]*?(?:\s+\"([^\"]*)\")?\)", lambda m: m.group(2) or m.group(1), line)
|
| 315 |
+
line = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", line)
|
| 316 |
+
line = re.sub(r"^\[\^([^\]\s]+)\]:[ \t]*", r"\1: ", line) # a footnote's text, led by its mark
|
| 317 |
+
line = re.sub(r"\[\^[^\]]+\]", "", line)
|
| 318 |
+
line = _MD_EMPHASIS.sub(r"\2", line)
|
| 319 |
+
line = _MD_SINGLE.sub(r"\2", line)
|
| 320 |
+
line = line.replace("`", "")
|
| 321 |
+
line = re.sub(r"\\([\\`*_{}\[\]()#+\-.!|~>$=])", r"\1", line)
|
| 322 |
+
out.append(line)
|
| 323 |
+
return _tidy("\n".join(out))
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _plain_blocks(blocks) -> str:
|
| 327 |
+
out: List[str] = []
|
| 328 |
+
|
| 329 |
+
def brk() -> None:
|
| 330 |
+
if out and not out[-1].endswith("\n"):
|
| 331 |
+
out.append("\n")
|
| 332 |
+
|
| 333 |
+
def walk(o) -> None:
|
| 334 |
+
if isinstance(o, dict):
|
| 335 |
+
name = str(o.get("_", ""))
|
| 336 |
+
if name == "PageTableRow":
|
| 337 |
+
brk()
|
| 338 |
+
for n, cell in enumerate(o.get("cells") or []):
|
| 339 |
+
if n:
|
| 340 |
+
out.append(" | ")
|
| 341 |
+
walk(cell)
|
| 342 |
+
brk()
|
| 343 |
+
return
|
| 344 |
+
item = name.startswith(("PageListItem", "PageListOrderedItem"))
|
| 345 |
+
if name.startswith("PageBlock") or item:
|
| 346 |
+
brk()
|
| 347 |
+
if item:
|
| 348 |
+
out.append(("☑ " if o.get("checked") else "☐ ") if o.get("checkbox") else "• ")
|
| 349 |
+
for key in sorted(o, key=lambda k: k != "title"): # a title (of a <details>, a table) is read first
|
| 350 |
+
v = o[key]
|
| 351 |
+
if key in ("text", "source") and isinstance(v, str):
|
| 352 |
+
out.append(v)
|
| 353 |
+
elif isinstance(v, (dict, list)):
|
| 354 |
+
walk(v)
|
| 355 |
+
if name.startswith("PageBlock") or item:
|
| 356 |
+
brk()
|
| 357 |
+
elif isinstance(o, list):
|
| 358 |
+
for v in o:
|
| 359 |
+
walk(v)
|
| 360 |
+
|
| 361 |
+
for b in blocks or []:
|
| 362 |
+
try:
|
| 363 |
+
walk(b.to_dict())
|
| 364 |
+
except Exception: # noqa: BLE001
|
| 365 |
+
continue
|
| 366 |
+
return _tidy("".join(out))
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def plain_of(r: Rich) -> str:
|
| 370 |
+
"""What the rich message says, as plain text (what My posts shows and searches)."""
|
| 371 |
+
if r.fmt == "html":
|
| 372 |
+
return _plain_html(r.source)
|
| 373 |
+
if r.fmt == "markdown":
|
| 374 |
+
return _plain_markdown(r.source)
|
| 375 |
+
try:
|
| 376 |
+
inp = _unb64(r.source)
|
| 377 |
+
return _plain_blocks(getattr(inp, "blocks", None))
|
| 378 |
+
except Exception: # noqa: BLE001
|
| 379 |
+
return ""
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def plain_of_message(m) -> str:
|
| 383 |
+
return _plain_blocks(getattr(getattr(m, "rich_message", None), "blocks", None))
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# ---------------------------------------------------------------------------- reading what the owner typed
|
| 387 |
+
_FORCE = re.compile(r"\A[ \t]*<rich((?:[ \t]+(?:rtl|noautolink))*)[ \t]*>[ \t]*(?:\r?\n|\Z)", re.I)
|
| 388 |
+
|
| 389 |
+
# HTML only rich messages have: headings, paragraphs, lists, tables, details, figures, media, maps, math, buttons, marks ...
|
| 390 |
+
_RICH_TAGS = (
|
| 391 |
+
r"h[1-6]", "p", "ul", "ol", "li", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "details", "summary",
|
| 392 |
+
"figure", "figcaption", "aside", "hr", "img", "video", "audio", "footer", "cite", "input", "mark", "sub", "sup",
|
| 393 |
+
"tg-document", "tg-map", "tg-collage", "tg-slideshow", "tg-math", "tg-math-block", "tg-button", "tg-button-row",
|
| 394 |
+
"tg-time", "tg-reference",
|
| 395 |
+
)
|
| 396 |
+
_RICH_HTML = re.compile(
|
| 397 |
+
r"<(?:" + "|".join(t if t.startswith("h[") else re.escape(t) for t in _RICH_TAGS) + r")(?=[\s/>])[^<>]*>"
|
| 398 |
+
r"|<a\s[^<>]*?\b(?:name\s*=|href\s*=\s*[\"']?#)",
|
| 399 |
+
re.I,
|
| 400 |
+
)
|
| 401 |
+
_BASIC_HTML = re.compile(r"<(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji)(?=[\s/>])[^<>]*>", re.I)
|
| 402 |
+
|
| 403 |
+
# Markdown only rich messages have (a bullet list or a rule alone does not count: plain posts use those as decoration)
|
| 404 |
+
_MD_RICH = (
|
| 405 |
+
re.compile(r"^ {0,3}#{1,6}[ \t]+\S", re.M), # # heading
|
| 406 |
+
re.compile(r"^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[[ xX]\](?:[ \t]+\S|[ \t]*$)", re.M), # - [x] task
|
| 407 |
+
re.compile(r"^[^\n]*\|[^\n]*\n[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$", re.M), # | table |
|
| 408 |
+
re.compile(r"\$\$.+?\$\$", re.S), # $$ block math $$
|
| 409 |
+
re.compile(r"^\[\^[^\]\s]+\]:[ \t]+\S", re.M), # [^1]: footnote
|
| 410 |
+
re.compile(r"(?<![=\w])==(?=\S)[^=\n]+?(?<=\S)==(?![=\w])"), # ==marked==
|
| 411 |
+
re.compile(r"^[ \t]*!\[[^\]\n]*\]\((?!tg://(?:emoji|time)\b)[^)\s]+(?:[ \t]+\"[^\"\n]*\")?\)[ \t]*$", re.M), # 
|
| 412 |
+
re.compile(r"!\[[^\]\n]*\]\(tg://time\?[^)\s]+\)"), # a formatted date
|
| 413 |
+
)
|
| 414 |
+
_CODE = re.compile(r"```.*?```|~~~.*?~~~|<pre\b.*?</pre\s*>|<code\b.*?</code\s*>|`[^`\n]+`", re.S | re.I)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def _blank(m: "re.Match") -> str:
|
| 418 |
+
return re.sub(r"[^\n]", " ", m.group())
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def detect(text: str, forced: bool = False) -> Optional[str]:
|
| 422 |
+
""""html" or "markdown" when `text` needs a rich message (or `forced` says it is one), else None."""
|
| 423 |
+
bare = _CODE.sub(_blank, text) # what stands in code is not markup
|
| 424 |
+
html_hit = bool(_RICH_HTML.search(bare))
|
| 425 |
+
if html_hit and bare.lstrip().startswith("<"):
|
| 426 |
+
return "html"
|
| 427 |
+
if any(rx.search(bare) for rx in _MD_RICH):
|
| 428 |
+
return "markdown"
|
| 429 |
+
if html_hit:
|
| 430 |
+
return "html"
|
| 431 |
+
if forced:
|
| 432 |
+
return "html" if _BASIC_HTML.search(bare) else "markdown"
|
| 433 |
+
return None
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def read_typed(text: str) -> Optional[Rich]:
|
| 437 |
+
"""The rich message that text typed as HTML or Markdown stands for, or None when it is an ordinary text.
|
| 438 |
+
|
| 439 |
+
It is a rich message when it has something only rich messages have (a heading, a table, a task list, math, a picture,
|
| 440 |
+
<details> ...), or when its first line is ``<rich>`` (``<rich rtl>``, ``<rich noautolink>``). Telegram reads the
|
| 441 |
+
language itself; what is passed on is exactly what was typed (without that first line)."""
|
| 442 |
+
text = text or ""
|
| 443 |
+
force = _FORCE.match(text)
|
| 444 |
+
body = text[force.end():] if force else text
|
| 445 |
+
fmt = detect(body, forced=bool(force))
|
| 446 |
+
if fmt is None or not body.strip():
|
| 447 |
+
return None
|
| 448 |
+
words = (force.group(1) or "").lower().split() if force else []
|
| 449 |
+
return Rich(fmt, body.strip(), rtl="rtl" in words, noautolink="noautolink" in words)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def check(r: Rich) -> None:
|
| 453 |
+
"""RichError when Telegram would not take it (only the limit that is cheap to check here)."""
|
| 454 |
+
if r.fmt != "blocks" and len(r.source) > MAX_SOURCE:
|
| 455 |
+
raise RichError(f"A rich message can have at most {MAX_SOURCE} characters (yours has {len(r.source)}).")
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def with_fingerprint(r: Rich, fp: Optional[str]) -> Rich:
|
| 459 |
+
return replace(r, fp=fp)
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# --------------------------------------------------------------------------------------- the /rich help pages
|
| 463 |
+
RICH_HELP = (
|
| 464 |
+
"🧩 <b>Rich messages</b>\n\n"
|
| 465 |
+
"Telegram's newer message type: headings, lists, tables, expandable sections, math, maps, pictures and buttons in one "
|
| 466 |
+
"message (up to 32768 characters, 500 blocks, 16 nesting levels, 50 pictures/files, 20 table columns). The bot "
|
| 467 |
+
"publishes your text as one when\n"
|
| 468 |
+
"• you send it a rich message made in Telegram, or forward one: it is kept block for block, or\n"
|
| 469 |
+
"• you type HTML or Markdown that has something only rich messages have: a heading, a table, a task list, math, a "
|
| 470 |
+
"picture, <code><details></code>, <code><h1></code>, <code><table></code> ... or\n"
|
| 471 |
+
"• the first line is just <code><rich></code> (<code><rich rtl></code> for right-to-left text, "
|
| 472 |
+
"<code><rich noautolink></code> so links, @names and #tags are not made clickable).\n\n"
|
| 473 |
+
"Telegram reads the text itself, so everything it supports works. 👁 Preview shows what it made of your text before "
|
| 474 |
+
"you publish. Rich messages are for text posts - a photo's caption can't be rich. The post's own link buttons "
|
| 475 |
+
"(➕ Button) work with them.\n\n"
|
| 476 |
+
"<b>Markdown</b>\n"
|
| 477 |
+
"<code>**bold** *italic* ~~strike~~ ||spoiler|| ==marked== `code`</code> (underline, subscript and superscript: HTML "
|
| 478 |
+
"<code><u> <sub> <sup></code>)\n"
|
| 479 |
+
"<code># Heading</code> … <code>###### Heading</code>\n"
|
| 480 |
+
"<code>- item</code> <code>1. item</code> lists, <code>- [ ] task</code> <code>- [x] done</code>, <code>---</code> a rule\n"
|
| 481 |
+
"<code>> quote</code> (a bare <code>></code> line is a new paragraph inside it)\n"
|
| 482 |
+
"<code>```python</code> … <code>```</code> a code block with a language\n"
|
| 483 |
+
"<code>$x^2$</code> inline math, <code>$$E = mc^2$$</code> or a <code>```math</code> block: a formula block\n"
|
| 484 |
+
"<code>| a | b |</code> then <code>|:--|--:|</code> then rows: a table (colons set the alignment; cells hold inline "
|
| 485 |
+
"formatting only)\n"
|
| 486 |
+
"<code>text[^1]</code> and a line <code>[^1]: the note</code>: a footnote\n"
|
| 487 |
+
"<code></code> alone on a line: a picture, video, audio, GIF or file (https links "
|
| 488 |
+
"only); put several such lines inside <code><tg-collage></code> or <code><tg-slideshow></code>\n"
|
| 489 |
+
"<code></code> a date shown in the reader's time\n"
|
| 490 |
+
"<code></code> a custom emoji\n"
|
| 491 |
+
"<code>[name](tg://user?id=123)</code> <code>[mail](mailto:a@b.c)</code> <code>[call](tel:+123)</code>\n"
|
| 492 |
+
"Raw HTML works inside Markdown, but Markdown is not read inside block tags (only inside <code><details></code>, "
|
| 493 |
+
"<code><tg-collage></code> and <code><tg-slideshow></code>)."
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
RICH_HELP_HTML = (
|
| 497 |
+
"🧩 <b>Rich HTML</b>\n\n"
|
| 498 |
+
"<code><h1></code> … <code><h6></code> <code><p></code> <code><hr></code> "
|
| 499 |
+
"<code><footer></code> <code><b> <i> <u> <s> <mark> <sub> <sup> "
|
| 500 |
+
"<code> <tg-spoiler></code> <code><br></code>\n"
|
| 501 |
+
"<code><pre><code class=\"language-python\"></code> a code block\n"
|
| 502 |
+
"<code><ul></code> <code><ol start=\"3\" type=\"a\" reversed></code> "
|
| 503 |
+
"<code><li value=\"7\" type=\"i\"></code> (type: a, A, i, I, 1) "
|
| 504 |
+
"<code><input type=\"checkbox\" checked></code>\n"
|
| 505 |
+
"<code><blockquote expandable>text<cite>who</cite></blockquote></code> and "
|
| 506 |
+
"<code><aside>pull quote<cite>who</cite></aside></code>\n"
|
| 507 |
+
"<code><details open><summary>Title</summary>…</details></code> an expandable section\n"
|
| 508 |
+
"<code><table bordered striped compact><caption>…</caption><tr><th colspan=\"2\" "
|
| 509 |
+
"align=\"center\" valign=\"middle\">…</code> (td and th: colspan, rowspan, align left/center/right, valign "
|
| 510 |
+
"top/middle/bottom)\n"
|
| 511 |
+
"<code><tg-math>x^2</tg-math></code> <code><tg-math-block>E = mc^2</tg-math-block></code> "
|
| 512 |
+
"(raw LaTeX)\n"
|
| 513 |
+
"<code><tg-time unix=\"1647531900\" format=\"wDT\">22:45 tomorrow</tg-time></code>\n"
|
| 514 |
+
"<code><tg-emoji emoji-id=\"5368324170671202286\">🙂</tg-emoji></code>\n"
|
| 515 |
+
"<code><a name=\"top\"></a></code> and <code><a href=\"#top\"></code> anchors; "
|
| 516 |
+
"<code><tg-reference name=\"n1\">text</tg-reference></code> with <code><a href=\"#n1\"></code> "
|
| 517 |
+
"a footnote\n"
|
| 518 |
+
"<code><img src=\"https://…\"></code> <code><video src=…></code> <code><audio src=…></code> "
|
| 519 |
+
"<code><tg-document src=…></code>, each on its own or in "
|
| 520 |
+
"<code><figure>…<figcaption>caption<cite>credit</cite></figcaption></figure></code> "
|
| 521 |
+
"(https links only; <code>tg-spoiler</code> on img/video hides it); several inside "
|
| 522 |
+
"<code><tg-collage></code> or <code><tg-slideshow></code>\n"
|
| 523 |
+
"<code><tg-map lat=\"41.9\" long=\"12.5\" zoom=\"14\"/></code> a map\n"
|
| 524 |
+
"<code><tg-button-row align=\"center\"><tg-button type=\"url\" url=\"https://t.me\" "
|
| 525 |
+
"style=\"success\">Open</tg-button></tg-button-row></code> buttons in the text. Types: url, "
|
| 526 |
+
"web_app and login_url (url=), callback_data (data=), copy_text (text=), switch_inline_query, "
|
| 527 |
+
"switch_inline_query_current_chat, switch_inline_query_chosen_chat (query=), disabled. Styles: primary, success, "
|
| 528 |
+
"danger, link.\n"
|
| 529 |
+
"<code>&lt; &gt; &amp;</code> stand for < > & when they must stay as they are.\n\n"
|
| 530 |
+
"<b>Good to know</b>\n"
|
| 531 |
+
"• A picture or video typed into a rich message must be a public https link. To use a file that exists only on "
|
| 532 |
+
"Telegram, send the bot a rich message that already contains it.\n"
|
| 533 |
+
"• A button inside the text that needs the bot (callback_data) only works for the bot that made it - for a forwarded "
|
| 534 |
+
"rich message that is another bot.\n"
|
| 535 |
+
"• /swap, /handleswap and the post-link changes of /repost change a rich post's buttons, not the links inside its text.\n"
|
| 536 |
+
"• Older Telegram apps may not show rich messages."
|
| 537 |
+
)
|
app/richtext.py
CHANGED
|
@@ -546,5 +546,7 @@ CHEAT_SHEET = (
|
|
| 546 |
"<b>Example</b>\n"
|
| 547 |
"<pre>**Attack on Titan** - Episode 12\n"
|
| 548 |
"__Hindi__ | ||spoiler||\n"
|
| 549 |
-
"[Watch here](https://t.me/yourchannel)</pre>"
|
|
|
|
|
|
|
| 550 |
)
|
|
|
|
| 546 |
"<b>Example</b>\n"
|
| 547 |
"<pre>**Attack on Titan** - Episode 12\n"
|
| 548 |
"__Hindi__ | ||spoiler||\n"
|
| 549 |
+
"[Watch here](https://t.me/yourchannel)</pre>\n\n"
|
| 550 |
+
"🧩 Headings, lists, tables, expandable sections, math, maps, pictures and buttons are <b>rich messages</b> - "
|
| 551 |
+
"see /rich."
|
| 552 |
)
|
app/suggest.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import Optional
|
|
| 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)
|
|
@@ -64,6 +65,8 @@ class Suggestion:
|
|
| 64 |
|
| 65 |
|
| 66 |
def build_suggestion(post) -> Optional[Suggestion]:
|
|
|
|
|
|
|
| 67 |
rows: list = []
|
| 68 |
pending: list = []
|
| 69 |
first: Optional[Advance] = None
|
|
|
|
| 9 |
from telethon.helpers import add_surrogate, del_surrogate
|
| 10 |
|
| 11 |
from .linkswap import apply_span_replacements
|
| 12 |
+
from .richmsg import is_rich
|
| 13 |
from .tgutil import de_entities, ser_entities
|
| 14 |
|
| 15 |
RANGE_RX = re.compile(r"(?P<a>\d+)(?P<sep>\s*(?:to|[-–—~&])\s*)(?P<pre>(?:ep?\.?\s*)?)(?P<b>\d+)", re.I)
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def build_suggestion(post) -> Optional[Suggestion]:
|
| 68 |
+
if is_rich(post): # its text lives in blocks Telegram made: the numbers can't be moved on in them
|
| 69 |
+
return None
|
| 70 |
rows: list = []
|
| 71 |
pending: list = []
|
| 72 |
first: Optional[Advance] = None
|
app/sync_engine.py
CHANGED
|
@@ -38,6 +38,7 @@ from .db import as_utc, utcnow
|
|
| 38 |
from .replace_engine import fetch_messages
|
| 39 |
from .repost_engine import delete_batch
|
| 40 |
from .restrictions import message_restriction, norm_text, probe_channel
|
|
|
|
| 41 |
from .tgutil import classify_media, explain_rpc, media_from_ref, media_info, peer_of, ser_entities, ser_markup
|
| 42 |
|
| 43 |
log = logging.getLogger(__name__)
|
|
@@ -127,12 +128,19 @@ def stored_media_key(ref: Optional[str]) -> Optional[tuple]:
|
|
| 127 |
return ("unreadable", ref[:40]) # differs from anything real: the saved reference gets replaced
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
def live_fields(m) -> dict:
|
| 131 |
"""The saved-post fields of a message as it is in the channel now."""
|
| 132 |
kind, ref = media_info(m)
|
|
|
|
| 133 |
return {
|
| 134 |
-
"text": m.message or "",
|
| 135 |
-
"entities": ser_entities(m.entities),
|
| 136 |
"buttons": ser_markup(m.reply_markup),
|
| 137 |
"media_kind": kind,
|
| 138 |
"media_file_id": ref,
|
|
@@ -140,8 +148,8 @@ def live_fields(m) -> dict:
|
|
| 140 |
|
| 141 |
|
| 142 |
def message_supported(m) -> bool:
|
| 143 |
-
"""Something My posts can show and edit: text, or a photo / video / GIF / audio / file."""
|
| 144 |
-
return bool((m.message or "").strip()) or classify_media(m) is not None
|
| 145 |
|
| 146 |
|
| 147 |
def adopt_fields(m) -> dict:
|
|
@@ -185,10 +193,24 @@ def diff_post(p, m) -> Diff:
|
|
| 185 |
"""What differs between the saved post `p` and the live message `m`."""
|
| 186 |
live = live_fields(m)
|
| 187 |
fields: dict = {}
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
if
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
# media: replaced media is taken over; media that seems to have vanished is not (a message can't lose its media)
|
| 193 |
if live["media_kind"] is not None:
|
| 194 |
if p.media_kind != live["media_kind"] or stored_media_key(p.media_file_id) != media_key(m.media):
|
|
@@ -199,7 +221,9 @@ def diff_post(p, m) -> Diff:
|
|
| 199 |
fields["buttons"] = live["buttons"]
|
| 200 |
elif p.buttons:
|
| 201 |
missing = True
|
| 202 |
-
quiet = set(fields) == {"text"} and (p.text or "").strip() == live["text"].strip()
|
|
|
|
|
|
|
| 203 |
return Diff(fields, missing, quiet)
|
| 204 |
|
| 205 |
|
|
|
|
| 38 |
from .replace_engine import fetch_messages
|
| 39 |
from .repost_engine import delete_batch
|
| 40 |
from .restrictions import message_restriction, norm_text, probe_channel
|
| 41 |
+
from .richmsg import Rich, entry as rich_entry, is_rich_message, plain_of_message, rich_from_received, rich_of, with_fingerprint
|
| 42 |
from .tgutil import classify_media, explain_rpc, media_from_ref, media_info, peer_of, ser_entities, ser_markup
|
| 43 |
|
| 44 |
log = logging.getLogger(__name__)
|
|
|
|
| 128 |
return ("unreadable", ref[:40]) # differs from anything real: the saved reference gets replaced
|
| 129 |
|
| 130 |
|
| 131 |
+
def live_rich(m) -> Optional[Rich]:
|
| 132 |
+
"""The rich message the live message is (as blocks), or None for an ordinary post."""
|
| 133 |
+
rm = getattr(m, "rich_message", None)
|
| 134 |
+
return rich_from_received(rm) if rm is not None else None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
def live_fields(m) -> dict:
|
| 138 |
"""The saved-post fields of a message as it is in the channel now."""
|
| 139 |
kind, ref = media_info(m)
|
| 140 |
+
rich = live_rich(m)
|
| 141 |
return {
|
| 142 |
+
"text": plain_of_message(m) if rich is not None else (m.message or ""),
|
| 143 |
+
"entities": [rich_entry(rich)] if rich is not None else ser_entities(m.entities),
|
| 144 |
"buttons": ser_markup(m.reply_markup),
|
| 145 |
"media_kind": kind,
|
| 146 |
"media_file_id": ref,
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
def message_supported(m) -> bool:
|
| 151 |
+
"""Something My posts can show and edit: text, a rich message, or a photo / video / GIF / audio / file."""
|
| 152 |
+
return bool((m.message or "").strip()) or classify_media(m) is not None or is_rich_message(m)
|
| 153 |
|
| 154 |
|
| 155 |
def adopt_fields(m) -> dict:
|
|
|
|
| 193 |
"""What differs between the saved post `p` and the live message `m`."""
|
| 194 |
live = live_fields(m)
|
| 195 |
fields: dict = {}
|
| 196 |
+
saved_rich, now_rich = rich_of(p), live_rich(m) if is_rich_message(m) else None
|
| 197 |
+
first_look = False
|
| 198 |
+
if saved_rich is None and now_rich is None:
|
| 199 |
+
if (p.text or "") != live["text"]:
|
| 200 |
+
fields["text"] = live["text"]
|
| 201 |
+
if list(p.entities or []) != live["entities"]:
|
| 202 |
+
fields["entities"] = live["entities"]
|
| 203 |
+
elif now_rich is not None:
|
| 204 |
+
# A rich message is compared by what Telegram shows of it (a fingerprint of its blocks), never by its text: the
|
| 205 |
+
# saved copy keeps the source the bot sent. A post that became rich in the channel is taken over as its blocks.
|
| 206 |
+
if saved_rich is None or (saved_rich.fp is not None and saved_rich.fp != now_rich.fp):
|
| 207 |
+
fields["text"], fields["entities"] = live["text"], live["entities"]
|
| 208 |
+
elif saved_rich.fp is None: # the bot's own rich post, seen for the first time: remember what Telegram shows
|
| 209 |
+
fields["entities"] = [rich_entry(with_fingerprint(saved_rich, now_rich.fp))]
|
| 210 |
+
first_look = True
|
| 211 |
+
elif live["text"].strip() or live["media_kind"] is not None:
|
| 212 |
+
# the channel shows an ordinary post where a rich message was saved (an empty answer is not trusted)
|
| 213 |
+
fields["text"], fields["entities"] = live["text"], live["entities"]
|
| 214 |
# media: replaced media is taken over; media that seems to have vanished is not (a message can't lose its media)
|
| 215 |
if live["media_kind"] is not None:
|
| 216 |
if p.media_kind != live["media_kind"] or stored_media_key(p.media_file_id) != media_key(m.media):
|
|
|
|
| 221 |
fields["buttons"] = live["buttons"]
|
| 222 |
elif p.buttons:
|
| 223 |
missing = True
|
| 224 |
+
quiet = (set(fields) == {"text"} and (p.text or "").strip() == live["text"].strip()) or (
|
| 225 |
+
first_look and set(fields) == {"entities"}
|
| 226 |
+
)
|
| 227 |
return Diff(fields, missing, quiet)
|
| 228 |
|
| 229 |
|
app/tgutil.py
CHANGED
|
@@ -381,19 +381,31 @@ async def flood_retry(factory: Callable[[], Awaitable[Any]], tries: int = 4) ->
|
|
| 381 |
|
| 382 |
|
| 383 |
async def edit_raw(
|
| 384 |
-
client,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
):
|
| 386 |
"""messages.editMessage with every field explicit.
|
| 387 |
|
| 388 |
-
`markup=None` together with `text` removes the keyboard (that is how Telegram behaves);
|
| 389 |
a markup-only edit (text=None) leaves the text alone. `media` (an InputMedia) replaces the photo / file.
|
|
|
|
| 390 |
"""
|
| 391 |
kw: dict = {"peer": peer, "id": message_id}
|
| 392 |
if markup is not None:
|
| 393 |
kw["reply_markup"] = markup
|
| 394 |
if media is not None:
|
| 395 |
kw["media"] = media
|
| 396 |
-
if
|
|
|
|
|
|
|
| 397 |
kw["message"] = text
|
| 398 |
kw["entities"] = list(entities or [])
|
| 399 |
if preview is not None:
|
|
|
|
| 381 |
|
| 382 |
|
| 383 |
async def edit_raw(
|
| 384 |
+
client,
|
| 385 |
+
peer,
|
| 386 |
+
message_id: int,
|
| 387 |
+
*,
|
| 388 |
+
text: Optional[str] = None,
|
| 389 |
+
entities=None,
|
| 390 |
+
markup=None,
|
| 391 |
+
preview=None,
|
| 392 |
+
media=None,
|
| 393 |
+
rich_message=None,
|
| 394 |
):
|
| 395 |
"""messages.editMessage with every field explicit.
|
| 396 |
|
| 397 |
+
`markup=None` together with `text` (or `rich_message`) removes the keyboard (that is how Telegram behaves);
|
| 398 |
a markup-only edit (text=None) leaves the text alone. `media` (an InputMedia) replaces the photo / file.
|
| 399 |
+
`rich_message` (an InputRichMessage*) makes the message that rich message instead of text.
|
| 400 |
"""
|
| 401 |
kw: dict = {"peer": peer, "id": message_id}
|
| 402 |
if markup is not None:
|
| 403 |
kw["reply_markup"] = markup
|
| 404 |
if media is not None:
|
| 405 |
kw["media"] = media
|
| 406 |
+
if rich_message is not None:
|
| 407 |
+
kw["rich_message"] = rich_message
|
| 408 |
+
elif text is not None:
|
| 409 |
kw["message"] = text
|
| 410 |
kw["entities"] = list(entities or [])
|
| 411 |
if preview is not None:
|
app/ui.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
| 4 |
from telethon import Button
|
| 5 |
|
| 6 |
from .buttons import count, grid_label, link_positions, missing_links
|
|
|
|
| 7 |
from .tgutil import esc, post_link, short
|
| 8 |
|
| 9 |
CANCEL = [[Button.inline("✖️ Cancel", "cx")]]
|
|
@@ -30,7 +31,12 @@ def panel_text(p, ch, note=None) -> str:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
@@ -47,18 +53,17 @@ def panel_keyboard(p) -> list:
|
|
| 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 |
-
|
| 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}"))
|
|
|
|
| 4 |
from telethon import Button
|
| 5 |
|
| 6 |
from .buttons import count, grid_label, link_positions, missing_links
|
| 7 |
+
from .richmsg import rich_of
|
| 8 |
from .tgutil import esc, post_link, short
|
| 9 |
|
| 10 |
CANCEL = [[Button.inline("✖️ Cancel", "cx")]]
|
|
|
|
| 31 |
lines = [f"<b>Post #{p.id}</b> · {state}", f"Channel: <b>{esc(ch.title)}</b>"]
|
| 32 |
if p.status == "sent" and p.message_id:
|
| 33 |
lines.append(f'<a href="{post_link(ch, p.message_id)}">Open in the channel</a>')
|
| 34 |
+
rich = rich_of(p)
|
| 35 |
+
if rich is not None:
|
| 36 |
+
origin = {"html": "typed as HTML", "markdown": "typed as Markdown"}.get(rich.fmt, "made in Telegram")
|
| 37 |
+
lines.append(f"Type: 🧩 rich message ({origin})")
|
| 38 |
+
else:
|
| 39 |
+
lines.append(f"Type: {KIND_ICON.get(p.media_kind, '📎')} {KIND_NAME.get(p.media_kind, p.media_kind)}")
|
| 40 |
n, miss = count(p.buttons), missing_links(p.buttons)
|
| 41 |
lines.append(f"Buttons: {n}" + (f" (⚠️ {miss} without a link)" if miss else ""))
|
| 42 |
if p.source == "adopted":
|
|
|
|
| 53 |
kb = []
|
| 54 |
for r, row in enumerate(p.buttons or []):
|
| 55 |
kb.append([Button.inline(grid_label(b), f"bs:{pid}:{r}:{c}") for c, b in enumerate(row)])
|
| 56 |
+
rich = rich_of(p) is not None
|
| 57 |
kb.append(
|
| 58 |
+
[Button.inline("✏️ Caption" if p.media_kind else "✏️ Text", f"et:{pid}")]
|
| 59 |
+
+ ([] if rich else [Button.inline("🖼 Media", f"em:{pid}")]) # a rich message has no media slot
|
|
|
|
|
|
|
| 60 |
)
|
| 61 |
row3 = [Button.inline("➕ Button", f"ba:{pid}"), Button.inline("📋 Set buttons", f"bp:{pid}")]
|
| 62 |
if p.buttons:
|
| 63 |
row3.append(Button.inline("🧹 Clear", f"bc:{pid}"))
|
| 64 |
kb.append(row3)
|
| 65 |
extra = []
|
| 66 |
+
if not p.media_kind and not rich:
|
| 67 |
extra.append(Button.inline(f"🔗 Link preview: {'on' if p.link_preview else 'off'}", f"lp:{pid}"))
|
| 68 |
if p.media_kind and p.status == "draft":
|
| 69 |
extra.append(Button.inline("🚫 Remove media", f"rm:{pid}"))
|
app/userbot.py
CHANGED
|
@@ -31,6 +31,7 @@ from typing import Optional
|
|
| 31 |
from telethon import TelegramClient, errors, functions, types, utils
|
| 32 |
from telethon.sessions import StringSession
|
| 33 |
|
|
|
|
| 34 |
from .tgutil import MediaFile, edit_raw, flood_retry, get_rights, media_from_ref, media_key, peer_of, with_media
|
| 35 |
|
| 36 |
log = logging.getLogger(__name__)
|
|
@@ -524,17 +525,24 @@ class UserbotEditor(_ChannelJob):
|
|
| 524 |
log.info("could not delete the helper message in the userbot's chat: %s", type(e).__name__)
|
| 525 |
await super().close()
|
| 526 |
|
| 527 |
-
async def edit(self, message_id: int, *, text: str, entities: list, media=None, preview=None) -> None:
|
| 528 |
"""Make the post show `text` (with `entities`) and, when given, `media`: what media_by_reference() returned (the
|
| 529 |
-
file does not move at all) or a MediaFile (uploaded again)."""
|
| 530 |
await self._begin()
|
| 531 |
client = self.ub.client
|
| 532 |
try:
|
| 533 |
if isinstance(media, MediaFile):
|
| 534 |
media = await self._upload(media)
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
except errors.MessageNotModifiedError:
|
| 539 |
return # it already looks like that
|
| 540 |
except errors.RPCError as e:
|
|
|
|
| 31 |
from telethon import TelegramClient, errors, functions, types, utils
|
| 32 |
from telethon.sessions import StringSession
|
| 33 |
|
| 34 |
+
from .richmsg import with_rich
|
| 35 |
from .tgutil import MediaFile, edit_raw, flood_retry, get_rights, media_from_ref, media_key, peer_of, with_media
|
| 36 |
|
| 37 |
log = logging.getLogger(__name__)
|
|
|
|
| 525 |
log.info("could not delete the helper message in the userbot's chat: %s", type(e).__name__)
|
| 526 |
await super().close()
|
| 527 |
|
| 528 |
+
async def edit(self, message_id: int, *, text: str, entities: list, media=None, preview=None, rich=None) -> None:
|
| 529 |
"""Make the post show `text` (with `entities`) and, when given, `media`: what media_by_reference() returned (the
|
| 530 |
+
file does not move at all) or a MediaFile (uploaded again). `rich` (a richmsg.Rich) makes it that rich message."""
|
| 531 |
await self._begin()
|
| 532 |
client = self.ub.client
|
| 533 |
try:
|
| 534 |
if isinstance(media, MediaFile):
|
| 535 |
media = await self._upload(media)
|
| 536 |
+
if rich is not None:
|
| 537 |
+
await flood_retry(
|
| 538 |
+
lambda: with_rich(rich, lambda inp: edit_raw(client, self.peer, message_id, rich_message=inp))
|
| 539 |
+
)
|
| 540 |
+
else:
|
| 541 |
+
await flood_retry(
|
| 542 |
+
lambda: edit_raw(
|
| 543 |
+
client, self.peer, message_id, text=text, entities=entities, preview=preview, media=media
|
| 544 |
+
)
|
| 545 |
+
)
|
| 546 |
except errors.MessageNotModifiedError:
|
| 547 |
return # it already looks like that
|
| 548 |
except errors.RPCError as e:
|
tests/fakes.py
CHANGED
|
@@ -303,7 +303,28 @@ class FakeChannelClient:
|
|
| 303 |
else:
|
| 304 |
m._hidden, m.reply_markup = None, markup
|
| 305 |
|
| 306 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
self._top += 1
|
| 308 |
self._pts += 1
|
| 309 |
m = Message(
|
|
@@ -317,6 +338,7 @@ class FakeChannelClient:
|
|
| 317 |
grouped_id=gid,
|
| 318 |
invert_media=invert,
|
| 319 |
reply_to=types.MessageReplyHeader(reply_to_msg_id=reply_to) if reply_to else None,
|
|
|
|
| 320 |
)
|
| 321 |
if self.hide_keyboard_on_create > 0 and markup is not None:
|
| 322 |
hide = True
|
|
@@ -458,7 +480,7 @@ class FakeChannelClient:
|
|
| 458 |
out.append(
|
| 459 |
self._new(
|
| 460 |
src.message, src.entities, src.reply_markup, src.media, gid, getattr(src, "invert_media", None),
|
| 461 |
-
hide=self.forward_hides_keyboard,
|
| 462 |
)
|
| 463 |
)
|
| 464 |
if not out:
|
|
@@ -482,6 +504,11 @@ class FakeChannelClient:
|
|
| 482 |
|
| 483 |
def _do_SendMessageRequest(self, req):
|
| 484 |
rid = self._check_reply(req)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
return self._updates([self._new(req.message, req.entities, req.reply_markup, None, None, req.invert_media, reply_to=rid)])
|
| 486 |
|
| 487 |
def _do_SendMediaRequest(self, req):
|
|
@@ -529,6 +556,19 @@ class FakeChannelClient:
|
|
| 529 |
raise rpc("MessageAuthorRequiredError")
|
| 530 |
if getattr(m, "via_other_bot", False): # buttons or a post made through another bot: only that bot may edit
|
| 531 |
raise rpc("MessageIdInvalidError")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
if req.message is None: # markup-only edit
|
| 533 |
text, ents = m.message, list(m.entities or [])
|
| 534 |
markup = req.reply_markup if req.reply_markup is not None else self._effective(m)
|
|
@@ -541,11 +581,21 @@ class FakeChannelClient:
|
|
| 541 |
):
|
| 542 |
raise rpc("MessageNotModifiedError")
|
| 543 |
m.message, m.entities = text, (ents or None)
|
|
|
|
|
|
|
| 544 |
self._set_markup(m, markup)
|
| 545 |
self.edits.append(req)
|
| 546 |
self._pts += 1
|
| 547 |
return self._updates([])
|
| 548 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
def _do_DeleteMessagesRequest(self, req):
|
| 550 |
ids = list(req.id)
|
| 551 |
self.delete_requests.append(ids)
|
|
@@ -772,6 +822,18 @@ class FakeUserClient:
|
|
| 772 |
media = self._by_reference(req.media)
|
| 773 |
else:
|
| 774 |
media = self._uploaded_media(req.media)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
text = m.message if req.message is None else req.message
|
| 776 |
ents = list(m.entities or []) if req.message is None else list(req.entities or [])
|
| 777 |
if media is None and text == m.message and [_bytes(e) for e in ents] == [_bytes(e) for e in (m.entities or [])]:
|
|
|
|
| 303 |
else:
|
| 304 |
m._hidden, m.reply_markup = None, markup
|
| 305 |
|
| 306 |
+
def _rich(self, inp):
|
| 307 |
+
"""What Telegram makes of an InputRichMessage*: for HTML / Markdown the "parse" is one paragraph holding the source
|
| 308 |
+
(the same source always gives the same blocks); a message sent as blocks keeps them, with its pictures and files."""
|
| 309 |
+
if isinstance(inp, (types.InputRichMessageHTML, types.InputRichMessageMarkdown)):
|
| 310 |
+
src = inp.html if isinstance(inp, types.InputRichMessageHTML) else inp.markdown
|
| 311 |
+
if not src.strip():
|
| 312 |
+
raise rpc("MessageEmptyError")
|
| 313 |
+
kind = "html" if isinstance(inp, types.InputRichMessageHTML) else "markdown"
|
| 314 |
+
return types.RichMessage(
|
| 315 |
+
blocks=[types.PageBlockParagraph(types.TextPlain(f"[{kind}]{src}"))], photos=[], documents=[], rtl=inp.rtl
|
| 316 |
+
)
|
| 317 |
+
if isinstance(inp, types.InputRichMessage):
|
| 318 |
+
photos = [
|
| 319 |
+
types.Photo(p.id, p.access_hash, p.file_reference, NOW, [], 1) for p in inp.photos or []
|
| 320 |
+
]
|
| 321 |
+
docs = [
|
| 322 |
+
types.Document(d.id, d.access_hash, d.file_reference, NOW, "video/mp4", 1, 1, []) for d in inp.documents or []
|
| 323 |
+
]
|
| 324 |
+
return types.RichMessage(blocks=list(inp.blocks), photos=photos, documents=docs, rtl=inp.rtl)
|
| 325 |
+
raise rpc("MediaInvalidError")
|
| 326 |
+
|
| 327 |
+
def _new(self, text, entities, markup, media=None, gid=None, invert=None, hide=False, reply_to=None, rich=None):
|
| 328 |
self._top += 1
|
| 329 |
self._pts += 1
|
| 330 |
m = Message(
|
|
|
|
| 338 |
grouped_id=gid,
|
| 339 |
invert_media=invert,
|
| 340 |
reply_to=types.MessageReplyHeader(reply_to_msg_id=reply_to) if reply_to else None,
|
| 341 |
+
rich_message=rich,
|
| 342 |
)
|
| 343 |
if self.hide_keyboard_on_create > 0 and markup is not None:
|
| 344 |
hide = True
|
|
|
|
| 480 |
out.append(
|
| 481 |
self._new(
|
| 482 |
src.message, src.entities, src.reply_markup, src.media, gid, getattr(src, "invert_media", None),
|
| 483 |
+
hide=self.forward_hides_keyboard, rich=getattr(src, "rich_message", None),
|
| 484 |
)
|
| 485 |
)
|
| 486 |
if not out:
|
|
|
|
| 504 |
|
| 505 |
def _do_SendMessageRequest(self, req):
|
| 506 |
rid = self._check_reply(req)
|
| 507 |
+
if req.rich_message is not None:
|
| 508 |
+
rich = self._rich(req.rich_message)
|
| 509 |
+
m = self._new("", None, req.reply_markup, None, None, None, reply_to=rid, rich=rich)
|
| 510 |
+
m.rich_input = req.rich_message # what the bot asked for (the tests look at it)
|
| 511 |
+
return self._updates([m])
|
| 512 |
return self._updates([self._new(req.message, req.entities, req.reply_markup, None, None, req.invert_media, reply_to=rid)])
|
| 513 |
|
| 514 |
def _do_SendMediaRequest(self, req):
|
|
|
|
| 556 |
raise rpc("MessageAuthorRequiredError")
|
| 557 |
if getattr(m, "via_other_bot", False): # buttons or a post made through another bot: only that bot may edit
|
| 558 |
raise rpc("MessageIdInvalidError")
|
| 559 |
+
if req.rich_message is not None: # the post becomes this rich message; no keyboard sent = keyboard removed
|
| 560 |
+
rich = self._rich(req.rich_message)
|
| 561 |
+
if (
|
| 562 |
+
m.rich_message is not None
|
| 563 |
+
and bytes(m.rich_message) == bytes(rich)
|
| 564 |
+
and _bytes(req.reply_markup) == _bytes(self._effective(m))
|
| 565 |
+
):
|
| 566 |
+
raise rpc("MessageNotModifiedError")
|
| 567 |
+
m.message, m.entities, m.rich_message, m.rich_input = "", None, rich, req.rich_message
|
| 568 |
+
self._set_markup(m, req.reply_markup)
|
| 569 |
+
self.edits.append(req)
|
| 570 |
+
self._pts += 1
|
| 571 |
+
return self._updates([])
|
| 572 |
if req.message is None: # markup-only edit
|
| 573 |
text, ents = m.message, list(m.entities or [])
|
| 574 |
markup = req.reply_markup if req.reply_markup is not None else self._effective(m)
|
|
|
|
| 581 |
):
|
| 582 |
raise rpc("MessageNotModifiedError")
|
| 583 |
m.message, m.entities = text, (ents or None)
|
| 584 |
+
if req.message is not None:
|
| 585 |
+
m.rich_message = None
|
| 586 |
self._set_markup(m, markup)
|
| 587 |
self.edits.append(req)
|
| 588 |
self._pts += 1
|
| 589 |
return self._updates([])
|
| 590 |
|
| 591 |
+
def _do_GetRichMessageRequest(self, req):
|
| 592 |
+
"""messages.getRichMessage: the whole rich message of a post (the tests may put a longer one in `full_rich`)."""
|
| 593 |
+
full = getattr(self, "full_rich", {}).get(req.id)
|
| 594 |
+
m = self.msgs.get(req.id)
|
| 595 |
+
if full is None and (m is None or m.rich_message is None):
|
| 596 |
+
raise rpc("MessageIdInvalidError")
|
| 597 |
+
return full if full is not None else m.rich_message
|
| 598 |
+
|
| 599 |
def _do_DeleteMessagesRequest(self, req):
|
| 600 |
ids = list(req.id)
|
| 601 |
self.delete_requests.append(ids)
|
|
|
|
| 822 |
media = self._by_reference(req.media)
|
| 823 |
else:
|
| 824 |
media = self._uploaded_media(req.media)
|
| 825 |
+
if req.rich_message is not None:
|
| 826 |
+
rich = chan._rich(req.rich_message)
|
| 827 |
+
if m.rich_message is not None and bytes(m.rich_message) == bytes(rich):
|
| 828 |
+
raise rpc("MessageNotModifiedError")
|
| 829 |
+
m.message, m.entities, m.rich_message, m.rich_input = "", None, rich, req.rich_message
|
| 830 |
+
if not chan.user_edit_keeps_markup:
|
| 831 |
+
chan._set_markup(m, None)
|
| 832 |
+
if chan.user_edit_frees_message:
|
| 833 |
+
m.via_other_bot = False
|
| 834 |
+
chan.user_edits.append(req)
|
| 835 |
+
chan._pts += 1
|
| 836 |
+
return chan._updates([])
|
| 837 |
text = m.message if req.message is None else req.message
|
| 838 |
ents = list(m.entities or []) if req.message is None else list(req.entities or [])
|
| 839 |
if media is None and text == m.message and [_bytes(e) for e in ents] == [_bytes(e) for e in (m.entities or [])]:
|
tests/harness.py
CHANGED
|
@@ -180,6 +180,14 @@ class FakeTG(FakeChannelClient):
|
|
| 180 |
return None
|
| 181 |
if isinstance(req, functions.channels.DeleteMessagesRequest):
|
| 182 |
self.deleted.append(list(req.id))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
return await super().__call__(req)
|
| 184 |
|
| 185 |
|
|
|
|
| 180 |
return None
|
| 181 |
if isinstance(req, functions.channels.DeleteMessagesRequest):
|
| 182 |
self.deleted.append(list(req.id))
|
| 183 |
+
if isinstance(req, functions.messages.SendMessageRequest) and req.rich_message is not None:
|
| 184 |
+
self.sent.append(("rich", req.peer, req.rich_message, {"reply_markup": req.reply_markup}))
|
| 185 |
+
if isinstance(req.peer, types.InputPeerUser): # a chat with a person (the preview): nothing lands in a channel
|
| 186 |
+
if self.fail.get("SendMessageRequest") is not None:
|
| 187 |
+
raise self.fail["SendMessageRequest"]
|
| 188 |
+
bytes(req) # it must serialise
|
| 189 |
+
self._rich(req.rich_message) # Telegram has to be able to make blocks of it
|
| 190 |
+
return types.UpdateShortSentMessage(id=next(self.ids), pts=0, pts_count=0, date=None, out=True)
|
| 191 |
return await super().__call__(req)
|
| 192 |
|
| 193 |
|
tests/test_rich.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rich messages (headings, lists, tables, <details>, math, maps, pictures, buttons ...): what is recognised, how a rich message is
|
| 2 |
+
kept in a post, what is sent to Telegram for it and how one that is sent to the bot is read."""
|
| 3 |
+
import asyncio
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from telethon import errors, functions, types
|
| 8 |
+
|
| 9 |
+
from app.richmsg import (
|
| 10 |
+
RICH_HELP,
|
| 11 |
+
RICH_HELP_HTML,
|
| 12 |
+
MAX_SOURCE,
|
| 13 |
+
Rich,
|
| 14 |
+
RichError,
|
| 15 |
+
blank_references,
|
| 16 |
+
check,
|
| 17 |
+
detect,
|
| 18 |
+
entry,
|
| 19 |
+
fingerprint,
|
| 20 |
+
input_users,
|
| 21 |
+
is_rich,
|
| 22 |
+
is_rich_message,
|
| 23 |
+
mention_ids,
|
| 24 |
+
plain_of,
|
| 25 |
+
plain_of_message,
|
| 26 |
+
read_typed,
|
| 27 |
+
real_entities,
|
| 28 |
+
received,
|
| 29 |
+
rich_from_received,
|
| 30 |
+
rich_of,
|
| 31 |
+
send_rich,
|
| 32 |
+
sent_message,
|
| 33 |
+
to_input,
|
| 34 |
+
with_rich,
|
| 35 |
+
)
|
| 36 |
+
from app.tgutil import de_entities, edit_raw
|
| 37 |
+
|
| 38 |
+
from .fakes import photo_media, rpc, video_media
|
| 39 |
+
|
| 40 |
+
T = types.TextPlain
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def blocks(word="Hello"):
|
| 44 |
+
return [types.PageBlockHeading1(T("Title")), types.PageBlockParagraph(types.TextBold(T(word)))]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def rich_msg(bl=None, *, photos=None, documents=None, rtl=None, part=None):
|
| 48 |
+
"""A rich message as Telegram hands it to the bot."""
|
| 49 |
+
return types.RichMessage(blocks=bl or blocks(), photos=photos or [], documents=documents or [], rtl=rtl, part=part)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def same(a, b):
|
| 53 |
+
"""The same TL objects (they have no usable ==)."""
|
| 54 |
+
return [bytes(x) for x in a] == [bytes(x) for x in b]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ============================================================================================ what is a rich message
|
| 58 |
+
HTML_RICH = [
|
| 59 |
+
"<h1>Title</h1>",
|
| 60 |
+
"<h4 id='x'>Small</h4>",
|
| 61 |
+
"<p>one</p><p>two</p>",
|
| 62 |
+
"<ul><li>a</li><li>b</li></ul>",
|
| 63 |
+
"<ol start=\"3\"><li>a</li></ol>",
|
| 64 |
+
"<table bordered><tr><td>a</td></tr></table>",
|
| 65 |
+
"<details><summary>More</summary>text</details>",
|
| 66 |
+
"<tg-map lat=\"41.9\" long=\"12.5\" zoom=\"14\"/>",
|
| 67 |
+
"<tg-math-block>E = mc^2</tg-math-block>",
|
| 68 |
+
"<aside>pull<cite>me</cite></aside>",
|
| 69 |
+
"<hr>",
|
| 70 |
+
"<footer>fine print</footer>",
|
| 71 |
+
"<img src=\"https://x.org/a.jpg\">",
|
| 72 |
+
"<figure><img src=\"https://x.org/a.jpg\"><figcaption>c</figcaption></figure>",
|
| 73 |
+
"<tg-collage><img src=\"https://x.org/a.jpg\"></tg-collage>",
|
| 74 |
+
"<tg-button-row><tg-button type=\"url\" url=\"https://t.me\">Open</tg-button></tg-button-row>",
|
| 75 |
+
"text with <mark>marked</mark> words",
|
| 76 |
+
"H<sub>2</sub>O and x<sup>2</sup>",
|
| 77 |
+
"<input type=\"checkbox\" checked> done",
|
| 78 |
+
"<tg-time unix=\"1647531900\" format=\"wDT\">soon</tg-time>",
|
| 79 |
+
"<a name=\"top\"></a>go",
|
| 80 |
+
"back to <a href=\"#top\">the top</a>",
|
| 81 |
+
"<tg-reference name=\"n1\">note</tg-reference>",
|
| 82 |
+
]
|
| 83 |
+
MARKDOWN_RICH = [
|
| 84 |
+
"# Title",
|
| 85 |
+
"text\n\n### Sub heading",
|
| 86 |
+
"###### Smallest",
|
| 87 |
+
"- [ ] todo",
|
| 88 |
+
"1. [x] done",
|
| 89 |
+
"| a | b |\n|---|---|\n| 1 | 2 |",
|
| 90 |
+
"a | b\n:-:|--:\n1 | 2",
|
| 91 |
+
"$$E = mc^2$$",
|
| 92 |
+
"a note[^1]\n\n[^1]: the note",
|
| 93 |
+
"some ==marked== text",
|
| 94 |
+
"",
|
| 95 |
+
"",
|
| 96 |
+
"see ",
|
| 97 |
+
]
|
| 98 |
+
NOT_RICH = [
|
| 99 |
+
"Episode 12 is out",
|
| 100 |
+
"#anime #naruto",
|
| 101 |
+
"#1 hit", # a hashtag with no space after the # is not a heading
|
| 102 |
+
"Price: $5 and $6",
|
| 103 |
+
"a == b",
|
| 104 |
+
"1 == 1 and 2 == 2",
|
| 105 |
+
"I <3 this",
|
| 106 |
+
"- one\n- two",
|
| 107 |
+
"* a\n* b",
|
| 108 |
+
"---",
|
| 109 |
+
"1) first\n2) second",
|
| 110 |
+
"**bold** and __under__ and ~~strike~~ and ||spoiler|| and `code`",
|
| 111 |
+
"<b>bold</b> <i>it</i> <u>u</u> <s>s</s> <a href=\"https://t.me\">x</a> <code>c</code> <tg-spoiler>s</tg-spoiler>",
|
| 112 |
+
"<blockquote>quote</blockquote> and <pre>block</pre>",
|
| 113 |
+
"<pre><h1> stays text</pre>",
|
| 114 |
+
"`<h1>` is a tag",
|
| 115 |
+
"```\n# not a heading\n```",
|
| 116 |
+
"~~~\n| a | b |\n|---|---|\n~~~",
|
| 117 |
+
"a | b | c",
|
| 118 |
+
"5 > 3 and 2 < 4",
|
| 119 |
+
"@channel_name look at https://example.com/a#section",
|
| 120 |
+
"[watch](https://t.me/x) and ",
|
| 121 |
+
"",
|
| 122 |
+
" ",
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@pytest.mark.parametrize("text", HTML_RICH)
|
| 127 |
+
def test_html_with_rich_blocks_is_a_rich_message(text):
|
| 128 |
+
assert detect(text) == "html"
|
| 129 |
+
assert read_typed(text) == Rich("html", text)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@pytest.mark.parametrize("text", MARKDOWN_RICH)
|
| 133 |
+
def test_markdown_with_rich_blocks_is_a_rich_message(text):
|
| 134 |
+
assert detect(text) == "markdown"
|
| 135 |
+
assert read_typed(text) == Rich("markdown", text)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@pytest.mark.parametrize("text", NOT_RICH)
|
| 139 |
+
def test_ordinary_text_is_not_turned_into_a_rich_message(text):
|
| 140 |
+
assert detect(text) is None
|
| 141 |
+
assert read_typed(text) is None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_html_wins_when_the_text_starts_with_a_tag_and_markdown_otherwise():
|
| 145 |
+
assert detect("<h1>Title</h1>\n\n- [ ] not a task here") == "html"
|
| 146 |
+
assert detect("# Title\n\nsome <mark>marked</mark> html inside") == "markdown"
|
| 147 |
+
assert detect("intro\n<h2>Sub</h2>") == "html"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def test_the_rich_marker_makes_any_text_a_rich_message():
|
| 151 |
+
assert read_typed("<rich>\nhello **x**") == Rich("markdown", "hello **x**")
|
| 152 |
+
assert read_typed("<rich>\n<b>x</b> and <i>y</i>") == Rich("html", "<b>x</b> and <i>y</i>")
|
| 153 |
+
r = read_typed("<rich rtl noautolink>\nhello #tag @name")
|
| 154 |
+
assert (r.fmt, r.source, r.rtl, r.noautolink) == ("markdown", "hello #tag @name", True, True)
|
| 155 |
+
r = read_typed(" <RICH rtl >\r\nمرحبا") # spaces, any case and a Windows line end are fine
|
| 156 |
+
assert (r.source, r.rtl, r.noautolink) == ("مرحبا", True, False)
|
| 157 |
+
assert read_typed("<rich noautolink>\n# Title") == Rich("markdown", "# Title", noautolink=True)
|
| 158 |
+
# the marker is a line of its own, at the start
|
| 159 |
+
assert read_typed("<rich> hello") is None
|
| 160 |
+
assert read_typed("hello <rich>\nx") is None
|
| 161 |
+
assert read_typed("hello\n<rich>\nx") is None
|
| 162 |
+
assert read_typed("<rich>") is None and read_typed("<rich>\n \n") is None
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_what_is_typed_is_handed_over_exactly():
|
| 166 |
+
src = "# Title \n\n indented\n\n| a | b |\n|---|---|\n| 1 | 2 |"
|
| 167 |
+
assert read_typed("\n" + src + "\n\n").source == src.strip()
|
| 168 |
+
assert read_typed(src).source == src
|
| 169 |
+
assert read_typed("<rich>\n" + src).source == src
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_code_is_not_markup():
|
| 173 |
+
assert detect("```python\n# comment\nprint('<h1>')\n```") is None
|
| 174 |
+
assert detect("use `# Heading` for a heading and <code><h1></code> in HTML") is None
|
| 175 |
+
assert detect("```\n- [ ] x\n```\n# Real heading") == "markdown"
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ================================================================================================= keeping a rich message
|
| 179 |
+
def test_a_rich_message_is_one_entry_among_the_entities_of_a_post():
|
| 180 |
+
r = Rich("html", "<h1>x</h1>", rtl=True, noautolink=True, fp="abc")
|
| 181 |
+
e = entry(r)
|
| 182 |
+
assert e == {"k": "rich", "f": "html", "s": "<h1>x</h1>", "r": 1, "n": 1, "p": "abc"}
|
| 183 |
+
assert entry(Rich("markdown", "# x")) == {"k": "rich", "f": "markdown", "s": "# x"}
|
| 184 |
+
post = SimpleNamespace(entities=[{"k": "bold", "o": 0, "l": 1}, e])
|
| 185 |
+
assert rich_of(post) == r and is_rich(post)
|
| 186 |
+
assert real_entities(post.entities) == [{"k": "bold", "o": 0, "l": 1}]
|
| 187 |
+
assert real_entities(None) == []
|
| 188 |
+
for bad in ({"k": "rich", "f": "pdf", "s": "x"}, {"k": "rich", "f": "html", "s": ""}, {"k": "bold", "o": 0, "l": 1}, "x"):
|
| 189 |
+
assert rich_of(SimpleNamespace(entities=[bad])) is None
|
| 190 |
+
assert rich_of(SimpleNamespace(entities=None)) is None and rich_of(SimpleNamespace()) is None
|
| 191 |
+
assert not is_rich(SimpleNamespace(entities=[]))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_the_entry_is_not_formatting():
|
| 195 |
+
assert de_entities([entry(Rich("markdown", "# x"))]) == []
|
| 196 |
+
assert de_entities([entry(Rich("html", "<b>x</b>")), {"k": "bold", "o": 0, "l": 2}]) == [types.MessageEntityBold(0, 2)]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ============================================================================================ what is sent to Telegram
|
| 200 |
+
def test_typed_text_goes_to_telegram_as_html_or_markdown_for_it_to_read():
|
| 201 |
+
i = to_input(Rich("html", "<h1>x</h1>", rtl=True))
|
| 202 |
+
assert isinstance(i, types.InputRichMessageHTML) and i.html == "<h1>x</h1>" and i.rtl is True and i.noautolink is None
|
| 203 |
+
i = to_input(Rich("markdown", "# x", noautolink=True))
|
| 204 |
+
assert isinstance(i, types.InputRichMessageMarkdown) and i.markdown == "# x" and i.noautolink is True and i.rtl is None
|
| 205 |
+
bytes(to_input(Rich("markdown", "# x"))) # serialises
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_a_rich_message_that_was_received_is_sent_again_block_for_block():
|
| 209 |
+
rm = rich_msg(photos=[photo_media(5).photo], documents=[video_media(81).document], rtl=True)
|
| 210 |
+
r = rich_from_received(rm)
|
| 211 |
+
assert (r.fmt, r.rtl, r.fp) == ("blocks", True, fingerprint(rm))
|
| 212 |
+
inp = to_input(r)
|
| 213 |
+
assert isinstance(inp, types.InputRichMessage) and inp.rtl is True
|
| 214 |
+
assert same(inp.blocks, rm.blocks)
|
| 215 |
+
assert [(p.id, p.access_hash, p.file_reference) for p in inp.photos] == [(5, 35, b"ref5")]
|
| 216 |
+
assert [(d.id, d.access_hash, d.file_reference) for d in inp.documents] == [(81, 81 * 7, b"fr81")]
|
| 217 |
+
bytes(inp)
|
| 218 |
+
plain = to_input(rich_from_received(rich_msg()))
|
| 219 |
+
assert plain.photos is None and plain.documents is None and plain.users is None and not plain.rtl
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_file_references_can_be_left_out():
|
| 223 |
+
inp = to_input(rich_from_received(rich_msg(photos=[photo_media(5).photo], documents=[video_media(81).document])))
|
| 224 |
+
blank = blank_references(inp)
|
| 225 |
+
assert blank.photos[0].file_reference == b"" and (blank.photos[0].id, blank.photos[0].access_hash) == (5, 35)
|
| 226 |
+
assert blank.documents[0].file_reference == b"" and blank.documents[0].id == 81
|
| 227 |
+
assert inp.photos[0].file_reference == b"ref5" # the original is untouched
|
| 228 |
+
html = types.InputRichMessageHTML(html="x")
|
| 229 |
+
assert blank_references(html) is html
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def test_a_stale_file_reference_is_retried_without_it():
|
| 233 |
+
async def run():
|
| 234 |
+
r = rich_from_received(rich_msg(photos=[photo_media(5).photo]))
|
| 235 |
+
seen = []
|
| 236 |
+
|
| 237 |
+
async def call(inp):
|
| 238 |
+
seen.append(inp)
|
| 239 |
+
if len(seen) == 1:
|
| 240 |
+
raise rpc("FileReferenceExpiredError")
|
| 241 |
+
return "ok"
|
| 242 |
+
|
| 243 |
+
assert await with_rich(r, call) == "ok"
|
| 244 |
+
assert seen[0].photos[0].file_reference == b"ref5" and seen[1].photos[0].file_reference == b""
|
| 245 |
+
|
| 246 |
+
async def always(inp):
|
| 247 |
+
raise rpc("FileReferenceExpiredError")
|
| 248 |
+
|
| 249 |
+
with pytest.raises(errors.RPCError):
|
| 250 |
+
await with_rich(r, always) # the second try failed as well: that error is shown
|
| 251 |
+
with pytest.raises(errors.RPCError):
|
| 252 |
+
await with_rich(Rich("html", "<p>x</p>"), always) # typed text has no references to blame
|
| 253 |
+
|
| 254 |
+
async def other(inp):
|
| 255 |
+
raise rpc("MessageEmptyError")
|
| 256 |
+
|
| 257 |
+
with pytest.raises(errors.MessageEmptyError):
|
| 258 |
+
await with_rich(r, other)
|
| 259 |
+
|
| 260 |
+
asyncio.run(run())
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_send_rich_makes_one_raw_request_and_finds_the_message_it_created():
|
| 264 |
+
async def run():
|
| 265 |
+
sent = []
|
| 266 |
+
|
| 267 |
+
async def client(req):
|
| 268 |
+
sent.append(req)
|
| 269 |
+
return types.Updates(
|
| 270 |
+
updates=[types.UpdateNewChannelMessage(message=types.Message(id=41, peer_id=types.PeerChannel(1), date=None, message=""), pts=1, pts_count=1)],
|
| 271 |
+
users=[], chats=[], date=None, seq=0,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
markup = types.ReplyInlineMarkup([])
|
| 275 |
+
m = await send_rich(client, types.InputPeerChannel(1, 5), Rich("markdown", "# x"), markup=markup, silent=True)
|
| 276 |
+
assert m.id == 41
|
| 277 |
+
(req,) = sent
|
| 278 |
+
assert isinstance(req, functions.messages.SendMessageRequest)
|
| 279 |
+
assert req.message == "" and req.reply_markup is markup and req.silent is True
|
| 280 |
+
assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == "# x"
|
| 281 |
+
bytes(req)
|
| 282 |
+
|
| 283 |
+
asyncio.run(run())
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_the_message_a_send_created_is_found_in_every_answer_telegram_gives():
|
| 287 |
+
short = types.UpdateShortSentMessage(id=7, pts=1, pts_count=1, date=None, out=True)
|
| 288 |
+
assert sent_message(short).id == 7
|
| 289 |
+
new = types.UpdateNewMessage(message=types.Message(id=8, peer_id=types.PeerUser(1), date=None, message=""), pts=1, pts_count=1)
|
| 290 |
+
assert sent_message(types.Updates(updates=[new], users=[], chats=[], date=None, seq=0)).id == 8
|
| 291 |
+
with pytest.raises(RuntimeError):
|
| 292 |
+
sent_message(types.Updates(updates=[], users=[], chats=[], date=None, seq=0))
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def test_edit_raw_makes_the_message_that_rich_message():
|
| 296 |
+
async def run():
|
| 297 |
+
sent = []
|
| 298 |
+
|
| 299 |
+
async def client(req):
|
| 300 |
+
sent.append(req)
|
| 301 |
+
|
| 302 |
+
inp = types.InputRichMessageMarkdown(markdown="# x")
|
| 303 |
+
peer = types.InputPeerChannel(1, 5)
|
| 304 |
+
await edit_raw(client, peer, 9, rich_message=inp, markup=None, text="ignored", entities=[types.MessageEntityBold(0, 1)], preview=True)
|
| 305 |
+
(req,) = sent
|
| 306 |
+
assert req.rich_message is inp and req.message is None and not req.entities and not req.no_webpage and req.reply_markup is None
|
| 307 |
+
bytes(req)
|
| 308 |
+
sent.clear()
|
| 309 |
+
await edit_raw(client, peer, 9, text="plain", entities=[], markup=None)
|
| 310 |
+
assert sent[0].rich_message is None and sent[0].message == "plain"
|
| 311 |
+
|
| 312 |
+
asyncio.run(run())
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def test_a_rich_message_longer_than_telegram_allows_is_refused():
|
| 316 |
+
check(Rich("html", "x" * MAX_SOURCE))
|
| 317 |
+
check(Rich("blocks", "x" * (MAX_SOURCE * 2))) # received blocks are Telegram's own: nothing to count here
|
| 318 |
+
with pytest.raises(RichError) as exc:
|
| 319 |
+
check(Rich("markdown", "x" * (MAX_SOURCE + 1)))
|
| 320 |
+
assert "32768" in str(exc.value)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# ===================================================================================== a rich message sent to the bot
|
| 324 |
+
def test_the_fingerprint_follows_what_the_message_shows():
|
| 325 |
+
a, b = rich_msg(blocks("x")), rich_msg(blocks("x"), photos=[photo_media(5).photo])
|
| 326 |
+
assert fingerprint(a) == fingerprint(b) # the pictures' file references change from fetch to fetch
|
| 327 |
+
assert fingerprint(a) != fingerprint(rich_msg(blocks("y")))
|
| 328 |
+
assert fingerprint(a) != fingerprint(rich_msg(blocks("x"), rtl=True))
|
| 329 |
+
assert len(fingerprint(a)) == 20
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def test_is_rich_message():
|
| 333 |
+
assert is_rich_message(SimpleNamespace(rich_message=rich_msg()))
|
| 334 |
+
assert not is_rich_message(SimpleNamespace(rich_message=None)) and not is_rich_message(SimpleNamespace())
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def test_people_mentioned_by_link_are_found_and_looked_up():
|
| 338 |
+
async def run():
|
| 339 |
+
bl = [types.PageBlockParagraph(types.TextConcat([T("hi "), types.TextMentionName(T("Bob"), 42), types.TextMentionName(T("Bob"), 42), types.TextMentionName(T("Al"), 43)]))]
|
| 340 |
+
assert mention_ids(bl) == [42, 43]
|
| 341 |
+
assert mention_ids(blocks()) == [] and mention_ids(None) == []
|
| 342 |
+
|
| 343 |
+
class Client:
|
| 344 |
+
async def get_input_entity(self, uid):
|
| 345 |
+
if uid == 42:
|
| 346 |
+
return types.InputPeerUser(42, 777)
|
| 347 |
+
raise ValueError("unknown")
|
| 348 |
+
|
| 349 |
+
users = await input_users(Client(), [42, 43])
|
| 350 |
+
assert [(u.user_id, u.access_hash) for u in users] == [(42, 777)] # the one that can't be found is left out
|
| 351 |
+
r = rich_from_received(rich_msg(bl), users)
|
| 352 |
+
assert [u.user_id for u in to_input(r).users] == [42]
|
| 353 |
+
|
| 354 |
+
asyncio.run(run())
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class Event:
|
| 358 |
+
"""The message event of a rich message that arrived (what `received` reads)."""
|
| 359 |
+
|
| 360 |
+
def __init__(self, rm, mid=77):
|
| 361 |
+
self.message = SimpleNamespace(rich_message=rm, id=mid)
|
| 362 |
+
|
| 363 |
+
async def get_input_chat(self):
|
| 364 |
+
return types.InputPeerUser(1, 0)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
class Client:
|
| 368 |
+
def __init__(self, answer=None, error=None):
|
| 369 |
+
self.answer, self.error, self.requests = answer, error, []
|
| 370 |
+
|
| 371 |
+
async def __call__(self, req):
|
| 372 |
+
self.requests.append(req)
|
| 373 |
+
if self.error:
|
| 374 |
+
raise self.error
|
| 375 |
+
return self.answer
|
| 376 |
+
|
| 377 |
+
async def get_input_entity(self, uid):
|
| 378 |
+
raise ValueError(uid)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def test_a_whole_rich_message_is_read_as_it_arrived():
|
| 382 |
+
async def run():
|
| 383 |
+
rm = rich_msg(photos=[photo_media(5).photo])
|
| 384 |
+
client = Client()
|
| 385 |
+
r = await received(client, Event(rm))
|
| 386 |
+
assert client.requests == [] # nothing more to ask
|
| 387 |
+
assert (r.fmt, r.fp) == ("blocks", fingerprint(rm)) and plain_of(r) == "Title\nHello"
|
| 388 |
+
assert await received(client, Event(None)) is None # an ordinary message
|
| 389 |
+
|
| 390 |
+
asyncio.run(run())
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def test_a_rich_message_that_arrives_in_parts_is_fetched_whole():
|
| 394 |
+
async def run():
|
| 395 |
+
part = rich_msg([types.PageBlockParagraph(T("first"))], part=True)
|
| 396 |
+
client = Client(answer=rich_msg(blocks("whole")))
|
| 397 |
+
r = await received(client, Event(part, mid=9))
|
| 398 |
+
(req,) = client.requests
|
| 399 |
+
assert isinstance(req, functions.messages.GetRichMessageRequest) and req.id == 9
|
| 400 |
+
assert plain_of(r) == "Title\nwhole"
|
| 401 |
+
|
| 402 |
+
asyncio.run(run())
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def test_a_rich_message_that_cannot_be_had_whole_is_refused_in_plain_words():
|
| 406 |
+
async def run():
|
| 407 |
+
part = rich_msg([types.PageBlockParagraph(T("first"))], part=True)
|
| 408 |
+
for client in (
|
| 409 |
+
Client(answer=rich_msg(blocks(), part=True)), # still only a part
|
| 410 |
+
Client(error=rpc("MessageIdInvalidError")), # Telegram refuses to say more
|
| 411 |
+
Client(answer=None), # nothing came back
|
| 412 |
+
):
|
| 413 |
+
with pytest.raises(RichError) as exc:
|
| 414 |
+
await received(client, Event(part))
|
| 415 |
+
assert "part of that rich message" in str(exc.value)
|
| 416 |
+
with pytest.raises(RichError) as exc:
|
| 417 |
+
await received(Client(), Event(types.RichMessage(blocks=[], photos=[], documents=[])))
|
| 418 |
+
assert "no content" in str(exc.value)
|
| 419 |
+
|
| 420 |
+
asyncio.run(run())
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ==================================================================================== the plain text of a rich message
|
| 424 |
+
def test_the_plain_text_of_typed_html():
|
| 425 |
+
html = (
|
| 426 |
+
"<h1>Schedule</h1><p>Every <b>Monday</b></p><ul><li>one</li><li><input type=\"checkbox\" checked> two</li></ul>"
|
| 427 |
+
"<table><tr><th>Day</th><th>Show</th></tr><tr><td>Mon</td><td>Naruto</td></tr></table>"
|
| 428 |
+
"<details><summary>More</summary>hidden text</details><blockquote>quote<cite>me</cite></blockquote>"
|
| 429 |
+
"<tg-math-block>E = mc^2</tg-math-block>"
|
| 430 |
+
)
|
| 431 |
+
assert plain_of(Rich("html", html)) == (
|
| 432 |
+
"Schedule\nEvery Monday\n• one\n• ☑ two\nDay | Show\nMon | Naruto\nMore\nhidden text\nquote\nme\nE = mc^2"
|
| 433 |
+
)
|
| 434 |
+
assert plain_of(Rich("html", "line one<br>line two<br><br>after a gap & more")) == "line one\nline two\n\nafter a gap & more"
|
| 435 |
+
assert plain_of(Rich("html", "a<script>var x = 1;</script>b")) == "ab"
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def test_the_plain_text_of_typed_markdown():
|
| 439 |
+
md = (
|
| 440 |
+
"# Title\n\nSome **bold**, *it* and ==mark== [link](https://t.me/x)\n\n- [x] done\n- [ ] todo\n- item\n\n"
|
| 441 |
+
"| a | b |\n|:--|--:|\n| 1 | 2 |\n\n> quoted\n\n```python\nprint(1)\n```\n\n$$E = mc^2$$\n\n"
|
| 442 |
+
"note[^1]\n\n[^1]: the note\n\n\n\n---\n\nend"
|
| 443 |
+
)
|
| 444 |
+
assert plain_of(Rich("markdown", md)) == (
|
| 445 |
+
"Title\n\nSome bold, it and mark link\n\n☑ done\n☐ todo\n• item\n\na | b\n1 | 2\n\nquoted\n\nprint(1)\n\n"
|
| 446 |
+
"$$E = mc^2$$\n\nnote\n\n1: the note\n\na caption\n\nend"
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def test_the_plain_text_of_a_rich_message_made_in_telegram():
|
| 451 |
+
bl = [
|
| 452 |
+
types.PageBlockHeading1(T("Title")),
|
| 453 |
+
types.PageBlockParagraph(types.TextConcat([T("Every "), types.TextBold(T("Monday"))])),
|
| 454 |
+
types.PageBlockList([types.PageListItemText(T("one")), types.PageListItemText(T("two"), checkbox=True, checked=True)]),
|
| 455 |
+
types.PageBlockTable(
|
| 456 |
+
T("sched"),
|
| 457 |
+
[
|
| 458 |
+
types.PageTableRow([types.PageTableCell(text=T("Day"), header=True), types.PageTableCell(text=T("Show"), header=True)]),
|
| 459 |
+
types.PageTableRow([types.PageTableCell(text=T("Mon")), types.PageTableCell(text=T("Naruto"))]),
|
| 460 |
+
],
|
| 461 |
+
),
|
| 462 |
+
types.PageBlockMath("E = mc^2"),
|
| 463 |
+
types.PageBlockDetails([types.PageBlockParagraph(T("hidden"))], T("More")),
|
| 464 |
+
]
|
| 465 |
+
want = "Title\nEvery Monday\n• one\n☑ two\nsched\nDay | Show\nMon | Naruto\nE = mc^2\nMore\nhidden"
|
| 466 |
+
rm = rich_msg(bl)
|
| 467 |
+
assert plain_of(rich_from_received(rm)) == want
|
| 468 |
+
assert plain_of_message(SimpleNamespace(rich_message=rm)) == want
|
| 469 |
+
assert plain_of_message(SimpleNamespace(rich_message=None)) == ""
|
| 470 |
+
assert plain_of(Rich("blocks", "not base64 of anything")) == ""
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
# ============================================================================================================== the help
|
| 474 |
+
def test_the_help_pages_fit_in_a_message_and_cover_the_blocks():
|
| 475 |
+
for page in (RICH_HELP, RICH_HELP_HTML):
|
| 476 |
+
assert len(page) < 4096
|
| 477 |
+
help_all = RICH_HELP + RICH_HELP_HTML
|
| 478 |
+
for word in (
|
| 479 |
+
"<rich>", "# Heading", "- [ ] task", "```python", "$$E = mc^2$$", "[^1]", "tg://time", "tg://emoji", "==marked==",
|
| 480 |
+
"<details", "<table", "<tg-math-block>", "<tg-map", "<tg-collage>", "<tg-slideshow>",
|
| 481 |
+
"<tg-button-row", "<aside>", "<figure>", "<tg-reference", "<tg-time", "<sub>", "https",
|
| 482 |
+
):
|
| 483 |
+
assert word in help_all, word
|
tests/test_rich_flows.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rich messages through the bot: /new, the edits in My posts, the preview, /broadcast, the userbot, /swap, the sync and /repost."""
|
| 2 |
+
import asyncio
|
| 3 |
+
from types import SimpleNamespace
|
| 4 |
+
|
| 5 |
+
from telethon import functions, types
|
| 6 |
+
|
| 7 |
+
from app.replace_engine import ScanOptions, apply_channel, scan_channel, undo_batch
|
| 8 |
+
from app.repost_engine import RepostOptions, final_state, run_repost, stand_in
|
| 9 |
+
from app.richmsg import Rich, entry, fingerprint, rich_of
|
| 10 |
+
from app.suggest import build_suggestion
|
| 11 |
+
from app.sync_engine import diff_post, live_fields, message_supported, sync_channel
|
| 12 |
+
from app.tgutil import button_url, pack_file_id
|
| 13 |
+
|
| 14 |
+
from .fakes import FakeChannelClient, make_msg, markup, photo_media, rpc, url_btn, video_media
|
| 15 |
+
from .harness import App, norm
|
| 16 |
+
from .test_broadcast import broadcast, make_app as broadcast_app
|
| 17 |
+
from .test_engine import setup as engine_setup
|
| 18 |
+
from .test_flows import make_app
|
| 19 |
+
from .test_markup_flows import msg, new_post
|
| 20 |
+
from .test_replace_userbot import BUTTONS, CID, MID, make_app as userbot_app, replacement, user_edits
|
| 21 |
+
from .test_repost import NEW, OLD, edit_requests, forwarded_ids, setup as repost_setup
|
| 22 |
+
from .test_rich import blocks, rich_msg, same
|
| 23 |
+
from .test_sync import old_msg, opts, world
|
| 24 |
+
|
| 25 |
+
HEADING = "# Release notes\n\n- [x] fixed\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |"
|
| 26 |
+
TABLE = '<h2>Schedule</h2><table bordered><tr><th>Day</th><th>Show</th></tr><tr><td>Mon</td><td>Naruto</td></tr></table>'
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def native(rm=None, mid=77):
|
| 30 |
+
"""A rich message sent to the bot (or forwarded to it)."""
|
| 31 |
+
return SimpleNamespace(
|
| 32 |
+
raw_text="", entities=[], media=None, grouped_id=None, forward=None, rich_message=rm or rich_msg(), id=mid
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def raw_edits(app):
|
| 37 |
+
"""The messages.editMessage requests the bot sent, each once."""
|
| 38 |
+
out = []
|
| 39 |
+
for e in app.tg.edits:
|
| 40 |
+
r = e[2] if isinstance(e, tuple) and e[0] == "raw" else e
|
| 41 |
+
if isinstance(r, functions.messages.EditMessageRequest) and not any(r is o for o in out):
|
| 42 |
+
out.append(r)
|
| 43 |
+
return out
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def rich_sends(app):
|
| 47 |
+
return [e for e in app.tg.sent if e[0] == "rich"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def md_post(source):
|
| 51 |
+
return dict(text="x", entities=[entry(Rich("markdown", source))])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ============================================================================================== typed text: /new
|
| 55 |
+
def test_markdown_with_a_heading_is_published_as_a_rich_message(tmp_path):
|
| 56 |
+
async def run():
|
| 57 |
+
app = await make_app(tmp_path)
|
| 58 |
+
pid, prompt = await new_post(app, HEADING)
|
| 59 |
+
assert "Rich message read from Markdown" in prompt and "/rich" in prompt and "buttons" in prompt.lower()
|
| 60 |
+
post = await app.db.get_post(pid)
|
| 61 |
+
assert post.status == "sent" and post.entities == [{"k": "rich", "f": "markdown", "s": HEADING}]
|
| 62 |
+
assert post.text.startswith("Release notes\n") and "☑ fixed" in post.text and "☐ todo" in post.text
|
| 63 |
+
kind, peer, inp, kw = app.tg.sent[-1]
|
| 64 |
+
assert kind == "rich" and isinstance(peer, types.InputPeerChannel) and peer.channel_id == 1
|
| 65 |
+
assert isinstance(inp, types.InputRichMessageMarkdown) and inp.markdown == HEADING and not inp.rtl
|
| 66 |
+
assert kw["reply_markup"] is None
|
| 67 |
+
live = app.tg.msgs[post.message_id] # Telegram made a rich message of it
|
| 68 |
+
assert live.rich_message is not None and live.message == ""
|
| 69 |
+
assert [e for e in app.tg.sent if e[0] in ("text", "file")] == [] # nothing went out as an ordinary message
|
| 70 |
+
await app.db.close()
|
| 71 |
+
|
| 72 |
+
asyncio.run(run())
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_html_with_a_table_is_published_as_a_rich_message(tmp_path):
|
| 76 |
+
async def run():
|
| 77 |
+
app = await make_app(tmp_path)
|
| 78 |
+
pid, prompt = await new_post(app, TABLE)
|
| 79 |
+
assert "Rich message read from HTML" in prompt
|
| 80 |
+
post = await app.db.get_post(pid)
|
| 81 |
+
assert post.entities == [{"k": "rich", "f": "html", "s": TABLE}]
|
| 82 |
+
assert post.text == "Schedule\nDay | Show\nMon | Naruto"
|
| 83 |
+
kind, peer, inp, kw = app.tg.sent[-1]
|
| 84 |
+
assert kind == "rich" and isinstance(inp, types.InputRichMessageHTML) and inp.html == TABLE
|
| 85 |
+
await app.db.close()
|
| 86 |
+
|
| 87 |
+
asyncio.run(run())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_the_rich_marker_turns_any_text_into_a_rich_message_with_its_options(tmp_path):
|
| 91 |
+
async def run():
|
| 92 |
+
app = await make_app(tmp_path)
|
| 93 |
+
pid, prompt = await new_post(app, "<rich rtl noautolink>\nhello **x** #tag @name")
|
| 94 |
+
assert "Rich message read from Markdown" in prompt
|
| 95 |
+
post = await app.db.get_post(pid)
|
| 96 |
+
assert post.text == "hello x #tag @name"
|
| 97 |
+
assert post.entities == [{"k": "rich", "f": "markdown", "s": "hello **x** #tag @name", "r": 1, "n": 1}]
|
| 98 |
+
kind, peer, inp, kw = app.tg.sent[-1]
|
| 99 |
+
assert isinstance(inp, types.InputRichMessageMarkdown) and inp.markdown == "hello **x** #tag @name"
|
| 100 |
+
assert inp.rtl is True and inp.noautolink is True
|
| 101 |
+
await app.db.close()
|
| 102 |
+
|
| 103 |
+
asyncio.run(run())
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_text_that_only_looks_a_little_like_markup_stays_an_ordinary_post(tmp_path):
|
| 107 |
+
async def run():
|
| 108 |
+
app = await make_app(tmp_path)
|
| 109 |
+
for text in ("#anime #naruto new episode", "Price: $5 and $6 - a == b - I <3 it\n- one\n- two\n---", "**bold** [x](https://t.me/x)"):
|
| 110 |
+
pid, prompt = await new_post(app, text)
|
| 111 |
+
assert "Rich message" not in prompt
|
| 112 |
+
assert rich_of(await app.db.get_post(pid)) is None
|
| 113 |
+
assert app.tg.sent[-1][0] == "text"
|
| 114 |
+
await app.db.close()
|
| 115 |
+
|
| 116 |
+
asyncio.run(run())
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_markup_input_off_keeps_typed_blocks_literal(tmp_path):
|
| 120 |
+
async def run():
|
| 121 |
+
app = App(tmp_path, markup_input=False)
|
| 122 |
+
await app.start()
|
| 123 |
+
await app.db.save_channel(1, 5, "Anime Channel", "animech", 1)
|
| 124 |
+
pid, prompt = await new_post(app, HEADING)
|
| 125 |
+
assert "Rich message" not in prompt
|
| 126 |
+
kind, peer, text, kw = app.tg.sent[-1]
|
| 127 |
+
assert kind == "text" and text == HEADING and kw["formatting_entities"] == []
|
| 128 |
+
# a rich message made in Telegram is not typed text: it is still kept
|
| 129 |
+
pid, prompt = await new_post(app, "", message=native())
|
| 130 |
+
assert "Rich message kept as it is" in prompt and app.tg.sent[-1][0] == "rich"
|
| 131 |
+
await app.db.close()
|
| 132 |
+
|
| 133 |
+
asyncio.run(run())
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_a_typed_rich_message_that_is_too_long_is_refused(tmp_path):
|
| 137 |
+
async def run():
|
| 138 |
+
app = await make_app(tmp_path)
|
| 139 |
+
await app.text("/new")
|
| 140 |
+
await app.text("# Title\n" + "x" * 33000, message=msg("# Title\n" + "x" * 33000))
|
| 141 |
+
assert "32768" in norm(app.out.last_text)
|
| 142 |
+
assert (await app.db.list_posts(1, None, 0, 10))[1] == 0 # nothing was saved
|
| 143 |
+
await app.db.close()
|
| 144 |
+
|
| 145 |
+
asyncio.run(run())
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# =================================================================================== a rich message sent to the bot
|
| 149 |
+
def test_a_rich_message_sent_to_the_bot_is_published_block_for_block(tmp_path):
|
| 150 |
+
async def run():
|
| 151 |
+
app = await make_app(tmp_path)
|
| 152 |
+
rm = rich_msg(photos=[photo_media(5).photo], documents=[video_media(81).document], rtl=True)
|
| 153 |
+
pid, prompt = await new_post(app, "", message=native(rm))
|
| 154 |
+
assert "Rich message kept as it is" in prompt
|
| 155 |
+
post = await app.db.get_post(pid)
|
| 156 |
+
(e,) = post.entities
|
| 157 |
+
assert (e["k"], e["f"], e["r"], e["p"]) == ("rich", "blocks", 1, fingerprint(rm)) and post.text == "Title\nHello"
|
| 158 |
+
kind, peer, inp, kw = app.tg.sent[-1]
|
| 159 |
+
assert kind == "rich" and isinstance(inp, types.InputRichMessage) and inp.rtl
|
| 160 |
+
assert same(inp.blocks, rm.blocks)
|
| 161 |
+
assert [(p.id, p.access_hash, p.file_reference) for p in inp.photos] == [(5, 35, b"ref5")]
|
| 162 |
+
assert [d.id for d in inp.documents] == [81]
|
| 163 |
+
live = app.tg.msgs[post.message_id].rich_message
|
| 164 |
+
assert same(live.blocks, rm.blocks) and [p.id for p in live.photos] == [5]
|
| 165 |
+
await app.db.close()
|
| 166 |
+
|
| 167 |
+
asyncio.run(run())
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_a_rich_message_that_arrives_in_parts_is_asked_for_whole(tmp_path):
|
| 171 |
+
async def run():
|
| 172 |
+
app = await make_app(tmp_path)
|
| 173 |
+
part = rich_msg([types.PageBlockParagraph(types.TextPlain("first"))], part=True)
|
| 174 |
+
app.tg.full_rich = {77: rich_msg(blocks("whole"))}
|
| 175 |
+
pid, prompt = await new_post(app, "", message=native(part))
|
| 176 |
+
assert "Rich message kept as it is" in prompt
|
| 177 |
+
assert (await app.db.get_post(pid)).text == "Title\nwhole"
|
| 178 |
+
assert any(isinstance(r, functions.messages.GetRichMessageRequest) and r.id == 77 for r in app.tg.requests)
|
| 179 |
+
await app.db.close()
|
| 180 |
+
|
| 181 |
+
asyncio.run(run())
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_a_rich_message_that_cannot_be_had_whole_makes_no_post(tmp_path):
|
| 185 |
+
async def run():
|
| 186 |
+
app = await make_app(tmp_path)
|
| 187 |
+
part = rich_msg([types.PageBlockParagraph(types.TextPlain("first"))], part=True)
|
| 188 |
+
app.tg.full_rich = {77: rich_msg(blocks(), part=True)}
|
| 189 |
+
await app.text("/new")
|
| 190 |
+
await app.text("", message=native(part))
|
| 191 |
+
assert "part of that rich message" in norm(app.out.last_text)
|
| 192 |
+
assert (await app.db.list_posts(1, None, 0, 10))[1] == 0
|
| 193 |
+
assert app.ctx.state[app.uid]["mode"] == "new_content" # still waiting for the message
|
| 194 |
+
await app.text("hello", message=msg("hello")) # and a better one is taken
|
| 195 |
+
assert (await app.db.list_posts(1, None, 0, 10))[1] == 1
|
| 196 |
+
await app.db.close()
|
| 197 |
+
|
| 198 |
+
asyncio.run(run())
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ================================================================================ buttons, preview, publishing
|
| 202 |
+
def test_the_link_buttons_of_a_post_go_with_its_rich_message(tmp_path):
|
| 203 |
+
async def run():
|
| 204 |
+
app = await make_app(tmp_path)
|
| 205 |
+
await app.text("/new")
|
| 206 |
+
await app.text(HEADING, message=msg(HEADING))
|
| 207 |
+
await app.text("Download - https://t.me/x?start=a | Other - https://example.com", message=msg("x"))
|
| 208 |
+
pid = int(app.out.callback_data("Publish").split(":")[1])
|
| 209 |
+
await app.press(f"pb:{pid}")
|
| 210 |
+
await app.press(f"pby:{pid}")
|
| 211 |
+
kind, peer, inp, kw = app.tg.sent[-1]
|
| 212 |
+
rows = kw["reply_markup"].rows
|
| 213 |
+
assert kind == "rich" and [b.text for b in rows[0].buttons] == ["Download", "Other"]
|
| 214 |
+
assert [button_url(b) for b in rows[0].buttons] == ["https://t.me/x?start=a", "https://example.com"]
|
| 215 |
+
assert app.tg.msgs[(await app.db.get_post(pid)).message_id].reply_markup is not None
|
| 216 |
+
await app.db.close()
|
| 217 |
+
|
| 218 |
+
asyncio.run(run())
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_the_preview_of_a_rich_draft_goes_to_the_owner_and_not_to_the_channel(tmp_path):
|
| 222 |
+
async def run():
|
| 223 |
+
app = await make_app(tmp_path)
|
| 224 |
+
pid, _ = await new_post(app, HEADING, publish=False)
|
| 225 |
+
await app.press(f"pv:{pid}")
|
| 226 |
+
kind, peer, inp, kw = app.tg.sent[-1]
|
| 227 |
+
assert kind == "rich" and isinstance(peer, types.InputPeerUser) and inp.markdown == HEADING
|
| 228 |
+
assert app.tg.msgs == {} and (await app.db.get_post(pid)).status == "draft"
|
| 229 |
+
await app.db.close()
|
| 230 |
+
|
| 231 |
+
asyncio.run(run())
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_telegram_not_being_able_to_make_the_message_is_explained_in_the_preview_and_when_publishing(tmp_path):
|
| 235 |
+
async def run():
|
| 236 |
+
app = await make_app(tmp_path)
|
| 237 |
+
pid, _ = await new_post(app, "<rich>\n<tg-map lat=\"x\"/>", publish=False)
|
| 238 |
+
app.tg.fail["SendMessageRequest"] = rpc("MessageEmptyError")
|
| 239 |
+
await app.press(f"pv:{pid}")
|
| 240 |
+
assert "see /rich for the syntax" in norm(app.out.last_text)
|
| 241 |
+
await app.press(f"pb:{pid}")
|
| 242 |
+
await app.press(f"pby:{pid}")
|
| 243 |
+
assert "NOT published" in norm(app.out.last_text)
|
| 244 |
+
assert (await app.db.get_post(pid)).status == "draft" and app.tg.msgs == {}
|
| 245 |
+
await app.db.close()
|
| 246 |
+
|
| 247 |
+
asyncio.run(run())
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_the_panel_of_a_rich_post_has_no_media_or_link_preview(tmp_path):
|
| 251 |
+
async def run():
|
| 252 |
+
app = await make_app(tmp_path)
|
| 253 |
+
pid, _ = await new_post(app, HEADING, publish=False)
|
| 254 |
+
await app.press(f"po:{pid}")
|
| 255 |
+
text = norm(app.out.last_text)
|
| 256 |
+
assert "Type: 🧩 rich message (typed as Markdown)" in text and "Release notes" in text
|
| 257 |
+
labels = [b for row in app.out.last_buttons() for b in row]
|
| 258 |
+
assert "✏️ Text" in labels and "👁 Preview" in labels and "➕ Button" in labels
|
| 259 |
+
assert not any("Media" in b or "Link preview" in b for b in labels if b)
|
| 260 |
+
native_pid, _ = await new_post(app, "", message=native(), publish=False)
|
| 261 |
+
assert "made in Telegram" in norm((await _panel(app, native_pid)))
|
| 262 |
+
await app.db.close()
|
| 263 |
+
|
| 264 |
+
asyncio.run(run())
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
async def _panel(app, pid):
|
| 268 |
+
await app.press(f"po:{pid}")
|
| 269 |
+
return app.out.last_text
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ====================================================================================== editing in My posts
|
| 273 |
+
def test_the_text_of_a_published_rich_post_is_changed_with_a_new_rich_message(tmp_path):
|
| 274 |
+
async def run():
|
| 275 |
+
app = await make_app(tmp_path)
|
| 276 |
+
pid, _ = await new_post(app, "# One\n\ntext")
|
| 277 |
+
before = app.tg.msgs[(await app.db.get_post(pid)).message_id].rich_message
|
| 278 |
+
await app.press(f"et:{pid}")
|
| 279 |
+
assert "/rich" in norm(app.out.last_text)
|
| 280 |
+
new = "# Two\n\n- [ ] a"
|
| 281 |
+
await app.text(new, message=msg(new))
|
| 282 |
+
(req,) = raw_edits(app)
|
| 283 |
+
assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == new
|
| 284 |
+
assert req.message is None and not req.entities
|
| 285 |
+
post = await app.db.get_post(pid)
|
| 286 |
+
assert post.entities == [{"k": "rich", "f": "markdown", "s": new}] and post.text == "Two\n\n☐ a"
|
| 287 |
+
assert bytes(app.tg.msgs[post.message_id].rich_message) != bytes(before) # the channel shows the new one
|
| 288 |
+
assert "Rich message read from Markdown" in norm(app.out.last_text)
|
| 289 |
+
await app.db.close()
|
| 290 |
+
|
| 291 |
+
asyncio.run(run())
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def test_a_native_rich_message_can_replace_the_text_of_a_post(tmp_path):
|
| 295 |
+
async def run():
|
| 296 |
+
app = await make_app(tmp_path)
|
| 297 |
+
pid, _ = await new_post(app, "plain words")
|
| 298 |
+
await app.press(f"et:{pid}")
|
| 299 |
+
await app.text("", message=native(rich_msg(blocks("brand new"))))
|
| 300 |
+
(req,) = raw_edits(app)
|
| 301 |
+
assert isinstance(req.rich_message, types.InputRichMessage) and req.message is None
|
| 302 |
+
post = await app.db.get_post(pid)
|
| 303 |
+
assert rich_of(post).fmt == "blocks" and post.text == "Title\nbrand new"
|
| 304 |
+
assert "Rich message kept as it is" in norm(app.out.last_text)
|
| 305 |
+
await app.db.close()
|
| 306 |
+
|
| 307 |
+
asyncio.run(run())
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_a_plain_post_becomes_a_rich_one_and_a_rich_one_a_plain_one_again(tmp_path):
|
| 311 |
+
async def run():
|
| 312 |
+
app = await make_app(tmp_path)
|
| 313 |
+
pid, _ = await new_post(app, "hello")
|
| 314 |
+
await app.press(f"et:{pid}")
|
| 315 |
+
await app.text(HEADING, message=msg(HEADING))
|
| 316 |
+
assert isinstance(raw_edits(app)[-1].rich_message, types.InputRichMessageMarkdown)
|
| 317 |
+
assert rich_of(await app.db.get_post(pid)) is not None
|
| 318 |
+
await app.press(f"et:{pid}")
|
| 319 |
+
await app.text("plain again", message=msg("plain again"))
|
| 320 |
+
last = raw_edits(app)[-1]
|
| 321 |
+
assert last.message == "plain again" and last.rich_message is None
|
| 322 |
+
post = await app.db.get_post(pid)
|
| 323 |
+
assert rich_of(post) is None and post.text == "plain again" and post.entities == []
|
| 324 |
+
await app.db.close()
|
| 325 |
+
|
| 326 |
+
asyncio.run(run())
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def test_a_draft_can_be_changed_into_a_rich_message_before_it_is_published(tmp_path):
|
| 330 |
+
async def run():
|
| 331 |
+
app = await make_app(tmp_path)
|
| 332 |
+
pid, _ = await new_post(app, "first", publish=False)
|
| 333 |
+
await app.press(f"et:{pid}")
|
| 334 |
+
await app.text(TABLE, message=msg(TABLE))
|
| 335 |
+
post = await app.db.get_post(pid)
|
| 336 |
+
assert post.status == "draft" and rich_of(post).fmt == "html" and raw_edits(app) == []
|
| 337 |
+
await app.press(f"pb:{pid}")
|
| 338 |
+
await app.press(f"pby:{pid}")
|
| 339 |
+
assert app.tg.sent[-1][0] == "rich" and app.tg.sent[-1][2].html == TABLE
|
| 340 |
+
await app.db.close()
|
| 341 |
+
|
| 342 |
+
asyncio.run(run())
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_the_buttons_of_a_published_rich_post_are_changed_without_losing_the_message(tmp_path):
|
| 346 |
+
async def run():
|
| 347 |
+
app = await make_app(tmp_path)
|
| 348 |
+
pid, _ = await new_post(app, HEADING)
|
| 349 |
+
await app.press(f"ba:{pid}")
|
| 350 |
+
await app.text("Watch", message=msg("Watch"))
|
| 351 |
+
await app.text("https://example.com/w", message=msg("https://example.com/w"))
|
| 352 |
+
req = raw_edits(app)[-1]
|
| 353 |
+
assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == HEADING
|
| 354 |
+
assert [button_url(b) for b in req.reply_markup.rows[0].buttons] == ["https://example.com/w"]
|
| 355 |
+
post = await app.db.get_post(pid)
|
| 356 |
+
assert rich_of(post) is not None and post.buttons == [[{"t": "Watch", "u": "https://example.com/w"}]]
|
| 357 |
+
await app.db.close()
|
| 358 |
+
|
| 359 |
+
asyncio.run(run())
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def test_a_rich_post_has_no_media_slot(tmp_path):
|
| 363 |
+
async def run():
|
| 364 |
+
app = await make_app(tmp_path)
|
| 365 |
+
pid, _ = await new_post(app, HEADING)
|
| 366 |
+
await app.press(f"em:{pid}")
|
| 367 |
+
assert "has no media slot" in norm(app.out.last_text)
|
| 368 |
+
assert app.ctx.state.get(app.uid) is None or app.ctx.state[app.uid]["mode"] != "edit_media"
|
| 369 |
+
await app.db.close()
|
| 370 |
+
|
| 371 |
+
asyncio.run(run())
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_a_caption_is_never_a_rich_message(tmp_path):
|
| 375 |
+
async def run():
|
| 376 |
+
app = await make_app(tmp_path)
|
| 377 |
+
post = await app.db.create_post(
|
| 378 |
+
channel_id=1, message_id=7, status="sent", source="bot", text="old", entities=[], buttons=[],
|
| 379 |
+
media_kind="photo", media_file_id=pack_file_id(make_msg(1, "", media=photo_media(3))),
|
| 380 |
+
)
|
| 381 |
+
await app.press(f"et:{post.id}")
|
| 382 |
+
assert "Rich messages" not in norm(app.out.last_text)
|
| 383 |
+
await app.text("", message=native()) # a rich message as a caption
|
| 384 |
+
assert "caption can't be a rich message" in norm(app.out.last_text)
|
| 385 |
+
await app.text("# Heading\n\n- [ ] task", message=msg("# Heading\n\n- [ ] task")) # typed blocks stay words
|
| 386 |
+
saved_post = await app.db.get_post(post.id)
|
| 387 |
+
assert rich_of(saved_post) is None and saved_post.text.startswith("# Heading") and saved_post.media_kind == "photo"
|
| 388 |
+
assert all(r.rich_message is None for r in raw_edits(app))
|
| 389 |
+
await app.db.close()
|
| 390 |
+
|
| 391 |
+
asyncio.run(run())
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def test_a_rich_message_can_not_be_made_a_media_post_by_the_media_edit(tmp_path):
|
| 395 |
+
async def run():
|
| 396 |
+
app = await make_app(tmp_path)
|
| 397 |
+
await app.text("/new")
|
| 398 |
+
await app.text("", message=SimpleNamespace(raw_text="", entities=[], media=photo_media(5), grouped_id=None, forward=None, rich_message=rich_msg()))
|
| 399 |
+
post = (await app.db.list_posts(1, None, 0, 10))[0][0]
|
| 400 |
+
assert post.media_kind == "photo" and rich_of(post) is None # a photo wins: the message is not a rich post
|
| 401 |
+
await app.db.close()
|
| 402 |
+
|
| 403 |
+
asyncio.run(run())
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# =================================================================================================== /broadcast
|
| 407 |
+
def test_a_broadcast_can_be_a_rich_message(tmp_path):
|
| 408 |
+
async def run():
|
| 409 |
+
app = await broadcast_app(tmp_path)
|
| 410 |
+
await app.text("/broadcast 1h")
|
| 411 |
+
assert "/rich" in norm(app.out.last_text)
|
| 412 |
+
text = "# Maintenance\n\n- tonight 22:00\n- [ ] backups"
|
| 413 |
+
await app.text(text, message=msg(text))
|
| 414 |
+
assert "Rich message read from Markdown" in norm(app.out.last_text)
|
| 415 |
+
await app.press(app.out.callback_data("No buttons"))
|
| 416 |
+
assert rich_sends(app)[-1][1].__class__ is types.InputPeerUser # the preview was made as a rich message too
|
| 417 |
+
await app.press(app.out.callback_data("Send to all channels"))
|
| 418 |
+
for cid in (1, 2):
|
| 419 |
+
chan = app.tg.registry[cid]
|
| 420 |
+
(live,) = [m for m in chan.msgs.values() if m.rich_message is not None]
|
| 421 |
+
assert isinstance(live.rich_input, types.InputRichMessageMarkdown) and live.rich_input.markdown == text
|
| 422 |
+
(p,) = await app.db.sent_posts(cid)
|
| 423 |
+
assert p.message_id == live.id and p.entities == [{"k": "rich", "f": "markdown", "s": text}]
|
| 424 |
+
assert p.text.startswith("Maintenance")
|
| 425 |
+
assert len(await app.db.pending_deletes()) == 2 # it is still deleted when its time is up
|
| 426 |
+
await app.db.close()
|
| 427 |
+
|
| 428 |
+
asyncio.run(run())
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def test_a_broadcast_with_a_native_rich_message_and_buttons(tmp_path):
|
| 432 |
+
async def run():
|
| 433 |
+
app = await broadcast_app(tmp_path)
|
| 434 |
+
await broadcast(app, "/broadcast 1h", "", buttons="Open - https://t.me/x", message=native())
|
| 435 |
+
for cid in (1, 2):
|
| 436 |
+
(live,) = [m for m in app.tg.registry[cid].msgs.values() if m.rich_message is not None]
|
| 437 |
+
assert same(live.rich_message.blocks, blocks())
|
| 438 |
+
assert [button_url(b) for b in live.reply_markup.rows[0].buttons] == ["https://t.me/x"]
|
| 439 |
+
await app.db.close()
|
| 440 |
+
|
| 441 |
+
asyncio.run(run())
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
# ======================================================================================================== /replace
|
| 445 |
+
def test_replace_leaves_a_rich_post_alone(tmp_path):
|
| 446 |
+
async def run():
|
| 447 |
+
app, chan, user = await userbot_app(tmp_path)
|
| 448 |
+
live = chan.msgs[MID]
|
| 449 |
+
live.media, live.message, live.rich_message = None, "", rich_msg() # the channel's post is a rich message
|
| 450 |
+
await app.text(f"/replace https://t.me/c/{CID}/{MID}", reply=replacement())
|
| 451 |
+
assert "is a rich message" in norm(app.out.last_text)
|
| 452 |
+
assert user_edits(user) == [] and not any(isinstance(r, functions.messages.EditMessageRequest) for r in chan.requests)
|
| 453 |
+
assert chan.msgs[MID].rich_message is not None
|
| 454 |
+
await app.db.close()
|
| 455 |
+
|
| 456 |
+
asyncio.run(run())
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
# ================================================================================================ the userbot
|
| 460 |
+
def test_a_rich_edit_that_telegram_refuses_the_bot_is_made_by_the_userbot(tmp_path):
|
| 461 |
+
async def run():
|
| 462 |
+
app, chan, user = await userbot_app(tmp_path) # the post belongs to another bot: the bot may not edit it
|
| 463 |
+
live = chan.msgs[MID]
|
| 464 |
+
live.media, live.message, live.rich_message = None, "", chan._rich(types.InputRichMessageMarkdown(markdown="# old"))
|
| 465 |
+
post = await app.db.create_post(
|
| 466 |
+
channel_id=CID, message_id=MID, status="sent", source="adopted", text="old",
|
| 467 |
+
entities=[entry(Rich("markdown", "# old"))], buttons=BUTTONS,
|
| 468 |
+
)
|
| 469 |
+
await app.press(f"et:{post.id}")
|
| 470 |
+
new = "# New\n\n- [x] a"
|
| 471 |
+
await app.text(new, message=msg(new))
|
| 472 |
+
(req,) = user_edits(user)
|
| 473 |
+
assert isinstance(req.rich_message, types.InputRichMessageMarkdown) and req.rich_message.markdown == new
|
| 474 |
+
assert req.message is None and req.reply_markup is None # a user account sends no keyboard
|
| 475 |
+
assert isinstance(chan.msgs[MID].rich_message, types.RichMessage) and chan.msgs[MID].rich_input.markdown == new
|
| 476 |
+
assert "the userbot made the edit" in norm(app.out.last_text) and "Rich message read from Markdown" in norm(app.out.last_text)
|
| 477 |
+
saved_post = await app.db.get_post(post.id)
|
| 478 |
+
assert saved_post.entities == [{"k": "rich", "f": "markdown", "s": new}] and saved_post.buttons == BUTTONS
|
| 479 |
+
await app.db.close()
|
| 480 |
+
|
| 481 |
+
asyncio.run(run())
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def test_the_userbot_editor_can_set_a_rich_message():
|
| 485 |
+
from .test_userbot import channel
|
| 486 |
+
from .test_userbot_edit import ready_editor
|
| 487 |
+
|
| 488 |
+
async def go():
|
| 489 |
+
bot = channel()
|
| 490 |
+
ub, made, e = await ready_editor(bot)
|
| 491 |
+
await e.edit(5, text="", entities=[], rich=Rich("html", "<h1>New</h1>"))
|
| 492 |
+
await e.close()
|
| 493 |
+
req = bot.user_edits[-1]
|
| 494 |
+
assert isinstance(req.rich_message, types.InputRichMessageHTML) and req.rich_message.html == "<h1>New</h1>"
|
| 495 |
+
assert req.message is None and bot.msgs[5].rich_message is not None and bot.msgs[5].message == ""
|
| 496 |
+
|
| 497 |
+
asyncio.run(go())
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
# ============================================================================================ /swap and /handleswap
|
| 501 |
+
OLD_LINK = "https://t.me/bharath?start=AAA"
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def live_rich_post(i=1, *, out=True):
|
| 505 |
+
m = make_msg(i, "", markup=markup([url_btn("Get", OLD_LINK)]), out=out)
|
| 506 |
+
m.rich_message = rich_msg(
|
| 507 |
+
[types.PageBlockParagraph(types.TextUrl(types.TextPlain("t.me/bharath"), "https://t.me/bharath?start=ZZZ", 0))]
|
| 508 |
+
)
|
| 509 |
+
return m
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def test_swap_changes_only_the_buttons_of_a_rich_post_and_undo_puts_them_back(tmp_path):
|
| 513 |
+
async def run():
|
| 514 |
+
live = live_rich_post()
|
| 515 |
+
db, ch, client = await engine_setup(tmp_path, {1: live})
|
| 516 |
+
typed = Rich("markdown", "# Title\n\n@bharath t.me/bharath")
|
| 517 |
+
await db.create_post(
|
| 518 |
+
channel_id=1, message_id=1, status="sent", source="bot", text="Title", entities=[entry(typed)],
|
| 519 |
+
buttons=[[{"t": "Get", "u": OLD_LINK}]],
|
| 520 |
+
)
|
| 521 |
+
o = ScanOptions("bharath", "goku")
|
| 522 |
+
scan = await scan_channel(client, ch, o, can_edit_others=True)
|
| 523 |
+
assert set(scan.infos) == {1}
|
| 524 |
+
await db.create_batch("b1", "bharath", "goku", 99)
|
| 525 |
+
res = await apply_channel(client, db, scan, o, "b1", 99, edit_delay=0)
|
| 526 |
+
assert (res.edited, res.failed) == (1, [])
|
| 527 |
+
req = client.edits[-1]
|
| 528 |
+
assert req.message is None and req.rich_message is None # only the keyboard was sent
|
| 529 |
+
assert button_url(live.reply_markup.rows[0].buttons[0]) == "https://t.me/goku?start=AAA"
|
| 530 |
+
assert live.message == "" and live.rich_message is not None
|
| 531 |
+
p = await db.find_by_message(1, 1)
|
| 532 |
+
assert rich_of(p) == typed and p.text == "Title" and p.buttons == [[{"t": "Get", "u": "https://t.me/goku?start=AAA"}]]
|
| 533 |
+
|
| 534 |
+
ures = await undo_batch(client, db, "b1", edit_delay=0)
|
| 535 |
+
assert ures.restored == 1 and not ures.failed
|
| 536 |
+
assert client.edits[-1].message is None and client.edits[-1].rich_message is None
|
| 537 |
+
assert button_url(live.reply_markup.rows[0].buttons[0]) == OLD_LINK
|
| 538 |
+
p = await db.find_by_message(1, 1)
|
| 539 |
+
assert rich_of(p) == typed and p.text == "Title" and p.buttons == [[{"t": "Get", "u": OLD_LINK}]]
|
| 540 |
+
await db.close()
|
| 541 |
+
|
| 542 |
+
asyncio.run(run())
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
def test_a_rich_post_nobody_saved_is_adopted_by_a_swap_as_its_blocks(tmp_path):
|
| 546 |
+
async def run():
|
| 547 |
+
live = live_rich_post()
|
| 548 |
+
db, ch, client = await engine_setup(tmp_path, {1: live})
|
| 549 |
+
o = ScanOptions("bharath", "goku")
|
| 550 |
+
scan = await scan_channel(client, ch, o, can_edit_others=True)
|
| 551 |
+
await db.create_batch("b1", "bharath", "goku", 99)
|
| 552 |
+
await apply_channel(client, db, scan, o, "b1", 99, edit_delay=0)
|
| 553 |
+
p = await db.find_by_message(1, 1)
|
| 554 |
+
r = rich_of(p)
|
| 555 |
+
assert p.source == "adopted" and r.fmt == "blocks" and r.fp == fingerprint(live.rich_message)
|
| 556 |
+
assert p.text == "t.me/bharath" and p.buttons == [[{"t": "Get", "u": "https://t.me/goku?start=AAA"}]]
|
| 557 |
+
await db.close()
|
| 558 |
+
|
| 559 |
+
asyncio.run(run())
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def test_swaps_leave_the_text_of_a_rich_post_alone(tmp_path):
|
| 563 |
+
async def run():
|
| 564 |
+
for mode in ("links", "handles"):
|
| 565 |
+
live = make_msg(1, "")
|
| 566 |
+
live.rich_message = rich_msg([types.PageBlockParagraph(types.TextPlain("@bharath https://t.me/bharath?start=A"))])
|
| 567 |
+
db, ch, client = await engine_setup(tmp_path, {1: live})
|
| 568 |
+
scan = await scan_channel(client, ch, ScanOptions("bharath", "goku", mode=mode), can_edit_others=True)
|
| 569 |
+
assert scan.infos == {} # no button to change: nothing to do
|
| 570 |
+
await db.close()
|
| 571 |
+
|
| 572 |
+
asyncio.run(run())
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def test_a_handleswap_renames_the_buttons_of_a_rich_post_only(tmp_path):
|
| 576 |
+
async def run():
|
| 577 |
+
live = make_msg(1, "", markup=markup([url_btn("Join @bharath", "https://example.com")]), out=True)
|
| 578 |
+
live.rich_message = rich_msg()
|
| 579 |
+
db, ch, client = await engine_setup(tmp_path, {1: live})
|
| 580 |
+
o = ScanOptions("bharath", "goku", mode="handles")
|
| 581 |
+
scan = await scan_channel(client, ch, o, can_edit_others=True)
|
| 582 |
+
await db.create_batch("b1", "bharath", "goku", 99)
|
| 583 |
+
await apply_channel(client, db, scan, o, "b1", 99, edit_delay=0)
|
| 584 |
+
assert live.reply_markup.rows[0].buttons[0].text == "Join @goku" and client.edits[-1].message is None
|
| 585 |
+
assert live.rich_message is not None
|
| 586 |
+
await db.close()
|
| 587 |
+
|
| 588 |
+
asyncio.run(run())
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
# ===================================================================================================== the sync
|
| 592 |
+
class SavedLike:
|
| 593 |
+
"""Anything with the Post fields."""
|
| 594 |
+
|
| 595 |
+
def __init__(self, m, **kw):
|
| 596 |
+
for k, v in live_fields(m).items():
|
| 597 |
+
setattr(self, k, v)
|
| 598 |
+
self.link_preview = False
|
| 599 |
+
for k, v in kw.items():
|
| 600 |
+
setattr(self, k, v)
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
def rich_live(i, word="Hello", **kw):
|
| 604 |
+
m = old_msg(i, "", **kw)
|
| 605 |
+
m.rich_message = rich_msg(blocks(word))
|
| 606 |
+
return m
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def test_a_saved_rich_post_that_is_unchanged_shows_no_difference():
|
| 610 |
+
m = rich_live(1)
|
| 611 |
+
p = SavedLike(m)
|
| 612 |
+
d = diff_post(p, m)
|
| 613 |
+
assert d.fields == {} and not d.quiet and not d.buttons_missing
|
| 614 |
+
assert message_supported(m) and not message_supported(old_msg(2, ""))
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def test_a_rich_post_edited_in_the_channel_is_taken_over_as_its_new_blocks():
|
| 618 |
+
m = rich_live(1, "Hello")
|
| 619 |
+
p = SavedLike(m)
|
| 620 |
+
edited = rich_live(1, "Changed")
|
| 621 |
+
d = diff_post(p, edited)
|
| 622 |
+
assert set(d.fields) == {"text", "entities"} and d.fields["text"] == "Title\nChanged"
|
| 623 |
+
(e,) = d.fields["entities"]
|
| 624 |
+
assert e["f"] == "blocks" and e["p"] == fingerprint(edited.rich_message) and not d.quiet
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def test_the_bots_own_typed_rich_post_is_only_noted_the_first_time_the_sync_sees_it():
|
| 628 |
+
m = rich_live(1)
|
| 629 |
+
typed = Rich("markdown", HEADING)
|
| 630 |
+
p = SavedLike(m, text="Release notes", entities=[entry(typed)])
|
| 631 |
+
d = diff_post(p, m)
|
| 632 |
+
assert d.quiet and set(d.fields) == {"entities"}
|
| 633 |
+
(e,) = d.fields["entities"]
|
| 634 |
+
assert (e["f"], e["s"], e["p"]) == ("markdown", HEADING, fingerprint(m.rich_message)) # its source is kept, the fingerprint added
|
| 635 |
+
again = SavedLike(m, text="Release notes", entities=d.fields["entities"])
|
| 636 |
+
assert diff_post(again, m).fields == {}
|
| 637 |
+
changed = rich_live(1, "Edited by an admin")
|
| 638 |
+
assert set(diff_post(again, changed).fields) == {"text", "entities"}
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
def test_a_rich_post_that_became_an_ordinary_one_or_the_other_way_round_is_followed():
|
| 642 |
+
rich = rich_live(1)
|
| 643 |
+
plain = old_msg(1, "now just words")
|
| 644 |
+
d = diff_post(SavedLike(rich), plain)
|
| 645 |
+
assert d.fields == {"text": "now just words", "entities": []}
|
| 646 |
+
d = diff_post(SavedLike(plain), rich)
|
| 647 |
+
assert d.fields["text"] == "Title\nHello" and d.fields["entities"][0]["f"] == "blocks"
|
| 648 |
+
empty = old_msg(1, "") # Telegram shows nothing this time: that is not an edit
|
| 649 |
+
assert diff_post(SavedLike(rich), empty).fields == {}
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def test_the_sync_saves_a_rich_post_it_finds_and_leaves_it_alone_afterwards(tmp_path):
|
| 653 |
+
async def run():
|
| 654 |
+
chan, db, ch = await world(tmp_path, {1: old_msg(1, "plain"), 2: rich_live(2)}, save=False)
|
| 655 |
+
rep = await sync_channel(chan, db, ch, opts())
|
| 656 |
+
p = await db.find_by_message(1, 2)
|
| 657 |
+
assert p is not None and rich_of(p).fmt == "blocks" and p.text == "Title\nHello" and p.source == "adopted"
|
| 658 |
+
again = await sync_channel(chan, db, ch, opts())
|
| 659 |
+
assert again.edited == [] and again.adopted == [] and rep.error is None
|
| 660 |
+
chan.msgs[2].rich_message = rich_msg(blocks("Later")) # somebody edits it in the channel
|
| 661 |
+
third = await sync_channel(chan, db, ch, opts())
|
| 662 |
+
assert third.edited == [2] and (await db.find_by_message(1, 2)).text == "Title\nLater"
|
| 663 |
+
await db.close()
|
| 664 |
+
|
| 665 |
+
asyncio.run(run())
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
# ===================================================================================================== /repost
|
| 669 |
+
def rich_channel():
|
| 670 |
+
welcome = make_msg(1, "Welcome")
|
| 671 |
+
same_post = make_msg(2, "")
|
| 672 |
+
same_post.rich_message = rich_msg(blocks("one"))
|
| 673 |
+
with_button = make_msg(3, "", markup=markup([url_btn("Get", f"https://t.me/{OLD}?start=AAA"), url_btn("Other", "https://example.com/x")]))
|
| 674 |
+
with_button.rich_message = rich_msg(blocks("two"), photos=[photo_media(5).photo])
|
| 675 |
+
return {1: welcome, 2: same_post, 3: with_button}
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
def test_a_repost_copies_rich_posts_and_rebuilds_the_one_with_buttons(tmp_path):
|
| 679 |
+
async def run():
|
| 680 |
+
db, ch, chan, mig = await repost_setup(tmp_path, FakeChannelClient(rich_channel()), include_typed=True)
|
| 681 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 682 |
+
assert (res.copied_units, res.failed, res.aborted) == (3, [], None)
|
| 683 |
+
assert forwarded_ids(chan) == [[1], [2]] and edit_requests(chan) == []
|
| 684 |
+
new = {i: m for i, m in chan.msgs.items() if i > 3}
|
| 685 |
+
assert list(new) == [4, 5, 6]
|
| 686 |
+
assert new[4].message == "Welcome"
|
| 687 |
+
assert same(new[5].rich_message.blocks, blocks("one")) # a copy by Telegram: still the same rich message
|
| 688 |
+
m = new[6] # the post with buttons is created again, with the link swapped
|
| 689 |
+
assert isinstance(m.rich_input, types.InputRichMessage) and same(m.rich_input.blocks, blocks("two"))
|
| 690 |
+
assert [(p.id, p.file_reference) for p in m.rich_input.photos] == [(5, b"ref5")]
|
| 691 |
+
rows = m.reply_markup.rows
|
| 692 |
+
assert [button_url(b) for b in rows[0].buttons] == [f"https://t.me/{NEW}?start=AAA", "https://example.com/x"]
|
| 693 |
+
saved_row = await db.find_by_message(1, 6)
|
| 694 |
+
assert saved_row.text == "Title\ntwo" and rich_of(saved_row).fmt == "blocks"
|
| 695 |
+
assert saved_row.buttons[0][0]["u"] == f"https://t.me/{NEW}?start=AAA"
|
| 696 |
+
assert rich_of(await db.find_by_message(1, 5)) is not None
|
| 697 |
+
await db.close()
|
| 698 |
+
|
| 699 |
+
asyncio.run(run())
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
def test_a_channel_that_forbids_forwarding_gets_its_rich_posts_rebuilt(tmp_path):
|
| 703 |
+
async def run():
|
| 704 |
+
chan = FakeChannelClient(rich_channel())
|
| 705 |
+
chan.restrict_forwards = True
|
| 706 |
+
db, ch, chan, mig = await repost_setup(tmp_path, chan)
|
| 707 |
+
res = await run_repost(chan, db, ch, mig, 99, delay=0, settle_pause=0)
|
| 708 |
+
assert (res.copied_units, res.failed) == (3, []) and forwarded_ids(chan) == [[1]] # only the first, refused, try
|
| 709 |
+
new = {i: m for i, m in chan.msgs.items() if i > 3}
|
| 710 |
+
assert same(new[5].rich_input.blocks, blocks("one")) and same(new[6].rich_input.blocks, blocks("two"))
|
| 711 |
+
assert new[5].reply_markup is None and new[6].reply_markup is not None
|
| 712 |
+
await db.close()
|
| 713 |
+
|
| 714 |
+
asyncio.run(run())
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
def test_a_post_held_back_by_telegram_is_rebuilt_from_its_saved_rich_message():
|
| 718 |
+
row = SimpleNamespace(
|
| 719 |
+
text="Release notes", entities=[entry(Rich("markdown", HEADING))], buttons=[], media_file_id=None, link_preview=False
|
| 720 |
+
)
|
| 721 |
+
live = make_msg(7, "This message couldn't be displayed on your device due to copyright infringement.")
|
| 722 |
+
final = final_state(stand_in(live, row), RepostOptions(OLD, NEW))
|
| 723 |
+
assert final.rich == Rich("markdown", HEADING) and final.text == "" and final.entities == []
|
| 724 |
+
assert final_state(make_msg(8, "plain"), RepostOptions(OLD, NEW)).rich is None
|
| 725 |
+
|
| 726 |
+
|
| 727 |
+
# ============================================================================================== everything else
|
| 728 |
+
def test_a_rich_post_is_kept_by_an_export_and_an_import(tmp_path):
|
| 729 |
+
async def run():
|
| 730 |
+
app = await make_app(tmp_path)
|
| 731 |
+
pid, _ = await new_post(app, HEADING)
|
| 732 |
+
data = await app.db.export_all()
|
| 733 |
+
(p,) = [x for x in data["posts"] if x["id"] == pid]
|
| 734 |
+
assert p["entities"] == [{"k": "rich", "f": "markdown", "s": HEADING}]
|
| 735 |
+
await app.db.close()
|
| 736 |
+
|
| 737 |
+
asyncio.run(run())
|
| 738 |
+
|
| 739 |
+
|
| 740 |
+
def test_the_next_post_suggestion_leaves_rich_posts_out():
|
| 741 |
+
post = SimpleNamespace(
|
| 742 |
+
text="x", entities=[entry(Rich("markdown", HEADING))], buttons=[[{"t": "Download Episodes 1 to 20", "u": "https://t.me/x?start=a"}]],
|
| 743 |
+
)
|
| 744 |
+
assert build_suggestion(post) is None
|
| 745 |
+
|
| 746 |
+
|
| 747 |
+
def test_the_rich_command_and_the_help_pages(tmp_path):
|
| 748 |
+
async def run():
|
| 749 |
+
app = await make_app(tmp_path)
|
| 750 |
+
await app.text("/rich")
|
| 751 |
+
said = [norm(t) for kind, t, _ in app.out.log if kind == "say"]
|
| 752 |
+
assert len(said) == 2 and "Rich messages" in said[0] and "<tg-map" in said[1] and "# Heading" in said[0]
|
| 753 |
+
await app.text("/help")
|
| 754 |
+
assert "/rich" in norm(app.out.last_text)
|
| 755 |
+
await app.text("/formatting")
|
| 756 |
+
assert "/rich" in norm(app.out.last_text)
|
| 757 |
+
await app.db.close()
|
| 758 |
+
|
| 759 |
+
asyncio.run(run())
|