tgdata 0.0.7__tar.gz → 0.0.9__tar.gz

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.
Files changed (61) hide show
  1. tgdata-0.0.9/PKG-INFO +1067 -0
  2. tgdata-0.0.9/README.md +1038 -0
  3. {tgdata-0.0.7 → tgdata-0.0.9}/setup.py +9 -5
  4. tgdata-0.0.9/tgdata/__init__.py +108 -0
  5. tgdata-0.0.9/tgdata/batch_engine.py +201 -0
  6. tgdata-0.0.9/tgdata/batch_files.py +179 -0
  7. tgdata-0.0.9/tgdata/budget_client.py +155 -0
  8. tgdata-0.0.9/tgdata/connection_engine.py +986 -0
  9. tgdata-0.0.9/tgdata/discovery_engine.py +630 -0
  10. tgdata-0.0.9/tgdata/group_operations.py +328 -0
  11. tgdata-0.0.9/tgdata/health.py +669 -0
  12. tgdata-0.0.9/tgdata/join_budget.py +213 -0
  13. tgdata-0.0.9/tgdata/join_client.py +70 -0
  14. tgdata-0.0.9/tgdata/message_batch.py +296 -0
  15. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/message_engine.py +43 -3
  16. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/models.py +45 -2
  17. tgdata-0.0.9/tgdata/read_budget.py +322 -0
  18. tgdata-0.0.9/tgdata/session_store.py +211 -0
  19. tgdata-0.0.9/tgdata/smoke_tests/test_11_discover_groups.py +224 -0
  20. tgdata-0.0.9/tgdata/smoke_tests/test_12_proxy.py +367 -0
  21. tgdata-0.0.9/tgdata/smoke_tests/test_13_device_identity.py +221 -0
  22. tgdata-0.0.9/tgdata/smoke_tests/test_14_flood_threshold.py +468 -0
  23. tgdata-0.0.9/tgdata/smoke_tests/test_15_login_checks.py +340 -0
  24. tgdata-0.0.9/tgdata/smoke_tests/test_16_health_events.py +977 -0
  25. tgdata-0.0.9/tgdata/smoke_tests/test_17_session_store.py +602 -0
  26. tgdata-0.0.9/tgdata/smoke_tests/test_18_read_budget.py +718 -0
  27. tgdata-0.0.9/tgdata/smoke_tests/test_19_message_batches.py +932 -0
  28. tgdata-0.0.9/tgdata/smoke_tests/test_20_group_operations.py +692 -0
  29. tgdata-0.0.9/tgdata/tgdata.py +1154 -0
  30. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/utils.py +53 -2
  31. tgdata-0.0.9/tgdata.egg-info/PKG-INFO +1067 -0
  32. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata.egg-info/SOURCES.txt +21 -0
  33. tgdata-0.0.9/tgdata.egg-info/requires.txt +5 -0
  34. tgdata-0.0.7/PKG-INFO +0 -402
  35. tgdata-0.0.7/README.md +0 -376
  36. tgdata-0.0.7/tgdata/__init__.py +0 -64
  37. tgdata-0.0.7/tgdata/connection_engine.py +0 -428
  38. tgdata-0.0.7/tgdata/tgdata.py +0 -662
  39. tgdata-0.0.7/tgdata.egg-info/PKG-INFO +0 -402
  40. tgdata-0.0.7/tgdata.egg-info/requires.txt +0 -2
  41. {tgdata-0.0.7 → tgdata-0.0.9}/setup.cfg +0 -0
  42. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/progress.py +0 -0
  43. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/__init__.py +0 -0
  44. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/my_test.py +0 -0
  45. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_00_connection.py +0 -0
  46. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_01_list_group_chats.py +0 -0
  47. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_02_get_message_count.py +0 -0
  48. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_03_get_all_messages.py +0 -0
  49. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +0 -0
  50. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_05_with_progress_feature.py +0 -0
  51. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_06_polling_simple.py +0 -0
  52. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_07_on_new_message_simple.py +0 -0
  53. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_07_real_time_event_handler.py +0 -0
  54. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_08_batch_callback.py +0 -0
  55. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_08_polling.py +0 -0
  56. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_10_advanced_features.py +0 -0
  57. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_debug_min_id.py +0 -0
  58. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_polling_debug.py +0 -0
  59. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata/smoke_tests/test_username_support.py +0 -0
  60. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata.egg-info/dependency_links.txt +0 -0
  61. {tgdata-0.0.7 → tgdata-0.0.9}/tgdata.egg-info/top_level.txt +0 -0
