Repository navigation
Expand file tree
/
Copy pathbot.py
More file actions
4074 lines (3019 loc) · 124 KB
/
Copy pathbot.py
File metadata and controls
4074 lines (3019 loc) · 124 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Telepatch - a pass-through bridge between Telegram and Telegraph.
The bot stores nothing. A Telegraph access_token is the entire identity,
so it is round-tripped through Telegram's own message payloads:
- deep link t.me/<bot>?start=<token> -> opens the manage menu
- callback_data "<action>:<token>" -> 62 of the allowed 64 bytes
- ForceReply token hidden in a text_link -> recovered from the reply
There is no database, no file written, no persistence layer, and nothing
kept in memory between updates. See /privacy and the notes in main().
"""
import asyncio
import json
import logging
import os
import random
import re
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from html.parser import HTMLParser
from urllib.parse import parse_qsl, quote, urlencode
import requests
from dotenv import load_dotenv
from telegram import (
ForceReply,
InlineKeyboardButton,
InlineKeyboardMarkup,
MessageEntity,
Update,
)
from telegram.constants import ParseMode
from telegram.ext import (
Application,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
TypeHandler,
filters,
)
load_dotenv()
log = logging.getLogger("telepatch")
# A Telegraph token is 60 hex characters; a bot token is digits, a colon,
# then 35 or so. Both travel through message payloads by design, so both
# will reach exception text eventually.
SECRETS = re.compile(r"\b[0-9a-fA-F]{60}\b|\b\d{6,12}:[A-Za-z0-9_-]{30,}\b")
class Redact(logging.Filter):
"""
Strip credentials from every log record, at the logging layer rather
than at each of the places one could leak. A token that reaches a log
file has escaped the design, so the last gate has to be unconditional.
"""
def filter(self, record):
if isinstance(record.msg, str):
record.msg = SECRETS.sub("[redacted]", record.msg)
if record.args:
args = record.args if isinstance(record.args, tuple) else (record.args,)
record.args = tuple(
SECRETS.sub("[redacted]", a) if isinstance(a, str) else a
for a in args
)
return True
def config(name, default=None, required=False):
"""
Read one setting, and fail at startup rather than on first use. A bot
that starts and then breaks on somebody's first command is worse than
one that refuses to start.
"""
value = (os.environ.get(name) or "").strip() or default
if required and not value:
raise SystemExit(
f"{name} is not set. Copy .env.example to .env, or set it in "
"the service unit."
)
return value
TELEGRAM_TOKEN = config("TELEGRAM_TOKEN", required=True)
if not re.fullmatch(r"\d{6,12}:[A-Za-z0-9_-]{30,}", TELEGRAM_TOKEN):
raise SystemExit(
"TELEGRAM_TOKEN does not look like a BotFather token "
"(digits, a colon, then the secret)."
)
# Where errors and heartbeats go. Both optional: without them the bot runs
# exactly as before, which is what a laptop wants and a server does not.
OPERATOR_CHAT = config("OPERATOR_CHAT")
HEARTBEAT_URL = config("HEARTBEAT_URL")
HEARTBEAT_SECONDS = int(config("HEARTBEAT_SECONDS", "300"))
LOG_LEVEL = config("LOG_LEVEL", "INFO").upper()
# -----------------------
# Activity counters
# -----------------------
# What telepatch-observer publishes. Read the privacy section of
# docs/superpowers/specs/2026-08-01-observer-design.md before touching any
# of this - the guarantee it describes lives here, not in the observer.
#
# THE RULE: tally() takes one argument and that argument must be a string
# literal from COUNTED. No token, chat id, user id, title, path or category
# may ever reach it, and no f-string or variable may ever be passed to it.
#
# This is not a convention. tests/test_observer_tally.py walks the AST of
# this file, finds every tally() call site, and fails if any argument is
# not a literal in the set below. An edit that interpolates a variable
# fails the suite rather than quietly publishing somebody's identity.
#
# Counters are RAM only. They are written to tmpfs (see export_counters)
# and systemd deletes that directory when this unit stops, so nothing here
# survives a restart. That is the property /privacy will describe.
COUNTED = frozenset({
"command.total", # any /command, without recording which person
"page.published", # a createPage that succeeded
"index.rebuilt", # an index rewritten by insert_entry
"telegraph.call", # every Telegraph API request
"telegraph.failed", # ... and the ones that did not come back
"error", # anything that reached on_error
})
COUNTS = {}
STARTED = time.time()
# Where the observer reads them from. tmpfs, created by
# RuntimeDirectory=telepatch in the unit and destroyed with the service.
COUNTERS_PATH = config("COUNTERS_PATH", "/run/telepatch/counters.json")
def tally(event):
"""
One event happened. Which one, and nothing else about it.
The increment is the privacy mechanism: `+= 1` is irreversible and
retains nothing about the update that caused it. An unrecognised name
is dropped rather than counted under itself, so a bad edit that gets
past the test still cannot invent a new public key at runtime.
"""
if event in COUNTED:
COUNTS[event] = COUNTS.get(event, 0) + 1
def export_counters():
"""
Hand the current counts to the observer.
Written to a temporary file and renamed, because the observer reads on
its own timer with no lock between us - rename is atomic, so it sees
either the previous snapshot or this one, never half of one.
Fails silently on purpose. No /run/telepatch means either a laptop or a
unit without RuntimeDirectory=, and neither is a reason to take the bot
down. The observer reports the absence as "not exporting", which is
honest and visible.
"""
payload = {"since": STARTED, "events": dict(COUNTS)}
temporary = COUNTERS_PATH + ".tmp"
try:
os.makedirs(os.path.dirname(COUNTERS_PATH), exist_ok=True)
with open(temporary, "w") as handle:
json.dump(payload, handle)
# The unit sets UMask=0077, so this would land as 0600 and only be
# readable by root or by this account. That works while the observer
# runs as root and silently stops working the moment it does not -
# exactly the assumption RuntimeDirectoryMode=0755 was chosen to
# avoid. The file holds counts and nothing else; there is nothing
# in it to protect.
os.chmod(temporary, 0o644)
os.replace(temporary, COUNTERS_PATH)
except OSError as error:
log.debug("counters.export.failed error=%s", error)
TELEGRAPH_API = "https://api.telegra.ph"
SOURCE_URL = "https://github.com/babbworks/telepatch"
# The static site that renders a master post. The path after the hash is a
# Telegraph page path, so no credential ever appears in a shared URL.
SITE_URL = os.environ.get("SITE_URL", "https://babbworks.github.io/telepatch/")
# Separates a title from its categories in the generated master post. Chosen
# so the master post still reads naturally on telegra.ph.
INDEX_SEP = " — "
# The browser editor. Same origin as the site; the token rides in the
# fragment, which is never sent to a server and is stripped from Referer.
EDITOR_URL = SITE_URL.rstrip("/") + "/edit.html"
# A page tagged with this becomes a top-bar link on the site instead of an
# index entry, and the word itself is hidden when categories are displayed.
NAV_CATEGORY = "nav"
# Two more reserved words. An entry tagged with either leaves the index and
# joins the menu behind the globe at the right of the bar: "site" for
# another Telepatch collection, "web" for anywhere else worth reaching.
# Added with /link like any other entry.
#
# Authored, never discovered. A reader cannot enumerate somebody's
# collections - getPageList needs the token - so the only way one site can
# point at another is for its publisher to say so.
SITE_CATEGORY = "site"
WEB_CATEGORY = "web"
RESERVED_CATEGORIES = (NAV_CATEGORY, SITE_CATEGORY, WEB_CATEGORY)
# Ceiling on how many pages /site will read, to bound the rebuild.
INDEX_LIMIT = 200
# Written into every master post so the bot can recognise its own work on a
# later run. Plain text with no link: the website only collects anchors, so
# this never shows up as an index entry.
#
# It matters because Telegraph has no deletePage. A /site that created a new
# page each time would litter the account permanently, and every stray index
# would then be listed as an article by the next rebuild.
MASTER_MARK = "Index generated by Telepatch."
# Telegraph pages cannot be deleted, so an index that has been superseded is
# retired instead. Both marks exclude a page from being listed as an article;
# only MASTER_MARK makes a page eligible to be rewritten.
RETIRED_MARK = "Retired Telepatch index."
INDEX_MARKS = (MASTER_MARK, RETIRED_MARK)
# One token may own several collections. Exactly one is the primary: the
# index /site enumerates the whole account into, and the only one that
# existed before this field did. Every other carries this on its marker
# line and is curated by hand, through /link, /repo and the extension.
#
# An index written before multiple collections existed has no such field
# and is therefore primary, which is what makes this change reversible:
# remove the feature and the primary is still the only thing anything
# looks for. docs/multiple-collections.md records the way back.
EXTRA_FIELD = "collection=extra"
# Stands in for the list when an account has nothing published. Named so the
# masthead reader can tell it apart from the publisher's own prose.
EMPTY_NOTICE = "Nothing published yet."
# How the website renders an article's byline. A site-wide choice, because
# Telegraph gives a page only author_name and author_url — there is nowhere
# to record a display preference per article. It rides on the marker line so
# the master post still has exactly one machine-readable sentence.
#
# linked the name is a link to the author URL
# separate the name is plain and the URL is shown beside it
# plain the name only; the URL is stored but never displayed
BYLINE_MODES = ("linked", "separate", "plain")
BYLINE_DEFAULT = "linked"
# Carrier URL for the hidden token in ForceReply prompts. Never visited -
# it exists only so the token rides along as a message entity.
CARRIER = "https://telepat.ch/t"
# Telegraph field limits, straight from the API docs.
LIMITS = {
"short_name": 32,
"author_name": 128,
"author_url": 512,
}
FIELD_LABELS = {
"short_name": "short name",
"author_name": "author name",
"author_url": "author URL",
}
# Single-letter action codes. "<action>:<token>" is 62 bytes and
# callback_data allows 64, so these cannot grow to two letters.
ACTION_FIELDS = {
"s": "short_name",
"a": "author_name",
"u": "author_url",
}
TITLE_LIMIT = 256
# Tags Telegraph accepts in page content. Anything else is unwrapped and
# only its children survive.
TELEGRAPH_TAGS = {
"a", "aside", "b", "blockquote", "br", "code", "em", "figcaption",
"figure", "h3", "h4", "hr", "i", "iframe", "img", "li", "ol", "p",
"pre", "s", "strong", "u", "ul", "video",
}
# -----------------------
# Helpers
# -----------------------
def normalize_url(url):
"""
Ensure URLs include http:// or https://
"""
if not url:
return None
url = url.strip()
if not url:
return None
if not url.startswith(("http://", "https://")):
url = "https://" + url
return url
def is_clear(value):
"""
Telegraph clears a field when sent an empty string. Let the user ask
for that explicitly, since a ForceReply cannot be answered with nothing.
"""
return value.strip().lower() in {"-", "none", "clear", "blank"}
def telegraph_path(text):
"""
Reduce a telegra.ph URL to the path Telegraph's public endpoints want.
Accepts a full URL or a bare path.
"""
text = text.strip().split("?")[0].split("#")[0].rstrip("/")
if not text:
return None
return text.rsplit("/", 1)[-1] or None
def carrier_link(field, token="", **extras):
"""
A one-glyph link whose href secretly carries the field being edited, the
access token, and anything else the next step needs. The reply handler
reads it back out of the entity.
Unlike callback_data this has no length limit, which is what makes a
per-post byline possible without remembering anything.
"""
query = f"?{urlencode(extras)}" if extras else ""
return f'<a href="{CARRIER}#{field}:{token}{query}">·</a>'
def read_carrier(message):
"""
Recover (field, token, extras) from the message this reply is answering.
Returns (None, None, {}) when the reply is not to one of our prompts.
"""
source = message.reply_to_message
if not source:
return None, None, {}
for entity in source.entities or []:
if entity.type != MessageEntity.TEXT_LINK:
continue
if not entity.url or not entity.url.startswith(CARRIER):
continue
head, _, query = entity.url.partition("#")[2].partition("?")
field, _, token = head.partition(":")
# keep_blank_values matters: an empty author URL is a real value,
# not an absent one.
return field, token, dict(parse_qsl(query, keep_blank_values=True))
return None, None, {}
# A Telegraph access token is 60 characters. Loose enough to survive a
# change of alphabet, tight enough not to match ordinary prose.
TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{60}")
def find_token(message):
"""
Recover a token from the message this one replies to, so the user never
has to paste it.
The identity card carries the token three ways - as a code span, inside
the deep-link button, and as plain text - so replying to it with any
command is enough. Checked in order of confidence.
"""
source = getattr(message, "reply_to_message", None)
if not source:
return None
markup = getattr(source, "reply_markup", None)
if markup:
for row in markup.inline_keyboard:
for button in row:
if not button.url or "?start=" not in button.url:
continue
candidate = button.url.split("?start=", 1)[1]
if TOKEN_RE.fullmatch(candidate):
return candidate
for entity in source.entities or []:
if entity.type != MessageEntity.TEXT_LINK or not entity.url:
continue
if entity.url.startswith(CARRIER):
# Stop at the "?": a carrier may hold extras after the token,
# and taking everything past the colon swallowed them into it.
# The malformed token then had the extras appended a second
# time when the next carrier was built.
token = entity.url.partition("#")[2].partition(":")[2] \
.partition("?")[0]
if TOKEN_RE.fullmatch(token):
return token
if "?start=" in entity.url:
candidate = entity.url.split("?start=", 1)[1]
if TOKEN_RE.fullmatch(candidate):
return candidate
found = TOKEN_RE.search(source.text or "")
return found.group(0) if found else None
def token_hint(command):
"""
Shown when a command needs a token and none could be recovered. Points
at the reply route first, since pasting is the thing to avoid.
"""
return (
"Reply to your pinned identity message with "
f"<code>{command}</code> and Telepatch will pick up the token "
"from it.\n\n"
"You can also open it with the <b>Manage this identity</b> button "
f"on that message, or paste it directly: <code>{command} <token></code>"
)
# -----------------------
# Telegraph API
# -----------------------
# One connection, reused. Every call used to open a fresh TCP and TLS
# handshake to the same host, which on a 200-page scan is 200 handshakes.
SESSION = requests.Session()
SESSION.headers["User-Agent"] = "Telepatch (+https://github.com/babbworks/telepatch)"
# Retried only where a repeat cannot do damage. createPage and
# createAccount are excluded on purpose: a timeout tells us nothing about
# whether the write landed, and Telegraph has no way to delete the second
# page. editPage is safe because writing the same content twice is the
# same as writing it once.
NEVER_RETRY = {"createPage", "createAccount"}
RETRIES = 3
BACKOFF = 0.4
def _post(method, **params):
"""
Every Telegraph method is a flat POST returning {ok, result|error}.
Blocking, on purpose - the async wrapper below is what handlers use.
This form is kept for the pooled workers, which are already off the
event loop and would only be paying for a second hop.
"""
url = f"{TELEGRAPH_API}/{method}"
data = {k: v for k, v in params.items() if v is not None}
attempts = 1 if method in NEVER_RETRY else RETRIES
last = None
# Every Telegraph request in the process funnels through here,
# including the pooled workers that call _post directly rather than
# going through telegraph(). Counting at this one choke point is what
# keeps every tally() argument a literal - the alternative was
# tally("telegraph." + method) at three call sites, which is exactly
# the dynamic key the AST test exists to forbid.
tally("telegraph.call")
for attempt in range(attempts):
try:
response = SESSION.post(url, data=data, timeout=10)
# Telegraph answering "no" is an answer, not a failure. Only a
# broken conversation is worth trying again.
if response.status_code < 500:
payload = response.json()
if not payload.get("ok"):
raise RuntimeError(payload.get("error", "unknown Telegraph error"))
if method == "createPage":
tally("page.published")
return payload["result"]
last = RuntimeError(f"Telegraph returned {response.status_code}")
except (requests.RequestException, ValueError) as error:
last = error
if attempt + 1 < attempts:
time.sleep(BACKOFF * (2 ** attempt) + random.random() * 0.1)
tally("telegraph.failed")
log.warning("telegraph.failed method=%s attempts=%s error=%s",
method, attempts, last)
raise last if last else RuntimeError("Telegraph did not answer")
async def telegraph(method, **params):
"""
What handlers call. requests is blocking and the bot now serves several
people at once, so every call goes to a worker thread rather than
stopping the event loop for up to ten seconds.
"""
started = time.monotonic()
try:
return await asyncio.to_thread(_post, method, **params)
finally:
spent = (time.monotonic() - started) * 1000
if spent > 1500:
log.info("telegraph.slow method=%s ms=%.0f", method, spent)
async def get_account_info(access_token):
"""
Note for publishing: createPage does NOT inherit the account's
author_name / author_url. Only the telegra.ph web editor reads those.
Any publish path must call this first and forward both explicitly,
or the article renders with an empty byline and just a date.
"""
return await telegraph(
"getAccountInfo",
access_token=access_token,
fields='["short_name","author_name","author_url","page_count"]',
)
# -----------------------
# Content conversion
# -----------------------
class _NodeBuilder(HTMLParser):
"""
Turn Telegram's own HTML rendering of a message into Telegraph nodes.
Using message.text_html means the bold, italics, links and code the
user typed survive into the article - Telegram has already resolved
its entities into markup, so there is no offset arithmetic to get wrong.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = []
self.stack = [self.root]
def handle_starttag(self, tag, attrs):
if tag not in TELEGRAPH_TAGS:
# Unsupported wrapper (span, tg-spoiler, tg-emoji): keep the
# children, drop the tag, and mirror the stack so the matching
# end tag still pops something.
self.stack.append(self.stack[-1])
return
node = {"tag": tag, "children": []}
href = dict(attrs).get("href")
if tag == "a" and href:
node["attrs"] = {"href": href}
self.stack[-1].append(node)
self.stack.append(node["children"])
def handle_startendtag(self, tag, attrs):
if tag in TELEGRAPH_TAGS:
self.stack[-1].append({"tag": tag})
def handle_endtag(self, tag):
if len(self.stack) > 1:
self.stack.pop()
def handle_data(self, data):
self.stack[-1].append(data)
# Telegraph's own /upload endpoint is closed to third parties (HTTP 400),
# so images have to be hotlinked from wherever they already live. Telegraph
# stores whatever src it is given and never validates it.
IMAGE_RE = re.compile(r"\.(jpe?g|png|gif|webp)(?:[?#]|$)", re.I)
TELEGRAPH_FILE = "telegra.ph/file/"
EMBED_PROVIDERS = (
(re.compile(r"^https?://(?:www\.)?(?:youtube\.com|youtu\.be)/", re.I), "youtube"),
(re.compile(r"^https?://(?:www\.)?vimeo\.com/", re.I), "vimeo"),
(re.compile(r"^https?://(?:www\.)?(?:twitter\.com|x\.com)/", re.I), "twitter"),
)
def media_node(url, caption):
"""
Build a <figure> for an image or a supported embed, or None if the URL
is just an ordinary link that should stay inline.
"""
if IMAGE_RE.search(url) or TELEGRAPH_FILE in url:
inner = {"tag": "img", "attrs": {"src": url}}
else:
for pattern, provider in EMBED_PROVIDERS:
if not pattern.match(url):
continue
# Telegraph's embed resolver predates the rename.
target = url.replace("//x.com/", "//twitter.com/")
inner = {
"tag": "iframe",
"attrs": {
"src": f"/embed/{provider}?url={quote(target, safe='')}"
},
}
break
else:
return None
children = [inner]
if caption:
children.append({"tag": "figcaption", "children": caption})
return {"tag": "figure", "children": children}
def leading_url(para):
"""
Return (url, caption_nodes) when a paragraph opens with a bare URL.
Telegram auto-links URLs, so the first node is usually an <a>. Anything
after the URL on that line becomes the caption, which makes the natural
gesture - paste a link, type a few words - do the obvious thing.
"""
if not para:
return None, []
first = para[0]
if isinstance(first, dict):
if first.get("tag") != "a":
return None, []
return first.get("attrs", {}).get("href"), list(para[1:])
stripped = first.lstrip()
if not stripped.startswith(("http://", "https://")):
return None, []
url, _, rest = stripped.partition(" ")
caption = ([rest] if rest.strip() else []) + list(para[1:])
return url, caption
# Telegram has no heading format of its own, so /post borrows Markdown's.
# h3 and h4 are the only headings Telegraph accepts, and they are what the
# website builds an article's contents from.
HEADING_RE = re.compile(r"^(#{1,3})\s+")
def as_heading(para):
"""
Promote a paragraph opening with # to a heading. # and ## give h3, ###
gives h4 - Telegraph offers nothing above h3, and the page title already
occupies that level.
"""
if not para or not isinstance(para[0], str):
return None
match = HEADING_RE.match(para[0].lstrip())
if not match:
return None
tag = "h4" if len(match.group(1)) == 3 else "h3"
# Strip the marker but keep any bold or links that followed it.
first = para[0].lstrip()[match.end():]
children = ([first] if first else []) + list(para[1:])
if not any(
not isinstance(c, str) or c.strip() for c in children
):
return None
return {"tag": tag, "children": children}
QUOTE_RE = re.compile(r"^>\s+")
# Block-level tags that must not end up wrapped in a <p>.
BLOCK_TAGS = {
"blockquote", "pre", "aside", "h3", "h4", "ul", "ol", "hr", "figure",
}
def as_quote(para):
"""
A paragraph opening with "> " becomes a blockquote.
Telegram's own quote formatting already arrives as <blockquote>; this is
for people who type the marker instead. A blockquote at the top of an
article is read as its intro by the website, so the two authoring routes
- the bot and the Telegraph editor - produce the same thing.
"""
if not para or not isinstance(para[0], str):
return None
match = QUOTE_RE.match(para[0].lstrip())
if not match:
return None
first = para[0].lstrip()[match.end():]
children = ([first] if first else []) + list(para[1:])
if not any(not isinstance(c, str) or c.strip() for c in children):
return None
return {"tag": "blockquote", "children": children}
def as_block(para):
"""
Let a paragraph that is nothing but one block-level element through
unwrapped. Telegram sends quotes and code blocks as real elements, and
burying them in a <p> is neither valid nor what Telegraph renders well.
"""
real = [n for n in para if not isinstance(n, str) or n.strip()]
if len(real) != 1 or isinstance(real[0], str):
return None
return real[0] if real[0].get("tag") in BLOCK_TAGS else None
def as_media(para):
"""
Promote a paragraph to a figure when it is just a media URL.
"""
url, caption = leading_url(para)
if not url:
return None
while caption and isinstance(caption[0], str) and not caption[0].strip():
caption.pop(0)
if caption and isinstance(caption[0], str):
caption[0] = caption[0].lstrip(" -–—:")
return media_node(url, caption)
def to_content(text_html):
"""
Build a Telegraph content array, breaking into paragraphs on blank lines.
Splitting happens only inside plain text nodes, so a bold run that
straddles a blank line simply stays in one paragraph rather than
producing broken markup.
"""
builder = _NodeBuilder()
builder.feed(text_html)
# close() flushes anything the parser held back - text ending in a bare
# "<" looks like the start of a tag and stays buffered otherwise.
builder.close()
paragraphs = []
current = []
for node in builder.root:
if not isinstance(node, str):
current.append(node)
continue
chunks = re.split(r"\n\s*\n", node)
for index, chunk in enumerate(chunks):
if index:
paragraphs.append(current)
current = []
# Single newlines stay as line breaks within the paragraph.
lines = chunk.split("\n")
for line_index, line in enumerate(lines):
if line_index:
current.append({"tag": "br"})
if line:
current.append(line)
paragraphs.append(current)
content = []
for para in paragraphs:
if not any(not isinstance(n, str) or n.strip() for n in para):
continue
para = trim(para)
content.append(
as_heading(para) or as_quote(para) or as_media(para)
or as_block(para) or {"tag": "p", "children": para}
)
return content or [{"tag": "p", "children": [""]}]
def trim(para):
"""
Drop leading and trailing line breaks and blank strings.
A stray newline - the blank line after the category line, say - would
otherwise leave a <br> at the front of the paragraph, which hides the
"#" or ">" marker from the classifiers and renders as an empty line.
"""
def spare(node):
if isinstance(node, str):
return not node.strip()
return isinstance(node, dict) and node.get("tag") == "br"
out = list(para)
while out and spare(out[0]):
out.pop(0)
while out and spare(out[-1]):
out.pop()
return out
def parse_categories(line):
"""
Read line two as a category list, or return None to treat it as body.
Deliberately narrow so ordinary prose is never swallowed: no sentence
punctuation, and either one single word or a comma-separated list whose
items are short.
"""
line = (line or "").strip()
if not line or len(line) > 80:
return None
if any(mark in line for mark in ".!?:;"):
return None
items = [item.strip() for item in line.split(",")]
if any(not item for item in items):
return None
if len(items) == 1 and len(items[0].split()) > 1:
return None
if any(len(item.split()) > 3 for item in items):
return None
return items
def split_post(message):
"""
Line one is the title. Line two is categories when it looks like a list.
Everything after is the body.
Categories are stored as an <aside> at the top of the page, which
Telegraph renders as a centred italic subtitle and which the website can
read straight back out. Returns (title, categories, content).
"""
raw = (message.text or "").strip()
if not raw:
raise ValueError("Send a title on the first line, then the body.")
lines = raw.split("\n")
title = lines[0].strip()
if not title:
raise ValueError("The first line must be the title.")
if len(title) > TITLE_LIMIT:
raise ValueError(f"Title is too long (max {TITLE_LIMIT} characters).")
categories = parse_categories(lines[1]) if len(lines) > 1 else None
# text_html has the same line structure as text - tags never add newlines.
html_lines = (message.text_html or "").split("\n")
body_html = "\n".join(html_lines[2:] if categories else html_lines[1:])
if not body_html.strip():
raise ValueError(
"That was only a title. Add the body on the lines below it."
)
content = to_content(body_html)