tg-notes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tg_notes/__init__.py +4 -0
- tg_notes/cli.py +391 -0
- tg_notes/config.py +81 -0
- tg_notes/contacts.py +101 -0
- tg_notes/telegram.py +535 -0
- tg_notes-0.1.0.dist-info/METADATA +140 -0
- tg_notes-0.1.0.dist-info/RECORD +10 -0
- tg_notes-0.1.0.dist-info/WHEEL +4 -0
- tg_notes-0.1.0.dist-info/entry_points.txt +2 -0
- tg_notes-0.1.0.dist-info/licenses/LICENSE +674 -0
tg_notes/__init__.py
ADDED
tg_notes/cli.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""tg-notes command-line entrypoint.
|
|
2
|
+
|
|
3
|
+
Command surface (implemented incrementally — see TODO.md):
|
|
4
|
+
login one-time interactive Telegram login (TGN-2)
|
|
5
|
+
whoami print the logged-in account identity (TGN-2)
|
|
6
|
+
setup create/attach the storage group (TGN-3)
|
|
7
|
+
note add append a note to a notebook (TGN-4)
|
|
8
|
+
notes list list raw notes from a notebook (TGN-5)
|
|
9
|
+
contacts list|set|remove address book in the contacts topic (TGN-6)
|
|
10
|
+
send publish a compiled note to a contact (TGN-7)
|
|
11
|
+
notebooks list list notebook topics (TGN-8)
|
|
12
|
+
|
|
13
|
+
All Phase 1 commands are implemented; the intelligence (composing/compiling notes) lives
|
|
14
|
+
in the agent Skills on top, not here.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import __version__, config, telegram
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _login(args: argparse.Namespace) -> int:
|
|
28
|
+
"""Run the one-time interactive Telegram login and store the session."""
|
|
29
|
+
cfg = config.load()
|
|
30
|
+
try:
|
|
31
|
+
identity = telegram.login(cfg)
|
|
32
|
+
except telegram.NotConfiguredError as exc:
|
|
33
|
+
sys.stderr.write(f"{exc}\n")
|
|
34
|
+
return 1
|
|
35
|
+
print(json.dumps(identity, ensure_ascii=False))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _setup(args: argparse.Namespace) -> int:
|
|
40
|
+
"""Provision/attach the storage group, driving first-run onboarding as needed.
|
|
41
|
+
|
|
42
|
+
``setup`` is self-sufficient: if the Telegram credentials are missing it prompts for
|
|
43
|
+
them and saves them to local config (mode 600); if the device is not logged in it runs
|
|
44
|
+
the interactive ``login`` (phone → code → 2FA) and retries. Then it creates or attaches
|
|
45
|
+
the storage group and persists its id.
|
|
46
|
+
"""
|
|
47
|
+
cfg = config.load()
|
|
48
|
+
|
|
49
|
+
# 1. Ensure credentials — ask and persist (chmod 600) when they are missing.
|
|
50
|
+
if not cfg.is_configured():
|
|
51
|
+
if not _prompt_credentials(cfg):
|
|
52
|
+
_instruct_configure()
|
|
53
|
+
return 1
|
|
54
|
+
config.save(cfg)
|
|
55
|
+
|
|
56
|
+
# 2. Provision; a fresh/expired session surfaces as NotAuthorized → log in and retry.
|
|
57
|
+
try:
|
|
58
|
+
result = telegram.setup(cfg, notebook=args.notebook)
|
|
59
|
+
except telegram.NotConfiguredError:
|
|
60
|
+
_instruct_configure()
|
|
61
|
+
return 1
|
|
62
|
+
except telegram.NotAuthorizedError:
|
|
63
|
+
telegram.login(cfg) # interactive phone/code/2FA, then retry provisioning
|
|
64
|
+
result = telegram.setup(cfg, notebook=args.notebook)
|
|
65
|
+
|
|
66
|
+
cfg.storage_group_id = result["group_id"]
|
|
67
|
+
config.save(cfg)
|
|
68
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ask(prompt: str) -> str:
|
|
73
|
+
"""Read a single trimmed line from the user (wrapped for testability)."""
|
|
74
|
+
return input(prompt).strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _prompt_credentials(cfg: config.Config) -> bool:
|
|
78
|
+
"""Interactively collect ``api_id``/``api_hash`` onto ``cfg``.
|
|
79
|
+
|
|
80
|
+
Returns ``True`` when both were captured, ``False`` if the input was empty or the
|
|
81
|
+
``api_id`` was not an integer (the caller then prints full guidance and aborts).
|
|
82
|
+
"""
|
|
83
|
+
sys.stderr.write(
|
|
84
|
+
"tg-notes needs your Telegram api_id/api_hash (one-time).\n"
|
|
85
|
+
"Get them at https://my.telegram.org → 'API development tools'.\n"
|
|
86
|
+
)
|
|
87
|
+
raw_id = _ask("api_id: ")
|
|
88
|
+
api_hash = _ask("api_hash: ")
|
|
89
|
+
if not raw_id or not api_hash:
|
|
90
|
+
return False
|
|
91
|
+
try:
|
|
92
|
+
cfg.api_id = int(raw_id)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return False
|
|
95
|
+
cfg.api_hash = api_hash
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _instruct_configure() -> None:
|
|
100
|
+
"""Explain how to supply Telegram credentials by hand (used when the prompt is skipped
|
|
101
|
+
or the entered values were unusable)."""
|
|
102
|
+
path = config.config_path()
|
|
103
|
+
sys.stderr.write(
|
|
104
|
+
"tg-notes still has no usable Telegram api_id/api_hash.\n"
|
|
105
|
+
"\n"
|
|
106
|
+
"Set them up manually:\n"
|
|
107
|
+
" 1. Get an api_id and api_hash at https://my.telegram.org\n"
|
|
108
|
+
" (log in → 'API development tools' → create an app).\n"
|
|
109
|
+
f" 2. Save them to {path}:\n"
|
|
110
|
+
" api_id = 1234567\n"
|
|
111
|
+
' api_hash = "your_api_hash"\n'
|
|
112
|
+
f" Then keep the secrets private: chmod 600 {path}\n"
|
|
113
|
+
" 3. Run `tg-notes login` to authorize this device (phone → code → 2FA).\n"
|
|
114
|
+
" 4. Run `tg-notes setup` again.\n"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _whoami(args: argparse.Namespace) -> int:
|
|
119
|
+
"""Print the identity of the currently logged-in account."""
|
|
120
|
+
cfg = config.load()
|
|
121
|
+
try:
|
|
122
|
+
identity = telegram.whoami(cfg)
|
|
123
|
+
except telegram.NotConfiguredError as exc:
|
|
124
|
+
sys.stderr.write(f"{exc}\n")
|
|
125
|
+
return 1
|
|
126
|
+
except telegram.NotAuthorizedError as exc:
|
|
127
|
+
sys.stderr.write(f"{exc}\n")
|
|
128
|
+
return 3
|
|
129
|
+
print(json.dumps(identity, ensure_ascii=False))
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _handle_store_errors(exc: Exception) -> int:
|
|
134
|
+
"""Map a store-access exception to a user message and exit code (4 / 1 / 3)."""
|
|
135
|
+
if isinstance(exc, telegram.NotSetUpError):
|
|
136
|
+
sys.stderr.write(f"{exc}\n")
|
|
137
|
+
return 4
|
|
138
|
+
if isinstance(exc, telegram.NotConfiguredError):
|
|
139
|
+
_instruct_configure()
|
|
140
|
+
return 1
|
|
141
|
+
sys.stderr.write("not logged in — run `tg-notes login` (or `tg-notes setup`) first\n")
|
|
142
|
+
return 3
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_STORE_ERRORS = (
|
|
146
|
+
telegram.NotSetUpError,
|
|
147
|
+
telegram.NotConfiguredError,
|
|
148
|
+
telegram.NotAuthorizedError,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_text_arg(path: str) -> str:
|
|
153
|
+
"""Read note text from a file, or from stdin when ``path`` is ``-``."""
|
|
154
|
+
if path == "-":
|
|
155
|
+
return sys.stdin.read()
|
|
156
|
+
return Path(path).read_text(encoding="utf-8")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _note_add(args: argparse.Namespace) -> int:
|
|
160
|
+
"""Append a note to a notebook topic (TGN-4)."""
|
|
161
|
+
cfg = config.load()
|
|
162
|
+
try:
|
|
163
|
+
text = _read_text_arg(args.text_file)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
sys.stderr.write(f"cannot read note text from {args.text_file}: {exc}\n")
|
|
166
|
+
return 1
|
|
167
|
+
try:
|
|
168
|
+
result = telegram.note_add(
|
|
169
|
+
cfg, notebook=args.notebook, text=text, hashtags=args.hashtag
|
|
170
|
+
)
|
|
171
|
+
except _STORE_ERRORS as exc:
|
|
172
|
+
return _handle_store_errors(exc)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
sys.stderr.write(f"{exc}\n")
|
|
175
|
+
return 1
|
|
176
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_since(value: str) -> datetime:
|
|
181
|
+
"""Parse a ``--since`` bound into a timezone-aware datetime (naive input is local).
|
|
182
|
+
|
|
183
|
+
Accepts ``today``, ``HH:MM`` (today at that time), a date (``YYYY-MM-DD``), or a full
|
|
184
|
+
ISO datetime. Raises ``ValueError`` on anything else.
|
|
185
|
+
"""
|
|
186
|
+
text = value.strip()
|
|
187
|
+
now = datetime.now().astimezone()
|
|
188
|
+
if text.lower() == "today":
|
|
189
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
190
|
+
try:
|
|
191
|
+
clock = datetime.strptime(text, "%H:%M") # noqa: DTZ007 (time-of-day only)
|
|
192
|
+
except ValueError:
|
|
193
|
+
pass
|
|
194
|
+
else:
|
|
195
|
+
return now.replace(hour=clock.hour, minute=clock.minute, second=0, microsecond=0)
|
|
196
|
+
parsed = datetime.fromisoformat(text) # raises ValueError on bad input
|
|
197
|
+
return parsed if parsed.tzinfo is not None else parsed.astimezone()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _notes_list(args: argparse.Namespace) -> int:
|
|
201
|
+
"""List the raw notes of a notebook topic as JSON (TGN-5)."""
|
|
202
|
+
cfg = config.load()
|
|
203
|
+
since = None
|
|
204
|
+
if args.since:
|
|
205
|
+
try:
|
|
206
|
+
since = _parse_since(args.since)
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
sys.stderr.write(f"invalid --since value {args.since!r}: {exc}\n")
|
|
209
|
+
return 1
|
|
210
|
+
try:
|
|
211
|
+
notes = telegram.notes_list(cfg, notebook=args.notebook, since=since)
|
|
212
|
+
except _STORE_ERRORS as exc:
|
|
213
|
+
return _handle_store_errors(exc)
|
|
214
|
+
print(json.dumps(notes, ensure_ascii=False))
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _contacts_list(args: argparse.Namespace) -> int:
|
|
219
|
+
"""Print the address book as JSON (TGN-6)."""
|
|
220
|
+
cfg = config.load()
|
|
221
|
+
try:
|
|
222
|
+
items = telegram.contacts_list(cfg)
|
|
223
|
+
except _STORE_ERRORS as exc:
|
|
224
|
+
return _handle_store_errors(exc)
|
|
225
|
+
print(json.dumps(items, ensure_ascii=False))
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _contacts_set(args: argparse.Namespace) -> int:
|
|
230
|
+
"""Create or update a contact (TGN-6)."""
|
|
231
|
+
cfg = config.load()
|
|
232
|
+
try:
|
|
233
|
+
result = telegram.contacts_set(
|
|
234
|
+
cfg,
|
|
235
|
+
args.key,
|
|
236
|
+
chat_id=args.chat_id,
|
|
237
|
+
name=args.name,
|
|
238
|
+
topic_id=args.topic_id,
|
|
239
|
+
mention=args.mention,
|
|
240
|
+
style=args.style,
|
|
241
|
+
)
|
|
242
|
+
except _STORE_ERRORS as exc:
|
|
243
|
+
return _handle_store_errors(exc)
|
|
244
|
+
except ValueError as exc:
|
|
245
|
+
sys.stderr.write(f"{exc}\n")
|
|
246
|
+
return 1
|
|
247
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _contacts_remove(args: argparse.Namespace) -> int:
|
|
252
|
+
"""Remove a contact by key (TGN-6)."""
|
|
253
|
+
cfg = config.load()
|
|
254
|
+
try:
|
|
255
|
+
result = telegram.contacts_remove(cfg, args.key)
|
|
256
|
+
except _STORE_ERRORS as exc:
|
|
257
|
+
return _handle_store_errors(exc)
|
|
258
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _notebooks_list(args: argparse.Namespace) -> int:
|
|
263
|
+
"""List the storage group's notebook topics as JSON (TGN-8)."""
|
|
264
|
+
cfg = config.load()
|
|
265
|
+
try:
|
|
266
|
+
items = telegram.notebooks_list(cfg)
|
|
267
|
+
except _STORE_ERRORS as exc:
|
|
268
|
+
return _handle_store_errors(exc)
|
|
269
|
+
print(json.dumps(items, ensure_ascii=False))
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _send(args: argparse.Namespace) -> int:
|
|
274
|
+
"""Publish compiled text to a contact's chat/topic (TGN-7)."""
|
|
275
|
+
cfg = config.load()
|
|
276
|
+
try:
|
|
277
|
+
text = _read_text_arg(args.text_file)
|
|
278
|
+
except OSError as exc:
|
|
279
|
+
sys.stderr.write(f"cannot read text from {args.text_file}: {exc}\n")
|
|
280
|
+
return 1
|
|
281
|
+
try:
|
|
282
|
+
result = telegram.send(cfg, args.contact, text, dry_run=args.dry_run)
|
|
283
|
+
except _STORE_ERRORS as exc:
|
|
284
|
+
return _handle_store_errors(exc)
|
|
285
|
+
except telegram.ContactNotFoundError as exc:
|
|
286
|
+
sys.stderr.write(f"{exc}\n")
|
|
287
|
+
return 5
|
|
288
|
+
except ValueError as exc:
|
|
289
|
+
sys.stderr.write(f"{exc}\n")
|
|
290
|
+
return 1
|
|
291
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
296
|
+
parser = argparse.ArgumentParser(
|
|
297
|
+
prog="tg-notes",
|
|
298
|
+
description="Notes to a private Telegram group, published under your own account.",
|
|
299
|
+
)
|
|
300
|
+
parser.add_argument("--version", action="version", version=f"tg-notes {__version__}")
|
|
301
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>", required=True)
|
|
302
|
+
|
|
303
|
+
# login (TGN-2)
|
|
304
|
+
p_login = sub.add_parser("login", help="one-time interactive Telegram login")
|
|
305
|
+
p_login.set_defaults(func=_login)
|
|
306
|
+
|
|
307
|
+
# whoami (TGN-2)
|
|
308
|
+
p_whoami = sub.add_parser("whoami", help="print the logged-in account identity")
|
|
309
|
+
p_whoami.set_defaults(func=_whoami)
|
|
310
|
+
|
|
311
|
+
# setup (TGN-3)
|
|
312
|
+
p_setup = sub.add_parser("setup", help="create or attach the storage group")
|
|
313
|
+
p_setup.add_argument(
|
|
314
|
+
"--notebook",
|
|
315
|
+
default="daily",
|
|
316
|
+
help="name of the default notebook topic to ensure (default: daily)",
|
|
317
|
+
)
|
|
318
|
+
p_setup.set_defaults(func=_setup)
|
|
319
|
+
|
|
320
|
+
# note add (TGN-4)
|
|
321
|
+
p_note = sub.add_parser("note", help="work with notes")
|
|
322
|
+
note_sub = p_note.add_subparsers(dest="subcommand", metavar="<subcommand>", required=True)
|
|
323
|
+
p_note_add = note_sub.add_parser("add", help="append a note to a notebook")
|
|
324
|
+
p_note_add.add_argument("--notebook", default="daily", help="target notebook topic")
|
|
325
|
+
p_note_add.add_argument(
|
|
326
|
+
"--text-file", required=True, help="file with the note text (use - for stdin)"
|
|
327
|
+
)
|
|
328
|
+
p_note_add.add_argument(
|
|
329
|
+
"--hashtag",
|
|
330
|
+
action="append",
|
|
331
|
+
metavar="TAG",
|
|
332
|
+
help="append a #hashtag to the note (repeatable)",
|
|
333
|
+
)
|
|
334
|
+
p_note_add.set_defaults(func=_note_add)
|
|
335
|
+
|
|
336
|
+
# notes list (TGN-5)
|
|
337
|
+
p_notes = sub.add_parser("notes", help="query notes")
|
|
338
|
+
notes_sub = p_notes.add_subparsers(dest="subcommand", metavar="<subcommand>", required=True)
|
|
339
|
+
p_notes_list = notes_sub.add_parser("list", help="list raw notes from a notebook")
|
|
340
|
+
p_notes_list.add_argument("--notebook", default="daily", help="source notebook topic")
|
|
341
|
+
p_notes_list.add_argument(
|
|
342
|
+
"--since",
|
|
343
|
+
help="lower time bound: today | HH:MM | YYYY-MM-DD | ISO datetime (local if naive)",
|
|
344
|
+
)
|
|
345
|
+
p_notes_list.set_defaults(func=_notes_list)
|
|
346
|
+
|
|
347
|
+
# contacts (TGN-6)
|
|
348
|
+
p_contacts = sub.add_parser("contacts", help="address book")
|
|
349
|
+
contacts_sub = p_contacts.add_subparsers(dest="subcommand", metavar="<subcommand>", required=True)
|
|
350
|
+
contacts_sub.add_parser("list", help="list contacts").set_defaults(func=_contacts_list)
|
|
351
|
+
p_c_set = contacts_sub.add_parser("set", help="add or update a contact")
|
|
352
|
+
p_c_set.add_argument("key", help="contact key")
|
|
353
|
+
p_c_set.add_argument("--chat-id", dest="chat_id", help="-100… | @username | me")
|
|
354
|
+
p_c_set.add_argument("--name", help="human name (never sent)")
|
|
355
|
+
p_c_set.add_argument("--topic-id", dest="topic_id", type=int, help="forum topic id")
|
|
356
|
+
p_c_set.add_argument("--mention", help="@username to mention when posting")
|
|
357
|
+
p_c_set.add_argument("--style", help="prompt: how to compile notes for this recipient")
|
|
358
|
+
p_c_set.set_defaults(func=_contacts_set)
|
|
359
|
+
p_c_rm = contacts_sub.add_parser("remove", help="remove a contact")
|
|
360
|
+
p_c_rm.add_argument("key", help="contact key")
|
|
361
|
+
p_c_rm.set_defaults(func=_contacts_remove)
|
|
362
|
+
|
|
363
|
+
# send (TGN-7)
|
|
364
|
+
p_send = sub.add_parser("send", help="publish a compiled note to a contact")
|
|
365
|
+
p_send.add_argument("--contact", required=True, help="contact key from the address book")
|
|
366
|
+
p_send.add_argument(
|
|
367
|
+
"--text-file", required=True, help="file with the compiled text (use - for stdin)"
|
|
368
|
+
)
|
|
369
|
+
p_send.add_argument(
|
|
370
|
+
"--dry-run",
|
|
371
|
+
action="store_true",
|
|
372
|
+
help="compose and print what would be sent, without sending",
|
|
373
|
+
)
|
|
374
|
+
p_send.set_defaults(func=_send)
|
|
375
|
+
|
|
376
|
+
# notebooks list (TGN-8)
|
|
377
|
+
p_nb = sub.add_parser("notebooks", help="notebooks")
|
|
378
|
+
nb_sub = p_nb.add_subparsers(dest="subcommand", metavar="<subcommand>", required=True)
|
|
379
|
+
nb_sub.add_parser("list", help="list notebook topics").set_defaults(func=_notebooks_list)
|
|
380
|
+
|
|
381
|
+
return parser
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main(argv: list[str] | None = None) -> int:
|
|
385
|
+
parser = build_parser()
|
|
386
|
+
args = parser.parse_args(argv)
|
|
387
|
+
return args.func(args)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
raise SystemExit(main())
|
tg_notes/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Local configuration for tg-notes.
|
|
2
|
+
|
|
3
|
+
Only secrets and pointers live here — never notes or contacts data. Stored at the XDG
|
|
4
|
+
config path as TOML with mode 600. The store's group id is kept here as the source of
|
|
5
|
+
truth for which Telegram group is the store (see docs/architecture.md, TGN-D2).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_dir() -> Path:
|
|
16
|
+
base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
|
|
17
|
+
return Path(base) / "tg-notes"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
return config_dir() / "config.toml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_session_path() -> Path:
|
|
25
|
+
return config_dir() / "tg-notes.session"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
api_id: int | None = None
|
|
31
|
+
api_hash: str | None = None
|
|
32
|
+
session_path: str | None = None
|
|
33
|
+
storage_group_id: int | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def session(self) -> str:
|
|
37
|
+
return self.session_path or str(default_session_path())
|
|
38
|
+
|
|
39
|
+
def is_configured(self) -> bool:
|
|
40
|
+
return bool(self.api_id and self.api_hash)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load() -> Config:
|
|
44
|
+
path = config_path()
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return Config()
|
|
47
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
return Config(
|
|
49
|
+
api_id=data.get("api_id"),
|
|
50
|
+
api_hash=data.get("api_hash"),
|
|
51
|
+
session_path=data.get("session_path"),
|
|
52
|
+
storage_group_id=data.get("storage_group_id"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save(cfg: Config) -> Path:
|
|
57
|
+
directory = config_dir()
|
|
58
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
path = config_path()
|
|
60
|
+
path.write_text(_dump_toml(cfg), encoding="utf-8")
|
|
61
|
+
os.chmod(path, 0o600)
|
|
62
|
+
return path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dump_toml(cfg: Config) -> str:
|
|
66
|
+
lines = ["# tg-notes local config — secrets + storage pointer. Do not commit."]
|
|
67
|
+
fields = {
|
|
68
|
+
"api_id": cfg.api_id,
|
|
69
|
+
"api_hash": cfg.api_hash,
|
|
70
|
+
"session_path": cfg.session_path,
|
|
71
|
+
"storage_group_id": cfg.storage_group_id,
|
|
72
|
+
}
|
|
73
|
+
for key, value in fields.items():
|
|
74
|
+
if value is None:
|
|
75
|
+
continue
|
|
76
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
77
|
+
lines.append(f"{key} = {value}")
|
|
78
|
+
else:
|
|
79
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
80
|
+
lines.append(f'{key} = "{escaped}"')
|
|
81
|
+
return "\n".join(lines) + "\n"
|
tg_notes/contacts.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Contact records — the tg-notes address book stored in the ``contacts`` topic.
|
|
2
|
+
|
|
3
|
+
One Telegram message per contact, formatted as a parseable block (see
|
|
4
|
+
docs/architecture.md). This module is **pure**: it only (de)serializes the block; all
|
|
5
|
+
Telegram I/O lives in :mod:`tg_notes.telegram`.
|
|
6
|
+
|
|
7
|
+
Block layout::
|
|
8
|
+
|
|
9
|
+
#contact <key>
|
|
10
|
+
name: <human name, never sent>
|
|
11
|
+
chat_id: <-100… | @username | me>
|
|
12
|
+
topic_id: <forum topic id | empty>
|
|
13
|
+
mention: <@username | empty>
|
|
14
|
+
style: <prompt: how to compile notes for this recipient>
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
|
|
21
|
+
_HEADER = re.compile(r"^#contact\s+(\S+)\s*$")
|
|
22
|
+
_TEXT_FIELDS = ("name", "chat_id", "mention", "style")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Contact:
|
|
27
|
+
"""One address-book entry. ``chat_id`` stays a string (``-100…`` / ``@user`` / ``me``)."""
|
|
28
|
+
|
|
29
|
+
key: str
|
|
30
|
+
name: str | None = None
|
|
31
|
+
chat_id: str | None = None
|
|
32
|
+
topic_id: int | None = None
|
|
33
|
+
mention: str | None = None
|
|
34
|
+
style: str | None = None
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"key": self.key,
|
|
39
|
+
"name": self.name,
|
|
40
|
+
"chat_id": self.chat_id,
|
|
41
|
+
"topic_id": self.topic_id,
|
|
42
|
+
"mention": self.mention,
|
|
43
|
+
"style": self.style,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clean(value: object) -> str:
|
|
48
|
+
"""Render a field value on a single line (newlines collapsed) so the block parses."""
|
|
49
|
+
if value is None:
|
|
50
|
+
return ""
|
|
51
|
+
return " ".join(str(value).split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def serialize(contact: Contact) -> str:
|
|
55
|
+
"""Render a :class:`Contact` as its message block."""
|
|
56
|
+
return "\n".join(
|
|
57
|
+
[
|
|
58
|
+
f"#contact {contact.key}",
|
|
59
|
+
f"name: {_clean(contact.name)}",
|
|
60
|
+
f"chat_id: {_clean(contact.chat_id)}",
|
|
61
|
+
f"topic_id: {_clean(contact.topic_id)}",
|
|
62
|
+
f"mention: {_clean(contact.mention)}",
|
|
63
|
+
f"style: {_clean(contact.style)}",
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse(text: str) -> Contact | None:
|
|
69
|
+
"""Parse a message body into a :class:`Contact`, or ``None`` if it is not one."""
|
|
70
|
+
lines = (text or "").splitlines()
|
|
71
|
+
if not lines:
|
|
72
|
+
return None
|
|
73
|
+
header = _HEADER.match(lines[0].strip())
|
|
74
|
+
if header is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
values: dict[str, str] = {}
|
|
78
|
+
for line in lines[1:]:
|
|
79
|
+
field, sep, value = line.partition(":")
|
|
80
|
+
if not sep:
|
|
81
|
+
continue
|
|
82
|
+
field = field.strip()
|
|
83
|
+
if field in _TEXT_FIELDS or field == "topic_id":
|
|
84
|
+
values[field] = value.strip()
|
|
85
|
+
|
|
86
|
+
topic_id: int | None = None
|
|
87
|
+
raw_topic = values.get("topic_id")
|
|
88
|
+
if raw_topic:
|
|
89
|
+
try:
|
|
90
|
+
topic_id = int(raw_topic)
|
|
91
|
+
except ValueError:
|
|
92
|
+
topic_id = None
|
|
93
|
+
|
|
94
|
+
return Contact(
|
|
95
|
+
key=header.group(1),
|
|
96
|
+
name=values.get("name") or None,
|
|
97
|
+
chat_id=values.get("chat_id") or None,
|
|
98
|
+
topic_id=topic_id,
|
|
99
|
+
mention=values.get("mention") or None,
|
|
100
|
+
style=values.get("style") or None,
|
|
101
|
+
)
|