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 ADDED
@@ -0,0 +1,4 @@
1
+ """tg_notes — take notes into a private Telegram group and publish tailored updates
2
+ under your own account. See docs/architecture.md."""
3
+
4
+ __version__ = "0.1.0"
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
+ )