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.
@@ -0,0 +1,5 @@
1
+ """Local Telethon CLI tools."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "2.0.1"
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())
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ telegram-tools = telegram_tools.cli:main
@@ -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.