tgdata-0.0.9/PKG-INFO ADDED
@@ -0,0 +1,1067 @@
1
+ Metadata-Version: 2.4
2
+ Name: tgdata
3
+ Version: 0.0.9
4
+ Summary: A production-grade Python library for extracting and processing Telegram group and channel messages
5
+ Author: enes kuzucu
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.7
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.7
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: Telethon<2.0,>=1.45.0
18
+ Requires-Dist: pandas>=1.0
19
+ Provides-Extra: proxy
20
+ Requires-Dist: python-socks[asyncio]>=2.0; extra == "proxy"
21
+ Dynamic: author
22
+ Dynamic: classifier
23
+ Dynamic: description
24
+ Dynamic: description-content-type
25
+ Dynamic: provides-extra
26
+ Dynamic: requires-dist
27
+ Dynamic: requires-python
28
+ Dynamic: summary
29
+
30
+ # tgdata
31
+
32
+ A production-grade Python library for extracting and processing Telegram group and channel messages. Designed for ETL pipelines, data analysis, and archival purposes.
33
+
34
+ > **New in 0.0.8 — group discovery.** tgdata can now *find* groups and channels, not only read
35
+ > them: Telegram's name search, its "similar channels" recommendations, and links mined from
36
+ > posts, on the same persistent session, paced, flood-safe and budgeted. Four calls —
37
+ > `search_groups`, `similar_groups`, `linked_groups`, `discover_groups` — return candidate rooms
38
+ > in the `list_groups` row shape, already usable by `get_messages`. Quick start below under
39
+ > [Group discovery](#group-discovery--finding-groups-you-are-not-in); the full guide to using it
40
+ > properly is [`devdocs/guides/group_discovery.md`](devdocs/guides/group_discovery.md).
41
+
42
+ ## Features
43
+
44
+ - 🚀 **Production-Ready**: Built for reliability and scale in ETL pipelines
45
+ - 🆔 **Flexible Identification**: Use numeric IDs or usernames (@channelname) for all operations
46
+ - 📊 **Efficient Data Extraction**: Fetch messages with automatic rate limit handling
47
+ - 🔄 **Incremental Updates**: Fetch only new messages with `after_id` parameter
48
+ - 📈 **Progress Tracking**: Monitor long-running operations with real-time progress
49
+ - 🔌 **Clean Architecture**: Focused on data extraction with minimal dependencies
50
+ - 🛡️ **Robust Error Handling**: Automatic retries with exponential backoff
51
+ - 📁 **Multiple Export Formats**: Export to CSV, JSON, or integrate with your data pipeline
52
+ - 🔔 **Real-time Updates**: Listen for new messages with event handlers
53
+ - ⏱️ **Polling Support**: Poll for new messages at configurable intervals
54
+ - 🎯 **Batch Processing**: Handle large groups with configurable batch sizes and delays
55
+ - 🖼️ **Media Detection & Download**: Flag photos/videos per message (no download needed) and pull the files on demand or during the fetch
56
+ - 🔎 **Group Discovery**: Find rooms you are not in — Telegram's name search, its "similar channels", and links mined from posts — paced, flood-safe, budgeted
57
+ - 🛡️ **Proxy Support**: An optional per-account SOCKS5/SOCKS4/HTTP proxy for every connection, with `require_proxy` and no silent fallback to a direct line
58
+ - 🪪 **Fixed Device Identity**: Optionally pin the device model, system and app version an account presents, so it looks the same from every machine and after Telethon upgrades
59
+ - 🩺 **Account Health Events**: Every wait, logout, ban, restriction or lost group is reported as a plain-data event — through one callback, a log line and a per-account summary — and "ok" when it ends
60
+ - **Per-account read budgets**: An optional shared SQLite ledger limits message pulls over a rolling 24 hours, with persistent warm-up steps and recoverable partial results
61
+ - **Versioned message batches**: Read bounded raw snapshots with canonical JSON, replayable batch IDs, safe continuation cursors and optional content-addressed media
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install tgdata
67
+ ```
68
+
69
+
70
+ ## Authentication
71
+
72
+ tgdata supports 1 authentication at the moment.
73
+
74
+ (2 other are being implemented)
75
+
76
+ 1. get telegram development credentials in telegram API Development Tools from [https://my.telegram.org/apps](https://my.telegram.org/apps)
77
+
78
+ 2. Create a `config.ini` file with your Telegram API credentials like this:
79
+
80
+ ```ini
81
+ [Telegram]
82
+
83
+ # you can get telegram development credentials in telegram API Development Tools
84
+ api_id = 1234566
85
+ api_hash = a24adjfakjdfakjshdflkajsbdflk
86
+ phone = +905064004949 # use full phone number including + and country code
87
+
88
+ # Where the Telethon session lives (".session" is appended automatically).
89
+ # Use an ABSOLUTE path — a bare name creates the file in whatever directory
90
+ # the process happens to run from. Do not quote values: INI values are raw
91
+ # text, so quotes become part of the value (tgdata strips them defensively
92
+ # since 0.0.4, but don't rely on it).
93
+ session_file = /absolute/path/to/my_session
94
+ ```
95
+
96
+ **Session naming precedence** (since 0.0.4): an explicit `session_file`
97
+ always wins. A legacy `username` key is used as the session name only when
98
+ no `session_file` is set — configs that relied on username-named sessions
99
+ keep working, but new configs should set `session_file` and omit `username`
100
+ (it plays no part in authentication).
101
+
102
+ ### Logging in
103
+
104
+ The first run of a new session, started by hand in a terminal, asks for the
105
+ code Telegram sends you. After that, tgdata never asks Telegram for a login
106
+ code on its own: before connecting it checks with Telegram whether the session
107
+ is logged in, and if it is not, it raises `AuthRequiredError` instead of
108
+ prompting.
109
+
110
+ - `.reason` is Telegram's own name for the problem — `AUTH_KEY_UNREGISTERED`
111
+ or `SESSION_REVOKED` when Telegram logged the account out,
112
+ `USER_DEACTIVATED_BAN` when it is banned. `.banned` is true when logging in
113
+ again cannot help.
114
+ - To log back in after Telegram logged the account out, run once by hand in a
115
+ terminal with `TgData("config.ini", interactive_login=True)`.
116
+ `interactive_login=False` forbids even the first-run prompt.
117
+ - A background job should stop and alert someone on `AuthRequiredError`.
118
+ Retrying cannot log it back in — and, unlike before, it no longer sends the
119
+ owner a fresh login code on every retry.
120
+
121
+ ### Keeping sessions out of files
122
+
123
+ Pass a store once to keep each account's session in your application's storage:
124
+
125
+ ```python
126
+ import sqlite3
127
+ from tgdata import TgData
128
+
129
+ class SessionStore:
130
+ def __init__(self, path):
131
+ self.db = sqlite3.connect(path)
132
+ with self.db:
133
+ self.db.execute(
134
+ "CREATE TABLE IF NOT EXISTS sessions "
135
+ "(name TEXT PRIMARY KEY, data TEXT NOT NULL)"
136
+ )
137
+
138
+ def load(self, name):
139
+ row = self.db.execute(
140
+ "SELECT data FROM sessions WHERE name = ?", (name,)
141
+ ).fetchone()
142
+ return row[0] if row else None
143
+
144
+ def save(self, name, data):
145
+ with self.db:
146
+ self.db.execute(
147
+ "INSERT OR REPLACE INTO sessions (name, data) VALUES (?, ?)",
148
+ (name, data),
149
+ )
150
+
151
+ def delete(self, name): # optional; used on Telegram log-out
152
+ with self.db:
153
+ self.db.execute("DELETE FROM sessions WHERE name = ?", (name,))
154
+
155
+ store = SessionStore("accounts.sqlite3")
156
+ tg = TgData("config.ini", session_store=store)
157
+ # Use tg as usual. When finished, await tg.close(), then store.db.close().
158
+ ```
159
+
160
+ This example keeps sessions in a small SQLite table across restarts. Supply
161
+ your own store to use another database or storage service. The methods are
162
+ synchronous, not `async def`, and run on the
163
+ event loop: keep them quick, for example with a local database or an in-memory
164
+ cache that writes behind. tgdata ships no storage backend.
165
+
166
+ - **Names:** `session_file` is the store key, used as configured; no `.session`
167
+ suffix is added. The existing fallback to `username`, then `telegram_session`,
168
+ still applies. Persistent and short-lived clients share that name; pool
169
+ connections use `<name>_1`, `<name>_2`, and so on.
170
+ - **Contents:** one opaque, versioned string containing the login key, data
171
+ centre, update states, groups and channels with their access hashes, and the
172
+ account's own identity rows. Message senders are excluded from this session
173
+ cache. The temporary key and sent-file cache are not persisted.
174
+ - **Keep the string as text.** It is not Telethon's `StringSession` format or a
175
+ JSON document for the store to interpret. Its encoding is not encryption;
176
+ access control and any encryption belong to your store.
177
+ - **Save points:** whenever Telethon saves (connect, data-centre changes, new
178
+ login keys, and periodically while connected), plus disconnect/`close()`.
179
+ Unchanged state causes no write. Always close the client when finished.
180
+ - **Failures:** a store load error or unreadable saved string raises before a
181
+ connection opens. A failed save is logged at ERROR and retried at the next
182
+ save point; failed deletes are logged too. Save/delete logs contain the
183
+ session name and error type, never the error text or traceback, which could
184
+ contain the login key.
185
+ - **Two clients:** with the same login, the later save wins for the cache.
186
+ Before a changed save or deletion, a client re-reads the stored key. If it
187
+ changed or was removed, the client skips the mutation and warns once. Logging
188
+ out an older client therefore preserves a newer login saved by another client.
189
+ This check and save are separate operations, not an atomic guarantee across
190
+ processes. Telethon's session copies for side connections stay in memory and
191
+ never write to the store.
192
+ - **Login rules stay the same:** `None` from `load` means a new session; an
193
+ existing session that Telegram logged out still raises `AuthRequiredError`.
194
+ If `delete` is provided, Telegram log-out removes the stored record only when
195
+ it still holds the login that client last loaded or saved.
196
+
197
+ Without `session_store`, existing `.session` files work exactly as before.
198
+ There is no automatic migration from files to a store. This option is designed
199
+ and tested against Telethon 1.45.0.
200
+
201
+ ### Connecting through a proxy (optional)
202
+
203
+ With no `proxy` key, tgdata connects directly, exactly as before. Add one, and
204
+ **every** connection to Telegram goes through it — the persistent client, each
205
+ pool connection, and every short lookup. All of them are built by one function
206
+ in the connection engine, so there is no second way in.
207
+
208
+ ```ini
209
+ [Telegram]
210
+ api_id = 1234566
211
+ api_hash = a24adjfakjdfakjshdflkajsbdflk
212
+ session_file = /absolute/path/to/my_session
213
+ proxy = socks5://user:password@203.0.113.7:1080
214
+ require_proxy = true
215
+ ```
216
+
217
+ - **Forms:** `socks5://host:port`, `socks5://user:pass@host:port`,
218
+ `socks4://…`, `http://…` (an HTTP CONNECT proxy). Percent-encode special
219
+ characters in the user name or password (`@` as `%40`, `:` as `%3A`).
220
+ - **`require_proxy = true`** refuses to connect when no proxy is configured.
221
+ Use it on accounts that must never touch Telegram from the machine's own
222
+ IP.
223
+ - **No fallback, ever.** A malformed URL, a missing proxy library, or
224
+ `require_proxy` without a proxy raises `tgdata.ProxyConfigError` before any
225
+ connection opens. A proxy that is down or refuses the tunnel makes the
226
+ connection fail with `ConnectionError`. Nothing retries without the proxy.
227
+ - **Install the extra:** `pip install 'tgdata[proxy]'`, which pulls in
228
+ `python-socks[asyncio]`.
229
+ - **Logs and health checks show the proxy masked**
230
+ (`socks5://user:***@203.0.113.7:1080`); the password never appears in a
231
+ log line or a repr. The engine logs once whether connections are direct or
232
+ via a proxy.
233
+ - **Not supported:** MTProto proxies (`tg://proxy?…`, `t.me/proxy?…`); they
234
+ are refused with a clear error.
235
+ - One `proxy` per config file, and the account pool has one config per
236
+ account, so each account gets its own proxy.
237
+ - An existing session keeps working through a proxy; a session file is not
238
+ tied to an IP.
239
+
240
+ ### A fixed device identity (optional)
241
+
242
+ Every connection tells Telegram what device it is running on — a device
243
+ model, a system version, an app version and language codes. Telegram shows
244
+ them in the account's active-sessions list. Unless you pin them, Telethon
245
+ derives them from the machine and from its own version, so the same account
246
+ presents a different device on another machine, after an OS update, and after
247
+ every Telethon upgrade. On a Mac today that is `arm64`, `25.6.0`, `1.40.0`.
248
+
249
+ Pin them in the account's config, and every connection presents exactly
250
+ that, from any machine:
251
+
252
+ ```ini
253
+ [Telegram]
254
+ device_model = PC 64bit
255
+ system_version = Windows 11
256
+ app_version = 4.16.8 x64
257
+ lang_code = en
258
+ system_lang_code = en
259
+ ```
260
+
261
+ - **All five keys are optional.** No keys means Telethon's defaults, exactly
262
+ as before; a key you leave out keeps its default. `lang_code` alone also
263
+ sets `system_lang_code`. Values may contain spaces; quotes and an inline
264
+ `; comment` are stripped.
265
+ - **You can add it later.** The identity is sent on every connection, not
266
+ only at login, so an existing session picks it up on its next connection —
267
+ no new login.
268
+ - **To keep what an account shows today**, pin its current values instead of
269
+ inventing new ones (switching identity is itself a visible change). Run
270
+ this on the machine the account normally runs on and paste the output into
271
+ its config:
272
+
273
+ ```python
274
+ print(TgData("config.ini").device_identity()['config_lines'])
275
+ ```
276
+
277
+ `device_identity()` needs no network and no session; it also returns the
278
+ presented values and which ones are pinned. `health_check()` reports the
279
+ presented identity too.
280
+ - Whether Telegram cares about a changing identity has not been measured;
281
+ pinning is a precaution for accounts that move between machines.
282
+
283
+
284
+ ## Per-account read budgets
285
+
286
+ Pass a `ReadBudget` to limit explicit message pulls across jobs for the same
287
+ Telegram account. Every persistent, pooled and short-lived client built by
288
+ that `TgData` uses it. Without `read_budget`, existing behavior is unchanged.
289
+
290
+ ```python
291
+ import asyncio
292
+ from tgdata import TgData, ReadBudget, ReadBudgetExceeded
293
+
294
+ async def main():
295
+ budget = ReadBudget("/absolute/path/read-budgets.sqlite3")
296
+ tg = TgData("config.ini", read_budget=budget)
297
+ try:
298
+ # Policy setup: get the authenticated account ID using metadata only.
299
+ client = await tg.connection_engine.get_client()
300
+ me = await client.get_me()
301
+ budget.configure(
302
+ account_id=me.id,
303
+ daily_limit=2000,
304
+ warmup=[(0, 100), (2, 300), (7, 2000)],
305
+ )
306
+
307
+ try:
308
+ messages = await tg.get_messages("@channelname", limit=2000)
309
+ except ReadBudgetExceeded as error:
310
+ messages = error.partial_result
311
+ print(error.status.to_dict())
312
+ print("Retry after seconds:", error.retry_after)
313
+
314
+ if messages is not None:
315
+ print(messages)
316
+ print((await tg.get_read_budget()).to_dict())
317
+ finally:
318
+ await tg.close()
319
+
320
+ asyncio.run(main())
321
+ ```
322
+
323
+ The numbers above illustrate configuration; they are **not Telegram-safe rate
324
+ recommendations**. Warm-up days are completed 24-hour periods since enrollment,
325
+ not calendar days. The example starts at 100, rises to 300 after two days, and
326
+ to 2,000 after seven. Without `warmup`, the full `daily_limit` applies immediately.
327
+ A zero cap pauses reads. A curve starts at day zero, has unique nonnegative
328
+ integer days and nondecreasing caps, and never exceeds `daily_limit`.
329
+
330
+ `configure()` deliberately replaces the limit and curve, while retaining usage
331
+ and the original start time. Omit it in later jobs to keep the stored policy.
332
+ Pass `started_at` explicitly to set a known enrollment time: a timezone-aware
333
+ datetime or UTC timestamp, no later than now. The last curve cap continues
334
+ until the policy changes; it does not automatically rise to `daily_limit`.
335
+
336
+ - **Shared identity and storage:** use the same SQLite file on the same host
337
+ for every reader that should share an allowance. Keys are authenticated
338
+ Telegram account IDs, not session names or pool suffixes. Different accounts
339
+ have separate policies. A missing policy stops message reads. The parent
340
+ directory must exist; `:memory:` is not supported. The ledger stores account
341
+ IDs, policies and counters, never login keys or message content.
342
+ - **What spends the allowance:** history, message search, replies, explicit
343
+ message-ID lookups, discovery post reading, and message re-fetches during
344
+ media operations. Repeated reads, service/empty message slots and messages
345
+ later filtered out still count. A count operation that fetches a message
346
+ counts too. User/group/dialog metadata and incidental dialog previews,
347
+ passive updates and update recovery, and downloading file bytes are excluded.
348
+ - **When it counts:** before each actual send, tgdata reserves the request's
349
+ maximum number of messages. Successful replies settle to the returned slot
350
+ count and release unused capacity. Failed, cancelled or crashed attempts
351
+ retain their full claim; each retry needs another reservation. Cached flood
352
+ waits do not spend capacity before a send. An oversized reply is charged at
353
+ its actual count and raises a local error.
354
+ - **Rolling expiry:** each claim expires 24 hours after its send admission,
355
+ regardless of when the reply arrives. There is no midnight reset. A persisted
356
+ clock high-water mark prevents backward clock changes from restoring spent
357
+ capacity. Keep the host clock accurate; forward jumps advance expiry and
358
+ warm-up. No SQLite transaction stays open while awaiting Telegram. Operations
359
+ are synchronous and normally short, but contention can block the event loop
360
+ for up to the five-second SQLite busy timeout before raising.
361
+ - **Status and stopping:** `budget.status(account_id)` is local;
362
+ `await tg.get_read_budget()` verifies the active account and returns its
363
+ status, or `None` without connecting when disabled. `used` includes outstanding
364
+ `reserved` capacity; `remaining` is the available allowance. `to_dict()` adds
365
+ ISO timestamps and `retry_after` for one message. An exhaustion exception
366
+ also has `requested`, `account_id`, `next_available_at` and `retry_after` for
367
+ that request. Retry times are estimates assuming no new claims or policy
368
+ changes; `None` means a smaller request or policy change is needed.
369
+ - **Partial results:** fetch/search/discovery errors carry the processed
370
+ DataFrame in `partial_result`; media-by-ID errors carry completed entries
371
+ in a dictionary. Batch callbacks may already have seen some of those rows.
372
+ Polling delivers and deduplicates its partial frame, then raises exhaustion
373
+ instead of automatically retrying. Other `ReadBudgetError` subclasses also
374
+ stop polling. These are local policy/storage errors, not Telegram health
375
+ verdicts; actual Telegram errors keep their existing health reporting.
376
+ - **Request boundaries:** history/search pages shrink to the available
377
+ allowance while preserving their cursors. ID chunks and raw request bounds
378
+ are not silently truncated, so a chunk larger than the remaining allowance
379
+ is refused. Use smaller ID lists when needed. Unbounded or unsupported raw
380
+ message-returning calls and message-bearing batches raise
381
+ `UnsupportedBudgetRequest`; metadata-only batches still work. Supported
382
+ invoke wrappers retain the inner message bound.
383
+ - **Identity verification:** each message send uses a fresh Telegram self
384
+ lookup, including retries; page sizing and status can add another lookup.
385
+ Cached session identities are not billing authority. Storage/configuration
386
+ errors fail closed before further message sends; failed settlement retains
387
+ the prior full claim. Clients created separately, other ledger files and
388
+ direct use of private transport internals are outside this guard.
389
+
390
+ The budget suite exercises real Telethon request/iterator code with scripted
391
+ replies on 1.45.0. It does not test live Telegram response bounds or
392
+ whether any chosen limit prevents account restrictions.
393
+
394
+ ## Group lookup, access and joining
395
+
396
+ These operations each open, authenticate and disconnect one short-lived client.
397
+ They use the configured proxy, device identity and session store, never prompt
398
+ for login, and do not change `current_group` or use the persistent client/pool.
399
+ Log the account in first as described under Authentication. Tested with
400
+ **Telethon 1.45.0**; the package now requires at least that version.
401
+
402
+ ```python
403
+ from tgdata import TgData, JoinBudget, JoinBudgetExceeded
404
+
405
+ budget = JoinBudget("/absolute/path/join-budgets.sqlite3")
406
+ tg = TgData("config.ini", join_budget=budget)
407
+
408
+ # In your async function: enroll the actual account once, then keep the policy.
409
+ async with tg.connection_engine.ephemeral_client() as client:
410
+ me = await client.get_me()
411
+ budget.configure(me.id, daily_limit=3) # Illustrative policy, not a safe rate.
412
+
413
+ details = await tg.lookup_group("@group_handle")
414
+ access = await tg.check_group_access("@group_handle")
415
+ print(details.to_dict(), access.to_dict())
416
+
417
+ # When your caller explicitly chooses to join:
418
+ try:
419
+ result = await tg.join_group("@group_handle")
420
+ print(result.status, result.to_dict())
421
+ except JoinBudgetExceeded as error:
422
+ print(error.status.to_dict(), error.retry_after)
423
+
424
+ print((await tg.get_join_budget()).to_dict())
425
+ ```
426
+
427
+ `lookup_group` and `check_group_access` also work without a join budget. Accepted
428
+ references are bare or `@` handles, `https://t.me/handle` (also `telegram.me`),
429
+ and invite links `https://t.me/+HASH` or `https://t.me/joinchat/HASH`. Optional
430
+ `http://`, `www.`, or a bare Telegram host are accepted. Handles use 4–32 ASCII
431
+ letters, digits or underscores and begin with a letter. Invite hashes retain
432
+ their case. Links with extra paths, query strings, fragments, userinfo or ports,
433
+ phone numbers and non-Telegram URLs are rejected before opening a client.
434
+
435
+ Lookup/access can also take a nonzero integer ID or its decimal string when the
436
+ session already knows that group. A marked channel ID such as `-1000000000007`
437
+ identifies its peer kind; a positive ID is accepted only when unambiguous among
438
+ cached users/chats/channels. Unknown IDs do not trigger a dialog scan. Joining
439
+ accepts handles or invite links. References resolving to users are rejected.
440
+
441
+ | Operation | Result | What it establishes |
442
+ |---|---|---|
443
+ | `lookup_group(target)` | `GroupLookup` | Available metadata, observed `member`, whether approval/payment is indicated, and any temporary preview expiry |
444
+ | `check_group_access(target)` | `GroupAccess` | `readable` after a successful history request, `denied` after a Telegram group-access error, or `unprobed` when no usable peer is available |
445
+ | `join_group(target)` | `GroupJoin` | `joined`, `already_joined`, `requested`, `interaction_required`, or `payment_required` |
446
+
447
+ The immutable results have `to_dict()` methods containing only JSON-ready data.
448
+ `group` contains `id`, marked `peer_id`, `title`, `username` (without `@`), `kind`
449
+ (`group`, `megagroup`, `channel` or `unknown`) and `participants_count`. Missing
450
+ details are `None`: an unjoined invite preview has no invented group ID. An access
451
+ denial during resolution can have `group=None`. Existing discovery `GroupInfo`
452
+ objects retain their previous format.
453
+
454
+ Lookup never reads history or joins. Membership can be unknown; it does not prove
455
+ read access. Access issues one logical history request with limit1 when possible,
456
+ including for nonmembers of public groups. Telethon may retry that request;
457
+ each actual send is bounded and metered by an optional ReadBudget. An empty
458
+ successful reply is still readable.
459
+ `GroupAccess.readable` is `True`, `False` or `None`; `reason` is a safe denial name
460
+ or `NO_PEER`. Authentication, transport, read-quota and local validation/storage
461
+ failures raise their actual error instead of becoming a denied result. An optional
462
+ `ReadBudget` meters this history request independently of the join allowance.
463
+
464
+ Joining requires a configured `JoinBudget` and account policy, even if preflight
465
+ finds existing membership. That observation returns `already_joined` without a
466
+ mutation or charge. `requested` means approval was requested; `interaction_required`
467
+ includes `bot_id` and `query_id`, without inventing a webview URL. Paid previews
468
+ return `payment_required` without sending a join. tgdata does not pay, open a
469
+ webview or wait for approval. Only `joined` and `already_joined` have `member=True`;
470
+ other join outcomes have `member=None`. None of these replaces a separate read check.
471
+
472
+ After a join acknowledgment, available returned details are cached locally. Missing
473
+ details or failed optional cache writes do not turn that acknowledgment into failed
474
+ joining; a cache failure logs its error type only. No extra lookup is required after
475
+ the acknowledgment. An unrecognized reply raises `GroupResponseError` and retains
476
+ the attempt charge.
477
+
478
+ The join allowance has these rules:
479
+
480
+ - **Unit:** one actual admitted join request. Every SDK retry costs another
481
+ attempt. Error replies (including already-participant or approval-requested),
482
+ transport loss, cancellation and crashes retain the charge. No uncertainty
483
+ refund is made. Preflight metadata and already-observed membership cost zero.
484
+ - **Storage:** all processes/clients sharing an allowance must use the same
485
+ SQLite file on the same host. Policies are keyed by fresh authenticated account
486
+ ID, never a session name or cached identity. The parent directory must exist;
487
+ `:memory:` is rejected. Only IDs, policy limits and admission times are stored.
488
+ ReadBudget can share the file; its tables and units remain separate.
489
+ - **Policy:** `configure(account_id, daily_limit)` requires explicit nonnegative
490
+ integer limits, preserves existing usage, and provides no default rate. Zero
491
+ stops new attempts. Lowering a limit does not erase prior attempts. The example
492
+ limit is not a claim about avoiding Telegram restrictions.
493
+ - **Time:** each attempt expires 24 hours after admission, with no midnight reset.
494
+ Backward clock movement cannot restore spent capacity; forward jumps advance
495
+ expiry, so keep the host clock accurate. SQLite claims are atomic and no lock
496
+ is held while awaiting Telegram. Synchronous operations are normally short;
497
+ contention can block the event loop for up to the five-second busy timeout.
498
+ - **Status:** local `budget.status(account_id)` or ephemeral
499
+ `await tg.get_join_budget()` returns `JoinBudgetStatus`. Without a budget the
500
+ latter returns `None` without connecting. Status reports `limit`, `used`,
501
+ `remaining`, observation/next-availability times and `retry_after` for one attempt.
502
+ Availability assumes no new claims/policy changes; a zero cap has no timed retry.
503
+ - **Failures and boundary:** missing policy, exhausted allowance and storage
504
+ failure stop before mutation (`JoinBudgetConfigError`, `JoinBudgetExceeded`,
505
+ `JoinBudgetStorageError`). Configured factory clients also guard raw join calls;
506
+ join-bearing batches and unknown wrappers raise `UnsupportedJoinRequest`.
507
+ Separately constructed clients, other ledger files, direct private transport
508
+ use and delayed approvals are outside this local admission guarantee.
509
+
510
+ Health events use the ephemeral call's fresh identity and a token-free invite
511
+ label. Metadata and join results cannot clear a prior group read-access denial;
512
+ a successful access read can. Local failures do not inherit unrelated Telegram
513
+ errors from the caller. New operation requests raise flood waits immediately;
514
+ the existing connection-authorization check keeps its usual wait behavior.
515
+
516
+ `test_20_group_operations` verifies SDK dispatch, results, lifecycle, health and
517
+ real SQLite concurrency offline with sockets blocked. It does not establish live
518
+ Telegram acceptance or a safe joining rate.
519
+
520
+ ## Quick Start
521
+
522
+ ### List All Group Chats
523
+
524
+ This is required for finding chat id for the chat of interest.
525
+
526
+ ```python
527
+ from tgdata import TgData
528
+ import asyncio
529
+
530
+ async def main():
531
+ # Initialize the client
532
+ tg = TgData("config.ini")
533
+
534
+ # List available groups and channels
535
+ groups = await tg.list_groups()
536
+
537
+ print(groups)
538
+
539
+ ```
540
+
541
+ Outputs:
542
+
543
+ ```
544
+ Python Devs 10012312313
545
+ ```
546
+
547
+ ### Group discovery — finding groups you are not in
548
+
549
+ `list_groups` shows what the account already follows. To find *new* rooms there are three
550
+ routes, all returning the same columns as `list_groups` plus `FoundVia` and `FoundBy`.
551
+ This is the quick start; the rules for running discovery on a real account without earning
552
+ a block — pacing, budgets, resumable runs, reading the output — are in
553
+ [`devdocs/guides/group_discovery.md`](devdocs/guides/group_discovery.md).
554
+
555
+ ```python
556
+ from tgdata import TgData, build_search_queries
557
+
558
+ tg = TgData("config.ini")
559
+
560
+ # 1. Telegram's global search — matches room NAMES and @usernames only, never post text
561
+ df = await tg.search_groups("Анталия аренда")
562
+
563
+ # 2. Telegram's "similar channels" — rooms whose subscribers overlap the seeds', no keyword needed
564
+ df = await tg.similar_groups(["@antalyadaa", "@sprout_antalya"], rounds=2)
565
+
566
+ # 3. t.me links found in a room's recent posts — usernames only (free), or resolved
567
+ df = await tg.linked_groups(group_id, posts=100, resolve=False)
568
+
569
+ # All three on one session, deduplicated by id, in cost order
570
+ df = await tg.discover_groups(
571
+ seeds=["@antalyadaa"],
572
+ queries=build_search_queries(["Анталия", "Antalya"], topics=["чат", "аренда", "новости"],
573
+ prefixes=["Турция"]),
574
+ mine_links=True,
575
+ heartbeat=lambda phase: print(phase), # liveness ticks: search / similar / links / pause / flood-wait Ns
576
+ found_callback=lambda row: rows.append(row), # each room as it is found — persist as you go
577
+ )
578
+ ```
579
+
580
+ **The name-only rule.** Telegram's search matches names and @usernames, never what a room
581
+ posts. A perfect room under an unrelated name is invisible to every query — that is what
582
+ `similar_groups` and `linked_groups` are for. Rooms name themselves "place + topic", so
583
+ `build_search_queries` combines your place spellings with topic words, in both word orders.
584
+
585
+ | Column | Meaning |
586
+ |---|---|
587
+ | `GroupID` … `ParticipantsCount` | the `list_groups` columns; `ParticipantsCount` is present on search and similar-channel results, absent (`NA`) on rooms resolved from links — Telegram's lookup reply does not carry it |
588
+ | `FoundVia` | `search` · `similar` · `link` — the cheapest route that found the room |
589
+ | `FoundBy` | the query, the seed's `@name`, or the source room |
590
+
591
+ Unresolved link rows carry only `Username`/`Identifier`; `GroupID` and the flags are `NA`
592
+ (the columns are nullable, so dtypes never drift). Every row with an id is already usable
593
+ by `get_messages(row['GroupID'])` — Telegram results land in the session cache with their
594
+ access hash, no dialog sync needed.
595
+
596
+ **Pacing and waits.** Every request is followed by `pace` seconds (default 2, the proven
597
+ pace for a personal account). A rate-limit wait up to `max_flood_wait` is obeyed exactly,
598
+ with heartbeat ticks so a watchdog sees life; a longer one raises `DiscoveryInterrupted`,
599
+ which carries `.found` (everything collected so far) and `.retry_after`. Two steps keep
600
+ Telethon's own handling: reading a room's posts for links, and the dialog sync for a numeric
601
+ id — a wait of up to a minute there is slept without ticks. A dropped connection
602
+ is waited out up to `max_offline` and the same request retried. Nothing found is ever lost.
603
+
604
+ **The budget.** Resolving a username to a room is the request Telegram punishes hardest —
605
+ a production account lost 8 and 21 hours of lookups to it. So every call has `max_resolve`
606
+ (default 100), `discover_groups` mines links from the first `link_sources` rooms only
607
+ (default 50), `resolve_links` is off by default (mined names come back as text, which costs
608
+ nothing), and a wait on a lookup is skipped or stopped, never waited out — for a
609
+ `linked_groups` source it ends the call with `DiscoveryInterrupted`. A truncating cap
610
+ logs a warning telling you which parameter lifts it.
611
+
612
+ **Similar channels** work on channels and megagroups, not basic groups; two rounds by
613
+ default (a third drifts off topic); the seeds themselves are never returned. Yield depends
614
+ on the account — a Premium account got 64 recommendations from one seed, a normal account
615
+ reportedly about 10.
616
+
617
+ **Links** are read from the visible text, from hyperlinked text (where channels put nearly
618
+ all of them) and from URL buttons. Accepted: `t.me/name`, `t.me/name/123`, `t.me/s/name`,
619
+ `t.me/boost/name`, `telegram.me/name`. Skipped: invite links (`t.me/+…`), `t.me/c/…`,
620
+ reserved paths, and bots (`…bot`). Discovery reads; it never joins.
621
+
622
+
623
+ ### Get all messages of a chat
624
+
625
+ You can use either the numeric chat ID or the username (if the chat has one).
626
+
627
+ **Note:** For large groups (2500+ messages), use batch processing with rate limit protection.
628
+
629
+
630
+ ```python
631
+ from tgdata import TgData
632
+ import asyncio
633
+
634
+ async def main():
635
+ # Initialize the client
636
+ tg = TgData("config.ini")
637
+
638
+ # Fetch messages using numeric ID
639
+ messages = await tg.get_messages(
640
+ group_id=-1001234567890, # Numeric ID
641
+ limit=1000,
642
+ with_progress=True
643
+ )
644
+
645
+ # OR using username (if the chat has one)
646
+ messages = await tg.get_messages(
647
+ group_id="@channelname", # Username with @
648
+ limit=1000,
649
+ with_progress=True
650
+ )
651
+
652
+ # Export to CSV
653
+ tg.export_messages(messages, "messages.csv")
654
+
655
+ asyncio.run(main())
656
+ ```
657
+
658
+ ### Get message count of a chat
659
+
660
+
661
+
662
+ ```python
663
+ from tgdata import TgData
664
+ import asyncio
665
+
666
+ async def main():
667
+ # Initialize the client
668
+ tg = TgData("config.ini")
669
+
670
+ # Get count using numeric ID or username
671
+ message_count = await tg.get_message_count(
672
+ group_id=-1001234567890, # Numeric ID
673
+ # OR: group_id="@channelname" # Username
674
+ )
675
+
676
+ print(message_count)
677
+
678
+ asyncio.run(main())
679
+ ```
680
+
681
+
682
+
683
+
684
+ ## Advanced Usage
685
+
686
+ ### Using Usernames vs Numeric IDs
687
+
688
+ ```python
689
+ # Both approaches work identically:
690
+
691
+ # Option 1: Using username (recommended if available)
692
+ messages = await tg.get_messages(
693
+ group_id="@channelname",
694
+ limit=100
695
+ )
696
+
697
+ # Option 2: Using numeric ID
698
+ messages = await tg.get_messages(
699
+ group_id=-1001234567890,
700
+ limit=100
701
+ )
702
+ ```
703
+
704
+ A numeric id resolves only if the session can actually see the group. On a
705
+ cache miss tgdata syncs the account's chat list once and retries — so fresh
706
+ sessions work for groups the account belongs to. If the group still can't be
707
+ found it raises `tgdata.GroupAccessError` (the account is not a member, or
708
+ the id is wrong) instead of Telethon's generic "Could not find the input
709
+ entity" ValueError.
710
+
711
+ ### get_messages with start_date parameter
712
+
713
+ ```python
714
+ tg = TgData("config.ini")
715
+ # Fetch recent messages for ETL processing
716
+ yesterday = datetime.now() - timedelta(days=1)
717
+ messages = await tg.get_messages(
718
+ group_id="@channelname", # Can use username
719
+ start_date=yesterday,
720
+ with_progress=True
721
+ )
722
+
723
+
724
+ ```
725
+
726
+ ### Incremental Message Fetching
727
+
728
+ ```python
729
+ from tgdata import TgData
730
+
731
+ async def incremental_fetch():
732
+ tg = TgData("config.ini")
733
+
734
+ # Get the latest message ID from your storage, or db
735
+ last_processed_id = load_checkpoint() # Your implementation
736
+
737
+ # Fetch only messages after that ID
738
+ new_messages = await tg.get_messages(
739
+ group_id="@channelname", # Can use username or numeric ID
740
+ after_id=last_processed_id
741
+ )
742
+
743
+ save_checkpoint(new_messages['MessageId'].max()) # Your implementation
744
+
745
+
746
+ asyncio.run(incremental_fetch())
747
+ ```
748
+
749
+ ### Stable message batches for storage and delivery
750
+
751
+ Use `get_message_batch` for a versioned raw snapshot that can be saved and
752
+ replayed. It keeps senderless and service messages, reads oldest first after an
753
+ exclusive cursor, and uses the same client and optional read budget. Designed
754
+ and tested with Telethon **1.45.0**.
755
+
756
+ ```python
757
+ from tgdata import TgData, MessageBatch
758
+
759
+ async def prepare_batch():
760
+ tg = TgData("config.ini")
761
+ try:
762
+ batch = await tg.get_message_batch(
763
+ "@channelname", after_id=100, limit=200,
764
+ download_media_to="out/media", # omit for references only
765
+ )
766
+ manifest = batch.save("out/batches")
767
+ replay = MessageBatch.from_json(manifest.read_bytes())
768
+ assert replay.batch_id == batch.batch_id
769
+ return manifest, batch.next_after_id
770
+ finally:
771
+ await tg.close()
772
+ ```
773
+
774
+ Send the saved batch using `batch_id` for receiver deduplication, and advance
775
+ your stored cursor only after acknowledgment. If acknowledgment is lost, replay
776
+ that file: a fresh Telegram fetch can produce a different observation. Requested
777
+ photo/document files are named by their content hash; saving the manifest does
778
+ not copy its media. Blob paths are relative to `out/media` in this example.
779
+
780
+ Ordinary failures after group resolution carry the completed prefix as a
781
+ `MessageBatch` in `error.partial_result` and still raise. `next_after_id` never
782
+ passes an unfinished record/file. Local file errors keep their type without
783
+ inventing a Telegram health verdict. A secondary cleanup failure is logged by
784
+ operation/type and preserves the original error or cancellation; storage that
785
+ refuses cleanup may leave a private temporary file. The exact schema, limits,
786
+ interruption rules
787
+ and delivery protocol are in [Message batch v1](docs/message_batch_v1.md).
788
+
789
+ ### Detecting & Downloading Media (photos / videos)
790
+
791
+ Every fetched message carries media-reference columns, so you can tell whether a
792
+ message has a photo **without downloading anything**:
793
+
794
+ | Column | Meaning |
795
+ |---|---|
796
+ | `MediaType` | `'photo'`, `'video'`, `'document'`, `'webpage'`, … or `None` (text-only) |
797
+ | `GroupedId` | album tag — rows sharing the same value are one multi-photo post (else `None`) |
798
+ | `MessageLink` | `t.me` deep link to the original message |
799
+ | `MediaPath` | local file path once media is downloaded (else `None`) |
800
+
801
+ > ⚠️ **`'webpage'` is NOT a real photo.** It is a link-preview thumbnail, not an
802
+ > attached image. Filter on the actual attachment type (`'photo'` / `'video'`) —
803
+ > **never** on "`MediaType` is not null" — or you will count URL previews as media.
804
+
805
+ ```python
806
+ messages = await tg.get_messages(group_id="@channelname", limit=100)
807
+
808
+ # ✅ correct — real attached photos only
809
+ photos = messages[messages['MediaType'] == 'photo']
810
+
811
+ # ❌ wrong — also catches 'webpage' link previews, which are NOT real images
812
+ # media = messages[messages['MediaType'].notna()]
813
+
814
+ # how many photos each album/listing has
815
+ photos.groupby('GroupedId').size()
816
+ ```
817
+
818
+ Get the actual media in one of three ways:
819
+
820
+ ```python
821
+ # A) On demand — fetch references cheaply, then download only what you keep.
822
+ # Pass every MessageId sharing a GroupedId to grab a whole album.
823
+ paths = await tg.download_media_by_id("@channelname", [12345, 12346], output_dir="media")
824
+ # -> {12345: "media/photo_....jpg", 12346: None} (None = nothing downloadable, e.g. a webpage)
825
+
826
+ # B) During the fetch, to disk — one call returns the data AND writes the files.
827
+ messages = await tg.get_messages(
828
+ group_id="@channelname",
829
+ limit=100,
830
+ download_media_to="media", # each file's path is recorded in the MediaPath column
831
+ )
832
+
833
+ # C) During the fetch, in memory — bytes land in the DataFrame itself (MediaData column).
834
+ # Great for small scrapes; holds every file in RAM, so scope it.
835
+ messages = await tg.get_messages(
836
+ group_id="@channelname",
837
+ limit=100,
838
+ include_media=True, # MediaData = raw bytes per row (None for text/webpage)
839
+ )
840
+ # Note: CSV export drops MediaData; JSON stores a "[Binary data]" placeholder.
841
+ ```
842
+
843
+ Downloaded files (modes A and B) are named `<ChatId>_<MessageId>.<ext>` — e.g.
844
+ `1707717812_183019.jpg` — so every file is traceable to its exact message and
845
+ group from the filename alone (and won't collide across groups sharing a folder).
846
+ The precise path is also recorded per row in the `MediaPath` column.
847
+
848
+ **Re-scraping is idempotent:** if a message's file is already on disk, it is
849
+ reused, not re-downloaded — so pulling the same (or an overlapping) range again
850
+ never duplicates files or re-fetches bytes. Each message maps to exactly one file.
851
+
852
+ ### Progress Monitoring
853
+
854
+ ```python
855
+ async def monitor_extraction():
856
+ tg = TgData()
857
+
858
+ def progress_callback(current, total, rate):
859
+ percent = (current / total * 100) if total else 0
860
+ print(f"Progress: {current}/{total} ({percent:.1f}%) - {rate:.1f} msg/s")
861
+
862
+ messages = await tg.get_messages(
863
+ group_id=-1001234567890,
864
+ limit=10000,
865
+ progress_callback=progress_callback
866
+ )
867
+ ```
868
+
869
+ ### Batch Processing for Large Groups
870
+
871
+ ```python
872
+ # For groups with 100k+ messages, use batch processing with rate limit protection
873
+ async def process_large_group():
874
+ tg = TgData("config.ini")
875
+
876
+ async def save_batch(batch_df, batch_info):
877
+ # Process each batch (e.g., save to database)
878
+ print(f"Batch {batch_info['batch_num']}: {len(batch_df)} messages")
879
+ batch_df.to_csv(f"batch_{batch_info['batch_num']}.csv")
880
+
881
+ await tg.get_messages(
882
+ group_id="@largechannel", # Works with username
883
+ batch_size=500, # Process 500 messages at a time
884
+ batch_callback=save_batch,
885
+ batch_delay=2.0, # Wait 2 seconds between batches
886
+ rate_limit_strategy='exponential' # Handle rate limits gracefully
887
+ )
888
+ ```
889
+
890
+ ### Custom callback
891
+
892
+
893
+
894
+
895
+ ### Polling for New Messages
896
+
897
+ ```python
898
+ async def poll_messages():
899
+ tg = TgData()
900
+
901
+ # Define callback for new messages
902
+ async def process_batch(messages_df):
903
+ print(f"Got {len(messages_df)} new messages")
904
+ # Process messages here
905
+
906
+ # Poll every 30 seconds
907
+ await tg.poll_for_messages(
908
+ group_id="@channelname", # Works with username
909
+ interval=30,
910
+ callback=process_batch,
911
+ max_iterations=10 # Stop after 10 polls
912
+ )
913
+ ```
914
+
915
+ Polling stops, raising the error, when polling again cannot help:
916
+ - the account is logged out, banned or restricted;
917
+ - it cannot read the group (`CHANNEL_PRIVATE`, `GroupAccessError`, ...);
918
+ - the session is not logged in (`AuthRequiredError`);
919
+ - the proxy setting is unusable (`ProxyConfigError`).
920
+
921
+ Network errors, rate-limit waits and server errors are logged and retried at
922
+ the next interval, as before. An exception raised by your callback now reaches
923
+ you; it used to be logged, and those messages were skipped.
924
+
925
+
926
+ ### Real-time Message Events
927
+
928
+ ```python
929
+ # Monitor messages in real-time
930
+ tg = TgData("config.ini")
931
+
932
+ @tg.on_new_message(group_id="@channelname") # Works with username
933
+ async def handle_message(event):
934
+ print(f"New message: {event.message.text}")
935
+
936
+ await tg.run_with_event_loop()
937
+ ```
938
+
939
+
940
+ ### Account health events
941
+
942
+ Every time Telegram says no to an account, tgdata tells you:
943
+ - which verdict;
944
+ - about what — the account, one kind of request, or one group;
945
+ - from which call;
946
+ - for how long;
947
+ - when it ended.
948
+
949
+ It reports and never acts. No exception changes, and nothing is paused or
950
+ retried because of a verdict.
951
+
952
+ ```python
953
+ def on_health(event): # or an async function
954
+ print(event['verdict'], event['scope'], event['error'], event['call'])
955
+
956
+ tg = TgData("config.ini", health_callback=on_health, account_label="reader-7")
957
+ ```
958
+
959
+ | Verdict | About | Telegram's names |
960
+ |---|---|---|
961
+ | `waiting` | one request type | `FLOOD_WAIT_X`, `FLOOD_PREMIUM_WAIT_X`, `SLOWMODE_WAIT_X`, `FLOOD_TEST_PHONE_WAIT_X` — with the seconds |
962
+ | `logged out` | the account | `AUTH_KEY_UNREGISTERED`, `SESSION_REVOKED`, `SESSION_EXPIRED`, `AUTH_KEY_INVALID`, `AUTH_KEY_DUPLICATED` |
963
+ | `banned` | the account | `USER_DEACTIVATED_BAN`, `USER_DEACTIVATED`, `PHONE_NUMBER_BANNED` |
964
+ | `restricted` | the account | `FROZEN_METHOD_INVALID`, `FROZEN_PARTICIPANT_MISSING`, `USER_RESTRICTED`, `PEER_FLOOD` |
965
+ | `no access` | one group | `CHANNEL_PRIVATE`, `CHAT_FORBIDDEN`, `CHANNEL_INVALID`, `USER_BANNED_IN_CHANNEL`, `CHANNEL_PUBLIC_GROUP_NA`, `CHANNEL_BANNED`, and tgdata's `GroupAccessError` |
966
+ | `unclassified` | — | any other Telegram error in the 401, 403, 406 or 420 categories, with its name (file-reference errors excepted) |
967
+ | `ok` | what recovered | — |
968
+
969
+ Nothing else is an event: bad requests (400), server errors, network and proxy
970
+ errors, a lookup that found nothing, or a session that was never logged in.
971
+
972
+ An event is a plain dict that survives `json.dumps`:
973
+
974
+ ```python
975
+ {'kind': 'health', 'time': '2026-10-03T14:02:11.418523+00:00',
976
+ 'account': {'label': 'reader-7', 'session': 'reader7', 'user_id': 123456789},
977
+ 'verdict': 'no access', 'scope': 'group', 'group': 'somegroup',
978
+ 'call': 'get_messages', 'request': 'GetHistoryRequest',
979
+ 'wait_seconds': None, 'error': 'CHANNEL_PRIVATE', 'source': 'error'}
980
+ ```
981
+
982
+ - **`group`** is the group as you named it: an int stays an int; `@Name` and
983
+ t.me links become `name`.
984
+ - **`call`** is the `TgData` method; **`request`** is Telegram's request type.
985
+ - **`source`** says where it came from:
986
+ - `error` — it ended the call, and you also get the exception;
987
+ - `sleep` — Telethon slept through a wait of up to a minute, silently;
988
+ - `handled` — tgdata waited it out itself;
989
+ - `swallowed` — tgdata skipped the item and carried on, such as a
990
+ discovery seed or link;
991
+ - `recovery` — an `ok`.
992
+
993
+ **"ok" only when Telegram shows a condition ended:**
994
+ - **a wait** — the call that waited completes successfully;
995
+ - **logged out or banned** — a call completes that Telegram answered after
996
+ the problem was recorded;
997
+ - **restricted** — the same, from the method that was refused;
998
+ - **no access** — the same, from a call naming that group.
999
+
1000
+ A call that merely completes proves nothing. A call Telegram answered before
1001
+ the problem was seen, or one that sent no request at all, never sends `ok`.
1002
+
1003
+ **Delivery.** The event that ends a call is delivered before its exception
1004
+ reaches you. The callback may be a function or a coroutine function. If it
1005
+ raises, the error is logged — first at WARNING with its traceback, then at
1006
+ DEBUG — and nothing else changes. A wait Telethon sleeps through is delivered
1007
+ from inside the request: a function runs there, a coroutine is scheduled.
1008
+ The callback may call tgdata itself. Events caused by those calls are logged
1009
+ and counted, but not delivered back to it, so it never calls itself.
1010
+
1011
+ **Summary.** `(await tg.health_check())['health']` holds, for this `TgData`,
1012
+ in memory:
1013
+ - the account's verdict, and since when;
1014
+ - the open waits per request type;
1015
+ - the groups without access;
1016
+ - the wait count and total seconds;
1017
+ - the event count;
1018
+ - the last unclassified name.
1019
+
1020
+ **Log lines.** Each event is also logged on `tgdata.tgdata.health`, so
1021
+ `log_file=` receives it: WARNING for logged out, banned, restricted and
1022
+ unclassified; INFO for waiting, no access and ok. Your own logging output is
1023
+ otherwise unchanged. The INFO lines in which Telethon logs its silent sleeps
1024
+ stay hidden unless you asked for them.
1025
+
1026
+ **`validate_connection()` and `health_check()` ask Telegram directly.**
1027
+ Telethon's `get_me()` answers a logout or a ban with `None` instead of an
1028
+ error, so both now confirm with Telegram whether the session is logged in.
1029
+ - `validate_connection()` still returns `True` or `False`. On `False` it says
1030
+ why: at WARNING with Telegram's name when it is a verdict, and as an event.
1031
+ - `health_check()` marks such a connection unhealthy and names the verdict in
1032
+ `errors`, e.g. `logged out (AUTH_KEY_UNREGISTERED)`. It reports it as an
1033
+ event, and the same result's `['health']` already includes it.
1034
+
1035
+ **Not covered yet.**
1036
+ - Signals that Telethon's background update loop only logs: an account banned
1037
+ in one channel, and waits while catching up.
1038
+ - `restricted` follows Telegram's documentation; how a frozen account behaves
1039
+ when it only reads has not been observed.
1040
+ - Capturing Telethon's silent sleeps relies on Telethon creating its INFO
1041
+ record: `logging.disable(logging.INFO)` or higher turns the capture off, and
1042
+ a level set on `telethon.client.users` during a call applies from the next
1043
+ call.
1044
+
1045
+ ### Performance Tips
1046
+
1047
+ - The client authenticates once and keeps a single **persistent connection**,
1048
+ reused across all calls; release it with `await tg.close()` or use
1049
+ `async with TgData("config.ini") as tg:`
1050
+ - Implement checkpoint logic for incremental processing
1051
+ - Implement progress callbacks for visibility
1052
+ - Export data incrementally for large datasets
1053
+
1054
+ ## Requirements
1055
+
1056
+ - Python 3.7+
1057
+ - Telethon 1.45.0 or newer within 1.x (verification target: 1.45.0)
1058
+ - Telegram API credentials (not bot tokens)
1059
+ - Group/channel membership
1060
+
1061
+ ## License
1062
+
1063
+ MIT License - see LICENSE file for details
1064
+
1065
+ ## Contributing
1066
+
1067
+ Contributions are welcome! Please feel free to submit a Pull Request.