tgdata 0.0.8__tar.gz → 0.1__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.
- tgdata-0.1/PKG-INFO +1242 -0
- tgdata-0.1/README.md +1213 -0
- {tgdata-0.0.8 → tgdata-0.1}/setup.py +9 -5
- tgdata-0.1/tgdata/__init__.py +184 -0
- tgdata-0.1/tgdata/account_operation.py +137 -0
- tgdata-0.1/tgdata/account_pool.py +691 -0
- tgdata-0.1/tgdata/backfill.py +498 -0
- tgdata-0.1/tgdata/backfill_engine.py +747 -0
- tgdata-0.1/tgdata/backfill_state.py +316 -0
- tgdata-0.1/tgdata/batch_engine.py +235 -0
- tgdata-0.1/tgdata/batch_files.py +179 -0
- tgdata-0.1/tgdata/budget_client.py +155 -0
- tgdata-0.1/tgdata/connection_engine.py +1051 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/discovery_engine.py +72 -21
- tgdata-0.1/tgdata/group_operations.py +423 -0
- tgdata-0.1/tgdata/health.py +763 -0
- tgdata-0.1/tgdata/history_window.py +77 -0
- tgdata-0.1/tgdata/join_budget.py +311 -0
- tgdata-0.1/tgdata/join_client.py +108 -0
- tgdata-0.1/tgdata/login.py +524 -0
- tgdata-0.1/tgdata/message_batch.py +307 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/message_engine.py +43 -3
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/models.py +16 -2
- tgdata-0.1/tgdata/owned_health.py +239 -0
- tgdata-0.1/tgdata/pool_lifecycle.py +137 -0
- tgdata-0.1/tgdata/pool_observation.py +82 -0
- tgdata-0.1/tgdata/pool_result.py +138 -0
- tgdata-0.1/tgdata/pool_source.py +206 -0
- tgdata-0.1/tgdata/pool_state.py +236 -0
- tgdata-0.1/tgdata/progress_facade.py +140 -0
- tgdata-0.1/tgdata/read_budget.py +322 -0
- tgdata-0.1/tgdata/session_store.py +211 -0
- tgdata-0.1/tgdata/smoke_tests/pool_storage_cases.py +419 -0
- tgdata-0.1/tgdata/smoke_tests/test_12_proxy.py +367 -0
- tgdata-0.1/tgdata/smoke_tests/test_13_device_identity.py +221 -0
- tgdata-0.1/tgdata/smoke_tests/test_14_flood_threshold.py +468 -0
- tgdata-0.1/tgdata/smoke_tests/test_15_login_checks.py +340 -0
- tgdata-0.1/tgdata/smoke_tests/test_16_health_events.py +977 -0
- tgdata-0.1/tgdata/smoke_tests/test_17_session_store.py +602 -0
- tgdata-0.1/tgdata/smoke_tests/test_18_read_budget.py +718 -0
- tgdata-0.1/tgdata/smoke_tests/test_19_message_batches.py +932 -0
- tgdata-0.1/tgdata/smoke_tests/test_22_daily_continuation.py +758 -0
- tgdata-0.1/tgdata/smoke_tests/test_23_fixed_windows.py +489 -0
- tgdata-0.1/tgdata/smoke_tests/test_24_backfill_state.py +536 -0
- tgdata-0.1/tgdata/smoke_tests/test_25_backfill_delivery.py +836 -0
- tgdata-0.1/tgdata/smoke_tests/test_26_backfill_completion.py +436 -0
- tgdata-0.1/tgdata/smoke_tests/test_27_backfill_pacing.py +682 -0
- tgdata-0.1/tgdata/smoke_tests/test_28_backfill_controls.py +599 -0
- tgdata-0.1/tgdata/smoke_tests/test_29_backfill_public.py +487 -0
- tgdata-0.1/tgdata/smoke_tests/test_30_backfill_integration.py +403 -0
- tgdata-0.1/tgdata/smoke_tests/test_31_account_pool.py +1040 -0
- tgdata-0.1/tgdata/smoke_tests/test_32_account_operation.py +621 -0
- tgdata-0.1/tgdata/smoke_tests/test_33_owned_health.py +1045 -0
- tgdata-0.1/tgdata/smoke_tests/test_34_group_access.py +865 -0
- tgdata-0.1/tgdata/smoke_tests/test_35_join_budget.py +595 -0
- tgdata-0.1/tgdata/smoke_tests/test_36_group_join.py +865 -0
- tgdata-0.1/tgdata/smoke_tests/test_37_worker.py +683 -0
- tgdata-0.1/tgdata/smoke_tests/test_38_worker_process.py +644 -0
- tgdata-0.1/tgdata/smoke_tests/test_39_pool_lifecycle.py +490 -0
- tgdata-0.1/tgdata/smoke_tests/test_40_pool_outcomes.py +763 -0
- tgdata-0.1/tgdata/smoke_tests/test_41_stepwise_login.py +702 -0
- tgdata-0.1/tgdata/sync_engine.py +188 -0
- tgdata-0.1/tgdata/sync_store.py +364 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/tgdata.py +417 -52
- tgdata-0.1/tgdata/worker.py +264 -0
- tgdata-0.1/tgdata/worker_engine.py +280 -0
- tgdata-0.1/tgdata/worker_protocol.py +306 -0
- tgdata-0.1/tgdata.egg-info/PKG-INFO +1242 -0
- tgdata-0.1/tgdata.egg-info/SOURCES.txt +92 -0
- tgdata-0.1/tgdata.egg-info/entry_points.txt +2 -0
- tgdata-0.1/tgdata.egg-info/requires.txt +5 -0
- tgdata-0.0.8/PKG-INFO +0 -483
- tgdata-0.0.8/README.md +0 -457
- tgdata-0.0.8/tgdata/__init__.py +0 -69
- tgdata-0.0.8/tgdata/connection_engine.py +0 -428
- tgdata-0.0.8/tgdata.egg-info/PKG-INFO +0 -483
- tgdata-0.0.8/tgdata.egg-info/SOURCES.txt +0 -33
- tgdata-0.0.8/tgdata.egg-info/requires.txt +0 -2
- {tgdata-0.0.8 → tgdata-0.1}/setup.cfg +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/progress.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/__init__.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/my_test.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_00_connection.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_01_list_group_chats.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_02_get_message_count.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_03_get_all_messages.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_05_with_progress_feature.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_06_polling_simple.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_07_on_new_message_simple.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_07_real_time_event_handler.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_08_batch_callback.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_08_polling.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_10_advanced_features.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_11_discover_groups.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_debug_min_id.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_polling_debug.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/smoke_tests/test_username_support.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata/utils.py +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata.egg-info/dependency_links.txt +0 -0
- {tgdata-0.0.8 → tgdata-0.1}/tgdata.egg-info/top_level.txt +0 -0
tgdata-0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tgdata
|
|
3
|
+
Version: 0.1
|
|
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
|
|
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
|
+
- **Durable daily continuation**: Keep per-group progress and pending batches in a pluggable store, replay after restart, and advance only after explicit acknowledgment
|
|
63
|
+
- **Reading worker**: Run a serial process that accepts explicit-account JSON jobs for batches, group lookup, access checks and joining, with correlated health replies
|
|
64
|
+
|
|
65
|
+
- **Account routing and failover**: An optional pool chooses among distinct existing accounts, enforces their budgets and preserves daily/backfill delivery; automatic joining is excluded
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install tgdata
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Reading worker
|
|
74
|
+
|
|
75
|
+
Run `python -m tgdata.worker --config worker.json` to accept serial JSON-line
|
|
76
|
+
jobs for an explicitly selected account: read a message batch, look up a group,
|
|
77
|
+
check access or join. The worker reuses existing sessions, proxy/device settings
|
|
78
|
+
and provisioned budgets, verifies the account for each job, and returns correlated
|
|
79
|
+
results and health events. It never requests a login code or retries a lost job.
|
|
80
|
+
|
|
81
|
+
See [the worker protocol and setup](docs/worker.md) for Telethon 1.45.0, inventory,
|
|
82
|
+
reply/exit semantics and session-store bootstrap. Try
|
|
83
|
+
`python examples/reading_worker.py --demo` for an offline process demonstration.
|
|
84
|
+
Scheduling, result storage and continuation acknowledgments stay with the caller.
|
|
85
|
+
|
|
86
|
+
## Authentication
|
|
87
|
+
|
|
88
|
+
Use terminal login for a local first run, or the explicit `LoginFlow` API for
|
|
89
|
+
an application that collects the phone, code and optional password itself.
|
|
90
|
+
|
|
91
|
+
1. get telegram development credentials in telegram API Development Tools from [https://my.telegram.org/apps](https://my.telegram.org/apps)
|
|
92
|
+
|
|
93
|
+
2. Create a `config.ini` file with your Telegram API credentials like this:
|
|
94
|
+
|
|
95
|
+
```ini
|
|
96
|
+
[Telegram]
|
|
97
|
+
|
|
98
|
+
# you can get telegram development credentials in telegram API Development Tools
|
|
99
|
+
api_id = 1234566
|
|
100
|
+
api_hash = a24adjfakjdfakjshdflkajsbdflk
|
|
101
|
+
phone = +905064004949 # use full phone number including + and country code
|
|
102
|
+
|
|
103
|
+
# Where the Telethon session lives (".session" is appended automatically).
|
|
104
|
+
# Use an ABSOLUTE path — a bare name creates the file in whatever directory
|
|
105
|
+
# the process happens to run from. Do not quote values: INI values are raw
|
|
106
|
+
# text, so quotes become part of the value (tgdata strips them defensively
|
|
107
|
+
# since 0.0.4, but don't rely on it).
|
|
108
|
+
session_file = /absolute/path/to/my_session
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Session naming precedence** (since 0.0.4): an explicit `session_file`
|
|
112
|
+
always wins. A legacy `username` key is used as the session name only when
|
|
113
|
+
no `session_file` is set — configs that relied on username-named sessions
|
|
114
|
+
keep working, but new configs should set `session_file` and omit `username`
|
|
115
|
+
(it plays no part in authentication).
|
|
116
|
+
|
|
117
|
+
### Logging in
|
|
118
|
+
|
|
119
|
+
The first run of a new session, started by hand in a terminal, asks for the
|
|
120
|
+
code Telegram sends you. After that, tgdata never asks Telegram for a login
|
|
121
|
+
code on its own: before connecting it checks with Telegram whether the session
|
|
122
|
+
is logged in, and if it is not, it raises `AuthRequiredError` instead of
|
|
123
|
+
prompting.
|
|
124
|
+
|
|
125
|
+
- `.reason` is Telegram's own name for the problem — `AUTH_KEY_UNREGISTERED`
|
|
126
|
+
or `SESSION_REVOKED` when Telegram logged the account out,
|
|
127
|
+
`USER_DEACTIVATED_BAN` when it is banned. `.banned` is true when logging in
|
|
128
|
+
again cannot help.
|
|
129
|
+
- To log back in after Telegram logged the account out, run once by hand in a
|
|
130
|
+
terminal with `TgData("config.ini", interactive_login=True)`.
|
|
131
|
+
`interactive_login=False` forbids even the first-run prompt.
|
|
132
|
+
- A background job should stop and alert someone on `AuthRequiredError`.
|
|
133
|
+
Retrying cannot log it back in — and, unlike before, it no longer sends the
|
|
134
|
+
owner a fresh login code on every retry.
|
|
135
|
+
|
|
136
|
+
### Step-by-step login without console prompts
|
|
137
|
+
|
|
138
|
+
`LoginFlow` lets your application supply each input in a separate call:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from tgdata import LoginFlow
|
|
142
|
+
|
|
143
|
+
flow = LoginFlow("config.ini")
|
|
144
|
+
status = await flow.start(phone) # Deliberately requests one code.
|
|
145
|
+
status = await flow.submit_code(code)
|
|
146
|
+
if status.step == "password":
|
|
147
|
+
status = await flow.submit_password(password)
|
|
148
|
+
if status.step == "complete" and not status.cleanup_pending:
|
|
149
|
+
# session_name must equal the configured session_file store key.
|
|
150
|
+
store.save(session_name, flow.session_data)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Inspect `status` after every call: waits, expired codes and unsupported Telegram
|
|
154
|
+
interactions need application handling. The flow uses the configured proxy and
|
|
155
|
+
device settings and never loads or replaces an existing session automatically.
|
|
156
|
+
The application saves the finished credential explicitly.
|
|
157
|
+
|
|
158
|
+
You can keep the flow in memory or store `flow.export_state()` privately and
|
|
159
|
+
restore it with `LoginFlow("config.ini", state=saved_state)`. Use only the latest
|
|
160
|
+
state with one owner at a time. The state is secret, not a browser token; restoring
|
|
161
|
+
it does not make an interrupted request safe to repeat. See the
|
|
162
|
+
[login guide](docs/stepwise_login.md) for errors, cancellation, cleanup and
|
|
163
|
+
session-store integration. Qualified offline against Telethon 1.45.0; live code
|
|
164
|
+
delivery and password acceptance remain unqualified.
|
|
165
|
+
|
|
166
|
+
### Keeping sessions out of files
|
|
167
|
+
|
|
168
|
+
Pass a store once to keep each account's session in your application's storage:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
import sqlite3
|
|
172
|
+
from tgdata import TgData
|
|
173
|
+
|
|
174
|
+
class SessionStore:
|
|
175
|
+
def __init__(self, path):
|
|
176
|
+
self.db = sqlite3.connect(path)
|
|
177
|
+
with self.db:
|
|
178
|
+
self.db.execute(
|
|
179
|
+
"CREATE TABLE IF NOT EXISTS sessions "
|
|
180
|
+
"(name TEXT PRIMARY KEY, data TEXT NOT NULL)"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def load(self, name):
|
|
184
|
+
row = self.db.execute(
|
|
185
|
+
"SELECT data FROM sessions WHERE name = ?", (name,)
|
|
186
|
+
).fetchone()
|
|
187
|
+
return row[0] if row else None
|
|
188
|
+
|
|
189
|
+
def save(self, name, data):
|
|
190
|
+
with self.db:
|
|
191
|
+
self.db.execute(
|
|
192
|
+
"INSERT OR REPLACE INTO sessions (name, data) VALUES (?, ?)",
|
|
193
|
+
(name, data),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def delete(self, name): # optional; used on Telegram log-out
|
|
197
|
+
with self.db:
|
|
198
|
+
self.db.execute("DELETE FROM sessions WHERE name = ?", (name,))
|
|
199
|
+
|
|
200
|
+
store = SessionStore("accounts.sqlite3")
|
|
201
|
+
tg = TgData("config.ini", session_store=store)
|
|
202
|
+
# Use tg as usual. When finished, await tg.close(), then store.db.close().
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
This example keeps sessions in a small SQLite table across restarts. Supply
|
|
206
|
+
your own store to use another database or storage service. The methods are
|
|
207
|
+
synchronous, not `async def`, and run on the
|
|
208
|
+
event loop: keep them quick, for example with a local database or an in-memory
|
|
209
|
+
cache that writes behind. tgdata ships no storage backend.
|
|
210
|
+
|
|
211
|
+
- **Names:** `session_file` is the store key, used as configured; no `.session`
|
|
212
|
+
suffix is added. The existing fallback to `username`, then `telegram_session`,
|
|
213
|
+
still applies. Persistent and short-lived clients share that name; pool
|
|
214
|
+
connections use `<name>_1`, `<name>_2`, and so on.
|
|
215
|
+
- **Contents:** one opaque, versioned string containing the login key, data
|
|
216
|
+
centre, update states, groups and channels with their access hashes, and the
|
|
217
|
+
account's own identity rows. Message senders are excluded from this session
|
|
218
|
+
cache. The temporary key and sent-file cache are not persisted.
|
|
219
|
+
- **Keep the string as text.** It is not Telethon's `StringSession` format or a
|
|
220
|
+
JSON document for the store to interpret. Its encoding is not encryption;
|
|
221
|
+
access control and any encryption belong to your store.
|
|
222
|
+
- **Save points:** whenever Telethon saves (connect, data-centre changes, new
|
|
223
|
+
login keys, and periodically while connected), plus disconnect/`close()`.
|
|
224
|
+
Unchanged state causes no write. Always close the client when finished.
|
|
225
|
+
- **Failures:** a store load error or unreadable saved string raises before a
|
|
226
|
+
connection opens. A failed save is logged at ERROR and retried at the next
|
|
227
|
+
save point; failed deletes are logged too. Save/delete logs contain the
|
|
228
|
+
session name and error type, never the error text or traceback, which could
|
|
229
|
+
contain the login key.
|
|
230
|
+
- **Two clients:** with the same login, the later save wins for the cache.
|
|
231
|
+
Before a changed save or deletion, a client re-reads the stored key. If it
|
|
232
|
+
changed or was removed, the client skips the mutation and warns once. Logging
|
|
233
|
+
out an older client therefore preserves a newer login saved by another client.
|
|
234
|
+
This check and save are separate operations, not an atomic guarantee across
|
|
235
|
+
processes. Telethon's session copies for side connections stay in memory and
|
|
236
|
+
never write to the store.
|
|
237
|
+
- **Login rules stay the same:** `None` from `load` means a new session; an
|
|
238
|
+
existing session that Telegram logged out still raises `AuthRequiredError`.
|
|
239
|
+
If `delete` is provided, Telegram log-out removes the stored record only when
|
|
240
|
+
it still holds the login that client last loaded or saved.
|
|
241
|
+
|
|
242
|
+
Without `session_store`, existing `.session` files work exactly as before.
|
|
243
|
+
There is no automatic migration from files to a store. This option is designed
|
|
244
|
+
and tested against Telethon 1.45.0.
|
|
245
|
+
|
|
246
|
+
### Connecting through a proxy (optional)
|
|
247
|
+
|
|
248
|
+
With no `proxy` key, tgdata connects directly, exactly as before. Add one, and
|
|
249
|
+
**every** connection to Telegram goes through it — the persistent client, each
|
|
250
|
+
pool connection, and every short lookup. All of them are built by one function
|
|
251
|
+
in the connection engine, so there is no second way in.
|
|
252
|
+
|
|
253
|
+
```ini
|
|
254
|
+
[Telegram]
|
|
255
|
+
api_id = 1234566
|
|
256
|
+
api_hash = a24adjfakjdfakjshdflkajsbdflk
|
|
257
|
+
session_file = /absolute/path/to/my_session
|
|
258
|
+
proxy = socks5://user:password@203.0.113.7:1080
|
|
259
|
+
require_proxy = true
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
- **Forms:** `socks5://host:port`, `socks5://user:pass@host:port`,
|
|
263
|
+
`socks4://…`, `http://…` (an HTTP CONNECT proxy). Percent-encode special
|
|
264
|
+
characters in the user name or password (`@` as `%40`, `:` as `%3A`).
|
|
265
|
+
- **`require_proxy = true`** refuses to connect when no proxy is configured.
|
|
266
|
+
Use it on accounts that must never touch Telegram from the machine's own
|
|
267
|
+
IP.
|
|
268
|
+
- **No fallback, ever.** A malformed URL, a missing proxy library, or
|
|
269
|
+
`require_proxy` without a proxy raises `tgdata.ProxyConfigError` before any
|
|
270
|
+
connection opens. A proxy that is down or refuses the tunnel makes the
|
|
271
|
+
connection fail with `ConnectionError`. Nothing retries without the proxy.
|
|
272
|
+
- **Install the extra:** `pip install 'tgdata[proxy]'`, which pulls in
|
|
273
|
+
`python-socks[asyncio]`.
|
|
274
|
+
- **Logs and health checks show the proxy masked**
|
|
275
|
+
(`socks5://user:***@203.0.113.7:1080`); the password never appears in a
|
|
276
|
+
log line or a repr. The engine logs once whether connections are direct or
|
|
277
|
+
via a proxy.
|
|
278
|
+
- **Not supported:** MTProto proxies (`tg://proxy?…`, `t.me/proxy?…`); they
|
|
279
|
+
are refused with a clear error.
|
|
280
|
+
- One `proxy` per config file, and the account pool has one config per
|
|
281
|
+
account, so each account gets its own proxy.
|
|
282
|
+
- An existing session keeps working through a proxy; a session file is not
|
|
283
|
+
tied to an IP.
|
|
284
|
+
|
|
285
|
+
### A fixed device identity (optional)
|
|
286
|
+
|
|
287
|
+
Every connection tells Telegram what device it is running on — a device
|
|
288
|
+
model, a system version, an app version and language codes. Telegram shows
|
|
289
|
+
them in the account's active-sessions list. Unless you pin them, Telethon
|
|
290
|
+
derives them from the machine and from its own version, so the same account
|
|
291
|
+
presents a different device on another machine, after an OS update, and after
|
|
292
|
+
every Telethon upgrade. On a Mac today that is `arm64`, `25.6.0`, `1.40.0`.
|
|
293
|
+
|
|
294
|
+
Pin them in the account's config, and every connection presents exactly
|
|
295
|
+
that, from any machine:
|
|
296
|
+
|
|
297
|
+
```ini
|
|
298
|
+
[Telegram]
|
|
299
|
+
device_model = PC 64bit
|
|
300
|
+
system_version = Windows 11
|
|
301
|
+
app_version = 4.16.8 x64
|
|
302
|
+
lang_code = en
|
|
303
|
+
system_lang_code = en
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- **All five keys are optional.** No keys means Telethon's defaults, exactly
|
|
307
|
+
as before; a key you leave out keeps its default. `lang_code` alone also
|
|
308
|
+
sets `system_lang_code`. Values may contain spaces; quotes and an inline
|
|
309
|
+
`; comment` are stripped.
|
|
310
|
+
- **You can add it later.** The identity is sent on every connection, not
|
|
311
|
+
only at login, so an existing session picks it up on its next connection —
|
|
312
|
+
no new login.
|
|
313
|
+
- **To keep what an account shows today**, pin its current values instead of
|
|
314
|
+
inventing new ones (switching identity is itself a visible change). Run
|
|
315
|
+
this on the machine the account normally runs on and paste the output into
|
|
316
|
+
its config:
|
|
317
|
+
|
|
318
|
+
```python
|
|
319
|
+
print(TgData("config.ini").device_identity()['config_lines'])
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
`device_identity()` needs no network and no session; it also returns the
|
|
323
|
+
presented values and which ones are pinned. `health_check()` reports the
|
|
324
|
+
presented identity too.
|
|
325
|
+
- Whether Telegram cares about a changing identity has not been measured;
|
|
326
|
+
pinning is a precaution for accounts that move between machines.
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
## Per-account read budgets
|
|
330
|
+
|
|
331
|
+
Pass a `ReadBudget` to limit explicit message pulls across jobs for the same
|
|
332
|
+
Telegram account. Every persistent, pooled and short-lived client built by
|
|
333
|
+
that `TgData` uses it. Without `read_budget`, existing behavior is unchanged.
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
import asyncio
|
|
337
|
+
from tgdata import TgData, ReadBudget, ReadBudgetExceeded
|
|
338
|
+
|
|
339
|
+
async def main():
|
|
340
|
+
budget = ReadBudget("/absolute/path/read-budgets.sqlite3")
|
|
341
|
+
tg = TgData("config.ini", read_budget=budget)
|
|
342
|
+
try:
|
|
343
|
+
# Policy setup: get the authenticated account ID using metadata only.
|
|
344
|
+
client = await tg.connection_engine.get_client()
|
|
345
|
+
me = await client.get_me()
|
|
346
|
+
budget.configure(
|
|
347
|
+
account_id=me.id,
|
|
348
|
+
daily_limit=2000,
|
|
349
|
+
warmup=[(0, 100), (2, 300), (7, 2000)],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
messages = await tg.get_messages("@channelname", limit=2000)
|
|
354
|
+
except ReadBudgetExceeded as error:
|
|
355
|
+
messages = error.partial_result
|
|
356
|
+
print(error.status.to_dict())
|
|
357
|
+
print("Retry after seconds:", error.retry_after)
|
|
358
|
+
|
|
359
|
+
if messages is not None:
|
|
360
|
+
print(messages)
|
|
361
|
+
print((await tg.get_read_budget()).to_dict())
|
|
362
|
+
finally:
|
|
363
|
+
await tg.close()
|
|
364
|
+
|
|
365
|
+
asyncio.run(main())
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
The numbers above illustrate configuration; they are **not Telegram-safe rate
|
|
369
|
+
recommendations**. Warm-up days are completed 24-hour periods since enrollment,
|
|
370
|
+
not calendar days. The example starts at 100, rises to 300 after two days, and
|
|
371
|
+
to 2,000 after seven. Without `warmup`, the full `daily_limit` applies immediately.
|
|
372
|
+
A zero cap pauses reads. A curve starts at day zero, has unique nonnegative
|
|
373
|
+
integer days and nondecreasing caps, and never exceeds `daily_limit`.
|
|
374
|
+
|
|
375
|
+
`configure()` deliberately replaces the limit and curve, while retaining usage
|
|
376
|
+
and the original start time. Omit it in later jobs to keep the stored policy.
|
|
377
|
+
Pass `started_at` explicitly to set a known enrollment time: a timezone-aware
|
|
378
|
+
datetime or UTC timestamp, no later than now. The last curve cap continues
|
|
379
|
+
until the policy changes; it does not automatically rise to `daily_limit`.
|
|
380
|
+
|
|
381
|
+
- **Shared identity and storage:** use the same SQLite file on the same host
|
|
382
|
+
for every reader that should share an allowance. Keys are authenticated
|
|
383
|
+
Telegram account IDs, not session names or pool suffixes. Different accounts
|
|
384
|
+
have separate policies. A missing policy stops message reads. The parent
|
|
385
|
+
directory must exist; `:memory:` is not supported. The ledger stores account
|
|
386
|
+
IDs, policies and counters, never login keys or message content.
|
|
387
|
+
- **What spends the allowance:** history, message search, replies, explicit
|
|
388
|
+
message-ID lookups, discovery post reading, and message re-fetches during
|
|
389
|
+
media operations. Repeated reads, service/empty message slots and messages
|
|
390
|
+
later filtered out still count. A count operation that fetches a message
|
|
391
|
+
counts too. User/group/dialog metadata and incidental dialog previews,
|
|
392
|
+
passive updates and update recovery, and downloading file bytes are excluded.
|
|
393
|
+
- **When it counts:** before each actual send, tgdata reserves the request's
|
|
394
|
+
maximum number of messages. Successful replies settle to the returned slot
|
|
395
|
+
count and release unused capacity. Failed, cancelled or crashed attempts
|
|
396
|
+
retain their full claim; each retry needs another reservation. Cached flood
|
|
397
|
+
waits do not spend capacity before a send. An oversized reply is charged at
|
|
398
|
+
its actual count and raises a local error.
|
|
399
|
+
- **Rolling expiry:** each claim expires 24 hours after its send admission,
|
|
400
|
+
regardless of when the reply arrives. There is no midnight reset. A persisted
|
|
401
|
+
clock high-water mark prevents backward clock changes from restoring spent
|
|
402
|
+
capacity. Keep the host clock accurate; forward jumps advance expiry and
|
|
403
|
+
warm-up. No SQLite transaction stays open while awaiting Telegram. Operations
|
|
404
|
+
are synchronous and normally short, but contention can block the event loop
|
|
405
|
+
for up to the five-second SQLite busy timeout before raising.
|
|
406
|
+
- **Status and stopping:** `budget.status(account_id)` is local;
|
|
407
|
+
`await tg.get_read_budget()` verifies the active account and returns its
|
|
408
|
+
status, or `None` without connecting when disabled. `used` includes outstanding
|
|
409
|
+
`reserved` capacity; `remaining` is the available allowance. `to_dict()` adds
|
|
410
|
+
ISO timestamps and `retry_after` for one message. An exhaustion exception
|
|
411
|
+
also has `requested`, `account_id`, `next_available_at` and `retry_after` for
|
|
412
|
+
that request. Retry times are estimates assuming no new claims or policy
|
|
413
|
+
changes; `None` means a smaller request or policy change is needed.
|
|
414
|
+
- **Partial results:** fetch/search/discovery errors carry the processed
|
|
415
|
+
DataFrame in `partial_result`; media-by-ID errors carry completed entries
|
|
416
|
+
in a dictionary. Batch callbacks may already have seen some of those rows.
|
|
417
|
+
Polling delivers and deduplicates its partial frame, then raises exhaustion
|
|
418
|
+
instead of automatically retrying. Other `ReadBudgetError` subclasses also
|
|
419
|
+
stop polling. These are local policy/storage errors, not Telegram health
|
|
420
|
+
verdicts; actual Telegram errors keep their existing health reporting.
|
|
421
|
+
- **Request boundaries:** history/search pages shrink to the available
|
|
422
|
+
allowance while preserving their cursors. ID chunks and raw request bounds
|
|
423
|
+
are not silently truncated, so a chunk larger than the remaining allowance
|
|
424
|
+
is refused. Use smaller ID lists when needed. Unbounded or unsupported raw
|
|
425
|
+
message-returning calls and message-bearing batches raise
|
|
426
|
+
`UnsupportedBudgetRequest`; metadata-only batches still work. Supported
|
|
427
|
+
invoke wrappers retain the inner message bound.
|
|
428
|
+
- **Identity verification:** each message send uses a fresh Telegram self
|
|
429
|
+
lookup, including retries; page sizing and status can add another lookup.
|
|
430
|
+
Cached session identities are not billing authority. Storage/configuration
|
|
431
|
+
errors fail closed before further message sends; failed settlement retains
|
|
432
|
+
the prior full claim. Clients created separately, other ledger files and
|
|
433
|
+
direct use of private transport internals are outside this guard.
|
|
434
|
+
|
|
435
|
+
The budget suite exercises real Telethon request/iterator code with scripted
|
|
436
|
+
replies on 1.45.0. It does not test live Telegram response bounds or
|
|
437
|
+
whether any chosen limit prevents account restrictions.
|
|
438
|
+
|
|
439
|
+
### Joining with an account allowance
|
|
440
|
+
|
|
441
|
+
`JoinBudget` provides persistent per-account policy and status for admitted join
|
|
442
|
+
attempts in a rolling 24-hour window. Provision explicitly with
|
|
443
|
+
`JoinBudget("allowances.sqlite3", create=True)`, then reopen without `create=True`
|
|
444
|
+
in later jobs. `configure(account_id, daily_limit)` retains spent allowance;
|
|
445
|
+
zero pauses admission. `status(account_id).to_dict()` returns a local snapshot.
|
|
446
|
+
|
|
447
|
+
Pass that ledger to `TgData(config_path, join_budget=budget)` and call
|
|
448
|
+
`await tg.join_group(target, account_id=expected_numeric_id)`. A temporary client
|
|
449
|
+
verifies the account, resolves the target, and claims one attempt immediately before
|
|
450
|
+
each new SDK join/import send. Known membership or a known payment requirement
|
|
451
|
+
returns without a mutation or charge. Failed or interrupted admitted attempts are
|
|
452
|
+
not refunded.
|
|
453
|
+
|
|
454
|
+
The portable result distinguishes `joined`, `already_joined`, `requested`,
|
|
455
|
+
`payment_required` and `interaction_required`. Its group metadata is from preflight;
|
|
456
|
+
joining does not prove history access. Payments and bot interactions are left to the
|
|
457
|
+
caller. The join guard applies to this operation; raw clients remain outside it.
|
|
458
|
+
See [joining and outcomes](docs/group_joining.md) and
|
|
459
|
+
[the allowance contract](docs/join_budget.md) for configuration and failure behavior.
|
|
460
|
+
|
|
461
|
+
## Account routing and failover
|
|
462
|
+
|
|
463
|
+
Use `AccountPool` when tgdata should choose among distinct, already logged-in accounts
|
|
464
|
+
for a group read. Supply account configurations, existing budget policies, canonical
|
|
465
|
+
groups and persistent pool state. It supports raw batches and existing daily/backfill
|
|
466
|
+
operations. Plain `TgData` connection pooling still means sessions of one account.
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
from tgdata import (
|
|
470
|
+
AccountPool, PoolAccount, PoolGroup, PoolReadCapture,
|
|
471
|
+
ReadBudget, SQLiteAccountPoolStore,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
pool = AccountPool(
|
|
475
|
+
[PoolAccount(111111, "account-a.ini"), PoolAccount(222222, "account-b.ini")],
|
|
476
|
+
[PoolGroup(-1001234567890, "@my_existing_group")],
|
|
477
|
+
read_budget=ReadBudget("existing-read-budgets.sqlite3"),
|
|
478
|
+
state_store=SQLiteAccountPoolStore("existing-pool.sqlite3", create=False),
|
|
479
|
+
)
|
|
480
|
+
capture = PoolReadCapture() # Keep this reference outside the awaited read.
|
|
481
|
+
try:
|
|
482
|
+
result = await pool.read(-1001234567890, capture=capture, after_id=500, limit=100)
|
|
483
|
+
print(result.account_id, result.batch.batch_id)
|
|
484
|
+
finally:
|
|
485
|
+
snapshot = capture.snapshot() # Available complete records also survive failure/cancellation.
|
|
486
|
+
await pool.close()
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Pool `read`, `get_message_batch`, `sync_group` and `prepare_backfill` require a new
|
|
490
|
+
`capture=` for each call, including stored replay. Its synchronous `snapshot()` returns
|
|
491
|
+
immutable state and available data independently of exceptions or asyncio wrappers.
|
|
492
|
+
While the call is running, its batch contains only complete records with an
|
|
493
|
+
`interrupted` stop reason. Final success preserves the actual batch; errors and
|
|
494
|
+
cancellation preserve the complete interrupted prefix. A snapshot is not an
|
|
495
|
+
acknowledgment receipt. Ordinary `TgData` calls keep their signatures.
|
|
496
|
+
|
|
497
|
+
For first provisioning, create a new pool store and explicitly call `initialize()`;
|
|
498
|
+
do not recreate missing known state on restart. Completed partial batches stop failover
|
|
499
|
+
so their data can be delivered first. Unknown attempts require explicit recovery.
|
|
500
|
+
No eligible account raises structured `PoolUnavailable`, not empty history.
|
|
501
|
+
|
|
502
|
+
The adapter requires Telethon **1.45.0**, one owning process and exclusive sessions.
|
|
503
|
+
It never joins or prompts. Results describe the selected account's visible history.
|
|
504
|
+
Cancellation waits for the call's owned source/storage work to settle. Closing the
|
|
505
|
+
pool joins its own operation and retires clients without waiting for unrelated
|
|
506
|
+
application work after that operation ends. A daily/backfill publication can still
|
|
507
|
+
be running under its caller; its capture becomes final when that public call ends.
|
|
508
|
+
Provider-owned interruption is a local storage error, not caller cancellation.
|
|
509
|
+
Completed data remains available through the retained capture without implying a
|
|
510
|
+
durable receipt. Captures are kept in memory; existing progress stores govern replay.
|
|
511
|
+
Failed client retirement blocks reuse until an explicit recheck or close succeeds.
|
|
512
|
+
Use synchronous `pool.drain_health_events()` for bounded passive health observations;
|
|
513
|
+
AccountPool does not accept application health callbacks.
|
|
514
|
+
Offline tests pass; two-account live qualification remains pending. See
|
|
515
|
+
[Account pools](docs/account_pool.md) for selection, recovery and delivery contracts.
|
|
516
|
+
Run the offline example with `python examples/account_pool.py --demo`.
|
|
517
|
+
|
|
518
|
+
## Quick Start
|
|
519
|
+
|
|
520
|
+
### List All Group Chats
|
|
521
|
+
|
|
522
|
+
This is required for finding chat id for the chat of interest.
|
|
523
|
+
|
|
524
|
+
```python
|
|
525
|
+
from tgdata import TgData
|
|
526
|
+
import asyncio
|
|
527
|
+
|
|
528
|
+
async def main():
|
|
529
|
+
# Initialize the client
|
|
530
|
+
tg = TgData("config.ini")
|
|
531
|
+
|
|
532
|
+
# List available groups and channels
|
|
533
|
+
groups = await tg.list_groups()
|
|
534
|
+
|
|
535
|
+
print(groups)
|
|
536
|
+
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Outputs:
|
|
540
|
+
|
|
541
|
+
```
|
|
542
|
+
Python Devs 10012312313
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
### Look up a group and check access
|
|
546
|
+
|
|
547
|
+
Use an expected numeric Telegram account ID for a short-lived, read-only observation:
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
async with TgData("config.ini", interactive_login=False) as tg:
|
|
551
|
+
access = await tg.check_group_access("@example_group", account_id=123456789)
|
|
552
|
+
print(access.status) # readable, denied, or unprobed
|
|
553
|
+
if access.lookup is not None:
|
|
554
|
+
print(access.group.title, access.member)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
`check_group_access` includes lookup details and probes at most one message of history
|
|
558
|
+
through the existing read budget. `lookup_group(target, account_id=...)` returns only
|
|
559
|
+
metadata and membership hints, without reading history. Both verify the actual account,
|
|
560
|
+
close their temporary client, and never log in or join. Operational failures still raise;
|
|
561
|
+
`unprobed` means there was no usable peer, and `denied` means a classified Telegram refusal.
|
|
562
|
+
|
|
563
|
+
Handles, supported Telegram links and deterministic numeric references are accepted.
|
|
564
|
+
Numeric channel IDs need a cached access hash. Readability is distinct from membership
|
|
565
|
+
and does not promise future access. See [the group-operations contract](docs/group_operations.md)
|
|
566
|
+
for input rules, result fields, errors, budget costs and the known SDK cache limitation.
|
|
567
|
+
|
|
568
|
+
### Group discovery — finding groups you are not in
|
|
569
|
+
|
|
570
|
+
`list_groups` shows what the account already follows. To find *new* rooms there are three
|
|
571
|
+
routes, all returning the same columns as `list_groups` plus `FoundVia` and `FoundBy`.
|
|
572
|
+
This is the quick start; the rules for running discovery on a real account without earning
|
|
573
|
+
a block — pacing, budgets, resumable runs, reading the output — are in
|
|
574
|
+
[`devdocs/guides/group_discovery.md`](devdocs/guides/group_discovery.md).
|
|
575
|
+
|
|
576
|
+
```python
|
|
577
|
+
from tgdata import TgData, build_search_queries
|
|
578
|
+
|
|
579
|
+
tg = TgData("config.ini")
|
|
580
|
+
|
|
581
|
+
# 1. Telegram's global search — matches room NAMES and @usernames only, never post text
|
|
582
|
+
df = await tg.search_groups("Анталия аренда")
|
|
583
|
+
|
|
584
|
+
# 2. Telegram's "similar channels" — rooms whose subscribers overlap the seeds', no keyword needed
|
|
585
|
+
df = await tg.similar_groups(["@antalyadaa", "@sprout_antalya"], rounds=2)
|
|
586
|
+
|
|
587
|
+
# 3. t.me links found in a room's recent posts — usernames only (free), or resolved
|
|
588
|
+
df = await tg.linked_groups(group_id, posts=100, resolve=False)
|
|
589
|
+
|
|
590
|
+
# All three on one session, deduplicated by id, in cost order
|
|
591
|
+
df = await tg.discover_groups(
|
|
592
|
+
seeds=["@antalyadaa"],
|
|
593
|
+
queries=build_search_queries(["Анталия", "Antalya"], topics=["чат", "аренда", "новости"],
|
|
594
|
+
prefixes=["Турция"]),
|
|
595
|
+
mine_links=True,
|
|
596
|
+
heartbeat=lambda phase: print(phase), # liveness ticks: search / similar / links / pause / flood-wait Ns
|
|
597
|
+
found_callback=lambda row: rows.append(row), # each room as it is found — persist as you go
|
|
598
|
+
)
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**The name-only rule.** Telegram's search matches names and @usernames, never what a room
|
|
602
|
+
posts. A perfect room under an unrelated name is invisible to every query — that is what
|
|
603
|
+
`similar_groups` and `linked_groups` are for. Rooms name themselves "place + topic", so
|
|
604
|
+
`build_search_queries` combines your place spellings with topic words, in both word orders.
|
|
605
|
+
|
|
606
|
+
| Column | Meaning |
|
|
607
|
+
|---|---|
|
|
608
|
+
| `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 |
|
|
609
|
+
| `FoundVia` | `search` · `similar` · `link` — the cheapest route that found the room |
|
|
610
|
+
| `FoundBy` | the query, the seed's `@name`, or the source room |
|
|
611
|
+
|
|
612
|
+
Unresolved link rows carry only `Username`/`Identifier`; `GroupID` and the flags are `NA`
|
|
613
|
+
(the columns are nullable, so dtypes never drift). Every row with an id is already usable
|
|
614
|
+
by `get_messages(row['GroupID'])` — Telegram results land in the session cache with their
|
|
615
|
+
access hash, no dialog sync needed.
|
|
616
|
+
|
|
617
|
+
**Pacing and waits.** Every request is followed by `pace` seconds (default 2, the proven
|
|
618
|
+
pace for a personal account). A rate-limit wait up to `max_flood_wait` is obeyed exactly,
|
|
619
|
+
with heartbeat ticks so a watchdog sees life; a longer one raises `DiscoveryInterrupted`,
|
|
620
|
+
which carries `.found` (everything collected so far) and `.retry_after`. Two steps keep
|
|
621
|
+
Telethon's own handling: reading a room's posts for links, and the dialog sync for a numeric
|
|
622
|
+
id — a wait of up to a minute there is slept without ticks. A dropped connection
|
|
623
|
+
is waited out up to `max_offline` and the same request retried. Nothing found is ever lost.
|
|
624
|
+
|
|
625
|
+
**The budget.** Resolving a username to a room is the request Telegram punishes hardest —
|
|
626
|
+
a production account lost 8 and 21 hours of lookups to it. So every call has `max_resolve`
|
|
627
|
+
(default 100), `discover_groups` mines links from the first `link_sources` rooms only
|
|
628
|
+
(default 50), `resolve_links` is off by default (mined names come back as text, which costs
|
|
629
|
+
nothing), and a wait on a lookup is skipped or stopped, never waited out — for a
|
|
630
|
+
`linked_groups` source it ends the call with `DiscoveryInterrupted`. A truncating cap
|
|
631
|
+
logs a warning telling you which parameter lifts it.
|
|
632
|
+
|
|
633
|
+
**Similar channels** work on channels and megagroups, not basic groups; two rounds by
|
|
634
|
+
default (a third drifts off topic); the seeds themselves are never returned. Yield depends
|
|
635
|
+
on the account — a Premium account got 64 recommendations from one seed, a normal account
|
|
636
|
+
reportedly about 10.
|
|
637
|
+
|
|
638
|
+
**Links** are read from the visible text, from hyperlinked text (where channels put nearly
|
|
639
|
+
all of them) and from URL buttons. Accepted: `t.me/name`, `t.me/name/123`, `t.me/s/name`,
|
|
640
|
+
`t.me/boost/name`, `telegram.me/name`. Skipped: invite links (`t.me/+…`), `t.me/c/…`,
|
|
641
|
+
reserved paths, and bots (`…bot`). Discovery reads; it never joins.
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
### Get all messages of a chat
|
|
645
|
+
|
|
646
|
+
You can use either the numeric chat ID or the username (if the chat has one).
|
|
647
|
+
|
|
648
|
+
**Note:** For large groups (2500+ messages), use batch processing with rate limit protection.
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
```python
|
|
652
|
+
from tgdata import TgData
|
|
653
|
+
import asyncio
|
|
654
|
+
|
|
655
|
+
async def main():
|
|
656
|
+
# Initialize the client
|
|
657
|
+
tg = TgData("config.ini")
|
|
658
|
+
|
|
659
|
+
# Fetch messages using numeric ID
|
|
660
|
+
messages = await tg.get_messages(
|
|
661
|
+
group_id=-1001234567890, # Numeric ID
|
|
662
|
+
limit=1000,
|
|
663
|
+
with_progress=True
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
# OR using username (if the chat has one)
|
|
667
|
+
messages = await tg.get_messages(
|
|
668
|
+
group_id="@channelname", # Username with @
|
|
669
|
+
limit=1000,
|
|
670
|
+
with_progress=True
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Export to CSV
|
|
674
|
+
tg.export_messages(messages, "messages.csv")
|
|
675
|
+
|
|
676
|
+
asyncio.run(main())
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### Get message count of a chat
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
```python
|
|
684
|
+
from tgdata import TgData
|
|
685
|
+
import asyncio
|
|
686
|
+
|
|
687
|
+
async def main():
|
|
688
|
+
# Initialize the client
|
|
689
|
+
tg = TgData("config.ini")
|
|
690
|
+
|
|
691
|
+
# Get count using numeric ID or username
|
|
692
|
+
message_count = await tg.get_message_count(
|
|
693
|
+
group_id=-1001234567890, # Numeric ID
|
|
694
|
+
# OR: group_id="@channelname" # Username
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
print(message_count)
|
|
698
|
+
|
|
699
|
+
asyncio.run(main())
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
## Advanced Usage
|
|
706
|
+
|
|
707
|
+
### Using Usernames vs Numeric IDs
|
|
708
|
+
|
|
709
|
+
```python
|
|
710
|
+
# Both approaches work identically:
|
|
711
|
+
|
|
712
|
+
# Option 1: Using username (recommended if available)
|
|
713
|
+
messages = await tg.get_messages(
|
|
714
|
+
group_id="@channelname",
|
|
715
|
+
limit=100
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
# Option 2: Using numeric ID
|
|
719
|
+
messages = await tg.get_messages(
|
|
720
|
+
group_id=-1001234567890,
|
|
721
|
+
limit=100
|
|
722
|
+
)
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
A numeric id resolves only if the session can actually see the group. On a
|
|
726
|
+
cache miss tgdata syncs the account's chat list once and retries — so fresh
|
|
727
|
+
sessions work for groups the account belongs to. If the group still can't be
|
|
728
|
+
found it raises `tgdata.GroupAccessError` (the account is not a member, or
|
|
729
|
+
the id is wrong) instead of Telethon's generic "Could not find the input
|
|
730
|
+
entity" ValueError.
|
|
731
|
+
|
|
732
|
+
### get_messages with start_date parameter
|
|
733
|
+
|
|
734
|
+
```python
|
|
735
|
+
tg = TgData("config.ini")
|
|
736
|
+
# Fetch recent messages for ETL processing
|
|
737
|
+
yesterday = datetime.now() - timedelta(days=1)
|
|
738
|
+
messages = await tg.get_messages(
|
|
739
|
+
group_id="@channelname", # Can use username
|
|
740
|
+
start_date=yesterday,
|
|
741
|
+
with_progress=True
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### Incremental Message Fetching
|
|
748
|
+
|
|
749
|
+
```python
|
|
750
|
+
from tgdata import TgData
|
|
751
|
+
|
|
752
|
+
async def incremental_fetch():
|
|
753
|
+
tg = TgData("config.ini")
|
|
754
|
+
|
|
755
|
+
# Get the latest message ID from your storage, or db
|
|
756
|
+
last_processed_id = load_checkpoint() # Your implementation
|
|
757
|
+
|
|
758
|
+
# Fetch only messages after that ID
|
|
759
|
+
new_messages = await tg.get_messages(
|
|
760
|
+
group_id="@channelname", # Can use username or numeric ID
|
|
761
|
+
after_id=last_processed_id
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
save_checkpoint(new_messages['MessageId'].max()) # Your implementation
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
asyncio.run(incremental_fetch())
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Stable message batches for storage and delivery
|
|
771
|
+
|
|
772
|
+
Use `get_message_batch` for a versioned raw snapshot that can be saved and
|
|
773
|
+
replayed. It keeps senderless and service messages, reads oldest first after an
|
|
774
|
+
exclusive cursor, and uses the same client and optional read budget. Designed
|
|
775
|
+
and tested with Telethon **1.45.0**.
|
|
776
|
+
|
|
777
|
+
```python
|
|
778
|
+
from tgdata import TgData, MessageBatch
|
|
779
|
+
|
|
780
|
+
async def prepare_batch():
|
|
781
|
+
tg = TgData("config.ini")
|
|
782
|
+
try:
|
|
783
|
+
batch = await tg.get_message_batch(
|
|
784
|
+
"@channelname", after_id=100, limit=200,
|
|
785
|
+
download_media_to="out/media", # omit for references only
|
|
786
|
+
)
|
|
787
|
+
manifest = batch.save("out/batches")
|
|
788
|
+
replay = MessageBatch.from_json(manifest.read_bytes())
|
|
789
|
+
assert replay.batch_id == batch.batch_id
|
|
790
|
+
return manifest, batch.next_after_id
|
|
791
|
+
finally:
|
|
792
|
+
await tg.close()
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Send the saved batch using `batch_id` for receiver deduplication, and advance
|
|
796
|
+
your stored cursor only after acknowledgment. If acknowledgment is lost, replay
|
|
797
|
+
that file: a fresh Telegram fetch can produce a different observation. Requested
|
|
798
|
+
photo/document files are named by their content hash; saving the manifest does
|
|
799
|
+
not copy its media. Blob paths are relative to `out/media` in this example.
|
|
800
|
+
|
|
801
|
+
Ordinary failures after group resolution carry the completed prefix as a
|
|
802
|
+
`MessageBatch` in `error.partial_result` and still raise. `next_after_id` never
|
|
803
|
+
passes an unfinished record/file. Local file errors keep their type without
|
|
804
|
+
inventing a Telegram health verdict. A secondary cleanup failure is logged by
|
|
805
|
+
operation/type and preserves the original error or cancellation; storage that
|
|
806
|
+
refuses cleanup may leave a private temporary file. The exact schema, limits,
|
|
807
|
+
interruption rules
|
|
808
|
+
and delivery protocol are in [Message batch v1](docs/message_batch_v1.md).
|
|
809
|
+
|
|
810
|
+
### Daily continuation across restarts
|
|
811
|
+
|
|
812
|
+
Use a progress store when tgdata should remember unfinished delivery and the last
|
|
813
|
+
accepted message for each group:
|
|
814
|
+
|
|
815
|
+
```python
|
|
816
|
+
from tgdata import TgData, SQLiteSyncStore
|
|
817
|
+
|
|
818
|
+
tg = TgData("config.ini", interactive_login=False,
|
|
819
|
+
sync_store=SQLiteSyncStore("daily-progress.sqlite3"))
|
|
820
|
+
chat_id = -1001234567890 # canonical MessageBatch.chat_id
|
|
821
|
+
|
|
822
|
+
# Initial setup: your existing archive's last accepted ID, or explicit 0.
|
|
823
|
+
await tg.initialize_sync(chat_id, after_id=48213)
|
|
824
|
+
try:
|
|
825
|
+
batch = await tg.sync_group(chat_id, limit=200)
|
|
826
|
+
if batch is not None:
|
|
827
|
+
await destination.accept(batch) # your durable, duplicate-safe destination
|
|
828
|
+
await tg.acknowledge_sync(chat_id, batch.batch_id)
|
|
829
|
+
finally:
|
|
830
|
+
await tg.close()
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
The next run replays a pending batch without contacting Telegram, or reads after
|
|
834
|
+
the acknowledged position. Preparing a batch never advances progress. Matching
|
|
835
|
+
repeated initialization does not reset it. `destination.accept` is your application
|
|
836
|
+
function; acknowledge only after its effects and duplicate markers are committed.
|
|
837
|
+
|
|
838
|
+
This first delivery supports one reader per group. Scheduling remains with the
|
|
839
|
+
caller; backfill is separate and edits/deletions are not reconciled. Optional media,
|
|
840
|
+
partial budget failures, custom storage and exact acknowledgment rules are covered
|
|
841
|
+
in [Daily continuation](docs/daily_continuation.md). Run the local example without
|
|
842
|
+
Telegram using `python examples/daily_continuation.py --demo`.
|
|
843
|
+
|
|
844
|
+
### Fixed historical windows
|
|
845
|
+
|
|
846
|
+
Give a historical collection its own progress store, then freeze its window at
|
|
847
|
+
first enrollment:
|
|
848
|
+
|
|
849
|
+
```python
|
|
850
|
+
from tgdata import TgData, SQLiteSyncStore
|
|
851
|
+
|
|
852
|
+
tg = TgData("config.ini", sync_store=SQLiteSyncStore("historical-progress.sqlite3"))
|
|
853
|
+
try:
|
|
854
|
+
await tg.initialize_sync(chat_id, after_id=0, last_days=30)
|
|
855
|
+
batch = await tg.sync_group(chat_id, limit=200)
|
|
856
|
+
if batch is not None:
|
|
857
|
+
await destination.accept(batch)
|
|
858
|
+
await tg.acknowledge_sync(chat_id, batch.batch_id)
|
|
859
|
+
finally:
|
|
860
|
+
await tg.close()
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
Repeated matching initialization reuses the saved dates. You can instead enroll
|
|
864
|
+
with timezone-aware `start_date` and `end_date`; the start is included and end
|
|
865
|
+
excluded. Daily and historical collections use separate stores, so their bookmarks
|
|
866
|
+
stay independent. See [Fixed historical windows](docs/fixed_windows.md) for date
|
|
867
|
+
limits, restart rules and direct batch reads. Scheduling and pacing remain with
|
|
868
|
+
the caller.
|
|
869
|
+
|
|
870
|
+
### Durable backfill runs
|
|
871
|
+
|
|
872
|
+
For historical reads with pause/cancel, restart recovery, pacing and explicit delivery
|
|
873
|
+
acknowledgments, configure a separate backfill progress store:
|
|
874
|
+
|
|
875
|
+
```python
|
|
876
|
+
from uuid import uuid4
|
|
877
|
+
from tgdata import TgData, SQLiteSyncStore, BackfillStartRequest, BackfillPrepareContext
|
|
878
|
+
|
|
879
|
+
tg = TgData("config.ini", interactive_login=False,
|
|
880
|
+
backfill_store=SQLiteSyncStore("backfill-progress.sqlite3"))
|
|
881
|
+
request = BackfillStartRequest(
|
|
882
|
+
collection_id="archive-history", chat_id=chat_id, run_id=uuid4().hex,
|
|
883
|
+
destination_id="my-archive", pause_seconds=240, expected_predecessor=None,
|
|
884
|
+
last_days=30, batch_size=200,
|
|
885
|
+
)
|
|
886
|
+
# Durably retain request.to_dict() in your application before submitting it.
|
|
887
|
+
try:
|
|
888
|
+
started = await tg.start_backfill(request, submission="new")
|
|
889
|
+
# Retain started.status.run.to_dict() for reopening and subsequent calls.
|
|
890
|
+
turn = await tg.prepare_backfill(BackfillPrepareContext.from_status(started.status))
|
|
891
|
+
if turn.batch is not None:
|
|
892
|
+
await destination.deliver_and_commit(turn.batch, turn.delivery)
|
|
893
|
+
await tg.acknowledge_backfill(turn.delivery)
|
|
894
|
+
finally:
|
|
895
|
+
await tg.close()
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
`destination.deliver_and_commit` is your application's durable receiver, not a tgdata
|
|
899
|
+
method. The full delivery reference identifies the obligation. Until acknowledged,
|
|
900
|
+
preparation replays the saved batch; a local wait returns without sleeping. The caller
|
|
901
|
+
schedules one source reader per group and keeps daily/backfill namespaces separate.
|
|
902
|
+
Collection identity does not automatically partition a backend; custom stores can use
|
|
903
|
+
separate namespaces in one database.
|
|
904
|
+
|
|
905
|
+
See [Backfill runs](docs/backfill_runs.md) for all six operations, status/error meanings,
|
|
906
|
+
exact retries, operator controls and recovery. Run the real SQLite receiver/restart
|
|
907
|
+
example without Telegram: `python examples/backfill_runs.py --demo`. The public API
|
|
908
|
+
passed offline fault tests and read-only live validation with Telethon 1.45.0, SQLite
|
|
909
|
+
and the selected test receiver. Your deployment must establish its own receiver
|
|
910
|
+
durability, reliable clock and single-reader ownership.
|
|
911
|
+
|
|
912
|
+
### Detecting & Downloading Media (photos / videos)
|
|
913
|
+
|
|
914
|
+
Every fetched message carries media-reference columns, so you can tell whether a
|
|
915
|
+
message has a photo **without downloading anything**:
|
|
916
|
+
|
|
917
|
+
| Column | Meaning |
|
|
918
|
+
|---|---|
|
|
919
|
+
| `MediaType` | `'photo'`, `'video'`, `'document'`, `'webpage'`, … or `None` (text-only) |
|
|
920
|
+
| `GroupedId` | album tag — rows sharing the same value are one multi-photo post (else `None`) |
|
|
921
|
+
| `MessageLink` | `t.me` deep link to the original message |
|
|
922
|
+
| `MediaPath` | local file path once media is downloaded (else `None`) |
|
|
923
|
+
|
|
924
|
+
> ⚠️ **`'webpage'` is NOT a real photo.** It is a link-preview thumbnail, not an
|
|
925
|
+
> attached image. Filter on the actual attachment type (`'photo'` / `'video'`) —
|
|
926
|
+
> **never** on "`MediaType` is not null" — or you will count URL previews as media.
|
|
927
|
+
|
|
928
|
+
```python
|
|
929
|
+
messages = await tg.get_messages(group_id="@channelname", limit=100)
|
|
930
|
+
|
|
931
|
+
# ✅ correct — real attached photos only
|
|
932
|
+
photos = messages[messages['MediaType'] == 'photo']
|
|
933
|
+
|
|
934
|
+
# ❌ wrong — also catches 'webpage' link previews, which are NOT real images
|
|
935
|
+
# media = messages[messages['MediaType'].notna()]
|
|
936
|
+
|
|
937
|
+
# how many photos each album/listing has
|
|
938
|
+
photos.groupby('GroupedId').size()
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
Get the actual media in one of three ways:
|
|
942
|
+
|
|
943
|
+
```python
|
|
944
|
+
# A) On demand — fetch references cheaply, then download only what you keep.
|
|
945
|
+
# Pass every MessageId sharing a GroupedId to grab a whole album.
|
|
946
|
+
paths = await tg.download_media_by_id("@channelname", [12345, 12346], output_dir="media")
|
|
947
|
+
# -> {12345: "media/photo_....jpg", 12346: None} (None = nothing downloadable, e.g. a webpage)
|
|
948
|
+
|
|
949
|
+
# B) During the fetch, to disk — one call returns the data AND writes the files.
|
|
950
|
+
messages = await tg.get_messages(
|
|
951
|
+
group_id="@channelname",
|
|
952
|
+
limit=100,
|
|
953
|
+
download_media_to="media", # each file's path is recorded in the MediaPath column
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
# C) During the fetch, in memory — bytes land in the DataFrame itself (MediaData column).
|
|
957
|
+
# Great for small scrapes; holds every file in RAM, so scope it.
|
|
958
|
+
messages = await tg.get_messages(
|
|
959
|
+
group_id="@channelname",
|
|
960
|
+
limit=100,
|
|
961
|
+
include_media=True, # MediaData = raw bytes per row (None for text/webpage)
|
|
962
|
+
)
|
|
963
|
+
# Note: CSV export drops MediaData; JSON stores a "[Binary data]" placeholder.
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
Downloaded files (modes A and B) are named `<ChatId>_<MessageId>.<ext>` — e.g.
|
|
967
|
+
`1707717812_183019.jpg` — so every file is traceable to its exact message and
|
|
968
|
+
group from the filename alone (and won't collide across groups sharing a folder).
|
|
969
|
+
The precise path is also recorded per row in the `MediaPath` column.
|
|
970
|
+
|
|
971
|
+
**Re-scraping is idempotent:** if a message's file is already on disk, it is
|
|
972
|
+
reused, not re-downloaded — so pulling the same (or an overlapping) range again
|
|
973
|
+
never duplicates files or re-fetches bytes. Each message maps to exactly one file.
|
|
974
|
+
|
|
975
|
+
### Progress Monitoring
|
|
976
|
+
|
|
977
|
+
```python
|
|
978
|
+
async def monitor_extraction():
|
|
979
|
+
tg = TgData()
|
|
980
|
+
|
|
981
|
+
def progress_callback(current, total, rate):
|
|
982
|
+
percent = (current / total * 100) if total else 0
|
|
983
|
+
print(f"Progress: {current}/{total} ({percent:.1f}%) - {rate:.1f} msg/s")
|
|
984
|
+
|
|
985
|
+
messages = await tg.get_messages(
|
|
986
|
+
group_id=-1001234567890,
|
|
987
|
+
limit=10000,
|
|
988
|
+
progress_callback=progress_callback
|
|
989
|
+
)
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
### Batch Processing for Large Groups
|
|
993
|
+
|
|
994
|
+
```python
|
|
995
|
+
# For groups with 100k+ messages, use batch processing with rate limit protection
|
|
996
|
+
async def process_large_group():
|
|
997
|
+
tg = TgData("config.ini")
|
|
998
|
+
|
|
999
|
+
async def save_batch(batch_df, batch_info):
|
|
1000
|
+
# Process each batch (e.g., save to database)
|
|
1001
|
+
print(f"Batch {batch_info['batch_num']}: {len(batch_df)} messages")
|
|
1002
|
+
batch_df.to_csv(f"batch_{batch_info['batch_num']}.csv")
|
|
1003
|
+
|
|
1004
|
+
await tg.get_messages(
|
|
1005
|
+
group_id="@largechannel", # Works with username
|
|
1006
|
+
batch_size=500, # Process 500 messages at a time
|
|
1007
|
+
batch_callback=save_batch,
|
|
1008
|
+
batch_delay=2.0, # Wait 2 seconds between batches
|
|
1009
|
+
rate_limit_strategy='exponential' # Handle rate limits gracefully
|
|
1010
|
+
)
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
### Custom callback
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
### Polling for New Messages
|
|
1019
|
+
|
|
1020
|
+
```python
|
|
1021
|
+
async def poll_messages():
|
|
1022
|
+
tg = TgData()
|
|
1023
|
+
|
|
1024
|
+
# Define callback for new messages
|
|
1025
|
+
async def process_batch(messages_df):
|
|
1026
|
+
print(f"Got {len(messages_df)} new messages")
|
|
1027
|
+
# Process messages here
|
|
1028
|
+
|
|
1029
|
+
# Poll every 30 seconds
|
|
1030
|
+
await tg.poll_for_messages(
|
|
1031
|
+
group_id="@channelname", # Works with username
|
|
1032
|
+
interval=30,
|
|
1033
|
+
callback=process_batch,
|
|
1034
|
+
max_iterations=10 # Stop after 10 polls
|
|
1035
|
+
)
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
Polling stops, raising the error, when polling again cannot help:
|
|
1039
|
+
- the account is logged out, banned or restricted;
|
|
1040
|
+
- it cannot read the group (`CHANNEL_PRIVATE`, `GroupAccessError`, ...);
|
|
1041
|
+
- the session is not logged in (`AuthRequiredError`);
|
|
1042
|
+
- the proxy setting is unusable (`ProxyConfigError`).
|
|
1043
|
+
|
|
1044
|
+
Network errors, rate-limit waits and server errors are logged and retried at
|
|
1045
|
+
the next interval, as before. An exception raised by your callback now reaches
|
|
1046
|
+
you; it used to be logged, and those messages were skipped.
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
### Real-time Message Events
|
|
1050
|
+
|
|
1051
|
+
```python
|
|
1052
|
+
# Monitor messages in real-time
|
|
1053
|
+
tg = TgData("config.ini")
|
|
1054
|
+
|
|
1055
|
+
@tg.on_new_message(group_id="@channelname") # Works with username
|
|
1056
|
+
async def handle_message(event):
|
|
1057
|
+
print(f"New message: {event.message.text}")
|
|
1058
|
+
|
|
1059
|
+
await tg.run_with_event_loop()
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
### Account health events
|
|
1064
|
+
|
|
1065
|
+
This section describes the existing public read methods and `health_check()`.
|
|
1066
|
+
Verified temporary operations have the separate local view described below.
|
|
1067
|
+
|
|
1068
|
+
Every time Telegram says no to an account, tgdata tells you:
|
|
1069
|
+
- which verdict;
|
|
1070
|
+
- about what — the account, one kind of request, or one group;
|
|
1071
|
+
- from which call;
|
|
1072
|
+
- for how long;
|
|
1073
|
+
- when it ended.
|
|
1074
|
+
|
|
1075
|
+
It reports and never acts. No exception changes, and nothing is paused or
|
|
1076
|
+
retried because of a verdict.
|
|
1077
|
+
|
|
1078
|
+
```python
|
|
1079
|
+
def on_health(event): # or an async function
|
|
1080
|
+
print(event['verdict'], event['scope'], event['error'], event['call'])
|
|
1081
|
+
|
|
1082
|
+
tg = TgData("config.ini", health_callback=on_health, account_label="reader-7")
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
| Verdict | About | Telegram's names |
|
|
1086
|
+
|---|---|---|
|
|
1087
|
+
| `waiting` | one request type | `FLOOD_WAIT_X`, `FLOOD_PREMIUM_WAIT_X`, `SLOWMODE_WAIT_X`, `FLOOD_TEST_PHONE_WAIT_X` — with the seconds |
|
|
1088
|
+
| `logged out` | the account | `AUTH_KEY_UNREGISTERED`, `SESSION_REVOKED`, `SESSION_EXPIRED`, `AUTH_KEY_INVALID`, `AUTH_KEY_DUPLICATED` |
|
|
1089
|
+
| `banned` | the account | `USER_DEACTIVATED_BAN`, `USER_DEACTIVATED`, `PHONE_NUMBER_BANNED` |
|
|
1090
|
+
| `restricted` | the account | `FROZEN_METHOD_INVALID`, `FROZEN_PARTICIPANT_MISSING`, `USER_RESTRICTED`, `PEER_FLOOD` |
|
|
1091
|
+
| `no access` | one group | `CHANNEL_PRIVATE`, `CHAT_FORBIDDEN`, `CHANNEL_INVALID`, `USER_BANNED_IN_CHANNEL`, `CHANNEL_PUBLIC_GROUP_NA`, `CHANNEL_BANNED`, and tgdata's `GroupAccessError` |
|
|
1092
|
+
| `unclassified` | — | any other Telegram error in the 401, 403, 406 or 420 categories, with its name (file-reference errors excepted) |
|
|
1093
|
+
| `ok` | what recovered | — |
|
|
1094
|
+
|
|
1095
|
+
Nothing else is an event: bad requests (400), server errors, network and proxy
|
|
1096
|
+
errors, a lookup that found nothing, or a session that was never logged in.
|
|
1097
|
+
|
|
1098
|
+
An event is a plain dict that survives `json.dumps`:
|
|
1099
|
+
|
|
1100
|
+
```python
|
|
1101
|
+
{'kind': 'health', 'time': '2026-10-03T14:02:11.418523+00:00',
|
|
1102
|
+
'account': {'label': 'reader-7', 'session': 'reader7', 'user_id': 123456789},
|
|
1103
|
+
'verdict': 'no access', 'scope': 'group', 'group': 'somegroup',
|
|
1104
|
+
'call': 'get_messages', 'request': 'GetHistoryRequest',
|
|
1105
|
+
'wait_seconds': None, 'error': 'CHANNEL_PRIVATE', 'source': 'error'}
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
- **`group`** is the group as you named it: an int stays an int; `@Name` and
|
|
1109
|
+
t.me links become `name`.
|
|
1110
|
+
- **`call`** is the `TgData` method; **`request`** is Telegram's request type.
|
|
1111
|
+
- **`source`** says where it came from:
|
|
1112
|
+
- `error` — it ended the call, and you also get the exception;
|
|
1113
|
+
- `sleep` — Telethon slept through a wait of up to a minute, silently;
|
|
1114
|
+
- `handled` — tgdata waited it out itself;
|
|
1115
|
+
- `swallowed` — tgdata skipped the item and carried on, such as a
|
|
1116
|
+
discovery seed or link;
|
|
1117
|
+
- `recovery` — an `ok`.
|
|
1118
|
+
|
|
1119
|
+
**"ok" only when Telegram shows a condition ended:**
|
|
1120
|
+
- **a wait** — the call that waited completes successfully;
|
|
1121
|
+
- **logged out or banned** — a call completes that Telegram answered after
|
|
1122
|
+
the problem was recorded;
|
|
1123
|
+
- **restricted** — the same, from the method that was refused;
|
|
1124
|
+
- **no access** — the same, from a call naming that group.
|
|
1125
|
+
|
|
1126
|
+
A call that merely completes proves nothing. A call Telegram answered before
|
|
1127
|
+
the problem was seen, or one that sent no request at all, never sends `ok`.
|
|
1128
|
+
|
|
1129
|
+
**Delivery.** The event that ends a call is delivered before its exception
|
|
1130
|
+
reaches you. The callback may be a function or a coroutine function. If it
|
|
1131
|
+
raises, the error is logged — first at WARNING with its traceback, then at
|
|
1132
|
+
DEBUG — and nothing else changes. A wait Telethon sleeps through is delivered
|
|
1133
|
+
from inside the request: a function runs there, a coroutine is scheduled.
|
|
1134
|
+
The callback may call tgdata itself. Events caused by those calls are logged
|
|
1135
|
+
and counted, but not delivered back to it, so it never calls itself.
|
|
1136
|
+
|
|
1137
|
+
**Summary.** `(await tg.health_check())['health']` holds, for this `TgData`,
|
|
1138
|
+
in memory:
|
|
1139
|
+
- the account's verdict, and since when;
|
|
1140
|
+
- the open waits per request type;
|
|
1141
|
+
- the groups without access;
|
|
1142
|
+
- the wait count and total seconds;
|
|
1143
|
+
- the event count;
|
|
1144
|
+
- the last unclassified name.
|
|
1145
|
+
|
|
1146
|
+
**Log lines.** Each event is also logged on `tgdata.tgdata.health`, so
|
|
1147
|
+
`log_file=` receives it: WARNING for logged out, banned, restricted and
|
|
1148
|
+
unclassified; INFO for waiting, no access and ok. Your own logging output is
|
|
1149
|
+
otherwise unchanged. The INFO lines in which Telethon logs its silent sleeps
|
|
1150
|
+
stay hidden unless you asked for them.
|
|
1151
|
+
|
|
1152
|
+
**`validate_connection()` and `health_check()` ask Telegram directly.**
|
|
1153
|
+
Telethon's `get_me()` answers a logout or a ban with `None` instead of an
|
|
1154
|
+
error, so both now confirm with Telegram whether the session is logged in.
|
|
1155
|
+
- `validate_connection()` still returns `True` or `False`. On `False` it says
|
|
1156
|
+
why: at WARNING with Telegram's name when it is a verdict, and as an event.
|
|
1157
|
+
- `health_check()` marks such a connection unhealthy and names the verdict in
|
|
1158
|
+
`errors`, e.g. `logged out (AUTH_KEY_UNREGISTERED)`. It reports it as an
|
|
1159
|
+
event, and the same result's `['health']` already includes it.
|
|
1160
|
+
|
|
1161
|
+
**Not covered yet.**
|
|
1162
|
+
- Signals that Telethon's background update loop only logs: an account banned
|
|
1163
|
+
in one channel, and waits while catching up.
|
|
1164
|
+
- `restricted` follows Telegram's documentation; how a frozen account behaves
|
|
1165
|
+
when it only reads has not been observed.
|
|
1166
|
+
- Capturing Telethon's silent sleeps relies on Telethon creating its INFO
|
|
1167
|
+
record: `logging.disable(logging.INFO)` or higher turns the capture off, and
|
|
1168
|
+
a level set on `telethon.client.users` during a call applies from the next
|
|
1169
|
+
call.
|
|
1170
|
+
|
|
1171
|
+
### Health for verified account operations
|
|
1172
|
+
|
|
1173
|
+
`tg.get_account_health(account_id)` returns this instance's local health snapshot
|
|
1174
|
+
for a **verified numeric account ID**, or `None` if it has not observed that account.
|
|
1175
|
+
The ID must be a positive integer, not a string or boolean. The call is synchronous:
|
|
1176
|
+
it does no connection, authentication or recovery. The returned dictionaries are
|
|
1177
|
+
independent copies, with the same fields as the health summary above.
|
|
1178
|
+
|
|
1179
|
+
`lookup_group`, `check_group_access` and `join_group` populate this account-owned view. Existing
|
|
1180
|
+
message reads and `health_check()` still use their existing health view; the views
|
|
1181
|
+
are not combined. A join result does not clear an earlier history-access failure.
|
|
1182
|
+
|
|
1183
|
+
Internally, an observation begins after a temporary client proves the expected
|
|
1184
|
+
account. Its event and retained state keep that numeric owner even if a cached
|
|
1185
|
+
identity changes later. The session name and account label are descriptive. Setup
|
|
1186
|
+
failures before proof remain errors to the caller, without inventing a condition
|
|
1187
|
+
for an unverified account. Forwarding those failures through another task or an
|
|
1188
|
+
exception wrapper preserves that neutral attribution. A separate Telegram error
|
|
1189
|
+
raised while handling one still reports normally; diagnostic classification of
|
|
1190
|
+
the original failure remains available.
|
|
1191
|
+
|
|
1192
|
+
Owned events use **`source: "rpc"`** for an actual health-related Telegram request
|
|
1193
|
+
failure, including one the operation subsequently handles. Local errors and their
|
|
1194
|
+
causes do not create these events. Recovery is conservative: successful work from
|
|
1195
|
+
an operation started before a condition was recorded cannot clear it; neither can
|
|
1196
|
+
the call that reported it. Owned event `request` and snapshot `waiting` keys name
|
|
1197
|
+
the logical request with its SDK namespace, such as `messages.GetHistoryRequest`
|
|
1198
|
+
or `channels.GetMessagesRequest`. Transport envelopes do not change that key;
|
|
1199
|
+
legacy health keeps its existing request names.
|
|
1200
|
+
|
|
1201
|
+
Waits require the matching request to succeed. A restriction requires that the
|
|
1202
|
+
previously refused request succeeds in a later operation with the same method.
|
|
1203
|
+
Another identity check cannot clear a history-read restriction. Group access
|
|
1204
|
+
requires explicit confirmation by the internal operation after an actual access
|
|
1205
|
+
result; self verification and metadata alone never imply access. The private
|
|
1206
|
+
[operation contract](docs/account_operations.md#owned-health-composition-stage-2)
|
|
1207
|
+
describes how request evidence is captured.
|
|
1208
|
+
|
|
1209
|
+
Owned state is updated before the operation returns. Both synchronous and async
|
|
1210
|
+
health callbacks run in separate tasks **after the source's disconnect attempt
|
|
1211
|
+
finishes**. A callback may run after the result or error reaches its caller. Its
|
|
1212
|
+
ordinary errors or cancellation cannot change that outcome; callback re-entry is
|
|
1213
|
+
suppressed as above. Unequal cleanup durations can make callbacks arrive in a
|
|
1214
|
+
different order from the observations; use event times and the local snapshot to
|
|
1215
|
+
interpret state. Notification delivery is not durable or guaranteed at process
|
|
1216
|
+
exit. Finish ongoing operations before `await tg.close()`, which cooperatively
|
|
1217
|
+
cancels and awaits pending owned notifications. A callback may itself call close.
|
|
1218
|
+
These timing rules apply to the new owned path; the existing public health callback
|
|
1219
|
+
timing remains as documented above.
|
|
1220
|
+
|
|
1221
|
+
### Performance Tips
|
|
1222
|
+
|
|
1223
|
+
- The client authenticates once and keeps a single **persistent connection**,
|
|
1224
|
+
reused across all calls; release it with `await tg.close()` or use
|
|
1225
|
+
`async with TgData("config.ini") as tg:`
|
|
1226
|
+
- Implement checkpoint logic for incremental processing
|
|
1227
|
+
- Implement progress callbacks for visibility
|
|
1228
|
+
- Export data incrementally for large datasets
|
|
1229
|
+
|
|
1230
|
+
## Requirements
|
|
1231
|
+
|
|
1232
|
+
- Python 3.7+
|
|
1233
|
+
- Telegram API credentials (not bot tokens)
|
|
1234
|
+
- Group/channel membership
|
|
1235
|
+
|
|
1236
|
+
## License
|
|
1237
|
+
|
|
1238
|
+
MIT License - see LICENSE file for details
|
|
1239
|
+
|
|
1240
|
+
## Contributing
|
|
1241
|
+
|
|
1242
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|