github-actions[bot] commited on
Commit
53e3df0
·
1 Parent(s): 1dfae7a

Automated sync from GitHub

Browse files
.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 "", entities=de_entities(p.entities), preview=None if p.media_kind else bool(p.link_preview)
 
 
 
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
- p.text = state.get("text", "")
830
- p.entities = state.get("entities", [])
 
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 extract_content, markup_note
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 = extract_content(m, ctx.cfg.markup_input)
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 extract_content, markup_note, show_panel
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 = extract_content(m, ctx.cfg.markup_input)
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); `how` says whether the text was read as "html" or "markdown"."""
 
 
 
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
- return f"🎨 Formatting read from {'HTML' if how == 'html' else 'Markdown'} - see /formatting.{sep}" if how else ""
 
 
 
 
 
 
 
 
 
 
 
64
 
65
 
66
- def extract_content(m, markup: bool = True) -> Content:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = message_text(m, markup)
74
- if not kind and not text.strip():
75
  raise UserError("Send some text, or a photo / video / GIF / file.")
76
- check_lengths(text, bool(kind))
 
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 ..richtext import message_text
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
- await say(event, f"✏️ Send the new {what}. Formatting is kept - or type HTML / Markdown (/formatting shows how).", CANCEL)
 
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
- text, entities, how = message_text(m, ctx.cfg.markup_input)
135
- if not post.media_kind and not text.strip():
 
136
  raise UserError("A text post can't be empty.")
137
- check_lengths(text, bool(post.media_kind))
 
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
- await send_post(client, await event.get_input_chat(), post)
 
 
 
 
 
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
- text = msg.message or ""
48
- ents = list(msg.entities or [])
 
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
- text = msg.message or ""
144
- ents = list(msg.entities or [])
 
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
- text, entities, n_typed, n_hyper = links.remap_text(msg.message or "", msg.entities)
 
 
 
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": state_of(change.new_text, change.new_entities, markup, preview),
264
  "adopt": adopt,
265
  }
266
  )
@@ -298,17 +311,22 @@ async def undo_batch(
298
  continue
299
  old = c.old_state or {}
300
  try:
301
- await flood_retry(
302
- lambda: edit_raw(
303
- client,
304
- peer_of(ch),
305
- c.message_id,
306
- text=old.get("text", ""),
307
- entities=de_entities(old.get("entities")),
308
- markup=build_markup(old.get("buttons")),
309
- preview=old.get("preview"),
 
 
 
 
 
 
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 (not live.strip() and getattr(m, "media", None) is None):
 
 
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
- if len(unit) > 1:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- await flood_retry(
1136
- lambda: edit_raw(
1137
- client, peer, m.id, text=rl.text, entities=rl.entities, markup=rl.markup, preview=preview_flag(m)
 
 
 
 
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
- await db.update_post_by_message(
1159
- ch.id, m.id, text=rl.text, entities=ser_entities(rl.entities), buttons=ser_markup(rl.markup)
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), # ![](picture)
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>&lt;details&gt;</code>, <code>&lt;h1&gt;</code>, <code>&lt;table&gt;</code> ... or\n"
471
+ "• the first line is just <code>&lt;rich&gt;</code> (<code>&lt;rich rtl&gt;</code> for right-to-left text, "
472
+ "<code>&lt;rich noautolink&gt;</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>&lt;u&gt; &lt;sub&gt; &lt;sup&gt;</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>&gt; quote</code> (a bare <code>&gt;</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>![](https://…/pic.jpg \"caption\")</code> alone on a line: a picture, video, audio, GIF or file (https links "
488
+ "only); put several such lines inside <code>&lt;tg-collage&gt;</code> or <code>&lt;tg-slideshow&gt;</code>\n"
489
+ "<code>![22:45 tomorrow](tg://time?unix=1647531900&amp;format=wDT)</code> a date shown in the reader's time\n"
490
+ "<code>![](tg://emoji?id=5368324170671202286)</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>&lt;details&gt;</code>, "
493
+ "<code>&lt;tg-collage&gt;</code> and <code>&lt;tg-slideshow&gt;</code>)."
494
+ )
495
+
496
+ RICH_HELP_HTML = (
497
+ "🧩 <b>Rich HTML</b>\n\n"
498
+ "<code>&lt;h1&gt;</code> … <code>&lt;h6&gt;</code> <code>&lt;p&gt;</code> <code>&lt;hr&gt;</code> "
499
+ "<code>&lt;footer&gt;</code> <code>&lt;b&gt; &lt;i&gt; &lt;u&gt; &lt;s&gt; &lt;mark&gt; &lt;sub&gt; &lt;sup&gt; "
500
+ "&lt;code&gt; &lt;tg-spoiler&gt;</code> <code>&lt;br&gt;</code>\n"
501
+ "<code>&lt;pre&gt;&lt;code class=\"language-python\"&gt;</code> a code block\n"
502
+ "<code>&lt;ul&gt;</code> <code>&lt;ol start=\"3\" type=\"a\" reversed&gt;</code> "
503
+ "<code>&lt;li value=\"7\" type=\"i\"&gt;</code> (type: a, A, i, I, 1) "
504
+ "<code>&lt;input type=\"checkbox\" checked&gt;</code>\n"
505
+ "<code>&lt;blockquote expandable&gt;text&lt;cite&gt;who&lt;/cite&gt;&lt;/blockquote&gt;</code> and "
506
+ "<code>&lt;aside&gt;pull quote&lt;cite&gt;who&lt;/cite&gt;&lt;/aside&gt;</code>\n"
507
+ "<code>&lt;details open&gt;&lt;summary&gt;Title&lt;/summary&gt;…&lt;/details&gt;</code> an expandable section\n"
508
+ "<code>&lt;table bordered striped compact&gt;&lt;caption&gt;…&lt;/caption&gt;&lt;tr&gt;&lt;th colspan=\"2\" "
509
+ "align=\"center\" valign=\"middle\"&gt;…</code> (td and th: colspan, rowspan, align left/center/right, valign "
510
+ "top/middle/bottom)\n"
511
+ "<code>&lt;tg-math&gt;x^2&lt;/tg-math&gt;</code> <code>&lt;tg-math-block&gt;E = mc^2&lt;/tg-math-block&gt;</code> "
512
+ "(raw LaTeX)\n"
513
+ "<code>&lt;tg-time unix=\"1647531900\" format=\"wDT\"&gt;22:45 tomorrow&lt;/tg-time&gt;</code>\n"
514
+ "<code>&lt;tg-emoji emoji-id=\"5368324170671202286\"&gt;🙂&lt;/tg-emoji&gt;</code>\n"
515
+ "<code>&lt;a name=\"top\"&gt;&lt;/a&gt;</code> and <code>&lt;a href=\"#top\"&gt;</code> anchors; "
516
+ "<code>&lt;tg-reference name=\"n1\"&gt;text&lt;/tg-reference&gt;</code> with <code>&lt;a href=\"#n1\"&gt;</code> "
517
+ "a footnote\n"
518
+ "<code>&lt;img src=\"https://…\"&gt;</code> <code>&lt;video src=…&gt;</code> <code>&lt;audio src=…&gt;</code> "
519
+ "<code>&lt;tg-document src=…&gt;</code>, each on its own or in "
520
+ "<code>&lt;figure&gt;…&lt;figcaption&gt;caption&lt;cite&gt;credit&lt;/cite&gt;&lt;/figcaption&gt;&lt;/figure&gt;</code> "
521
+ "(https links only; <code>tg-spoiler</code> on img/video hides it); several inside "
522
+ "<code>&lt;tg-collage&gt;</code> or <code>&lt;tg-slideshow&gt;</code>\n"
523
+ "<code>&lt;tg-map lat=\"41.9\" long=\"12.5\" zoom=\"14\"/&gt;</code> a map\n"
524
+ "<code>&lt;tg-button-row align=\"center\"&gt;&lt;tg-button type=\"url\" url=\"https://t.me\" "
525
+ "style=\"success\"&gt;Open&lt;/tg-button&gt;&lt;/tg-button-row&gt;</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>&amp;lt; &amp;gt; &amp;amp;</code> stand for &lt; &gt; &amp; 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
- if (p.text or "") != live["text"]:
189
- fields["text"] = live["text"]
190
- if list(p.entities or []) != live["entities"]:
191
- fields["entities"] = live["entities"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, peer, message_id: int, *, text: Optional[str] = None, entities=None, markup=None, preview=None, media=None
 
 
 
 
 
 
 
 
 
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 text is not None:
 
 
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
- lines.append(f"Type: {KIND_ICON.get(p.media_kind, '📎')} {KIND_NAME.get(p.media_kind, p.media_kind)}")
 
 
 
 
 
34
  n, miss = count(p.buttons), missing_links(p.buttons)
35
  lines.append(f"Buttons: {n}" + (f" (⚠️ {miss} without a link)" if miss else ""))
36
  if p.source == "adopted":
@@ -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
- Button.inline("✏️ Caption" if p.media_kind else "✏️ Text", f"et:{pid}"),
53
- Button.inline("🖼 Media", f"em:{pid}"),
54
- ]
55
  )
56
  row3 = [Button.inline("➕ Button", f"ba:{pid}"), Button.inline("📋 Set buttons", f"bp:{pid}")]
57
  if p.buttons:
58
  row3.append(Button.inline("🧹 Clear", f"bc:{pid}"))
59
  kb.append(row3)
60
  extra = []
61
- if not p.media_kind:
62
  extra.append(Button.inline(f"🔗 Link preview: {'on' if p.link_preview else 'off'}", f"lp:{pid}"))
63
  if p.media_kind and p.status == "draft":
64
  extra.append(Button.inline("🚫 Remove media", f"rm:{pid}"))
 
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
- await flood_retry(
536
- lambda: edit_raw(client, self.peer, message_id, text=text, entities=entities, preview=preview, media=media)
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 _new(self, text, entities, markup, media=None, gid=None, invert=None, hide=False, reply_to=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "![](https://x.org/a.jpg)",
95
+ "![](https://x.org/a.mp4 \"caption\")",
96
+ "see ![22:45 tomorrow](tg://time?unix=1647531900&format=wDT)",
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>&lt;h1&gt; 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 ![smile](tg://emoji?id=1)",
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&nbsp;a gap &amp; 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![](https://x.org/a.jpg \"a caption\")\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
+ "&lt;rich&gt;", "# Heading", "- [ ] task", "```python", "$$E = mc^2$$", "[^1]", "tg://time", "tg://emoji", "==marked==",
480
+ "&lt;details", "&lt;table", "&lt;tg-math-block&gt;", "&lt;tg-map", "&lt;tg-collage&gt;", "&lt;tg-slideshow&gt;",
481
+ "&lt;tg-button-row", "&lt;aside&gt;", "&lt;figure&gt;", "&lt;tg-reference", "&lt;tg-time", "&lt;sub&gt;", "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())