telegram-tools 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- telegram_tools/__init__.py +5 -0
- telegram_tools/cli.py +227 -0
- telegram_tools/client.py +12 -0
- telegram_tools/config.py +52 -0
- telegram_tools/delete.py +95 -0
- telegram_tools/discovery.py +138 -0
- telegram_tools/doctor.py +69 -0
- telegram_tools/exporters.py +30 -0
- telegram_tools/models.py +54 -0
- telegram_tools/records.py +82 -0
- telegram_tools/resolver.py +59 -0
- telegram_tools/search.py +81 -0
- telegram_tools/topics.py +72 -0
- telegram_tools-3.0.0.dist-info/METADATA +156 -0
- telegram_tools-3.0.0.dist-info/RECORD +18 -0
- telegram_tools-3.0.0.dist-info/WHEEL +4 -0
- telegram_tools-3.0.0.dist-info/entry_points.txt +2 -0
- telegram_tools-3.0.0.dist-info/licenses/LICENSE +21 -0
telegram_tools/cli.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from telegram_tools.client import create_client
|
|
11
|
+
from telegram_tools.config import ConfigError, load_config
|
|
12
|
+
from telegram_tools.delete import confirm_clear_topic_messages, delete_topic_messages
|
|
13
|
+
from telegram_tools.discovery import discover_chats, filter_chats, format_discovery_table
|
|
14
|
+
from telegram_tools.doctor import run_doctor
|
|
15
|
+
from telegram_tools.exporters import write_records
|
|
16
|
+
from telegram_tools.resolver import EntityResolutionError, resolve_chat
|
|
17
|
+
from telegram_tools.search import format_message_records, search_messages
|
|
18
|
+
from telegram_tools.topics import get_forum_topics, get_forum_topics_by_ids
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def positive_int(value: str) -> int:
|
|
22
|
+
parsed = int(value)
|
|
23
|
+
if parsed < 1:
|
|
24
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
25
|
+
return parsed
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(prog="telegram-tools")
|
|
30
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
31
|
+
|
|
32
|
+
discover = subparsers.add_parser("discover", help="List dialogs and forum topics")
|
|
33
|
+
discover.add_argument("--json", dest="json_output", help="Write discovery output to this JSON file")
|
|
34
|
+
discover.add_argument("--all", dest="all_chats", action="store_true", help="Show every chat instead of admin/managed chats only")
|
|
35
|
+
discover.add_argument("--admin-only", action="store_true", help=argparse.SUPPRESS)
|
|
36
|
+
|
|
37
|
+
clear_messages = subparsers.add_parser("clear-messages", help="Clear messages from forum topic(s), preserving topics and topic IDs")
|
|
38
|
+
clear_messages.add_argument("--chat", required=True, help="Chat/channel username, link, or ID")
|
|
39
|
+
topic_group = clear_messages.add_mutually_exclusive_group(required=True)
|
|
40
|
+
topic_group.add_argument("--topic", dest="topics", action="append", type=int, help="Topic ID to clear messages from; repeatable")
|
|
41
|
+
topic_group.add_argument("--all-topics", "--all-topics-in-chat", dest="all_topics", action="store_true", help="Clear messages from every forum topic")
|
|
42
|
+
clear_messages.add_argument("--execute", action="store_true", help="Actually clear messages after typing DELETE")
|
|
43
|
+
clear_messages.add_argument("--batch-size", type=positive_int, default=100, help="Clear-message batch size")
|
|
44
|
+
|
|
45
|
+
search = subparsers.add_parser("search", help="Search and export messages")
|
|
46
|
+
search.add_argument("--chat", required=True, help="Chat/channel username, link, or ID")
|
|
47
|
+
search.add_argument("--topic", type=int, help="Limit search/export to one topic ID")
|
|
48
|
+
search.add_argument("--keyword", "--contains", dest="keyword", help="Case-insensitive text filter")
|
|
49
|
+
search.add_argument("--from-user", help="Sender username, ID, or 'me'")
|
|
50
|
+
search.add_argument("--since", help="Inclusive ISO date or datetime lower bound")
|
|
51
|
+
search.add_argument("--until", help="Inclusive ISO date or datetime upper bound")
|
|
52
|
+
search.add_argument("--limit", type=positive_int, help="Maximum exported messages")
|
|
53
|
+
search.add_argument("--format", choices=("json", "csv"), default="json", help="Export format")
|
|
54
|
+
search.add_argument("--output", help="Output path; prints a readable table when omitted")
|
|
55
|
+
|
|
56
|
+
subparsers.add_parser("doctor", help="Check local setup without printing secrets")
|
|
57
|
+
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _require_delete_permission(client, chat) -> None:
|
|
62
|
+
me = await client.get_me()
|
|
63
|
+
permissions = await client.get_permissions(chat, me)
|
|
64
|
+
if not (getattr(permissions, "is_creator", False) or getattr(permissions, "delete_messages", False)):
|
|
65
|
+
raise PermissionError("Current user lacks Telegram delete_messages permission in this chat.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _run_discover(client, args) -> int:
|
|
69
|
+
chats = filter_chats(await discover_chats(client), admin_only=not args.all_chats)
|
|
70
|
+
payload = [chat.to_dict() for chat in chats]
|
|
71
|
+
if args.json_output:
|
|
72
|
+
output = Path(args.json_output)
|
|
73
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
output.write_text(json.dumps(payload, indent=2, default=str) + "\n")
|
|
75
|
+
else:
|
|
76
|
+
print(format_discovery_table(chats))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _run_clear_messages(client, args) -> int:
|
|
81
|
+
resolved = await resolve_chat(client, args.chat)
|
|
82
|
+
peer = resolved.input_entity
|
|
83
|
+
await _require_delete_permission(client, peer)
|
|
84
|
+
|
|
85
|
+
if args.all_topics:
|
|
86
|
+
topics = await get_forum_topics(client, peer)
|
|
87
|
+
else:
|
|
88
|
+
topics = await get_forum_topics_by_ids(client, peer, args.topics)
|
|
89
|
+
|
|
90
|
+
result = await delete_topic_messages(
|
|
91
|
+
client,
|
|
92
|
+
peer,
|
|
93
|
+
topics,
|
|
94
|
+
execute=args.execute,
|
|
95
|
+
batch_size=args.batch_size,
|
|
96
|
+
progress=print,
|
|
97
|
+
confirm=confirm_clear_topic_messages,
|
|
98
|
+
)
|
|
99
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
100
|
+
return 1 if result.cancelled else 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _run_search(client, args) -> int:
|
|
104
|
+
resolved = await resolve_chat(client, args.chat)
|
|
105
|
+
peer = resolved.input_entity
|
|
106
|
+
records = await search_messages(
|
|
107
|
+
client,
|
|
108
|
+
peer,
|
|
109
|
+
chat_id=resolved.id,
|
|
110
|
+
topic_id=args.topic,
|
|
111
|
+
keyword=args.keyword,
|
|
112
|
+
from_user=args.from_user,
|
|
113
|
+
since=args.since,
|
|
114
|
+
until=args.until,
|
|
115
|
+
limit=args.limit,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if args.output:
|
|
119
|
+
write_records(records, args.output, args.format)
|
|
120
|
+
elif args.format == "csv":
|
|
121
|
+
raise ValueError("--output is required for CSV export")
|
|
122
|
+
else:
|
|
123
|
+
print(format_message_records(records))
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _namespace(**kwargs):
|
|
128
|
+
return argparse.Namespace(**kwargs)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_execute(read) -> bool:
|
|
132
|
+
return read("Type DELETE to pass --execute, or press Enter for dry-run: ") == "DELETE"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def run_interactive_menu(*, read=input, write=print) -> int:
|
|
136
|
+
write(
|
|
137
|
+
"\n".join(
|
|
138
|
+
[
|
|
139
|
+
"telegram-tools",
|
|
140
|
+
"--------------------------------------------",
|
|
141
|
+
"1. Discover chats/topics",
|
|
142
|
+
"2. Search messages",
|
|
143
|
+
"3. Export messages",
|
|
144
|
+
"4. Clear topic messages",
|
|
145
|
+
"5. Clear multiple topics",
|
|
146
|
+
"6. Clear all topic messages",
|
|
147
|
+
"0. Exit",
|
|
148
|
+
]
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
choice = read("Choose: ").strip()
|
|
152
|
+
if choice == "0":
|
|
153
|
+
return 0
|
|
154
|
+
if choice == "1":
|
|
155
|
+
all_chats = read("Show all chats instead of admin/managed only? [y/N]: ").strip().lower() == "y"
|
|
156
|
+
return await run(_namespace(command="discover", json_output=None, all_chats=all_chats, admin_only=not all_chats))
|
|
157
|
+
if choice == "2":
|
|
158
|
+
chat = read("Chat (@username, t.me link, or numeric ID): ")
|
|
159
|
+
keyword = read("Contains text: ")
|
|
160
|
+
topic = read("Topic ID (optional): ").strip()
|
|
161
|
+
return await run(
|
|
162
|
+
_namespace(command="search", chat=chat, topic=int(topic) if topic else None, keyword=keyword, from_user=None, since=None, until=None, limit=None, format="json", output=None)
|
|
163
|
+
)
|
|
164
|
+
if choice == "3":
|
|
165
|
+
chat = read("Chat (@username, t.me link, or numeric ID): ")
|
|
166
|
+
topic = read("Topic ID (optional): ").strip()
|
|
167
|
+
output = read("Output file: ")
|
|
168
|
+
fmt = read("Format [json/csv, default json]: ").strip() or "json"
|
|
169
|
+
return await run(
|
|
170
|
+
_namespace(command="search", chat=chat, topic=int(topic) if topic else None, keyword=None, from_user=None, since=None, until=None, limit=None, format=fmt, output=output)
|
|
171
|
+
)
|
|
172
|
+
if choice == "4":
|
|
173
|
+
chat = read("Chat (@username, t.me link, or numeric ID): ")
|
|
174
|
+
topic = int(read("Topic ID: "))
|
|
175
|
+
return await run(_namespace(command="clear-messages", chat=chat, topics=[topic], all_topics=False, execute=_read_execute(read), batch_size=100))
|
|
176
|
+
if choice == "5":
|
|
177
|
+
chat = read("Chat (@username, t.me link, or numeric ID): ")
|
|
178
|
+
raw_topics = read("Topic IDs (space or comma separated): ")
|
|
179
|
+
topics = [int(value) for value in raw_topics.replace(",", " ").split()]
|
|
180
|
+
return await run(_namespace(command="clear-messages", chat=chat, topics=topics, all_topics=False, execute=_read_execute(read), batch_size=100))
|
|
181
|
+
if choice == "6":
|
|
182
|
+
chat = read("Chat (@username, t.me link, or numeric ID): ")
|
|
183
|
+
return await run(_namespace(command="clear-messages", chat=chat, topics=None, all_topics=True, execute=_read_execute(read), batch_size=100))
|
|
184
|
+
|
|
185
|
+
write("Unknown choice.")
|
|
186
|
+
return 2
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def run(args) -> int:
|
|
190
|
+
if args.command == "doctor":
|
|
191
|
+
return run_doctor()
|
|
192
|
+
|
|
193
|
+
config = load_config()
|
|
194
|
+
client = create_client(config)
|
|
195
|
+
await client.start()
|
|
196
|
+
try:
|
|
197
|
+
if args.command == "discover":
|
|
198
|
+
return await _run_discover(client, args)
|
|
199
|
+
if args.command == "clear-messages":
|
|
200
|
+
return await _run_clear_messages(client, args)
|
|
201
|
+
if args.command == "search":
|
|
202
|
+
return await _run_search(client, args)
|
|
203
|
+
raise ValueError(f"Unknown command: {args.command}")
|
|
204
|
+
finally:
|
|
205
|
+
await client.disconnect()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
209
|
+
parser = build_parser()
|
|
210
|
+
args = parser.parse_args(argv)
|
|
211
|
+
try:
|
|
212
|
+
if args.command is None:
|
|
213
|
+
return asyncio.run(run_interactive_menu())
|
|
214
|
+
return asyncio.run(run(args))
|
|
215
|
+
except ConfigError as exc:
|
|
216
|
+
parser.error(str(exc))
|
|
217
|
+
except EntityResolutionError as exc:
|
|
218
|
+
parser.error(str(exc))
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
parser.error(str(exc))
|
|
221
|
+
except PermissionError as exc:
|
|
222
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
223
|
+
return 2
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
raise SystemExit(main())
|
telegram_tools/client.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from telethon import TelegramClient
|
|
4
|
+
|
|
5
|
+
from telegram_tools.config import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_client(config: Config) -> TelegramClient:
|
|
9
|
+
config.session_path.parent.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
client = TelegramClient(str(config.session_path), config.api_id, config.api_hash)
|
|
11
|
+
client.flood_sleep_threshold = 24 * 60 * 60
|
|
12
|
+
return client
|
telegram_tools/config.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_dir(home: Path | None = None) -> Path:
|
|
16
|
+
return (home or Path.home()) / ".telegram-tools"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
api_id: int
|
|
22
|
+
api_hash: str
|
|
23
|
+
session_path: Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_config(
|
|
27
|
+
env: Mapping[str, str] | None = None,
|
|
28
|
+
*,
|
|
29
|
+
cwd: Path | None = None,
|
|
30
|
+
home: Path | None = None,
|
|
31
|
+
) -> Config:
|
|
32
|
+
cwd = cwd or Path.cwd()
|
|
33
|
+
if env is None:
|
|
34
|
+
load_dotenv(dotenv_path=cwd / ".env", override=False)
|
|
35
|
+
load_dotenv(dotenv_path=config_dir(home) / ".env", override=False)
|
|
36
|
+
env = os.environ
|
|
37
|
+
|
|
38
|
+
raw_api_id = env.get("TELEGRAM_API_ID")
|
|
39
|
+
if not raw_api_id:
|
|
40
|
+
raise ConfigError("TELEGRAM_API_ID is required.")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
api_id = int(raw_api_id)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise ConfigError("TELEGRAM_API_ID must be an integer.") from exc
|
|
46
|
+
|
|
47
|
+
api_hash = env.get("TELEGRAM_API_HASH")
|
|
48
|
+
if not api_hash:
|
|
49
|
+
raise ConfigError("TELEGRAM_API_HASH is required.")
|
|
50
|
+
|
|
51
|
+
session_path = Path(env.get("TELEGRAM_TOOLS_SESSION", config_dir(home) / "telegram-tools"))
|
|
52
|
+
return Config(api_id=api_id, api_hash=api_hash, session_path=session_path)
|
telegram_tools/delete.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from telethon.errors import FloodWaitError
|
|
8
|
+
|
|
9
|
+
from telegram_tools.models import DeleteResult, TopicInfo
|
|
10
|
+
|
|
11
|
+
CLEAR_TOPIC_MESSAGES_WARNING = """\
|
|
12
|
+
====================================================
|
|
13
|
+
WARNING: CLEAR TOPIC MESSAGES
|
|
14
|
+
|
|
15
|
+
This will permanently delete ALL MESSAGES from the selected topic(s).
|
|
16
|
+
|
|
17
|
+
OK: Forum topics will NOT be deleted.
|
|
18
|
+
OK: Topic IDs will NOT change.
|
|
19
|
+
OK: Only messages will be removed.
|
|
20
|
+
===================================================="""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _chunks(values: list[int], size: int) -> Iterable[list[int]]:
|
|
24
|
+
for index in range(0, len(values), size):
|
|
25
|
+
yield values[index : index + size]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _delete_batch_with_flood_wait(client, chat: Any, batch: list[int], *, sleep=asyncio.sleep, progress: Callable[[str], None]) -> int:
|
|
29
|
+
while True:
|
|
30
|
+
try:
|
|
31
|
+
await client.delete_messages(chat, batch)
|
|
32
|
+
return len(batch)
|
|
33
|
+
except FloodWaitError as exc:
|
|
34
|
+
seconds = int(getattr(exc, "seconds", 0))
|
|
35
|
+
progress(f"FloodWait: sleeping {seconds}s before retrying clear-message batch")
|
|
36
|
+
await sleep(seconds)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def confirm_clear_topic_messages(*, read=input, write=print) -> str:
|
|
40
|
+
write(CLEAR_TOPIC_MESSAGES_WARNING)
|
|
41
|
+
return read("Type DELETE to continue: ")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _collect_topic_message_ids(client, chat: Any, topic: TopicInfo) -> list[int]:
|
|
45
|
+
ids: list[int] = []
|
|
46
|
+
skip_ids = {topic.id}
|
|
47
|
+
if topic.top_message is not None:
|
|
48
|
+
skip_ids.add(topic.top_message)
|
|
49
|
+
|
|
50
|
+
async for message in client.iter_messages(chat, reply_to=topic.id, wait_time=1):
|
|
51
|
+
message_id = int(getattr(message, "id"))
|
|
52
|
+
if message_id not in skip_ids:
|
|
53
|
+
ids.append(message_id)
|
|
54
|
+
return ids
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def delete_topic_messages(
|
|
58
|
+
client,
|
|
59
|
+
chat: Any,
|
|
60
|
+
topics: list[TopicInfo],
|
|
61
|
+
*,
|
|
62
|
+
execute: bool = False,
|
|
63
|
+
confirm: Callable[[], str] = input,
|
|
64
|
+
batch_size: int = 100,
|
|
65
|
+
progress: Callable[[str], None] | None = None,
|
|
66
|
+
sleep=asyncio.sleep,
|
|
67
|
+
) -> DeleteResult:
|
|
68
|
+
progress = progress or (lambda _message: None)
|
|
69
|
+
if batch_size < 1:
|
|
70
|
+
raise ValueError("batch_size must be at least 1")
|
|
71
|
+
|
|
72
|
+
ids: list[int] = []
|
|
73
|
+
seen: set[int] = set()
|
|
74
|
+
for topic in topics:
|
|
75
|
+
progress(f"Scanning topic {topic.id} ({topic.title})")
|
|
76
|
+
for message_id in await _collect_topic_message_ids(client, chat, topic):
|
|
77
|
+
if message_id not in seen:
|
|
78
|
+
seen.add(message_id)
|
|
79
|
+
ids.append(message_id)
|
|
80
|
+
|
|
81
|
+
if not execute:
|
|
82
|
+
progress(f"Dry-run: {len(ids)} topic messages would be cleared")
|
|
83
|
+
return DeleteResult(matched=len(ids), deleted=0, dry_run=True)
|
|
84
|
+
|
|
85
|
+
if confirm() != "DELETE":
|
|
86
|
+
progress("Clear topic messages cancelled")
|
|
87
|
+
return DeleteResult(matched=len(ids), deleted=0, dry_run=False, cancelled=True)
|
|
88
|
+
|
|
89
|
+
deleted = 0
|
|
90
|
+
for batch in _chunks(ids, batch_size):
|
|
91
|
+
progress(f"Clearing batch of {len(batch)} topic messages")
|
|
92
|
+
deleted += await _delete_batch_with_flood_wait(client, chat, batch, sleep=sleep, progress=progress)
|
|
93
|
+
progress(f"Cleared {deleted}/{len(ids)} topic messages")
|
|
94
|
+
|
|
95
|
+
return DeleteResult(matched=len(ids), deleted=deleted, dry_run=False)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from telegram_tools.models import ChatInfo, TopicInfo
|
|
6
|
+
from telegram_tools.topics import get_forum_topics
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def classify_entity(entity: Any) -> str:
|
|
10
|
+
if getattr(entity, "broadcast", False):
|
|
11
|
+
return "channel"
|
|
12
|
+
if getattr(entity, "megagroup", False) and getattr(entity, "forum", False):
|
|
13
|
+
return "forum_group"
|
|
14
|
+
if getattr(entity, "megagroup", False):
|
|
15
|
+
return "supergroup"
|
|
16
|
+
if entity.__class__.__name__ == "User":
|
|
17
|
+
return "user"
|
|
18
|
+
return "group"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def dialog_to_chat_info(
|
|
22
|
+
dialog: Any,
|
|
23
|
+
*,
|
|
24
|
+
is_admin: bool,
|
|
25
|
+
topics: list[TopicInfo] | None = None,
|
|
26
|
+
) -> ChatInfo:
|
|
27
|
+
entity = dialog.entity
|
|
28
|
+
return ChatInfo(
|
|
29
|
+
id=int(dialog.id),
|
|
30
|
+
title=str(getattr(dialog, "title", None) or getattr(dialog, "name", None) or ""),
|
|
31
|
+
username=getattr(entity, "username", None),
|
|
32
|
+
type=classify_entity(entity),
|
|
33
|
+
is_admin=is_admin,
|
|
34
|
+
topics=topics or [],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def filter_chats(chats: list[ChatInfo], *, admin_only: bool) -> list[ChatInfo]:
|
|
39
|
+
if not admin_only:
|
|
40
|
+
return chats
|
|
41
|
+
return [chat for chat in chats if chat.is_admin]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _display_type(chat_type: str) -> str:
|
|
45
|
+
return chat_type.replace("_", " ").title()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _group_discovery_chats(chats: list[ChatInfo]) -> list[tuple[str, list[ChatInfo]]]:
|
|
49
|
+
forum_groups = [chat for chat in chats if chat.type == "forum_group"]
|
|
50
|
+
channels = [chat for chat in chats if chat.type == "channel"]
|
|
51
|
+
other_admin_groups = [
|
|
52
|
+
chat
|
|
53
|
+
for chat in chats
|
|
54
|
+
if chat.type in {"group", "supergroup"} and chat.is_admin and chat not in forum_groups
|
|
55
|
+
]
|
|
56
|
+
other_chats = [
|
|
57
|
+
chat
|
|
58
|
+
for chat in chats
|
|
59
|
+
if chat not in forum_groups and chat not in channels and chat not in other_admin_groups
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
groups = [
|
|
63
|
+
("Forum Groups", forum_groups),
|
|
64
|
+
("Channels", channels),
|
|
65
|
+
("Other Admin Groups", other_admin_groups),
|
|
66
|
+
]
|
|
67
|
+
if other_chats:
|
|
68
|
+
groups.append(("Other Chats", other_chats))
|
|
69
|
+
return groups
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _format_chat(chat: ChatInfo) -> str:
|
|
73
|
+
lines = [
|
|
74
|
+
chat.title or "(untitled)",
|
|
75
|
+
f"Chat ID: {chat.id}",
|
|
76
|
+
f"Type: {_display_type(chat.type)}",
|
|
77
|
+
f"Admin: {'yes' if chat.is_admin else 'no'}",
|
|
78
|
+
]
|
|
79
|
+
if chat.username:
|
|
80
|
+
lines.append(f"Username: @{chat.username}")
|
|
81
|
+
|
|
82
|
+
if chat.topics:
|
|
83
|
+
width = max(len(str(topic.id)) for topic in chat.topics)
|
|
84
|
+
lines.extend(
|
|
85
|
+
[
|
|
86
|
+
"",
|
|
87
|
+
"Topics",
|
|
88
|
+
"--------------------------------------------",
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
lines.extend(f"{topic.id:<{width}} {topic.title}" for topic in chat.topics)
|
|
92
|
+
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def format_discovery_table(chats: list[ChatInfo]) -> str:
|
|
97
|
+
if not chats:
|
|
98
|
+
return "No chats found."
|
|
99
|
+
|
|
100
|
+
sections: list[str] = []
|
|
101
|
+
for title, group in _group_discovery_chats(chats):
|
|
102
|
+
if not group:
|
|
103
|
+
continue
|
|
104
|
+
lines = [title, "=" * len(title)]
|
|
105
|
+
lines.extend(_format_chat(chat) for chat in group)
|
|
106
|
+
sections.append("\n\n".join(lines))
|
|
107
|
+
|
|
108
|
+
return "\n\n".join(sections)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def is_admin(client, entity, user) -> bool:
|
|
112
|
+
try:
|
|
113
|
+
permissions = await client.get_permissions(entity, user)
|
|
114
|
+
except Exception:
|
|
115
|
+
return False
|
|
116
|
+
return bool(getattr(permissions, "is_admin", False))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def discover_chats(client) -> list[ChatInfo]:
|
|
120
|
+
user = await client.get_me()
|
|
121
|
+
chats: list[ChatInfo] = []
|
|
122
|
+
|
|
123
|
+
async for dialog in client.iter_dialogs():
|
|
124
|
+
entity = dialog.entity
|
|
125
|
+
topics: list[TopicInfo] = []
|
|
126
|
+
if getattr(entity, "forum", False):
|
|
127
|
+
peer = getattr(dialog, "input_entity", entity)
|
|
128
|
+
topics = await get_forum_topics(client, peer)
|
|
129
|
+
|
|
130
|
+
chats.append(
|
|
131
|
+
dialog_to_chat_info(
|
|
132
|
+
dialog,
|
|
133
|
+
is_admin=await is_admin(client, entity, user),
|
|
134
|
+
topics=topics,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return chats
|
telegram_tools/doctor.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from telegram_tools.config import config_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
MIN_PYTHON = (3, 11)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DoctorCheck:
|
|
17
|
+
status: str
|
|
18
|
+
message: str
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def failed(self) -> bool:
|
|
22
|
+
return self.status == "FAIL"
|
|
23
|
+
|
|
24
|
+
def format(self) -> str:
|
|
25
|
+
return f"{self.status:<4} {self.message}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_python_version(version_info: tuple[int, ...] | None = None) -> DoctorCheck:
|
|
29
|
+
version_info = version_info or sys.version_info[:3]
|
|
30
|
+
if version_info >= MIN_PYTHON:
|
|
31
|
+
return DoctorCheck("OK", "Python version is supported")
|
|
32
|
+
return DoctorCheck("FAIL", "Python 3.11 or newer is required")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_config_presence(root: Path, env: Mapping[str, str], home: Path | None = None) -> DoctorCheck:
|
|
36
|
+
if env.get("TELEGRAM_API_ID") and env.get("TELEGRAM_API_HASH"):
|
|
37
|
+
return DoctorCheck("OK", "Telegram config is present")
|
|
38
|
+
if (root / ".env").exists() or (config_dir(home) / ".env").exists():
|
|
39
|
+
return DoctorCheck("OK", "Telegram config is present")
|
|
40
|
+
return DoctorCheck("FAIL", "Telegram config is missing")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def check_session_storage(env: Mapping[str, str], home: Path | None = None) -> DoctorCheck:
|
|
44
|
+
session_path = Path(env.get("TELEGRAM_TOOLS_SESSION", config_dir(home) / "telegram-tools"))
|
|
45
|
+
candidates = [session_path, Path(f"{session_path}.session")]
|
|
46
|
+
if any(path.exists() for path in candidates):
|
|
47
|
+
return DoctorCheck("OK", "Session storage exists")
|
|
48
|
+
return DoctorCheck("WARN", "Session storage was not found (created on first login)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run_doctor(
|
|
52
|
+
*,
|
|
53
|
+
root: Path | str | None = None,
|
|
54
|
+
env: Mapping[str, str] | None = None,
|
|
55
|
+
version_info: tuple[int, ...] | None = None,
|
|
56
|
+
home: Path | None = None,
|
|
57
|
+
) -> int:
|
|
58
|
+
root = Path(root) if root is not None else Path.cwd()
|
|
59
|
+
env = os.environ if env is None else env
|
|
60
|
+
checks = [
|
|
61
|
+
check_python_version(version_info),
|
|
62
|
+
check_config_presence(root, env, home),
|
|
63
|
+
check_session_storage(env, home),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
for check in checks:
|
|
67
|
+
print(check.format())
|
|
68
|
+
|
|
69
|
+
return 1 if any(check.failed for check in checks) else 0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_records(records: Iterable[dict[str, Any]], output: str | Path, fmt: str) -> None:
|
|
10
|
+
rows = list(records)
|
|
11
|
+
path = Path(output)
|
|
12
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
if fmt == "json":
|
|
15
|
+
path.write_text(json.dumps(rows, indent=2, default=str) + "\n")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
if fmt != "csv":
|
|
19
|
+
raise ValueError(f"Unsupported export format: {fmt}")
|
|
20
|
+
|
|
21
|
+
fieldnames: list[str] = []
|
|
22
|
+
for row in rows:
|
|
23
|
+
for key in row:
|
|
24
|
+
if key not in fieldnames:
|
|
25
|
+
fieldnames.append(key)
|
|
26
|
+
|
|
27
|
+
with path.open("w", newline="") as handle:
|
|
28
|
+
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
|
29
|
+
writer.writeheader()
|
|
30
|
+
writer.writerows(rows)
|
telegram_tools/models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TopicInfo:
|
|
9
|
+
id: int
|
|
10
|
+
title: str
|
|
11
|
+
top_message: int | None = None
|
|
12
|
+
|
|
13
|
+
def to_dict(self) -> dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"id": self.id,
|
|
16
|
+
"title": self.title,
|
|
17
|
+
"top_message": self.top_message,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ChatInfo:
|
|
23
|
+
id: int
|
|
24
|
+
title: str
|
|
25
|
+
username: str | None
|
|
26
|
+
type: str
|
|
27
|
+
is_admin: bool
|
|
28
|
+
topics: list[TopicInfo] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"id": self.id,
|
|
33
|
+
"title": self.title,
|
|
34
|
+
"username": self.username,
|
|
35
|
+
"type": self.type,
|
|
36
|
+
"is_admin": self.is_admin,
|
|
37
|
+
"topics": [topic.to_dict() for topic in self.topics],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class DeleteResult:
|
|
43
|
+
matched: int
|
|
44
|
+
deleted: int
|
|
45
|
+
dry_run: bool
|
|
46
|
+
cancelled: bool = False
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"matched": self.matched,
|
|
51
|
+
"cleared": self.deleted,
|
|
52
|
+
"dry_run": self.dry_run,
|
|
53
|
+
"cancelled": self.cancelled,
|
|
54
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, date, datetime, time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_date_bound(value: str | None, *, end_of_day: bool) -> datetime | None:
|
|
8
|
+
if not value:
|
|
9
|
+
return None
|
|
10
|
+
|
|
11
|
+
if "T" not in value and len(value) == 10:
|
|
12
|
+
parsed_date = date.fromisoformat(value)
|
|
13
|
+
parsed_time = time.max if end_of_day else time.min
|
|
14
|
+
return datetime.combine(parsed_date, parsed_time, tzinfo=UTC)
|
|
15
|
+
|
|
16
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
17
|
+
if parsed.tzinfo is None:
|
|
18
|
+
return parsed.replace(tzinfo=UTC)
|
|
19
|
+
return parsed.astimezone(UTC)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def topic_id_for_message(message: Any) -> int | None:
|
|
23
|
+
reply_to = getattr(message, "reply_to", None)
|
|
24
|
+
if not reply_to or not getattr(reply_to, "forum_topic", False):
|
|
25
|
+
return None
|
|
26
|
+
return getattr(reply_to, "reply_to_top_id", None) or getattr(reply_to, "reply_to_msg_id", None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def message_to_record(message: Any, *, chat_id: int | None = None, topic_id: int | None = None) -> dict[str, Any]:
|
|
30
|
+
reply_to = getattr(message, "reply_to", None)
|
|
31
|
+
sender = getattr(message, "sender", None)
|
|
32
|
+
message_date = getattr(message, "date", None)
|
|
33
|
+
if isinstance(message_date, datetime):
|
|
34
|
+
if message_date.tzinfo is None:
|
|
35
|
+
message_date = message_date.replace(tzinfo=UTC)
|
|
36
|
+
date_value = message_date.astimezone(UTC).isoformat()
|
|
37
|
+
else:
|
|
38
|
+
date_value = None
|
|
39
|
+
|
|
40
|
+
text = getattr(message, "raw_text", None)
|
|
41
|
+
if text is None:
|
|
42
|
+
text = getattr(message, "message", "") or ""
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
"id": int(getattr(message, "id")),
|
|
46
|
+
"chat_id": chat_id,
|
|
47
|
+
"topic_id": topic_id if topic_id is not None else topic_id_for_message(message),
|
|
48
|
+
"date": date_value,
|
|
49
|
+
"sender_id": getattr(message, "sender_id", None),
|
|
50
|
+
"sender_username": getattr(sender, "username", None),
|
|
51
|
+
"reply_to_msg_id": getattr(reply_to, "reply_to_msg_id", None) if reply_to else None,
|
|
52
|
+
"reply_to_top_id": getattr(reply_to, "reply_to_top_id", None) if reply_to else None,
|
|
53
|
+
"has_media": bool(getattr(message, "media", None)),
|
|
54
|
+
"text": text,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def message_matches_filters(
|
|
59
|
+
message: Any,
|
|
60
|
+
*,
|
|
61
|
+
keyword: str | None = None,
|
|
62
|
+
from_user_id: int | None = None,
|
|
63
|
+
since: datetime | None = None,
|
|
64
|
+
until: datetime | None = None,
|
|
65
|
+
) -> bool:
|
|
66
|
+
message_date = getattr(message, "date", None)
|
|
67
|
+
if isinstance(message_date, datetime):
|
|
68
|
+
if message_date.tzinfo is None:
|
|
69
|
+
message_date = message_date.replace(tzinfo=UTC)
|
|
70
|
+
message_date = message_date.astimezone(UTC)
|
|
71
|
+
|
|
72
|
+
if since and isinstance(message_date, datetime) and message_date < since:
|
|
73
|
+
return False
|
|
74
|
+
if until and isinstance(message_date, datetime) and message_date > until:
|
|
75
|
+
return False
|
|
76
|
+
if from_user_id is not None and getattr(message, "sender_id", None) != from_user_id:
|
|
77
|
+
return False
|
|
78
|
+
if keyword:
|
|
79
|
+
text = (getattr(message, "raw_text", None) or getattr(message, "message", "") or "").lower()
|
|
80
|
+
if keyword.lower() not in text:
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EntityResolutionError(ValueError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ResolvedChat:
|
|
13
|
+
id: int
|
|
14
|
+
entity: Any
|
|
15
|
+
input_entity: Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_numeric_reference(reference: Any) -> int | None:
|
|
19
|
+
if isinstance(reference, int):
|
|
20
|
+
return reference
|
|
21
|
+
if isinstance(reference, str):
|
|
22
|
+
value = reference.strip()
|
|
23
|
+
if value and value.lstrip("+-").isdigit():
|
|
24
|
+
return int(value)
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _resolve_from_dialogs(client, chat_id: int) -> ResolvedChat | None:
|
|
29
|
+
async for dialog in client.iter_dialogs():
|
|
30
|
+
if int(getattr(dialog, "id")) != chat_id:
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
entity = dialog.entity
|
|
34
|
+
input_entity = getattr(dialog, "input_entity", None)
|
|
35
|
+
if input_entity is None:
|
|
36
|
+
input_entity = await client.get_input_entity(entity)
|
|
37
|
+
return ResolvedChat(id=chat_id, entity=entity, input_entity=input_entity)
|
|
38
|
+
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def resolve_chat(client, reference: str | int) -> ResolvedChat:
|
|
43
|
+
numeric_reference = _parse_numeric_reference(reference)
|
|
44
|
+
if numeric_reference is not None:
|
|
45
|
+
resolved = await _resolve_from_dialogs(client, numeric_reference)
|
|
46
|
+
if resolved is not None:
|
|
47
|
+
return resolved
|
|
48
|
+
lookup_reference: str | int = numeric_reference
|
|
49
|
+
else:
|
|
50
|
+
lookup_reference = reference
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
entity = await client.get_entity(lookup_reference)
|
|
54
|
+
input_entity = await client.get_input_entity(entity)
|
|
55
|
+
peer_id = int(await client.get_peer_id(entity))
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
raise EntityResolutionError(f"Cannot resolve chat {reference!r}.") from exc
|
|
58
|
+
|
|
59
|
+
return ResolvedChat(id=peer_id, entity=entity, input_entity=input_entity)
|
telegram_tools/search.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from telegram_tools.records import message_matches_filters, message_to_record, parse_date_bound
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _truncate(value: str, max_length: int = 80) -> str:
|
|
9
|
+
value = " ".join(value.split())
|
|
10
|
+
if len(value) <= max_length:
|
|
11
|
+
return value
|
|
12
|
+
return value[: max_length - 1] + "..."
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_message_records(records: list[dict[str, Any]]) -> str:
|
|
16
|
+
if not records:
|
|
17
|
+
return "No messages found."
|
|
18
|
+
|
|
19
|
+
lines = ["Messages", "--------------------------------------------"]
|
|
20
|
+
for record in records:
|
|
21
|
+
sender = record.get("sender_username") or record.get("sender_id") or ""
|
|
22
|
+
topic = record.get("topic_id") or ""
|
|
23
|
+
date = record.get("date") or ""
|
|
24
|
+
text = _truncate(str(record.get("text") or ""))
|
|
25
|
+
lines.append(
|
|
26
|
+
f"{record.get('id')}\t{date}\ttopic={topic}\tsender={sender}\t{text}"
|
|
27
|
+
)
|
|
28
|
+
return "\n".join(lines)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _resolve_from_user_id(client, from_user: str | int | None) -> int | None:
|
|
32
|
+
if from_user is None:
|
|
33
|
+
return None
|
|
34
|
+
return int(await client.get_peer_id(from_user))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def search_messages(
|
|
38
|
+
client,
|
|
39
|
+
chat: Any,
|
|
40
|
+
*,
|
|
41
|
+
chat_id: int | None = None,
|
|
42
|
+
topic_id: int | None = None,
|
|
43
|
+
keyword: str | None = None,
|
|
44
|
+
from_user: str | int | None = None,
|
|
45
|
+
since: str | None = None,
|
|
46
|
+
until: str | None = None,
|
|
47
|
+
limit: int | None = None,
|
|
48
|
+
) -> list[dict[str, Any]]:
|
|
49
|
+
since_dt = parse_date_bound(since, end_of_day=False)
|
|
50
|
+
until_dt = parse_date_bound(until, end_of_day=True)
|
|
51
|
+
records: list[dict[str, Any]] = []
|
|
52
|
+
|
|
53
|
+
if topic_id is not None:
|
|
54
|
+
from_user_id = await _resolve_from_user_id(client, from_user)
|
|
55
|
+
iterator = client.iter_messages(chat, reply_to=topic_id, wait_time=1)
|
|
56
|
+
async for message in iterator:
|
|
57
|
+
if message_matches_filters(
|
|
58
|
+
message,
|
|
59
|
+
keyword=keyword,
|
|
60
|
+
from_user_id=from_user_id,
|
|
61
|
+
since=since_dt,
|
|
62
|
+
until=until_dt,
|
|
63
|
+
):
|
|
64
|
+
records.append(message_to_record(message, chat_id=chat_id, topic_id=topic_id))
|
|
65
|
+
if limit is not None and len(records) >= limit:
|
|
66
|
+
break
|
|
67
|
+
return records
|
|
68
|
+
|
|
69
|
+
kwargs: dict[str, Any] = {"limit": limit, "wait_time": 1}
|
|
70
|
+
if keyword:
|
|
71
|
+
kwargs["search"] = keyword
|
|
72
|
+
if from_user:
|
|
73
|
+
kwargs["from_user"] = from_user
|
|
74
|
+
if until_dt:
|
|
75
|
+
kwargs["offset_date"] = until_dt
|
|
76
|
+
|
|
77
|
+
async for message in client.iter_messages(chat, **kwargs):
|
|
78
|
+
if message_matches_filters(message, keyword=keyword, since=since_dt, until=until_dt):
|
|
79
|
+
records.append(message_to_record(message, chat_id=chat_id))
|
|
80
|
+
|
|
81
|
+
return records
|
telegram_tools/topics.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from telethon.tl.functions.messages import GetForumTopicsByIDRequest, GetForumTopicsRequest
|
|
6
|
+
|
|
7
|
+
from telegram_tools.models import TopicInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def topic_from_telethon(raw_topic) -> TopicInfo:
|
|
11
|
+
topic_id = int(getattr(raw_topic, "id"))
|
|
12
|
+
return TopicInfo(
|
|
13
|
+
id=topic_id,
|
|
14
|
+
title=str(getattr(raw_topic, "title", topic_id)),
|
|
15
|
+
top_message=getattr(raw_topic, "top_message", topic_id),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def get_forum_topics(client, peer, *, page_size: int = 100) -> list[TopicInfo]:
|
|
20
|
+
topics: list[TopicInfo] = []
|
|
21
|
+
seen: set[int] = set()
|
|
22
|
+
offset_date = None
|
|
23
|
+
offset_id = 0
|
|
24
|
+
offset_topic = 0
|
|
25
|
+
|
|
26
|
+
while True:
|
|
27
|
+
result = await client(
|
|
28
|
+
GetForumTopicsRequest(
|
|
29
|
+
peer=peer,
|
|
30
|
+
offset_date=offset_date,
|
|
31
|
+
offset_id=offset_id,
|
|
32
|
+
offset_topic=offset_topic,
|
|
33
|
+
limit=page_size,
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
raw_topics = list(getattr(result, "topics", []) or [])
|
|
37
|
+
if not raw_topics:
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
added = 0
|
|
41
|
+
for raw_topic in raw_topics:
|
|
42
|
+
topic_id = getattr(raw_topic, "id", None)
|
|
43
|
+
if topic_id is None or topic_id in seen:
|
|
44
|
+
continue
|
|
45
|
+
seen.add(topic_id)
|
|
46
|
+
topics.append(topic_from_telethon(raw_topic))
|
|
47
|
+
added += 1
|
|
48
|
+
|
|
49
|
+
total_count = getattr(result, "count", None)
|
|
50
|
+
if total_count is not None and len(topics) >= total_count:
|
|
51
|
+
break
|
|
52
|
+
if added == 0 or len(raw_topics) < page_size:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
last = raw_topics[-1]
|
|
56
|
+
offset_date = getattr(last, "date", None)
|
|
57
|
+
offset_id = int(getattr(last, "top_message", 0) or 0)
|
|
58
|
+
offset_topic = int(getattr(last, "id", 0) or 0)
|
|
59
|
+
|
|
60
|
+
return topics
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def get_forum_topics_by_ids(client, peer, topic_ids: Iterable[int]) -> list[TopicInfo]:
|
|
64
|
+
ids = [int(topic_id) for topic_id in topic_ids]
|
|
65
|
+
if not ids:
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
result = await client(GetForumTopicsByIDRequest(peer=peer, topics=ids))
|
|
69
|
+
topics = [topic_from_telethon(topic) for topic in getattr(result, "topics", []) or []]
|
|
70
|
+
found = {topic.id for topic in topics}
|
|
71
|
+
topics.extend(TopicInfo(id=topic_id, title=str(topic_id), top_message=topic_id) for topic_id in ids if topic_id not in found)
|
|
72
|
+
return topics
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: telegram-tools
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Local Telethon CLI for Telegram chat/topic ID discovery, message search/export, and topic message clearing.
|
|
5
|
+
Project-URL: Homepage, https://github.com/banozz0/telegram-tools
|
|
6
|
+
Project-URL: Repository, https://github.com/banozz0/telegram-tools
|
|
7
|
+
Project-URL: Issues, https://github.com/banozz0/telegram-tools/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/banozz0/telegram-tools/blob/main/CHANGELOG.md
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 telegram-tools contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: cli,export,forum-topics,telegram,telethon
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
41
|
+
Classifier: Topic :: Communications :: Chat
|
|
42
|
+
Classifier: Topic :: Utilities
|
|
43
|
+
Requires-Python: >=3.11
|
|
44
|
+
Requires-Dist: python-dotenv<2,>=1.0
|
|
45
|
+
Requires-Dist: telethon<2,>=1.44
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# telegram-tools
|
|
51
|
+
|
|
52
|
+
A local CLI for operating your own Telegram chats: find the real IDs of your groups, channels, and forum topics, search and export messages, and clear all messages out of forum topics without destroying the topics themselves.
|
|
53
|
+
|
|
54
|
+
Built on [Telethon](https://github.com/LonamiWebs/Telethon). Everything runs on your machine with your own Telegram API credentials — no server, no third party, nothing leaves your computer except the Telegram API calls you asked for.
|
|
55
|
+
|
|
56
|
+
## What it does
|
|
57
|
+
|
|
58
|
+
- **`discover`** — lists your chats, channels, and forum groups with their exact numeric IDs and every forum topic ID. The fastest way to answer "what is this chat's `-100…` ID and what are its topic IDs?"
|
|
59
|
+
- **`search`** — searches messages by text, sender, date range, or topic, and prints a table or exports JSON/CSV.
|
|
60
|
+
- **`clear-messages`** — deletes all messages inside selected forum topic(s) while preserving the topics and their IDs. Dry-run by default; deleting requires both `--execute` *and* typing `DELETE` at a prompt.
|
|
61
|
+
- **`doctor`** — checks your local setup without printing any secrets.
|
|
62
|
+
|
|
63
|
+
## What it doesn't do (on purpose)
|
|
64
|
+
|
|
65
|
+
- No deleting or creating forum topics — topic IDs never change.
|
|
66
|
+
- No media downloads.
|
|
67
|
+
- No sending messages, no bots, no automation loops.
|
|
68
|
+
- No cloud anything — credentials and session files stay in `~/.telegram-tools/`.
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pipx install telegram-tools
|
|
74
|
+
# or
|
|
75
|
+
uv tool install telegram-tools
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or from source: `pipx install git+https://github.com/banozz0/telegram-tools.git`
|
|
79
|
+
|
|
80
|
+
Requires Python 3.11+.
|
|
81
|
+
|
|
82
|
+
## Setup: your Telegram API credentials
|
|
83
|
+
|
|
84
|
+
The tool logs in as *you* (a user account, not a bot), so it needs a Telegram API key. One-time, about two minutes:
|
|
85
|
+
|
|
86
|
+
1. Open <https://my.telegram.org/apps> and log in with your Telegram phone number.
|
|
87
|
+
2. Fill in the short "Create new application" form (any name/short name works; platform "Desktop").
|
|
88
|
+
3. Copy the **App api_id** (a number) and **App api_hash** (a hex string).
|
|
89
|
+
4. Store them where the tool can find them:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
mkdir -p ~/.telegram-tools
|
|
93
|
+
cat > ~/.telegram-tools/.env <<'EOF'
|
|
94
|
+
TELEGRAM_API_ID=123456
|
|
95
|
+
TELEGRAM_API_HASH=your-api-hash-here
|
|
96
|
+
EOF
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Shell environment variables and a `.env` in the current directory also work, and win over `~/.telegram-tools/.env`.
|
|
100
|
+
|
|
101
|
+
Treat the api_hash like a password. The first command you run starts Telethon's interactive login (phone number + code from Telegram); the resulting session file is stored in `~/.telegram-tools/` and reused afterwards. Log out anytime by deleting the session file in that directory (your `.env` can stay) — the session also shows under Telegram's *Settings → Devices*.
|
|
102
|
+
|
|
103
|
+
## 30 seconds of usage
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# What are my chats and their IDs?
|
|
107
|
+
telegram-tools discover # admin/managed chats only
|
|
108
|
+
telegram-tools discover --all # everything
|
|
109
|
+
|
|
110
|
+
# Search a group
|
|
111
|
+
telegram-tools search --chat @mygroup --contains deploy
|
|
112
|
+
|
|
113
|
+
# Export a topic to JSON
|
|
114
|
+
telegram-tools search --chat @mygroup --topic 141 --output topic-141.json
|
|
115
|
+
|
|
116
|
+
# Clear a topic (dry-run first — this is the default)
|
|
117
|
+
telegram-tools clear-messages --chat @mygroup --topic 141
|
|
118
|
+
# Actually delete: needs --execute AND typing DELETE at the prompt
|
|
119
|
+
telegram-tools clear-messages --chat @mygroup --topic 141 --execute
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Running `telegram-tools` with no arguments opens an interactive menu with the same operations.
|
|
123
|
+
|
|
124
|
+
`discover` output looks like:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
Forum Groups
|
|
128
|
+
============
|
|
129
|
+
Example Forum
|
|
130
|
+
Chat ID: -1001234567890
|
|
131
|
+
Type: Forum Group
|
|
132
|
+
Admin: yes
|
|
133
|
+
|
|
134
|
+
Topics
|
|
135
|
+
--------------------------------------------
|
|
136
|
+
141 Deploys
|
|
137
|
+
217 Support
|
|
138
|
+
16 General
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Safety model
|
|
142
|
+
|
|
143
|
+
| Command | Destructive? |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `discover`, `search`, `doctor` | No — read-only |
|
|
146
|
+
| `clear-messages` | Yes — but only with `--execute` **and** a typed `DELETE`, only messages, never topics |
|
|
147
|
+
|
|
148
|
+
`clear-messages` also verifies you actually hold the delete-messages permission in the chat before doing anything, skips topic starter messages, and handles Telegram flood-wait limits automatically.
|
|
149
|
+
|
|
150
|
+
## Status
|
|
151
|
+
|
|
152
|
+
Stable for its three jobs; used regularly by its author. This is a solo project whose code was written by AI agents under review — issues are welcome, fixes are best-effort, and there is no support promise.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
telegram_tools/__init__.py,sha256=6OV0lQW5QffLv_UhmFj1MiK18NusHs7bvrU7nTGJjBo,82
|
|
2
|
+
telegram_tools/cli.py,sha256=BmathBTQqjQ_NS_oOeDPkDpZheaQ9iiBtp8B_R1X1p8,9519
|
|
3
|
+
telegram_tools/client.py,sha256=YzzrIXsHgcXw7-rLWlClVH8VcyCYpAEo74O4iwWJm40,387
|
|
4
|
+
telegram_tools/config.py,sha256=lOneibo5LM5FJCKvUIVYTdAW5zuSFYsUj3DF8soV-mk,1335
|
|
5
|
+
telegram_tools/delete.py,sha256=g0B2j6odhM6BDlnM47Efs7tY9-ECTmFTphYObyxmMIA,3270
|
|
6
|
+
telegram_tools/discovery.py,sha256=vXrgyjMPT9aEmPOtFyaN7oZlZe0t9upc21aC4s4iEaY,3980
|
|
7
|
+
telegram_tools/doctor.py,sha256=PvpjR0vuPtuE5HcTx8Va0855-VaQkschIO5qKbH6UuA,2217
|
|
8
|
+
telegram_tools/exporters.py,sha256=HpEmUs8vgWGy-IK2P4Q8rEUUjU9ozaj7Oe3LbV1318E,830
|
|
9
|
+
telegram_tools/models.py,sha256=yKBQ-fR7FK6Fi7H6_kai172SRhnur1aPBPGv6X3pQkA,1215
|
|
10
|
+
telegram_tools/records.py,sha256=IxEmywAoJq917KXoimGHKgRim1u8UXlPdf0-UgUmGnQ,3097
|
|
11
|
+
telegram_tools/resolver.py,sha256=WpcUZkqVImeUZo9wCfINmNU3yyJUvAR_9wWFxetEt6k,1803
|
|
12
|
+
telegram_tools/search.py,sha256=rVdQOH-IsU3Z1Kp7tmhUiCGGYqKe3KDQxuEGKvq2PvU,2713
|
|
13
|
+
telegram_tools/topics.py,sha256=5RTB6gtf6pCQ1FDv47VtIC6dw0tfmkIvZRZqw5vq-wc,2355
|
|
14
|
+
telegram_tools-3.0.0.dist-info/METADATA,sha256=a8gwMblQY88AFbCC9UJrFI-tFdc-vwZTMfkmq_kJmKA,6781
|
|
15
|
+
telegram_tools-3.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
16
|
+
telegram_tools-3.0.0.dist-info/entry_points.txt,sha256=hgizMaSMu4c4lHgjZEqdnB4_2ZEZqYZ0S8KeyWemhQ0,59
|
|
17
|
+
telegram_tools-3.0.0.dist-info/licenses/LICENSE,sha256=JCGfvNwIwXv0kA46GVRJYepcwlt4QM-qcrie9lZB03I,1084
|
|
18
|
+
telegram_tools-3.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 telegram-tools contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|