-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbot.py
More file actions
3039 lines (2621 loc) · 109 KB
/
Copy pathbot.py
File metadata and controls
3039 lines (2621 loc) · 109 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
from __future__ import annotations
import base64
import html
import ipaddress
import io
import json
import logging
import mimetypes
import os
import re
import secrets
import socket
import tempfile
import time
import uuid
from collections import defaultdict, deque
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, cast
from urllib.parse import urljoin, urlparse
from zoneinfo import ZoneInfo
import requests
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
BASE_RESPONSE_RULES = (
"Reply in plain text only. "
"Do not use Markdown. "
"Do not use #, *, **, backticks, tables, or bold formatting. "
"Do not add maximum or minimum labels or sections unless the user explicitly asks for them. "
"Keep the response minimal, simple, and easy to read."
)
FOUNDER_CONTEXT = (
"Founder context: Kaung Khant Ko is the founder, owner, and developer of "
"MENTOR Terminal. If a user mentions Kaung, Khant, "
"Ko, KaungKhantKo, or Kaung Khant Ko, understand that they are referring "
"to the founder unless the surrounding context clearly means someone else. "
"Do not invent private details about the founder."
)
def with_base_rules(prompt: str) -> str:
prompt = prompt.strip()
if BASE_RESPONSE_RULES in prompt:
return prompt
return f"{prompt} {BASE_RESPONSE_RULES}".strip()
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "").strip()
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "").strip()
OPENROUTER_API_URL = os.getenv(
"OPENROUTER_API_URL",
"https://openrouter.ai/api/v1/chat/completions",
).strip()
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
TELEGRAM_ADMIN_BOT_TOKEN = os.getenv("TELEGRAM_ADMIN_BOT_TOKEN", "").strip()
BOT_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"BOT_SYSTEM_PROMPT",
"You are MENTOR, a helpful terminal assistant.",
)
)
PRESENTATION_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"PRESENTATION_SYSTEM_PROMPT",
(
"You are an expert presentation and slide deck creator. "
"When the user asks for a presentation, produce a practical deck draft that is ready "
"to turn into slides. Start with a clear title and short deck objective, then provide "
"a slide-by-slide outline using plain text. For each slide, include the slide title and "
"concise points. When useful, also suggest speaker notes, visuals, or charts. Keep the "
"structure clear, persuasive, concise, and easy to paste into a text file."
),
)
)
BURMESE_SYSTEM_PROMPT = with_base_rules(
os.getenv("BURMESE_SYSTEM_PROMPT", "").strip()
or (
"You are MENTOR, a helpful terminal assistant who writes natural, smooth Burmese. "
"Reply like a real Burmese chatbot having a normal conversation with a Myanmar user. "
"Use fluent, modern Burmese sentence flow with complete thoughts and complete ending sentences. "
"Do not stop in the middle of a sentence. "
"Avoid stiff literal translation. "
"Do not mix random English words into Burmese unless the user asked for English or the term is truly standard. "
"Prefer natural Burmese wording over transliterated technical fragments when possible. "
"For technical terms, explain in clear Burmese first and include the English term only when it helps understanding. "
"Use polite, friendly Burmese particles naturally, but do not overuse them. "
"Avoid awkward word-for-word translation, unnatural punctuation, and broken sentence endings. "
"If the user writes in Burmese, reply in Burmese unless they ask otherwise."
)
)
LINK_ANALYSIS_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"LINK_ANALYSIS_SYSTEM_PROMPT",
(
"You analyze public web pages for the user. "
"Give a short plain-text summary, key points, and any obvious red flags or missing context. "
"If the page content is limited, say so clearly."
),
)
)
FILE_ANALYSIS_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"FILE_ANALYSIS_SYSTEM_PROMPT",
(
"You analyze user-uploaded files. "
"Read the extracted contents and answer the user's request clearly. "
"If no specific request is given, summarize the important points in a useful way."
),
)
)
IMAGE_ANALYSIS_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"IMAGE_ANALYSIS_SYSTEM_PROMPT",
(
"You analyze user-uploaded photos and images. "
"Describe the important details, answer the user's question, and extract visible text when useful. "
"If no question is given, provide a practical analysis of what is shown."
),
)
)
GENERIC_COMMAND_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"GENERIC_COMMAND_SYSTEM_PROMPT",
(
"You are a practical assistant that follows the user's requested output format closely. "
"Be useful, clear, and well-structured."
),
)
)
PDF_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"PDF_SYSTEM_PROMPT",
(
"You prepare clean document text that will be exported to PDF. "
"Keep the structure readable and helpful. "
"If the user asks for a PDF on a topic, produce content that is ready to turn into a document."
),
)
)
SOCIAL_CONTENT_SYSTEM_PROMPT = with_base_rules(
os.getenv(
"SOCIAL_CONTENT_SYSTEM_PROMPT",
(
"You are a strong social media content writer. "
"When the user message includes the keyword content or contents, produce a longer-form "
"social-media-ready version of the answer. "
"Make it engaging, practical, and easy to post. "
"Use a strong opening, clear structure, and a natural closing or call to action when useful. "
"Match the user's language. "
"If the user writes in Burmese, write smooth natural Burmese with complete ending sentences "
"and avoid random English fragments unless needed."
),
)
)
BOT_TIMEZONE = os.getenv("BOT_TIMEZONE", "Asia/Rangoon").strip()
BOT_STORAGE_PATH = os.getenv("BOT_STORAGE_PATH", "bot_state.json").strip()
ADMIN_DASHBOARD_PORT = int(os.getenv("ADMIN_DASHBOARD_PORT", "5060").strip())
ADMIN_DASHBOARD_PASSWORD = os.getenv("ADMIN_DASHBOARD_PASSWORD", "").strip()
ADMIN_DASHBOARD_SECRET = os.getenv("ADMIN_DASHBOARD_SECRET", "").strip()
URL_PATTERN = re.compile(r"https?://\S+", re.IGNORECASE)
MYANMAR_CHAR_PATTERN = re.compile(r"[\u1000-\u109F\uA9E0-\uA9FF\uAA60-\uAA7F]")
TAG_PATTERN = re.compile(r"<[^>]+>")
SIMPLE_TIME_PATTERN = re.compile(r"^\d{1,2}(:\d{2})?\s*(am|pm)?$", re.IGNORECASE)
CONTENTS_KEYWORD_PATTERN = re.compile(r"\bcontents?\b", re.IGNORECASE)
CONTENT_TYPE_JSON = "application/json"
MIME_TYPE_JPEG = "image/jpeg"
WEB_PAGE_SUMMARY_TITLE = "Web Page Summary"
MAX_LINK_FETCH_BYTES = 1_000_000
MAX_LINK_REDIRECTS = 5
CMD_HIDEBUTTONS = "/hidebuttons"
CMD_REWRITE = "/rewrite"
CMD_SUMMARIZE = "/summarize"
CMD_NOTE = "/note"
CMD_NOTES = "/notes"
USER_BOT_COMMANDS = [
{"command": "start", "description": "Show bot intro and quick actions"},
{"command": "help", "description": "Show all available commands"},
{"command": "login", "description": "Log in with an access hash"},
{"command": "menu", "description": "Show organized button menu"},
{"command": "hidebuttons", "description": "Hide the button keyboard"},
{"command": "reset", "description": "Clear chat memory and reset mode"},
{"command": "english", "description": "Reply in English"},
{"command": "burmese", "description": "Reply in smooth Burmese"},
{"command": "chinese", "description": "Reply in Chinese"},
{"command": "language", "description": "Reply in any language"},
{"command": "persona", "description": "Set a reusable response role"},
{"command": "tone", "description": "Set your preferred writing style"},
{"command": "note", "description": "Save a note from text"},
{"command": "notes", "description": "List or delete saved notes"},
{"command": "analyze", "description": "Analyze a replied file or photo"},
{"command": "pdf", "description": "Create a PDF from text or a reply"},
{"command": "webpdf", "description": "Turn a web page into a PDF"},
{"command": "caption", "description": "Create a social media caption"},
{"command": "hook", "description": "Create strong opening hooks"},
{"command": "carousel", "description": "Create a carousel post outline"},
{"command": "script", "description": "Create a video or reel script"},
{"command": "cta", "description": "Create call-to-action lines"},
{"command": "hashtags", "description": "Create relevant hashtags"},
{"command": "quiz", "description": "Create quiz questions from a topic"},
{"command": "flashcards", "description": "Create study flashcards"},
{"command": "explain", "description": "Explain a topic simply"},
{"command": "exam", "description": "Create exam-style questions"},
{"command": "plan", "description": "Create a business plan draft"},
{"command": "pitch", "description": "Create a business pitch"},
{"command": "pricing", "description": "Suggest pricing structure"},
{"command": "strategy", "description": "Create a business strategy"},
{"command": "swot", "description": "Create a SWOT analysis"},
{"command": "businessmodel", "description": "Create a business model outline"},
{"command": "qr", "description": "Create a QR code from text or a link"},
{"command": "rewrite", "description": "Rewrite text in a better way"},
{"command": "shorter", "description": "Make text shorter"},
{"command": "formal", "description": "Make text more formal"},
{"command": "friendly", "description": "Make text more friendly"},
{"command": "translate", "description": "Translate text"},
{"command": "fixgrammar", "description": "Fix grammar and wording"},
{"command": "summarize", "description": "Summarize text"},
{"command": "remind", "description": "Create a reminder"},
{"command": "reminders", "description": "List or delete reminders"},
{"command": "todo", "description": "Manage your to-do list"},
{"command": "idea", "description": "Save a quick idea"},
{"command": "ideas", "description": "List saved ideas"},
{"command": "link", "description": "Analyze a public link"},
{"command": "presentation", "description": "Create a simple deck outline"},
]
PUBLIC_BOT_COMMANDS = [
{"command": "start", "description": "Start and see login help"},
{"command": "help", "description": "Show basic help"},
{"command": "login", "description": "Log in with an access hash"},
]
MAIN_REPLY_KEYBOARD = {
"keyboard": [
[{"text": "/help"}, {"text": "/reset"}, {"text": CMD_HIDEBUTTONS}],
[{"text": "/analyze"}, {"text": "/pdf"}, {"text": "/webpdf"}],
[{"text": "/link"}, {"text": "/qr"}],
[{"text": "/caption"}, {"text": "/script"}, {"text": "/carousel"}],
[{"text": "/quiz"}, {"text": "/flashcards"}, {"text": "/plan"}],
[{"text": "/todo list"}, {"text": "/reminders"}, {"text": "/ideas"}],
[{"text": "/burmese"}, {"text": "/chinese"}, {"text": "/english"}],
[{"text": "/menu more"}],
],
"resize_keyboard": True,
"is_persistent": True,
"input_field_placeholder": "Choose a tool or type a message",
}
MORE_REPLY_KEYBOARD = {
"keyboard": [
[{"text": "/hook"}, {"text": "/cta"}, {"text": "/hashtags"}],
[{"text": "/explain"}, {"text": "/exam"}],
[{"text": "/pitch"}, {"text": "/pricing"}, {"text": "/strategy"}],
[{"text": "/swot"}, {"text": "/businessmodel"}],
[{"text": "/persona"}, {"text": "/tone"}],
[{"text": CMD_REWRITE}, {"text": "/translate"}, {"text": CMD_SUMMARIZE}],
[{"text": "/menu"}, {"text": CMD_HIDEBUTTONS}],
],
"resize_keyboard": True,
"is_persistent": True,
"input_field_placeholder": "Choose more tools or type a message",
}
REMOVE_REPLY_KEYBOARD = {
"remove_keyboard": True,
}
PERSONA_PROMPTS = {
"coach": "Act like a practical coach. Be encouraging, direct, and action-focused.",
"teacher": "Act like a clear teacher. Explain things simply and step by step.",
"marketer": "Act like a sharp marketer. Focus on positioning, persuasion, and audience clarity.",
"assistant": "Act like a reliable personal assistant. Be concise, organized, and useful.",
"coder": "Act like a senior coding assistant. Be technical, precise, and solution-oriented.",
"translator": "Act like a careful Burmese-English translator. Preserve meaning and natural phrasing.",
"storyteller": "Act like a storyteller. Use vivid but clean language with a natural flow.",
}
LANGUAGE_ALIASES = {
"default": "default",
"auto": "default",
"english": "default",
"en": "default",
"burmese": "Burmese",
"myanmar": "Burmese",
"mm": "Burmese",
"my": "Burmese",
"chinese": "Chinese",
"zh": "Chinese",
"cn": "Chinese",
"mandarin": "Chinese",
}
def load_persistent_state() -> dict[str, Any]:
if not os.path.exists(BOT_STORAGE_PATH):
return {}
try:
with open(BOT_STORAGE_PATH, "r", encoding="utf-8") as storage_file:
data = json.load(storage_file)
return data if isinstance(data, dict) else {}
except Exception:
logger.exception("Could not load bot state from %s", BOT_STORAGE_PATH)
return {}
def normalize_int_key_map(raw_data: Any) -> dict[int, Any]:
normalized: dict[int, Any] = {}
if not isinstance(raw_data, dict):
return normalized
for key, value in raw_data.items():
try:
normalized[int(key)] = value
except (TypeError, ValueError):
continue
return normalized
def normalize_int_set(raw_data: Any) -> set[int]:
normalized: set[int] = set()
if isinstance(raw_data, (list, tuple, set)):
for item in raw_data:
try:
normalized.add(int(item))
except (TypeError, ValueError):
continue
return normalized
def normalize_dict_list(raw_data: Any) -> list[dict[str, Any]]:
# Keep persisted collections structurally safe before the rest of the bot reads them.
if not isinstance(raw_data, list):
return []
return [item for item in raw_data if isinstance(item, dict)]
PERSISTENT_STATE = load_persistent_state()
def parse_allowed_user_ids() -> set[int]:
raw_ids = os.getenv("TELEGRAM_ALLOWED_USER_IDS", "").strip()
if raw_ids:
parsed_ids: set[int] = set()
for raw_id in raw_ids.split(","):
value = raw_id.strip()
if not value:
continue
try:
parsed_ids.add(int(value))
except ValueError as exc:
raise RuntimeError(
"TELEGRAM_ALLOWED_USER_IDS must be a comma-separated list of integers."
) from exc
if not parsed_ids:
raise RuntimeError("TELEGRAM_ALLOWED_USER_IDS is missing.")
return parsed_ids
legacy_user_id = os.getenv("TELEGRAM_ALLOWED_USER_ID", "").strip()
if not legacy_user_id:
raise RuntimeError(
"Set TELEGRAM_ALLOWED_USER_IDS or TELEGRAM_ALLOWED_USER_ID in the environment."
)
try:
return {int(legacy_user_id)}
except ValueError as exc:
raise RuntimeError("TELEGRAM_ALLOWED_USER_ID must be an integer.") from exc
TELEGRAM_ALLOWED_USER_IDS = parse_allowed_user_ids()
def parse_admin_user_ids() -> set[int]:
raw_ids = os.getenv("TELEGRAM_ADMIN_USER_IDS", "").strip()
if not raw_ids:
return {1201884652}
parsed_ids: set[int] = set()
for raw_id in raw_ids.split(","):
value = raw_id.strip()
if not value:
continue
try:
parsed_ids.add(int(value))
except ValueError as exc:
raise RuntimeError(
"TELEGRAM_ADMIN_USER_IDS must be a comma-separated list of integers."
) from exc
return parsed_ids or {1201884652}
TELEGRAM_ADMIN_USER_IDS = parse_admin_user_ids()
if not OPENROUTER_API_KEY:
raise RuntimeError("OPENROUTER_API_KEY is missing.")
if not OPENROUTER_MODEL:
raise RuntimeError("OPENROUTER_MODEL is missing.")
if not TELEGRAM_BOT_TOKEN:
raise RuntimeError("TELEGRAM_BOT_TOKEN is missing.")
TELEGRAM_API_BASE = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}"
MESSAGE_HISTORY_LIMIT = 6
TELEGRAM_MESSAGE_LIMIT = 4096
REQUEST_TIMEOUT = 60
POLL_TIMEOUT = 30
HISTORY_ENTRY_CHAR_LIMIT = 320
SUMMARY_ENTRY_CHAR_LIMIT = 180
CONVERSATION_SUMMARY_CHAR_LIMIT = 1200
RECENT_CONTEXT_CHAR_LIMIT = 2400
SOURCE_TEXT_CHAR_LIMIT = 3500
DEFAULT_MAX_TOKENS = 550
BURMESE_MAX_TOKENS = 700
PRESENTATION_MAX_TOKENS = 900
SOCIAL_CONTENT_MAX_TOKENS = 900
TRANSFORM_MAX_TOKENS = 380
SUMMARY_MAX_TOKENS = 220
LINK_ANALYSIS_MAX_TOKENS = 420
ATTACHMENT_ANALYSIS_MAX_TOKENS = 650
CONTINUATION_MAX_ROUNDS = 2
FILE_TEXT_CHAR_LIMIT = 8000
PDF_MAX_TOKENS = 900
COMMAND_PACK_MAX_TOKENS = 700
session = requests.Session()
conversation_history: dict[int, deque[dict[str, str]]] = defaultdict(
lambda: deque(maxlen=MESSAGE_HISTORY_LIMIT)
)
conversation_modes: dict[int, str] = defaultdict(lambda: "chat")
response_languages: dict[int, str] = defaultdict(
lambda: "default",
{
user_id: str(language)
for user_id, language in normalize_int_key_map(
PERSISTENT_STATE.get("response_languages")
).items()
},
)
tone_preferences: dict[int, str] = {
user_id: str(value)
for user_id, value in normalize_int_key_map(PERSISTENT_STATE.get("tone_preferences")).items()
}
conversation_summaries: dict[int, str] = {}
persona_preferences: dict[int, str] = {
user_id: str(value)
for user_id, value in normalize_int_key_map(PERSISTENT_STATE.get("persona_preferences")).items()
}
todo_store: dict[int, list[dict[str, Any]]] = {
user_id: value if isinstance(value, list) else []
for user_id, value in normalize_int_key_map(PERSISTENT_STATE.get("todos")).items()
}
idea_store: dict[int, list[dict[str, Any]]] = {
user_id: value if isinstance(value, list) else []
for user_id, value in normalize_int_key_map(PERSISTENT_STATE.get("ideas")).items()
}
reminder_store: list[dict[str, Any]] = normalize_dict_list(PERSISTENT_STATE.get("reminders"))
approved_user_ids: set[int] = normalize_int_set(PERSISTENT_STATE.get("approved_user_ids"))
approved_user_ids.update(TELEGRAM_ALLOWED_USER_IDS)
approved_user_ids.update(TELEGRAM_ADMIN_USER_IDS)
blocked_user_ids: set[int] = normalize_int_set(PERSISTENT_STATE.get("blocked_user_ids"))
blocked_user_ids.difference_update(TELEGRAM_ADMIN_USER_IDS)
login_hash_store: dict[str, dict[str, Any]] = {
str(key).strip().upper(): value
for key, value in (PERSISTENT_STATE.get("login_hashes") or {}).items()
if isinstance(key, str) and isinstance(value, dict)
}
known_user_profiles: dict[int, dict[str, Any]] = {
user_id: value if isinstance(value, dict) else {}
for user_id, value in normalize_int_key_map(PERSISTENT_STATE.get("known_user_profiles")).items()
}
activity_log: list[dict[str, Any]] = normalize_dict_list(PERSISTENT_STATE.get("activity_log"))
admin_session_log: list[dict[str, Any]] = normalize_dict_list(PERSISTENT_STATE.get("admin_session_log"))
note_store: dict[int, list[dict[str, Any]]] = {
user_id: value if isinstance(value, list) else []
for user_id, value in normalize_int_key_map(PERSISTENT_STATE.get("notes")).items()
}
def write_state_payload(payload: dict[str, Any]) -> None:
directory = os.path.dirname(BOT_STORAGE_PATH)
if directory:
os.makedirs(directory, exist_ok=True)
temp_path = f"{BOT_STORAGE_PATH}.tmp"
with open(temp_path, "w", encoding="utf-8") as storage_file:
json.dump(payload, storage_file, ensure_ascii=False, indent=2)
os.replace(temp_path, BOT_STORAGE_PATH)
def save_persistent_state() -> None:
payload = {
"response_languages": {str(key): value for key, value in response_languages.items()},
"tone_preferences": {str(key): value for key, value in tone_preferences.items()},
"persona_preferences": {str(key): value for key, value in persona_preferences.items()},
"todos": {str(key): value for key, value in todo_store.items()},
"ideas": {str(key): value for key, value in idea_store.items()},
"notes": {str(key): value for key, value in note_store.items()},
"reminders": reminder_store,
"approved_user_ids": sorted(approved_user_ids),
"blocked_user_ids": sorted(blocked_user_ids),
"login_hashes": login_hash_store,
"known_user_profiles": {str(key): value for key, value in known_user_profiles.items()},
"activity_log": activity_log,
"admin_session_log": admin_session_log,
}
write_state_payload(payload)
def refresh_auth_related_state() -> None:
disk_state = load_persistent_state()
approved_user_ids.clear()
approved_user_ids.update(normalize_int_set(disk_state.get("approved_user_ids")))
approved_user_ids.update(TELEGRAM_ALLOWED_USER_IDS)
approved_user_ids.update(TELEGRAM_ADMIN_USER_IDS)
blocked_user_ids.clear()
blocked_user_ids.update(normalize_int_set(disk_state.get("blocked_user_ids")))
blocked_user_ids.difference_update(TELEGRAM_ADMIN_USER_IDS)
login_hash_store.clear()
login_hash_store.update(
{
str(key).strip().upper(): value
for key, value in (disk_state.get("login_hashes") or {}).items()
if isinstance(key, str) and isinstance(value, dict)
}
)
known_user_profiles.clear()
known_user_profiles.update(
{
user_id: value if isinstance(value, dict) else {}
for user_id, value in normalize_int_key_map(disk_state.get("known_user_profiles")).items()
}
)
activity_log.clear()
raw_activity = disk_state.get("activity_log")
if isinstance(raw_activity, list):
for item in raw_activity:
if isinstance(item, dict):
activity_log.append(item)
admin_session_log.clear()
raw_admin_sessions = disk_state.get("admin_session_log")
if isinstance(raw_admin_sessions, list):
for item in raw_admin_sessions:
if isinstance(item, dict):
admin_session_log.append(item)
def save_auth_related_state() -> None:
disk_state = load_persistent_state()
payload = disk_state if isinstance(disk_state, dict) else {}
payload["approved_user_ids"] = sorted(approved_user_ids)
payload["blocked_user_ids"] = sorted(blocked_user_ids)
payload["login_hashes"] = login_hash_store
payload["known_user_profiles"] = {str(key): value for key, value in known_user_profiles.items()}
payload["activity_log"] = activity_log
payload["admin_session_log"] = admin_session_log
payload["notes"] = {str(key): value for key, value in note_store.items()}
write_state_payload(payload)
def now_local() -> datetime:
return datetime.now(ZoneInfo(BOT_TIMEZONE))
def new_item_id() -> str:
return uuid.uuid4().hex[:8]
def is_admin_user(user_id: int) -> bool:
return user_id in TELEGRAM_ADMIN_USER_IDS
def is_blocked_user(user_id: int) -> bool:
return user_id in blocked_user_ids and not is_admin_user(user_id)
def is_authorized_user(user_id: int) -> bool:
return not is_blocked_user(user_id) and (user_id in approved_user_ids or is_admin_user(user_id))
def generate_login_hash() -> str:
return secrets.token_hex(5).upper()
def create_login_hash(created_by: int) -> str:
refresh_auth_related_state()
login_hash = generate_login_hash()
while login_hash in login_hash_store:
login_hash = generate_login_hash()
login_hash_store[login_hash] = {
"created_at": now_local().isoformat(),
"created_by": created_by,
}
save_auth_related_state()
return login_hash
def consume_login_hash(user_id: int, login_hash: str) -> tuple[bool, str]:
refresh_auth_related_state()
normalized_hash = login_hash.strip().upper()
if not normalized_hash:
return False, "Use /login <hash>."
if is_blocked_user(user_id):
return False, "Your access is blocked. Ask the admin to unblock you."
if is_authorized_user(user_id):
return True, "You already have access."
hash_entry = login_hash_store.pop(normalized_hash, None)
if not hash_entry:
save_auth_related_state()
return False, "Invalid or already used hash."
approved_user_ids.add(user_id)
save_auth_related_state()
logger.info("Approved new user_id=%s via login hash", user_id)
return True, "Login successful. Access granted."
def describe_user_name(profile: dict[str, Any]) -> str:
first_name = str(profile.get("first_name", "")).strip()
last_name = str(profile.get("last_name", "")).strip()
full_name = " ".join(part for part in [first_name, last_name] if part).strip()
username = str(profile.get("username", "")).strip()
if full_name and username:
return f"{full_name} (@{username})"
if full_name:
return full_name
if username:
return f"@{username}"
return "Unknown user"
def get_account_link(user_id: int) -> str:
profile = known_user_profiles.get(user_id, {})
username = str(profile.get("username", "")).strip().lstrip("@")
if username:
return f"https://t.me/{username}"
return f"tg://user?id={user_id}"
def get_user_status_label(user_id: int) -> str:
if is_admin_user(user_id):
return "admin"
if user_id in blocked_user_ids:
return "blocked"
if user_id in approved_user_ids:
return "approved"
return "pending"
def format_profile_summary(user_id: int) -> str:
profile = known_user_profiles.get(user_id, {})
lines = [
f"User ID: {user_id}",
f"Status: {get_user_status_label(user_id)}",
f"Name: {describe_user_name(profile)}",
f"Account link: {get_account_link(user_id)}",
]
if profile.get("chat_id"):
lines.append(f"Chat ID: {profile['chat_id']}")
if profile.get("first_seen"):
lines.append(f"First seen: {format_local_datetime(str(profile['first_seen']))}")
if profile.get("last_seen"):
lines.append(f"Last seen: {format_local_datetime(str(profile['last_seen']))}")
last_request = str(profile.get("last_request", "")).strip()
if last_request:
lines.append(f"Last request: {last_request}")
return "\n".join(lines)
def append_activity_event(user_id: int, request_preview: str) -> None:
refresh_auth_related_state()
activity_log.append(
{
"id": new_item_id(),
"user_id": user_id,
"status": get_user_status_label(user_id),
"account_link": get_account_link(user_id),
"name": describe_user_name(known_user_profiles.get(user_id, {})),
"message": clip_text(request_preview, 1200),
"created_at": now_local().isoformat(),
}
)
if len(activity_log) > 200:
del activity_log[:-200]
save_auth_related_state()
def update_known_user_profile(
user_id: int,
chat_id: int,
from_user: dict[str, Any],
request_preview: str,
) -> None:
refresh_auth_related_state()
existing = known_user_profiles.get(user_id, {})
profile = {
"chat_id": chat_id,
"username": str(from_user.get("username") or "").strip(),
"first_name": str(from_user.get("first_name") or "").strip(),
"last_name": str(from_user.get("last_name") or "").strip(),
"first_seen": existing.get("first_seen") or now_local().isoformat(),
"last_seen": now_local().isoformat(),
"last_request": clip_text(request_preview, 700),
}
known_user_profiles[user_id] = profile
save_auth_related_state()
def get_message_request_preview(message: dict[str, Any], text: str) -> str:
normalized_text = normalize_plain_text(text).strip()
if normalized_text:
return normalized_text
if has_voice_input_attachment(message):
return "[audio message]"
if message.get("photo"):
caption = normalize_plain_text(str(message.get("caption") or "")).strip()
return f"[photo] {caption}".strip()
document = message.get("document") or {}
if document:
file_name = str(document.get("file_name") or "file").strip()
caption = normalize_plain_text(str(message.get("caption") or "")).strip()
suffix = f" {caption}" if caption else ""
return f"[document: {file_name}]{suffix}".strip()
return "[non-text request]"
def notify_admins(text: str, exclude_user_id: int | None = None) -> None:
for admin_user_id in sorted(TELEGRAM_ADMIN_USER_IDS):
if exclude_user_id is not None and admin_user_id == exclude_user_id:
continue
try:
if TELEGRAM_ADMIN_BOT_TOKEN:
send_message_via_token(TELEGRAM_ADMIN_BOT_TOKEN, admin_user_id, text)
else:
send_message(admin_user_id, text)
except Exception:
logger.exception("Could not notify admin user_id=%s", admin_user_id)
def build_admin_request_notice(user_id: int, request_preview: str) -> str:
status = get_user_status_label(user_id)
title = "Unknown user reached the bot" if status == "pending" else "User activity"
lines = [
title,
format_profile_summary(user_id),
f"Message: {clip_text(request_preview, 1200)}",
f"Reply command: /replyuser {user_id} | <text>",
]
return "\n".join(lines)
def approve_user_access(target_user_id: int) -> str:
refresh_auth_related_state()
if is_admin_user(target_user_id):
return "Admin accounts already have access."
blocked_user_ids.discard(target_user_id)
approved_user_ids.add(target_user_id)
save_auth_related_state()
return "User approved."
def block_user_access(target_user_id: int) -> str:
refresh_auth_related_state()
if is_admin_user(target_user_id):
return "Admin accounts cannot be blocked."
blocked_user_ids.add(target_user_id)
approved_user_ids.discard(target_user_id)
save_auth_related_state()
return "User blocked."
def unblock_user_access(target_user_id: int) -> str:
refresh_auth_related_state()
blocked_user_ids.discard(target_user_id)
save_auth_related_state()
return "User unblocked."
def format_local_datetime(value: str) -> str:
return datetime.fromisoformat(value).astimezone(ZoneInfo(BOT_TIMEZONE)).strftime(
"%Y-%m-%d %H:%M"
)
def chunk_text(text: str, limit: int = TELEGRAM_MESSAGE_LIMIT) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n", 0, limit)
if split_at == -1 or split_at < limit // 2:
split_at = limit
chunks.append(remaining[:split_at].rstrip())
remaining = remaining[split_at:].lstrip()
return chunks
def telegram_api(method: str, payload: dict[str, Any]) -> dict[str, Any]:
response = session.post(
f"{TELEGRAM_API_BASE}/{method}",
json=payload,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram API error: {data}")
return data
def send_message(
chat_id: int,
text: str,
reply_to_message_id: int | None = None,
reply_markup: dict[str, Any] | None = None,
) -> None:
for index, chunk in enumerate(chunk_text(text or "Empty response.")):
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": chunk,
}
if index == 0 and reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
if index == 0 and reply_markup is not None:
payload["reply_markup"] = reply_markup
telegram_api("sendMessage", payload)
def send_message_via_token(
bot_token: str,
chat_id: int,
text: str,
reply_to_message_id: int | None = None,
) -> None:
if not bot_token:
raise RuntimeError("Bot token is missing.")
api_base = f"https://api.telegram.org/bot{bot_token}"
for index, chunk in enumerate(chunk_text(text or "Empty response.")):
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": chunk,
}
if index == 0 and reply_to_message_id is not None:
payload["reply_to_message_id"] = reply_to_message_id
response = session.post(
f"{api_base}/sendMessage",
json=payload,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram API error: {data}")
def get_menu_keyboard(menu_name: str = "main") -> dict[str, Any]:
if menu_name == "more":
return MORE_REPLY_KEYBOARD
return MAIN_REPLY_KEYBOARD
def send_chat_action(chat_id: int, action: str = "typing") -> None:
telegram_api("sendChatAction", {"chat_id": chat_id, "action": action})
def send_document(
chat_id: int,
document_path: str,
file_name: str,
caption: str | None = None,
reply_to_message_id: int | None = None,
) -> None:
payload: dict[str, Any] = {
"chat_id": str(chat_id),
}
if caption:
payload["caption"] = caption
if reply_to_message_id is not None:
payload["reply_to_message_id"] = str(reply_to_message_id)
with open(document_path, "rb") as document_file:
response = session.post(
f"{TELEGRAM_API_BASE}/sendDocument",
data=payload,
files={
"document": (file_name, document_file, "application/pdf"),
},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram API error: {data}")
def send_photo_file(
chat_id: int,
photo_path: str,
file_name: str,
caption: str | None = None,
reply_to_message_id: int | None = None,
) -> None:
payload: dict[str, Any] = {
"chat_id": str(chat_id),
}
if caption:
payload["caption"] = caption
if reply_to_message_id is not None:
payload["reply_to_message_id"] = str(reply_to_message_id)
with open(photo_path, "rb") as photo_file:
response = session.post(
f"{TELEGRAM_API_BASE}/sendPhoto",
data=payload,
files={
"photo": (file_name, photo_file, "image/png"),
},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if not data.get("ok"):
raise RuntimeError(f"Telegram API error: {data}")
def get_telegram_file_bytes(file_id: str) -> tuple[bytes, str]:
file_data = telegram_api("getFile", {"file_id": file_id})
try:
file_path = str(file_data["result"]["file_path"])
except (KeyError, TypeError) as exc:
raise RuntimeError(f"Unexpected Telegram file response: {file_data}") from exc
response = session.get(
f"https://api.telegram.org/file/bot{TELEGRAM_BOT_TOKEN}/{file_path}",
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.content, file_path
def register_bot_commands() -> None:
refresh_auth_related_state()
telegram_api(
"setMyCommands",
{
"commands": PUBLIC_BOT_COMMANDS,
"scope": {"type": "default"},
},
)
telegram_api(
"setMyCommands",
{
"commands": PUBLIC_BOT_COMMANDS,
"scope": {"type": "all_private_chats"},
},
)
scoped_chat_ids = set(known_user_profiles) | approved_user_ids | blocked_user_ids | TELEGRAM_ADMIN_USER_IDS
for target_user_id in sorted(scoped_chat_ids):
scope = {"type": "chat", "chat_id": target_user_id}
if target_user_id in approved_user_ids and not is_blocked_user(target_user_id):
telegram_api("setMyCommands", {"commands": USER_BOT_COMMANDS, "scope": scope})
else:
telegram_api("deleteMyCommands", {"scope": scope})
logger.info(
"Registered scoped bot commands: public=%s user=%s",
len(PUBLIC_BOT_COMMANDS),
len(USER_BOT_COMMANDS),
)