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/telegram.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""Telegram client layer for tg-notes.
|
|
2
|
+
|
|
3
|
+
Thin, deterministic wrapper around Telethon (MTProto). Importing ``telethon.sync`` first
|
|
4
|
+
turns the async client methods into blocking calls, so the rest of this module — and the
|
|
5
|
+
CLI on top of it — stays synchronous and easy to reason about. All Telegram I/O in the
|
|
6
|
+
project funnels through here; no AI logic lives in this layer.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
import telethon.sync # noqa: F401 # enables synchronous TelegramClient methods
|
|
13
|
+
from telethon import TelegramClient, utils
|
|
14
|
+
from telethon.tl.functions.channels import CreateChannelRequest
|
|
15
|
+
from telethon.tl.functions.messages import (
|
|
16
|
+
CreateForumTopicRequest,
|
|
17
|
+
GetForumTopicsRequest,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from . import contacts as contacts_mod
|
|
21
|
+
from .config import Config
|
|
22
|
+
|
|
23
|
+
#: Fixed title for the storage supergroup — part of the recovery tag (docs/architecture).
|
|
24
|
+
STORAGE_TITLE = "tg-notes storage"
|
|
25
|
+
#: About text; explains what the group is and warns against renaming it.
|
|
26
|
+
STORAGE_ABOUT = "tg-notes note store — managed by the tg-notes CLI. Do not rename."
|
|
27
|
+
#: Marker embedded in the pinned message so a lost store can be re-discovered (TGN-D2).
|
|
28
|
+
STORAGE_MARKER = "tg-notes-store:v1"
|
|
29
|
+
#: The address-book topic every store has.
|
|
30
|
+
CONTACTS_TOPIC = "contacts"
|
|
31
|
+
#: The notebook topic created by default when none is named.
|
|
32
|
+
DEFAULT_NOTEBOOK = "daily"
|
|
33
|
+
#: Topics that are not notebooks (the forum default and the address book).
|
|
34
|
+
RESERVED_TOPICS = frozenset({"General", CONTACTS_TOPIC})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotConfiguredError(Exception):
|
|
38
|
+
"""Raised when ``api_id`` / ``api_hash`` are missing from local config."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotAuthorizedError(Exception):
|
|
42
|
+
"""Raised when the local session is not logged in (run ``tg-notes login``)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NotSetUpError(Exception):
|
|
46
|
+
"""Raised when no storage group is configured/resolvable (run ``tg-notes setup``)."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ContactNotFoundError(Exception):
|
|
50
|
+
"""Raised when ``send`` targets a contact key absent from the address book."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_client(cfg: Config) -> TelegramClient:
|
|
54
|
+
"""Construct a Telethon client from local config (does not connect).
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
NotConfiguredError: if ``cfg`` has no ``api_id`` / ``api_hash``.
|
|
58
|
+
"""
|
|
59
|
+
if not cfg.is_configured():
|
|
60
|
+
raise NotConfiguredError(
|
|
61
|
+
"api_id/api_hash are not set — configure tg-notes before connecting"
|
|
62
|
+
)
|
|
63
|
+
return TelegramClient(cfg.session, cfg.api_id, cfg.api_hash)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def connect_authorized(cfg: Config) -> TelegramClient:
|
|
67
|
+
"""Return a connected, authorized client.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
NotConfiguredError: if the credentials are missing.
|
|
71
|
+
NotAuthorizedError: if the session exists but is not logged in.
|
|
72
|
+
"""
|
|
73
|
+
client = build_client(cfg)
|
|
74
|
+
client.connect()
|
|
75
|
+
if not client.is_user_authorized():
|
|
76
|
+
client.disconnect() # do not leak a connected client on the failure path
|
|
77
|
+
raise NotAuthorizedError(
|
|
78
|
+
"not logged in — run `tg-notes login` to authorize this session"
|
|
79
|
+
)
|
|
80
|
+
return client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def whoami(cfg: Config) -> dict:
|
|
84
|
+
"""Return the logged-in account identity, then disconnect.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
NotConfiguredError / NotAuthorizedError: as in :func:`connect_authorized`.
|
|
88
|
+
"""
|
|
89
|
+
client = connect_authorized(cfg)
|
|
90
|
+
try:
|
|
91
|
+
me = client.get_me()
|
|
92
|
+
return {"id": me.id, "username": me.username, "first_name": me.first_name}
|
|
93
|
+
finally:
|
|
94
|
+
client.disconnect()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login(cfg: Config) -> dict:
|
|
98
|
+
"""Run the one-time interactive login and return the account identity.
|
|
99
|
+
|
|
100
|
+
``client.start()`` prompts for phone number, the login code, and 2FA password as
|
|
101
|
+
needed, then writes the ``*.session`` file (full account access — locked to 0o600).
|
|
102
|
+
Kept thin and side-effectful on purpose; the unit tests cover the pieces below it.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
NotConfiguredError: if the credentials are missing.
|
|
106
|
+
"""
|
|
107
|
+
session_file = cfg.session
|
|
108
|
+
if not session_file.endswith(".session"):
|
|
109
|
+
session_file += ".session"
|
|
110
|
+
parent = os.path.dirname(session_file)
|
|
111
|
+
if parent:
|
|
112
|
+
os.makedirs(parent, exist_ok=True)
|
|
113
|
+
os.chmod(parent, 0o700)
|
|
114
|
+
|
|
115
|
+
client = build_client(cfg)
|
|
116
|
+
old_umask = os.umask(0o077) # session file must be created private (0600), no race window
|
|
117
|
+
try:
|
|
118
|
+
client.start() # interactive: phone / code / 2FA
|
|
119
|
+
me = client.get_me()
|
|
120
|
+
if os.path.exists(session_file):
|
|
121
|
+
os.chmod(session_file, 0o600)
|
|
122
|
+
return {"id": me.id, "username": me.username, "first_name": me.first_name}
|
|
123
|
+
finally:
|
|
124
|
+
os.umask(old_umask)
|
|
125
|
+
client.disconnect()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def setup(cfg: Config, notebook: str = DEFAULT_NOTEBOOK) -> dict:
|
|
129
|
+
"""Provision (or attach to) the storage supergroup and its topics (TGN-3).
|
|
130
|
+
|
|
131
|
+
Idempotent: if ``cfg.storage_group_id`` points at a resolvable group the store is
|
|
132
|
+
attached and reused; otherwise a fresh forum supergroup is created and tagged with a
|
|
133
|
+
pinned recovery marker. Either way the ``contacts`` topic and the given ``notebook``
|
|
134
|
+
topic are ensured to exist. Persistence of the resolved id is the caller's job — this
|
|
135
|
+
layer only does Telegram I/O and returns a summary.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
``{"group_id": int, "created": bool, "title": str, "topics": {name: id}}``.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
NotConfiguredError / NotAuthorizedError: as in :func:`connect_authorized`.
|
|
142
|
+
"""
|
|
143
|
+
client = connect_authorized(cfg)
|
|
144
|
+
try:
|
|
145
|
+
entity, created = _resolve_or_create(client, cfg)
|
|
146
|
+
topics = _ensure_topics(client, entity, [CONTACTS_TOPIC, notebook])
|
|
147
|
+
return {
|
|
148
|
+
"group_id": utils.get_peer_id(entity),
|
|
149
|
+
"created": created,
|
|
150
|
+
"title": STORAGE_TITLE,
|
|
151
|
+
"topics": topics,
|
|
152
|
+
}
|
|
153
|
+
finally:
|
|
154
|
+
client.disconnect()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def note_add(
|
|
158
|
+
cfg: Config, notebook: str, text: str, hashtags: list[str] | None = None
|
|
159
|
+
) -> dict:
|
|
160
|
+
"""Append a note to a notebook topic, creating the topic on demand (TGN-4).
|
|
161
|
+
|
|
162
|
+
Returns ``{"notebook", "topic_id", "message_id", "date"}`` for the posted note.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
NotSetUpError: if no storage group is configured or it no longer resolves.
|
|
166
|
+
NotConfiguredError / NotAuthorizedError: as in :func:`connect_authorized`.
|
|
167
|
+
ValueError: if the composed note is empty.
|
|
168
|
+
"""
|
|
169
|
+
if not cfg.storage_group_id:
|
|
170
|
+
raise NotSetUpError(
|
|
171
|
+
"no storage group configured — run `tg-notes setup` first"
|
|
172
|
+
)
|
|
173
|
+
body = _compose_note(text, hashtags)
|
|
174
|
+
if not body:
|
|
175
|
+
raise ValueError("refusing to post an empty note")
|
|
176
|
+
|
|
177
|
+
client = connect_authorized(cfg)
|
|
178
|
+
try:
|
|
179
|
+
entity = _resolve_store(client, cfg)
|
|
180
|
+
topic_id = _ensure_topics(client, entity, [notebook])[notebook]
|
|
181
|
+
message = client.send_message(entity, body, reply_to=topic_id)
|
|
182
|
+
return {
|
|
183
|
+
"notebook": notebook,
|
|
184
|
+
"topic_id": topic_id,
|
|
185
|
+
"message_id": message.id,
|
|
186
|
+
"date": _message_date_iso(message),
|
|
187
|
+
}
|
|
188
|
+
finally:
|
|
189
|
+
client.disconnect()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def notes_list(cfg: Config, notebook: str, since: object | None = None) -> list[dict]:
|
|
193
|
+
"""Return the raw notes of a notebook topic, oldest first (TGN-5).
|
|
194
|
+
|
|
195
|
+
Feeds compilation: each note is ``{"message_id", "date", "text"}``. Service/empty
|
|
196
|
+
messages (e.g. the topic opener) are skipped. ``since`` is an optional lower bound on
|
|
197
|
+
the message date (a timezone-aware ``datetime``). An unknown notebook yields ``[]``.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
NotSetUpError: if no storage group is configured or it no longer resolves.
|
|
201
|
+
NotConfiguredError / NotAuthorizedError: as in :func:`connect_authorized`.
|
|
202
|
+
"""
|
|
203
|
+
if not cfg.storage_group_id:
|
|
204
|
+
raise NotSetUpError(
|
|
205
|
+
"no storage group configured — run `tg-notes setup` first"
|
|
206
|
+
)
|
|
207
|
+
client = connect_authorized(cfg)
|
|
208
|
+
try:
|
|
209
|
+
entity = _resolve_store(client, cfg)
|
|
210
|
+
topic_id = _list_topics(client, entity).get(notebook)
|
|
211
|
+
if topic_id is None:
|
|
212
|
+
return [] # notebook never created → no notes, and nothing to fetch
|
|
213
|
+
notes = []
|
|
214
|
+
for message in client.iter_messages(entity, reply_to=topic_id):
|
|
215
|
+
if since is not None and message.date is not None and message.date < since:
|
|
216
|
+
continue
|
|
217
|
+
if not message.text:
|
|
218
|
+
continue # skip the topic-opening service message and any empty ones
|
|
219
|
+
notes.append(
|
|
220
|
+
{
|
|
221
|
+
"message_id": message.id,
|
|
222
|
+
"date": _message_date_iso(message),
|
|
223
|
+
"text": message.text,
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
notes.sort(key=lambda note: note["message_id"]) # oldest first
|
|
227
|
+
return notes
|
|
228
|
+
finally:
|
|
229
|
+
client.disconnect()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def notebooks_list(cfg: Config) -> list[dict]:
|
|
233
|
+
"""Return the storage group's notebook topics as ``{name, topic_id}``, sorted (TGN-8).
|
|
234
|
+
|
|
235
|
+
Excludes the reserved topics (``General``, ``contacts``) — only real notebooks.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
NotSetUpError / NotConfiguredError / NotAuthorizedError: store/auth problems.
|
|
239
|
+
"""
|
|
240
|
+
if not cfg.storage_group_id:
|
|
241
|
+
raise NotSetUpError("no storage group configured — run `tg-notes setup` first")
|
|
242
|
+
client = connect_authorized(cfg)
|
|
243
|
+
try:
|
|
244
|
+
entity = _resolve_store(client, cfg)
|
|
245
|
+
notebooks = [
|
|
246
|
+
{"name": name, "topic_id": topic_id}
|
|
247
|
+
for name, topic_id in _list_topics(client, entity).items()
|
|
248
|
+
if name not in RESERVED_TOPICS
|
|
249
|
+
]
|
|
250
|
+
notebooks.sort(key=lambda entry: entry["name"])
|
|
251
|
+
return notebooks
|
|
252
|
+
finally:
|
|
253
|
+
client.disconnect()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def contacts_list(cfg: Config) -> list[dict]:
|
|
257
|
+
"""Return the address book (one entry per contact message), sorted by key (TGN-6)."""
|
|
258
|
+
if not cfg.storage_group_id:
|
|
259
|
+
raise NotSetUpError("no storage group configured — run `tg-notes setup` first")
|
|
260
|
+
client = connect_authorized(cfg)
|
|
261
|
+
try:
|
|
262
|
+
entity = _resolve_store(client, cfg)
|
|
263
|
+
topic_id = _list_topics(client, entity).get(CONTACTS_TOPIC)
|
|
264
|
+
if topic_id is None:
|
|
265
|
+
return []
|
|
266
|
+
found = []
|
|
267
|
+
for message in client.iter_messages(entity, reply_to=topic_id):
|
|
268
|
+
contact = contacts_mod.parse(message.text or "")
|
|
269
|
+
if contact is not None:
|
|
270
|
+
found.append(contact.to_dict())
|
|
271
|
+
found.sort(key=lambda entry: entry["key"])
|
|
272
|
+
return found
|
|
273
|
+
finally:
|
|
274
|
+
client.disconnect()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def contacts_set(
|
|
278
|
+
cfg: Config,
|
|
279
|
+
key: str,
|
|
280
|
+
*,
|
|
281
|
+
chat_id: str | None = None,
|
|
282
|
+
name: str | None = None,
|
|
283
|
+
topic_id: int | None = None,
|
|
284
|
+
mention: str | None = None,
|
|
285
|
+
style: str | None = None,
|
|
286
|
+
) -> dict:
|
|
287
|
+
"""Create or update a contact (TGN-6).
|
|
288
|
+
|
|
289
|
+
Only the provided fields override an existing record; a brand-new contact needs a
|
|
290
|
+
``chat_id``. Returns the stored contact plus ``"created"`` (True when newly added).
|
|
291
|
+
"""
|
|
292
|
+
if not cfg.storage_group_id:
|
|
293
|
+
raise NotSetUpError("no storage group configured — run `tg-notes setup` first")
|
|
294
|
+
client = connect_authorized(cfg)
|
|
295
|
+
try:
|
|
296
|
+
entity = _resolve_store(client, cfg)
|
|
297
|
+
contacts_topic = _ensure_topics(client, entity, [CONTACTS_TOPIC])[CONTACTS_TOPIC]
|
|
298
|
+
message, existing = _find_contact(client, entity, contacts_topic, key)
|
|
299
|
+
merged = _merge_contact(
|
|
300
|
+
key, existing, chat_id=chat_id, name=name, topic_id=topic_id,
|
|
301
|
+
mention=mention, style=style,
|
|
302
|
+
)
|
|
303
|
+
text = contacts_mod.serialize(merged)
|
|
304
|
+
if message is not None:
|
|
305
|
+
client.edit_message(entity, message.id, text)
|
|
306
|
+
created = False
|
|
307
|
+
else:
|
|
308
|
+
client.send_message(entity, text, reply_to=contacts_topic)
|
|
309
|
+
created = True
|
|
310
|
+
return {**merged.to_dict(), "created": created}
|
|
311
|
+
finally:
|
|
312
|
+
client.disconnect()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def contacts_remove(cfg: Config, key: str) -> dict:
|
|
316
|
+
"""Delete a contact's message. Returns ``{"key", "removed"}`` (TGN-6)."""
|
|
317
|
+
if not cfg.storage_group_id:
|
|
318
|
+
raise NotSetUpError("no storage group configured — run `tg-notes setup` first")
|
|
319
|
+
client = connect_authorized(cfg)
|
|
320
|
+
try:
|
|
321
|
+
entity = _resolve_store(client, cfg)
|
|
322
|
+
contacts_topic = _list_topics(client, entity).get(CONTACTS_TOPIC)
|
|
323
|
+
if contacts_topic is None:
|
|
324
|
+
return {"key": key, "removed": False}
|
|
325
|
+
message, _ = _find_contact(client, entity, contacts_topic, key)
|
|
326
|
+
if message is None:
|
|
327
|
+
return {"key": key, "removed": False}
|
|
328
|
+
client.delete_messages(entity, [message.id])
|
|
329
|
+
return {"key": key, "removed": True}
|
|
330
|
+
finally:
|
|
331
|
+
client.disconnect()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def send(cfg: Config, contact_key: str, text: str, *, dry_run: bool = False) -> dict:
|
|
335
|
+
"""Post ``text`` to a contact's chat/topic, as the user (TGN-7).
|
|
336
|
+
|
|
337
|
+
Looks the contact up in the address book, prepends its ``mention`` (if any), and posts
|
|
338
|
+
into its ``chat_id`` — into a forum topic when the contact has a ``topic_id``. With
|
|
339
|
+
``dry_run`` the message is composed and returned but nothing is sent (no target lookup).
|
|
340
|
+
|
|
341
|
+
Returns ``{"contact", "chat_id", "topic_id", "text", "sent", ...}``; a real send also
|
|
342
|
+
includes ``message_id`` and ``date``.
|
|
343
|
+
|
|
344
|
+
Raises:
|
|
345
|
+
NotSetUpError / NotConfiguredError / NotAuthorizedError: store/auth problems.
|
|
346
|
+
ContactNotFoundError: if ``contact_key`` is not in the address book.
|
|
347
|
+
ValueError: if ``text`` is empty.
|
|
348
|
+
"""
|
|
349
|
+
if not cfg.storage_group_id:
|
|
350
|
+
raise NotSetUpError("no storage group configured — run `tg-notes setup` first")
|
|
351
|
+
if not text.strip():
|
|
352
|
+
raise ValueError("refusing to send an empty message")
|
|
353
|
+
|
|
354
|
+
client = connect_authorized(cfg)
|
|
355
|
+
try:
|
|
356
|
+
store = _resolve_store(client, cfg)
|
|
357
|
+
contacts_topic = _list_topics(client, store).get(CONTACTS_TOPIC)
|
|
358
|
+
contact = None
|
|
359
|
+
if contacts_topic is not None:
|
|
360
|
+
_, contact = _find_contact(client, store, contacts_topic, contact_key)
|
|
361
|
+
if contact is None:
|
|
362
|
+
raise ContactNotFoundError(
|
|
363
|
+
f"no contact '{contact_key}' — add it with `tg-notes contacts set`"
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
body = _compose_outgoing(text, contact.mention)
|
|
367
|
+
result = {
|
|
368
|
+
"contact": contact_key,
|
|
369
|
+
"chat_id": contact.chat_id,
|
|
370
|
+
"topic_id": contact.topic_id,
|
|
371
|
+
"text": body,
|
|
372
|
+
}
|
|
373
|
+
if dry_run:
|
|
374
|
+
result["sent"] = False
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
target = client.get_entity(_target_from_chat_id(contact.chat_id))
|
|
378
|
+
message = client.send_message(target, body, reply_to=contact.topic_id)
|
|
379
|
+
result.update(
|
|
380
|
+
sent=True, message_id=message.id, date=_message_date_iso(message)
|
|
381
|
+
)
|
|
382
|
+
return result
|
|
383
|
+
finally:
|
|
384
|
+
client.disconnect()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _compose_outgoing(text: str, mention: str | None) -> str:
|
|
388
|
+
"""Trim the body and prepend the contact's mention on its own line, if set."""
|
|
389
|
+
body = text.strip()
|
|
390
|
+
return f"{mention}\n\n{body}" if mention else body
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _target_from_chat_id(chat_id: str):
|
|
394
|
+
"""Coerce a stored ``chat_id`` to what ``get_entity`` expects (int id, or @user / me)."""
|
|
395
|
+
text = str(chat_id).strip()
|
|
396
|
+
try:
|
|
397
|
+
return int(text)
|
|
398
|
+
except ValueError:
|
|
399
|
+
return text
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _find_contact(client, entity, contacts_topic, key):
|
|
403
|
+
"""Return ``(message, Contact)`` for ``key`` in the contacts topic, or ``(None, None)``."""
|
|
404
|
+
for message in client.iter_messages(entity, reply_to=contacts_topic):
|
|
405
|
+
contact = contacts_mod.parse(message.text or "")
|
|
406
|
+
if contact is not None and contact.key == key:
|
|
407
|
+
return message, contact
|
|
408
|
+
return None, None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _merge_contact(key, existing, *, chat_id, name, topic_id, mention, style):
|
|
412
|
+
"""Overlay the provided fields onto ``existing`` (or a blank record) for ``key``."""
|
|
413
|
+
base = existing or contacts_mod.Contact(key=key)
|
|
414
|
+
merged = contacts_mod.Contact(
|
|
415
|
+
key=key,
|
|
416
|
+
name=name if name is not None else base.name,
|
|
417
|
+
chat_id=chat_id if chat_id is not None else base.chat_id,
|
|
418
|
+
topic_id=topic_id if topic_id is not None else base.topic_id,
|
|
419
|
+
mention=mention if mention is not None else base.mention,
|
|
420
|
+
style=style if style is not None else base.style,
|
|
421
|
+
)
|
|
422
|
+
if not merged.chat_id:
|
|
423
|
+
raise ValueError("a new contact needs --chat-id (where to send)")
|
|
424
|
+
return merged
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _resolve_store(client: TelegramClient, cfg: Config) -> object:
|
|
428
|
+
"""Resolve the configured storage group, or raise :class:`NotSetUpError`."""
|
|
429
|
+
try:
|
|
430
|
+
return client.get_entity(cfg.storage_group_id)
|
|
431
|
+
except (ValueError, TypeError) as exc:
|
|
432
|
+
raise NotSetUpError(
|
|
433
|
+
"configured storage group not found — run `tg-notes setup`"
|
|
434
|
+
) from exc
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _compose_note(text: str, hashtags: list[str] | None = None) -> str:
|
|
438
|
+
"""Trim the note body and append normalized hashtags on a trailing blank line."""
|
|
439
|
+
body = text.strip()
|
|
440
|
+
tags = [_normalize_hashtag(h) for h in (hashtags or []) if h.strip()]
|
|
441
|
+
if not tags:
|
|
442
|
+
return body
|
|
443
|
+
tag_line = " ".join(tags)
|
|
444
|
+
return f"{body}\n\n{tag_line}" if body else tag_line
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _normalize_hashtag(raw: str) -> str:
|
|
448
|
+
"""Return ``raw`` as a single ``#tag`` token (strip, drop leading ``#``, re-prefix)."""
|
|
449
|
+
return "#" + raw.strip().lstrip("#")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _message_date_iso(message: object) -> str | None:
|
|
453
|
+
"""ISO-8601 timestamp of a sent message, or ``None`` if it carries no date."""
|
|
454
|
+
date = getattr(message, "date", None)
|
|
455
|
+
return date.isoformat() if date is not None else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _resolve_or_create(client: TelegramClient, cfg: Config) -> tuple[object, bool]:
|
|
459
|
+
"""Return ``(entity, created)`` — reuse the configured store or create a new one."""
|
|
460
|
+
if cfg.storage_group_id:
|
|
461
|
+
try:
|
|
462
|
+
return client.get_entity(cfg.storage_group_id), False
|
|
463
|
+
except (ValueError, TypeError):
|
|
464
|
+
pass # configured id no longer resolves → fall through and create a new store
|
|
465
|
+
channel = _create_storage_group(client)
|
|
466
|
+
_pin_marker(client, channel)
|
|
467
|
+
return channel, True
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _create_storage_group(client: TelegramClient) -> object:
|
|
471
|
+
"""Create the private forum supergroup and return its channel entity."""
|
|
472
|
+
result = client(
|
|
473
|
+
CreateChannelRequest(
|
|
474
|
+
title=STORAGE_TITLE,
|
|
475
|
+
about=STORAGE_ABOUT,
|
|
476
|
+
megagroup=True,
|
|
477
|
+
forum=True,
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
return result.chats[0]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _pin_marker(client: TelegramClient, entity: object) -> object:
|
|
484
|
+
"""Post and pin the recovery marker in the store's General topic."""
|
|
485
|
+
text = (
|
|
486
|
+
f"{STORAGE_MARKER}\n\n"
|
|
487
|
+
"Storage group for the tg-notes CLI. This pinned marker lets the tool "
|
|
488
|
+
"re-discover the group if local config is lost. Do not unpin or delete."
|
|
489
|
+
)
|
|
490
|
+
message = client.send_message(entity, text)
|
|
491
|
+
client.pin_message(entity, message)
|
|
492
|
+
return message
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _list_topics(client: TelegramClient, entity: object) -> dict[str, int]:
|
|
496
|
+
"""Return a ``{title: topic_id}`` map of the store's forum topics."""
|
|
497
|
+
result = client(
|
|
498
|
+
GetForumTopicsRequest(
|
|
499
|
+
peer=entity, offset_date=None, offset_id=0, offset_topic=0, limit=100
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
return {topic.title: topic.id for topic in result.topics}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _create_topic(client: TelegramClient, entity: object, title: str) -> int:
|
|
506
|
+
"""Create a forum topic and return its id."""
|
|
507
|
+
updates = client(CreateForumTopicRequest(peer=entity, title=title))
|
|
508
|
+
return _topic_id_from_updates(updates)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _topic_id_from_updates(updates: object) -> int:
|
|
512
|
+
"""Extract the new topic's id from a ``CreateForumTopicRequest`` response.
|
|
513
|
+
|
|
514
|
+
A forum topic's id is the id of the service message that opens it; scan the returned
|
|
515
|
+
updates for the first one carrying a message.
|
|
516
|
+
"""
|
|
517
|
+
for update in getattr(updates, "updates", []) or []:
|
|
518
|
+
message = getattr(update, "message", None)
|
|
519
|
+
message_id = getattr(message, "id", None)
|
|
520
|
+
if isinstance(message_id, int):
|
|
521
|
+
return message_id
|
|
522
|
+
raise RuntimeError("could not determine the created topic id from the server response")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _ensure_topics(
|
|
526
|
+
client: TelegramClient, entity: object, names: list[str]
|
|
527
|
+
) -> dict[str, int]:
|
|
528
|
+
"""Ensure each named topic exists; create the missing ones. Returns their ids."""
|
|
529
|
+
existing = _list_topics(client, entity)
|
|
530
|
+
missing = [name for name in names if name not in existing]
|
|
531
|
+
for name in missing:
|
|
532
|
+
_create_topic(client, entity, name)
|
|
533
|
+
if missing:
|
|
534
|
+
existing = _list_topics(client, entity) # re-list to pick up the new topic ids
|
|
535
|
+
return {name: existing[name] for name in names}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-notes
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Notes → compile → publish, using a private Telegram group as the store and posting under your own account.
|
|
5
|
+
Project-URL: Homepage, https://github.com/korkin25/tg_notes
|
|
6
|
+
Project-URL: Repository, https://github.com/korkin25/tg_notes
|
|
7
|
+
Author: korkin25
|
|
8
|
+
License-Expression: GPL-3.0-or-later
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,notes,telegram,telethon,userbot
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Communications :: Chat
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: telethon>=1.44
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: bandit; extra == 'dev'
|
|
18
|
+
Requires-Dist: build; extra == 'dev'
|
|
19
|
+
Requires-Dist: pip-audit; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# tg_notes
|
|
26
|
+
|
|
27
|
+
Turn a private Telegram group into your notes store, then publish tailored updates
|
|
28
|
+
**under your own account**. Capture notes from anywhere — any AI-agent chat, the CLI,
|
|
29
|
+
or your phone — and compile a subset into a per-recipient view that gets posted as
|
|
30
|
+
you, into a real chat or forum topic. Daily work reports are one built-in preset.
|
|
31
|
+
|
|
32
|
+
> **Status: planning.** No implementation yet. See [TODO.md](TODO.md) for the plan and
|
|
33
|
+
> [docs/architecture.md](docs/architecture.md) for the design.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
- **Your notes live in Telegram** — private, synced, reachable from your phone.
|
|
38
|
+
Nothing is stored in local files.
|
|
39
|
+
- **Posts go out as you.** Delivery uses the Telegram client API (userbot), so an
|
|
40
|
+
update can land in a real work group or topic under your own name, not a bot's.
|
|
41
|
+
- **Small CLI, portable Skill.** A single CLI does the Telegram work; AI-agent Skills
|
|
42
|
+
(Claude Code first) drive it and do the writing/summarizing. The Skill is meant to
|
|
43
|
+
be reused across other agent runtimes.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- Notes stored in a private Telegram forum group; one topic per notebook.
|
|
48
|
+
- A dedicated contacts topic acts as the address book (one message per contact).
|
|
49
|
+
- Compile a subset of notes into a recipient-specific view and post it as you.
|
|
50
|
+
- Per-contact style (e.g. verbatim technical for a lead, simplified for a manager).
|
|
51
|
+
- Post to a plain chat or a specific forum topic; optional mention.
|
|
52
|
+
- Daily work-report preset.
|
|
53
|
+
|
|
54
|
+
Full list: [docs/features.md](docs/features.md).
|
|
55
|
+
|
|
56
|
+
## How it works
|
|
57
|
+
|
|
58
|
+
A private **forum supergroup** is the store:
|
|
59
|
+
|
|
60
|
+
- `contacts` topic — the address book (chat/topic/style per recipient).
|
|
61
|
+
- one topic per **notebook** — the raw notes for a stream (project, audience, …).
|
|
62
|
+
|
|
63
|
+
The `tg-notes` CLI creates/attaches the group, appends notes, lists them for an agent
|
|
64
|
+
to compile, and publishes the result. See [docs/architecture.md](docs/architecture.md).
|
|
65
|
+
|
|
66
|
+
## Install & usage
|
|
67
|
+
|
|
68
|
+
Two pieces: the **CLI** (does the Telegram I/O) and the **Claude Code plugin** (the skills
|
|
69
|
+
that drive it).
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# CLI — from PyPI (once published; see CHANGELOG for release status):
|
|
73
|
+
pipx install tg-notes
|
|
74
|
+
|
|
75
|
+
# Plugin (bundles the tg-notes and tg-notes-send skills) — from this git marketplace:
|
|
76
|
+
# /plugin marketplace add korkin25/tg_notes
|
|
77
|
+
# /plugin install tg-notes@tg-notes-marketplace
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The skills drive the CLI, so `tg-notes` must be on `PATH`. The two skills:
|
|
81
|
+
- [`skills/tg-notes/`](skills/tg-notes/SKILL.md) — **capture**: compose a note (verbatim or
|
|
82
|
+
a summary of real session facts) and file it with `tg-notes note add`.
|
|
83
|
+
- [`skills/tg-notes-send/`](skills/tg-notes-send/SKILL.md) — **compile & send**: rewrite
|
|
84
|
+
stored notes per a contact's style, confirm, and publish with `tg-notes send` (daily
|
|
85
|
+
report is a preset).
|
|
86
|
+
|
|
87
|
+
Available so far:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# One-time: create (or attach to) the private forum supergroup that stores your notes.
|
|
91
|
+
# On first run it walks you through onboarding — prompts for your api_id/api_hash
|
|
92
|
+
# (get them at https://my.telegram.org), saves them to local config (chmod 600), and
|
|
93
|
+
# runs the interactive login (phone → code → 2FA). Then it ensures the `contacts` topic
|
|
94
|
+
# and a default `daily` notebook, pins a recovery marker, and saves the group id.
|
|
95
|
+
# Idempotent — safe to re-run.
|
|
96
|
+
tg-notes setup [--notebook <name>]
|
|
97
|
+
|
|
98
|
+
# Append a note to a notebook topic (creates the topic on demand). Read the text from a
|
|
99
|
+
# file or from stdin (-); attach optional #hashtags. Prints the posted note as JSON.
|
|
100
|
+
tg-notes note add --notebook daily --text-file note.txt --hashtag infra
|
|
101
|
+
echo "quick note" | tg-notes note add --text-file -
|
|
102
|
+
|
|
103
|
+
# List the raw notes of a notebook (oldest first) as JSON, optionally bounded by --since
|
|
104
|
+
# (today | HH:MM | YYYY-MM-DD | ISO datetime; local when no offset). Feeds compilation.
|
|
105
|
+
tg-notes notes list --notebook daily --since today
|
|
106
|
+
|
|
107
|
+
# Address book (one message per contact in the `contacts` topic). `set` creates or
|
|
108
|
+
# updates (only the given fields change; a new contact needs --chat-id). List/remove
|
|
109
|
+
# print JSON. chat_id is -100… | @username | me.
|
|
110
|
+
tg-notes contacts set boss --chat-id @alice --name Alice --style "verbatim technical"
|
|
111
|
+
tg-notes contacts list
|
|
112
|
+
tg-notes contacts remove boss
|
|
113
|
+
|
|
114
|
+
# Publish compiled text to a contact's chat, as you — into a forum topic when the contact
|
|
115
|
+
# has a topic_id, prepending its mention if set. Text from a file or stdin (-). Use
|
|
116
|
+
# --dry-run to print exactly what would be sent without sending.
|
|
117
|
+
tg-notes send --contact boss --text-file report.txt
|
|
118
|
+
echo "..." | tg-notes send --contact boss --text-file - --dry-run
|
|
119
|
+
|
|
120
|
+
# List the storage group's notebook topics as JSON (excludes General/contacts).
|
|
121
|
+
tg-notes notebooks list
|
|
122
|
+
|
|
123
|
+
# Log in on its own (setup calls this for you; useful to re-authorize a new device).
|
|
124
|
+
# Requires api_id/api_hash in local config; writes a chmod-600 session file.
|
|
125
|
+
tg-notes login
|
|
126
|
+
|
|
127
|
+
# Print the logged-in account identity (id / username / first name).
|
|
128
|
+
tg-notes whoami
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Security & Telegram ToS
|
|
132
|
+
|
|
133
|
+
- Userbot automation is a **gray area of Telegram's ToS**; you run it on your own
|
|
134
|
+
account at your own risk. The tool only publishes your own notes and reports.
|
|
135
|
+
- The Telethon session file grants **full access to your account**: it is `chmod 600`
|
|
136
|
+
and never committed. `api_id`/`api_hash` and local config stay on your machine.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
[GPL-3.0](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
tg_notes/__init__.py,sha256=l-F-FLjhEL10DmhvV6JBuNqErpjKK26ws1MK9hyoF_c,162
|
|
2
|
+
tg_notes/cli.py,sha256=Fcq92YbN4DntxSYwN2W75ZvYfjFELDaAl7nfuRW9Lp8,14230
|
|
3
|
+
tg_notes/config.py,sha256=zRk-bdjos8RI75_6As42A9MKvU5Vox0tAl6x10sJXBA,2331
|
|
4
|
+
tg_notes/contacts.py,sha256=qpvc5B49592cPAlgCarGlPgW2OzGI0fK6FYKxJobwpw,2941
|
|
5
|
+
tg_notes/telegram.py,sha256=Pq6lMZwkKWTOZognT7vryMvSDYoDWOi9a4UefKf29bk,20413
|
|
6
|
+
tg_notes-0.1.0.dist-info/METADATA,sha256=xQRQG_u6JMH1-Rhit-i9o5EUvdERphryjIj15YgFwDs,6176
|
|
7
|
+
tg_notes-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
tg_notes-0.1.0.dist-info/entry_points.txt,sha256=9Bpvd7xt85uE0XBsdisxTV7czZY4uFenDsKqservIGo,47
|
|
9
|
+
tg_notes-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
10
|
+
tg_notes-0.1.0.dist-info/RECORD,,
|