telegram-userbot-cli 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/PKG-INFO +21 -1
  2. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/README-zh.md +20 -0
  3. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/README.md +20 -0
  4. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/ROADMAP.md +26 -0
  5. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/pyproject.toml +1 -1
  6. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/__init__.py +1 -1
  7. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/cli.py +13 -2
  8. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/dialogs.py +28 -0
  9. telegram_userbot_cli-0.5.0/src/tgcli/discovery.py +197 -0
  10. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/groups.py +170 -0
  11. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/media.py +19 -0
  12. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/messages.py +98 -1
  13. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.env.example +0 -0
  14. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.github/workflows/ci.yml +0 -0
  15. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.github/workflows/release.yml +0 -0
  16. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.gitignore +0 -0
  17. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/LICENSE +0 -0
  18. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/__main__.py +0 -0
  19. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/auth.py +0 -0
  20. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/chats.py +0 -0
  21. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/client.py +0 -0
  22. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/config.py +0 -0
  23. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/contacts.py +0 -0
  24. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/output.py +0 -0
  25. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/resolve.py +0 -0
  26. {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: telegram-userbot-cli
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Full-featured Telegram command-line client powered by a Telethon userbot (your own account, not a bot token)
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -181,6 +181,26 @@ tg logout # remove the session
181
181
  | `tg login --account <label>` | Log in an additional account |
182
182
  | `tg vtr <chat> <msg_id>` | Transcribe a voice note (Telegram Premium required) |
183
183
 
184
+ ### Parity completion (v5)
185
+
186
+ | Command | Description |
187
+ |---|---|
188
+ | `tg buttons <chat> <msg_id>` | List a message's inline buttons (with press indexes) |
189
+ | `tg press <chat> <msg_id> <idx\|text>` | Press an inline button (shows the bot's answer) |
190
+ | `tg reactions <chat> <msg_id>` | Show reactions (counts + recent reactors) |
191
+ | `tg members <chat> [-n]` | Full member list (recent first) |
192
+ | `tg chat-permissions <chat> [--no-send-stickers …]` | Show/set default member permissions |
193
+ | `tg forum <chat> on\|off` | Enable/disable forum mode |
194
+ | `tg chat-photo-del <chat>` | Remove the group/channel photo |
195
+ | `tg folder-rm <folder> <chat...>` | Remove chats from a folder |
196
+ | `tg upload <file>` | Upload a file without sending (prints id/md5) |
197
+ | `tg search-public <query>` | Search public chats/users by name |
198
+ | `tg resolve <@username>` | Resolve a username to an id |
199
+ | `tg stickers` | List your sticker sets |
200
+ | `tg user-photos <user> [-n]` | List a user's profile photos |
201
+ | `tg bot-info <bot>` | Show a bot's description and commands |
202
+ | `tg bot-commands <cmd> <desc>...` | Set your bot's commands (bot accounts only; `--clear`) |
203
+
184
204
  Global flags: `--account` (v1: default only), `--json` (raw JSON output).
185
205
  Exit codes: `0` ok, `1` command error, `2` config/connection/session error,
186
206
  `130` interrupted.
@@ -158,6 +158,26 @@ tg logout # 退出登录并删除会话
158
158
  | `tg login --account <标签>` | 登录额外账号 |
159
159
  | `tg vtr <会话> <消息id>` | 语音转文字(需要 Telegram Premium) |
160
160
 
161
+ ### 对齐补全(v5)
162
+
163
+ | 命令 | 说明 |
164
+ |---|---|
165
+ | `tg buttons <会话> <消息id>` | 列出消息的内联按钮(带按压序号) |
166
+ | `tg press <会话> <消息id> <序号\|文本>` | 点击内联按钮(显示 bot 应答) |
167
+ | `tg reactions <会话> <消息id>` | 查看消息反应(计数 + 最近反应者) |
168
+ | `tg members <会话> [-n]` | 完整成员列表(最近加入优先) |
169
+ | `tg chat-permissions <会话> [--no-send-stickers …]` | 查看/设置群默认成员权限 |
170
+ | `tg forum <会话> on\|off` | 开启/关闭论坛模式 |
171
+ | `tg chat-photo-del <会话>` | 删除群/频道头像 |
172
+ | `tg folder-rm <文件夹> <会话...>` | 从文件夹移出会话 |
173
+ | `tg upload <文件>` | 上传文件到 Telegram 但不发送(输出 id/md5) |
174
+ | `tg search-public <关键词>` | 按名称搜索公开群组/用户 |
175
+ | `tg resolve <@用户名>` | 把用户名解析为 id(不依赖会话列表) |
176
+ | `tg stickers` | 列出你的贴纸包 |
177
+ | `tg user-photos <用户> [-n]` | 列出用户的头像 |
178
+ | `tg bot-info <bot>` | 查看 bot 的简介与命令 |
179
+ | `tg bot-commands <命令> <描述>...` | 设置自己 bot 的命令(仅 bot 账号;`--clear`) |
180
+
161
181
  全局选项:`--account`(v1 仅 default)、`--json`(原始 JSON 输出)。
162
182
  退出码:`0` 成功、`1` 命令错误、`2` 配置/连接/会话错误、`130` 中断。
163
183
 
@@ -168,6 +168,26 @@ tg logout # remove the session
168
168
  | `tg login --account <label>` | Log in an additional account |
169
169
  | `tg vtr <chat> <msg_id>` | Transcribe a voice note (Telegram Premium required) |
170
170
 
171
+ ### Parity completion (v5)
172
+
173
+ | Command | Description |
174
+ |---|---|
175
+ | `tg buttons <chat> <msg_id>` | List a message's inline buttons (with press indexes) |
176
+ | `tg press <chat> <msg_id> <idx\|text>` | Press an inline button (shows the bot's answer) |
177
+ | `tg reactions <chat> <msg_id>` | Show reactions (counts + recent reactors) |
178
+ | `tg members <chat> [-n]` | Full member list (recent first) |
179
+ | `tg chat-permissions <chat> [--no-send-stickers …]` | Show/set default member permissions |
180
+ | `tg forum <chat> on\|off` | Enable/disable forum mode |
181
+ | `tg chat-photo-del <chat>` | Remove the group/channel photo |
182
+ | `tg folder-rm <folder> <chat...>` | Remove chats from a folder |
183
+ | `tg upload <file>` | Upload a file without sending (prints id/md5) |
184
+ | `tg search-public <query>` | Search public chats/users by name |
185
+ | `tg resolve <@username>` | Resolve a username to an id |
186
+ | `tg stickers` | List your sticker sets |
187
+ | `tg user-photos <user> [-n]` | List a user's profile photos |
188
+ | `tg bot-info <bot>` | Show a bot's description and commands |
189
+ | `tg bot-commands <cmd> <desc>...` | Set your bot's commands (bot accounts only; `--clear`) |
190
+
171
191
  Global flags: `--account` (v1: default only), `--json` (raw JSON output).
172
192
  Exit codes: `0` ok, `1` command error, `2` config/connection/session error,
173
193
  `130` interrupted.
@@ -76,6 +76,32 @@ Chat management:
76
76
  `messages.TranscribeAudioRequest` (telethon has no wrapper). Telegram
77
77
  Premium is required server-side; free accounts get a clean error
78
78
 
79
+ ### v5 — MCP parity completion (shipped)
80
+
81
+ Closes the remaining gap against the chigwell/telegram-mcp 126-tool scope.
82
+ Everything below is now aligned; only the agent-oriented event tools stay
83
+ out of scope.
84
+
85
+ - ✅ `buttons` / `press` — inline keyboard interaction (press shows the
86
+ bot's callback answer)
87
+ - ✅ `reactions` — view message reactions (counts + recent reactors)
88
+ - ✅ `members` — full participant list (supergroups paginated via
89
+ `ChannelParticipantsRecent`; basic groups via `GetFullChat`)
90
+ - ✅ `chat-permissions` — show/set default member rights
91
+ (`messages.editChatDefaultBannedRights`; flags use
92
+ `--send-messages/--no-send-messages` pairs)
93
+ - ✅ `search-public` — public chat/user search (`contacts.Search`)
94
+ - ✅ `resolve` — username → id (`contacts.ResolveUsername`)
95
+ - ✅ `forum on|off` — forum mode toggle
96
+ - ✅ `chat-photo-del` — remove group/channel photo (`InputChatPhotoEmpty`)
97
+ - ✅ `stickers` — list installed sticker sets
98
+ - ✅ `user-photos` — list a user's profile photos
99
+ - ✅ `folder-rm` — remove chats from a folder (folders cannot become empty)
100
+ - ✅ `upload` — upload a file without sending
101
+ - ✅ `bot-info` — bot description + commands (`users.GetFullUser`)
102
+ - ✅ `bot-commands` — set own bot's commands (bot accounts only, like
103
+ telegram-mcp's implementation)
104
+
79
105
  ### Out of scope
80
106
 
81
107
  - ❌ Agent-oriented event tools (`wait_for_settled_message`, incoming feed
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "telegram-userbot-cli"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Full-featured Telegram command-line client powered by a Telethon userbot (your own account, not a bot token)"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """tgcli — standalone Telegram command-line client."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -6,7 +6,18 @@ import sys
6
6
 
7
7
  from telethon import errors
8
8
 
9
- from . import __version__, auth, chats, contacts, dialogs, groups, media, messages, resolve
9
+ from . import (
10
+ __version__,
11
+ auth,
12
+ chats,
13
+ contacts,
14
+ dialogs,
15
+ discovery,
16
+ groups,
17
+ media,
18
+ messages,
19
+ resolve,
20
+ )
10
21
  from .output import TgError
11
22
 
12
23
 
@@ -44,7 +55,7 @@ def build_parser() -> argparse.ArgumentParser:
44
55
  sp3 = sub.add_parser("accounts", parents=[common], help="List configured accounts")
45
56
  sp3.set_defaults(func=auth.cmd_accounts)
46
57
 
47
- for mod in (messages, chats, media, dialogs, groups, contacts):
58
+ for mod in (messages, chats, media, dialogs, groups, contacts, discovery):
48
59
  mod.setup(sub, common)
49
60
 
50
61
  resolve.setup_alias(sub, common)
@@ -266,6 +266,29 @@ async def cmd_folder_assign(args) -> None:
266
266
  print(f"Added {added} chat(s) to folder [{filt.id}] {_filter_title(filt)}")
267
267
 
268
268
 
269
+ async def cmd_folder_rm(args) -> None:
270
+ async with run_with_client(args) as client:
271
+ filt = await _find_filter(client, args.folder)
272
+ wanted = {utils.get_peer_id(await _input_peer(client, c)) for c in args.chat}
273
+ kept = [p for p in filt.include_peers if utils.get_peer_id(p) not in wanted]
274
+ removed = len(filt.include_peers) - len(kept)
275
+ if not removed:
276
+ raise TgError("none of the given chats are in that folder")
277
+ # the server rejects filters with an empty include_peers unless they
278
+ # match by category (contacts/groups/…)
279
+ if not kept and not any(
280
+ getattr(filt, f, False)
281
+ for f in ("contacts", "non_contacts", "groups", "broadcasts", "bots")
282
+ ):
283
+ raise TgError(
284
+ "a folder cannot become empty",
285
+ "delete it instead: tg folder-del <folder>",
286
+ )
287
+ filt.include_peers = kept
288
+ await client(functions.messages.UpdateDialogFilterRequest(id=filt.id, filter=filt))
289
+ print(f"Removed {removed} chat(s) from folder [{filt.id}] {_filter_title(filt)}")
290
+
291
+
269
292
  async def cmd_folder_del(args) -> None:
270
293
  async with run_with_client(args) as client:
271
294
  filt = await _find_filter(client, args.folder)
@@ -374,6 +397,11 @@ def setup(subparsers, common=None) -> None:
374
397
  sp.add_argument("chat", nargs="+")
375
398
  sp.set_defaults(func=cmd_folder_assign)
376
399
 
400
+ sp = subparsers.add_parser("folder-rm", parents=parents, help="Remove chats from a folder")
401
+ sp.add_argument("folder", help="folder name or id")
402
+ sp.add_argument("chat", nargs="+")
403
+ sp.set_defaults(func=cmd_folder_rm)
404
+
377
405
  sp = subparsers.add_parser("folder-del", parents=parents, help="Delete a folder")
378
406
  sp.add_argument("folder", help="folder name or id")
379
407
  sp.set_defaults(func=cmd_folder_del)
@@ -0,0 +1,197 @@
1
+ """Public surface discovery: search chats, resolve usernames, stickers,
2
+ user photos, bot info/commands."""
3
+
4
+ from telethon import functions, types
5
+
6
+ from .client import run_with_client
7
+ from .output import TgError, json_pp
8
+ from .resolve import resolve_chat
9
+
10
+
11
+ def _marked(peer) -> int:
12
+ """Marked id (-100 prefix for channels) via the peer's bare id/type."""
13
+ if isinstance(peer, types.PeerChannel):
14
+ return int(f"-100{peer.channel_id}")
15
+ if isinstance(peer, types.PeerUser):
16
+ return peer.user_id
17
+ if isinstance(peer, types.PeerChat):
18
+ return -peer.chat_id
19
+ return 0
20
+
21
+
22
+ async def cmd_search_public(args) -> None:
23
+ async with run_with_client(args) as client:
24
+ res = await client(functions.contacts.SearchRequest(q=args.query, limit=args.n))
25
+ chats = getattr(res, "chats", []) or []
26
+ users = getattr(res, "users", []) or []
27
+ if args.json:
28
+ print(
29
+ json_pp(
30
+ {"chats": [c.to_dict() for c in chats], "users": [u.to_dict() for u in users]}
31
+ )
32
+ )
33
+ return
34
+ if not chats and not users:
35
+ print("(no results)")
36
+ return
37
+ for c in chats:
38
+ uname = getattr(c, "username", None) or ""
39
+ kind = "channel" if getattr(c, "broadcast", False) else "group"
40
+ print(f"{c.id:>14} {kind:<8} {c.title} {'@' + uname if uname else ''}")
41
+ for u in users:
42
+ name = " ".join(x for x in (u.first_name, u.last_name) if x)
43
+ uname = getattr(u, "username", None) or ""
44
+ print(f"{u.id:>14} {'user':<8} {name} {'@' + uname if uname else ''}")
45
+
46
+
47
+ async def cmd_resolve(args) -> None:
48
+ async with run_with_client(args) as client:
49
+ res = await client(
50
+ functions.contacts.ResolveUsernameRequest(username=args.username.lstrip("@"))
51
+ )
52
+ peer = res.peer
53
+ chats = {c.id: c for c in getattr(res, "chats", []) or []}
54
+ users = {u.id: u for u in getattr(res, "users", []) or []}
55
+ if isinstance(peer, types.PeerUser):
56
+ u = users.get(peer.user_id)
57
+ name = (
58
+ " ".join(
59
+ x for x in (getattr(u, "first_name", None), getattr(u, "last_name", None)) if x
60
+ )
61
+ if u
62
+ else "?"
63
+ )
64
+ print(f"user {peer.user_id:>14} {name}")
65
+ elif isinstance(peer, types.PeerChannel):
66
+ c = chats.get(peer.channel_id)
67
+ title = getattr(c, "title", "?") if c else "?"
68
+ kind = "supergroup" if getattr(c, "megagroup", False) else "channel"
69
+ print(f"{kind} -100{peer.channel_id} {title}")
70
+ else:
71
+ print(peer)
72
+
73
+
74
+ async def cmd_stickers(args) -> None:
75
+ async with run_with_client(args) as client:
76
+ res = await client(functions.messages.GetAllStickersRequest(hash=0))
77
+ sets = getattr(res, "sets", []) or []
78
+ if args.json:
79
+ print(json_pp([s.to_dict() for s in sets]))
80
+ return
81
+ if not sets:
82
+ print("(no sticker sets)")
83
+ return
84
+ for s in sets:
85
+ flag = "*" if getattr(s, "installed", False) else " "
86
+ print(f"{s.id:>14} {flag} {s.title} ({s.count})")
87
+ print(f" t.me/addstickers/{s.short_name}")
88
+
89
+
90
+ async def cmd_user_photos(args) -> None:
91
+ async with run_with_client(args) as client:
92
+ user = await resolve_chat(client, args.user)
93
+ res = await client(
94
+ functions.photos.GetUserPhotosRequest(user_id=user, offset=0, max_id=0, limit=args.n)
95
+ )
96
+ photos = getattr(res, "photos", []) or []
97
+ if args.json:
98
+ print(json_pp([p.to_dict() for p in photos]))
99
+ return
100
+ if not photos:
101
+ print("(no profile photos)")
102
+ return
103
+ for p in photos:
104
+ date = p.date.astimezone().strftime("%Y-%m-%d %H:%M")
105
+ biggest = max(p.sizes, key=lambda s: getattr(s, "w", 0) or 0, default=None)
106
+ dims = f"{biggest.w}x{biggest.h}" if biggest and getattr(biggest, "w", 0) else ""
107
+ print(f"{p.id:>20} {date} {dims}")
108
+
109
+
110
+ async def cmd_bot_info(args) -> None:
111
+ async with run_with_client(args) as client:
112
+ user = await resolve_chat(client, args.bot)
113
+ res = await client(functions.users.GetFullUserRequest(id=user))
114
+ info = getattr(res.full_user, "bot_info", None)
115
+ if info is None:
116
+ raise TgError(f"'{args.bot}' is not a bot (no bot info)")
117
+ if args.json:
118
+ print(json_pp(info.to_dict()))
119
+ return
120
+ desc = getattr(info, "description", "") or ""
121
+ about = getattr(info, "about", "") or ""
122
+ if about:
123
+ print(about)
124
+ if desc:
125
+ print(desc)
126
+ commands = getattr(info, "commands", []) or []
127
+ if commands:
128
+ print("\ncommands:")
129
+ for c in commands:
130
+ print(f" /{c.command} {c.description}")
131
+ if not (about or desc or commands):
132
+ print("(no public bot info)")
133
+
134
+
135
+ async def cmd_bot_commands(args) -> None:
136
+ async with run_with_client(args) as client:
137
+ me = await client.get_me()
138
+ if not getattr(me, "bot", False):
139
+ raise TgError(
140
+ "only bot accounts can set their own commands",
141
+ "log the bot in with: tg login --account <bot-label>, then pass --account",
142
+ )
143
+ if args.clear:
144
+ commands: list = []
145
+ else:
146
+ p = args.pairs
147
+ if len(p) % 2:
148
+ raise TgError("commands come in <command> <description> pairs")
149
+ commands = [
150
+ types.BotCommand(command=p[i].lstrip("/"), description=p[i + 1])
151
+ for i in range(0, len(p), 2)
152
+ ]
153
+ await client(
154
+ functions.bots.SetBotCommandsRequest(
155
+ scope=types.BotCommandScopeDefault(), lang_code="", commands=commands
156
+ )
157
+ )
158
+ print(f"{'Cleared' if args.clear else 'Set'} {len(commands)} bot command(s)")
159
+
160
+
161
+ def setup(subparsers, common=None) -> None:
162
+ parents = [common] if common else []
163
+ sp = subparsers.add_parser(
164
+ "search-public", parents=parents, help="Search public chats/users by name"
165
+ )
166
+ sp.add_argument("query")
167
+ sp.add_argument("-n", type=int, default=10)
168
+ sp.set_defaults(func=cmd_search_public)
169
+
170
+ sp = subparsers.add_parser(
171
+ "resolve", parents=parents, help="Resolve a @username to an id without dialogs"
172
+ )
173
+ sp.add_argument("username")
174
+ sp.set_defaults(func=cmd_resolve)
175
+
176
+ sp = subparsers.add_parser("stickers", parents=parents, help="List your sticker sets")
177
+ sp.set_defaults(func=cmd_stickers)
178
+
179
+ sp = subparsers.add_parser("user-photos", parents=parents, help="List a user's profile photos")
180
+ sp.add_argument("user")
181
+ sp.add_argument("-n", type=int, default=10)
182
+ sp.set_defaults(func=cmd_user_photos)
183
+
184
+ sp = subparsers.add_parser(
185
+ "bot-info", parents=parents, help="Show a bot's description and commands"
186
+ )
187
+ sp.add_argument("bot")
188
+ sp.set_defaults(func=cmd_bot_info)
189
+
190
+ sp = subparsers.add_parser(
191
+ "bot-commands",
192
+ parents=parents,
193
+ help="Set your bot's commands (bot accounts only): /cmd desc [cmd desc...]",
194
+ )
195
+ sp.add_argument("pairs", nargs="*", default=[], metavar="CMD_DESC")
196
+ sp.add_argument("--clear", action="store_true", help="remove all commands")
197
+ sp.set_defaults(func=cmd_bot_commands)
@@ -1,5 +1,6 @@
1
1
  """Group administration: create, members, bans, admins, slow mode, topics, admin log."""
2
2
 
3
+ import argparse
3
4
  import random
4
5
  import re
5
6
  from datetime import datetime, timedelta, timezone
@@ -427,6 +428,143 @@ async def cmd_admin_log(args) -> None:
427
428
  print(f"[{e.id}] {date} {_display(users, e.user_id)}: {_action_text(e.action)}")
428
429
 
429
430
 
431
+ # ---- members, default permissions, forum mode, group photo ----
432
+
433
+ # rights shown by `chat-permissions`; True in ChatBannedRights means banned,
434
+ # so the CLI exposes them as "allowed" flags and inverts when sending
435
+ _PERMISSION_RIGHTS = [
436
+ "send_messages",
437
+ "send_media",
438
+ "send_stickers",
439
+ "send_gifs",
440
+ "send_games",
441
+ "send_inline",
442
+ "embed_links",
443
+ "send_polls",
444
+ "send_photos",
445
+ "send_videos",
446
+ "send_roundvideos",
447
+ "send_audios",
448
+ "send_voices",
449
+ "send_docs",
450
+ "send_plain",
451
+ "send_reactions",
452
+ "change_info",
453
+ "invite_users",
454
+ "pin_messages",
455
+ "manage_topics",
456
+ ]
457
+
458
+ # Telegram's out-of-the-box defaults when a chat has no explicit rights set
459
+ _DEFAULT_ALLOWED = {r: not r.startswith(("change_", "pin_", "manage_")) for r in _PERMISSION_RIGHTS}
460
+
461
+
462
+ async def cmd_members(args) -> None:
463
+ async with run_with_client(args) as client:
464
+ peer = await _peer(client, args.chat)
465
+ kind = _require_group(peer)
466
+ rows: list = []
467
+ users: dict = {}
468
+ if kind == "channel":
469
+ want = min(args.n, 1000)
470
+ offset = 0
471
+ while len(rows) < want:
472
+ res = await client(
473
+ functions.channels.GetParticipantsRequest(
474
+ peer,
475
+ types.ChannelParticipantsRecent(),
476
+ offset=offset,
477
+ limit=min(100, want - len(rows)),
478
+ hash=0,
479
+ )
480
+ )
481
+ rows.extend(res.participants)
482
+ users.update(_users_by_id(res))
483
+ if len(res.participants) < 100:
484
+ break
485
+ offset += len(res.participants)
486
+ else:
487
+ res = await client(functions.messages.GetFullChatRequest(chat_id=peer.chat_id))
488
+ rows = list(res.full_chat.participants.participants)
489
+ users = _users_by_id(res)
490
+ if args.json:
491
+ print(json_pp([p.to_dict() for p in rows]))
492
+ return
493
+ if not rows:
494
+ print("(no participants)")
495
+ return
496
+ for p in rows:
497
+ uid = getattr(p, "user_id", None)
498
+ if uid is None: # ChannelParticipantBanned wraps a Peer
499
+ uid = getattr(getattr(p, "peer", None), "user_id", "?")
500
+ rank = getattr(p, "rank", "") or ""
501
+ print(f"{uid:>14} {rank:<12} {_display(users, uid)}")
502
+
503
+
504
+ async def cmd_chat_permissions(args) -> None:
505
+ async with run_with_client(args) as client:
506
+ peer = await _peer(client, args.chat)
507
+ ent = await client.get_entity(peer)
508
+ cur = getattr(ent, "default_banned_rights", None)
509
+ allowed = {
510
+ r: (not getattr(cur, r, False)) if cur else _DEFAULT_ALLOWED[r]
511
+ for r in _PERMISSION_RIGHTS
512
+ }
513
+ overrides = {}
514
+ for r in _PERMISSION_RIGHTS:
515
+ v = getattr(args, r, None)
516
+ if v is not None:
517
+ overrides[r] = v
518
+ allowed[r] = v
519
+ if not overrides:
520
+ banned = [r for r in _PERMISSION_RIGHTS if not allowed[r]]
521
+ print("current default permissions:")
522
+ print(
523
+ " allowed: " + (", ".join(r for r in _PERMISSION_RIGHTS if allowed[r]) or "(none)")
524
+ )
525
+ print(" banned: " + (", ".join(banned) or "(none)"))
526
+ return
527
+ banned_rights = types.ChatBannedRights(
528
+ until_date=None, view_messages=False, **{r: not allowed[r] for r in _PERMISSION_RIGHTS}
529
+ )
530
+ await client(
531
+ functions.messages.EditChatDefaultBannedRightsRequest(
532
+ peer=peer, banned_rights=banned_rights
533
+ )
534
+ )
535
+ newly = ", ".join(f"no-{r.replace('_', '-')}" for r, v in overrides.items() if not v)
536
+ restored = ", ".join(f"{r.replace('_', '-')}" for r, v in overrides.items() if v)
537
+ msg = "Default permissions updated"
538
+ if newly:
539
+ msg += f"; banned: {newly}"
540
+ if restored:
541
+ msg += f"; allowed: {restored}"
542
+ print(msg)
543
+
544
+
545
+ async def cmd_forum(args) -> None:
546
+ if args.state not in ("on", "off"):
547
+ raise TgError('state must be "on" or "off"')
548
+ async with run_with_client(args) as client:
549
+ peer = await _require_channel(await _peer(client, args.chat))
550
+ await client(functions.channels.ToggleForumRequest(peer, args.state == "on", args.tabs))
551
+ print(f"Forum mode {'enabled' if args.state == 'on' else 'disabled'}")
552
+
553
+
554
+ async def cmd_chat_photo_del(args) -> None:
555
+ async with run_with_client(args) as client:
556
+ peer = await _peer(client, args.chat)
557
+ if _require_group(peer) == "channel":
558
+ await client(functions.channels.EditPhotoRequest(peer, types.InputChatPhotoEmpty()))
559
+ else:
560
+ await client(
561
+ functions.messages.EditChatPhotoRequest(
562
+ chat_id=peer.chat_id, photo=types.InputChatPhotoEmpty()
563
+ )
564
+ )
565
+ print("Chat photo removed")
566
+
567
+
430
568
  def setup(subparsers, common=None) -> None:
431
569
  parents = [common] if common else []
432
570
  sp = subparsers.add_parser(
@@ -532,3 +670,35 @@ def setup(subparsers, common=None) -> None:
532
670
  sp.add_argument("chat")
533
671
  sp.add_argument("-n", type=int, default=20)
534
672
  sp.set_defaults(func=cmd_admin_log)
673
+
674
+ sp = subparsers.add_parser("members", parents=parents, help="List group members (recent first)")
675
+ sp.add_argument("chat")
676
+ sp.add_argument("-n", type=int, default=100, help="max members (default 100)")
677
+ sp.set_defaults(func=cmd_members)
678
+
679
+ sp = subparsers.add_parser(
680
+ "chat-permissions",
681
+ parents=parents,
682
+ help="Show or set default member permissions (--send-messages / --no-send-messages …)",
683
+ )
684
+ sp.add_argument("chat")
685
+ for r in _PERMISSION_RIGHTS:
686
+ sp.add_argument(
687
+ f"--{r.replace('_', '-')}",
688
+ action=argparse.BooleanOptionalAction,
689
+ default=None,
690
+ dest=r,
691
+ )
692
+ sp.set_defaults(func=cmd_chat_permissions)
693
+
694
+ sp = subparsers.add_parser(
695
+ "forum", parents=parents, help="Enable/disable forum mode: tg forum <chat> on|off"
696
+ )
697
+ sp.add_argument("chat")
698
+ sp.add_argument("state", help="on or off")
699
+ sp.add_argument("--tabs", action="store_true", help="enable forum tabs (on only)")
700
+ sp.set_defaults(func=cmd_forum)
701
+
702
+ sp = subparsers.add_parser("chat-photo-del", parents=parents, help="Remove the chat photo")
703
+ sp.add_argument("chat")
704
+ sp.set_defaults(func=cmd_chat_photo_del)
@@ -123,6 +123,19 @@ async def cmd_vtr(args) -> None:
123
123
  print(text)
124
124
 
125
125
 
126
+ async def cmd_upload(args) -> None:
127
+ path = os.path.expanduser(args.file)
128
+ if not os.path.exists(path):
129
+ raise TgError(f"file not found: {path}")
130
+ async with run_with_client(args) as client:
131
+ handle = await client.upload_file(path)
132
+ md5 = getattr(handle, "md5", None)
133
+ print(f"Uploaded {path}")
134
+ print(f" id={handle.id} parts={handle.parts} name={handle.name}")
135
+ if md5:
136
+ print(f" md5={md5.hex()}")
137
+
138
+
126
139
  def setup(subparsers, common=None) -> None:
127
140
  parents = [common] if common else []
128
141
  sp = subparsers.add_parser(
@@ -170,3 +183,9 @@ def setup(subparsers, common=None) -> None:
170
183
  sp.add_argument("chat")
171
184
  sp.add_argument("msg_id", type=int)
172
185
  sp.set_defaults(func=cmd_vtr)
186
+
187
+ sp = subparsers.add_parser(
188
+ "upload", parents=parents, help="Upload a file to Telegram without sending it"
189
+ )
190
+ sp.add_argument("file")
191
+ sp.set_defaults(func=cmd_upload)
@@ -1,6 +1,7 @@
1
1
  """Messaging commands: send, reply, edit, del, fwd, poll, schedule, draft,
2
- reactions, pinning, read state, message links, contact cards."""
2
+ reactions, pinning, read state, message links, contact cards, buttons."""
3
3
 
4
+ import contextlib
4
5
  import random
5
6
  import re
6
7
  from datetime import datetime, timedelta
@@ -235,6 +236,82 @@ async def cmd_contact_card(args) -> None:
235
236
  print(f"Contact card sent (id: {msg.id})")
236
237
 
237
238
 
239
+ def _reaction_text(reaction) -> str:
240
+ if isinstance(reaction, types.ReactionEmoji):
241
+ return reaction.emoticon
242
+ if isinstance(reaction, types.ReactionCustomEmoji):
243
+ return f"custom-emoji:{reaction.document_id}"
244
+ return str(reaction)
245
+
246
+
247
+ async def cmd_buttons(args) -> None:
248
+ async with run_with_client(args) as client:
249
+ entity = await resolve_chat(client, args.chat)
250
+ msg = await client.get_messages(entity, ids=args.msg_id)
251
+ rows = getattr(msg, "buttons", None)
252
+ if not rows:
253
+ raise TgError(f"message {args.msg_id} has no inline buttons")
254
+ idx = 0
255
+ for row in rows:
256
+ cells = []
257
+ for b in row:
258
+ kind = "url" if getattr(b, "url", None) else "callback"
259
+ cells.append(f"[{idx}] {b.text} ({kind})")
260
+ idx += 1
261
+ print(" ".join(cells))
262
+ print(f"\npress one with: tg press {args.chat} {args.msg_id} <index|text>")
263
+
264
+
265
+ async def cmd_press(args) -> None:
266
+ async with run_with_client(args) as client:
267
+ entity = await resolve_chat(client, args.chat)
268
+ msg = await client.get_messages(entity, ids=args.msg_id)
269
+ if not getattr(msg, "buttons", None):
270
+ raise TgError(f"message {args.msg_id} has no inline buttons")
271
+ spec = args.button
272
+ if spec.lstrip("-").isdigit():
273
+ res = await msg.click(int(spec))
274
+ else:
275
+ res = await msg.click(text=spec)
276
+ answer = getattr(res, "message", None) if res is not None else None
277
+ alert = bool(getattr(res, "alert", None)) if res is not None else False
278
+ out = f"Pressed button {spec!r}"
279
+ if answer:
280
+ out += f" — bot answered: {answer!r}" + (" (alert)" if alert else "")
281
+ print(out)
282
+
283
+
284
+ async def cmd_reactions(args) -> None:
285
+ async with run_with_client(args) as client:
286
+ entity = await resolve_chat(client, args.chat)
287
+ msg = await client.get_messages(entity, ids=args.msg_id)
288
+ r = getattr(msg, "reactions", None)
289
+ # after removing your own reaction the server still returns an
290
+ # (empty) MessageReactions object instead of None
291
+ if r is None or not getattr(r, "results", None):
292
+ print("(no reactions)")
293
+ return
294
+ if args.json:
295
+ print(json_pp(r.to_dict()))
296
+ return
297
+ for rc in r.results:
298
+ chosen = " *" if rc.chosen_order is not None else ""
299
+ print(f"{_reaction_text(rc.reaction)} x{rc.count}{chosen}")
300
+ recent = getattr(r, "recent_reactions", None) or []
301
+ if recent:
302
+ print("\nrecent:")
303
+ for rr in recent:
304
+ pid = getattr(rr.peer_id, "user_id", None) or rr.peer_id
305
+ who = str(pid)
306
+ with contextlib.suppress(Exception):
307
+ ent = await client.get_entity(rr.peer_id)
308
+ who = (
309
+ " ".join(x for x in (ent.first_name, getattr(ent, "last_name", None)) if x)
310
+ or f"@{ent.username}"
311
+ )
312
+ print(f" {_reaction_text(rr.reaction)} {who}")
313
+
314
+
238
315
  def setup(subparsers, common=None) -> None:
239
316
  parents = [common] if common else []
240
317
  sp = subparsers.add_parser(
@@ -363,3 +440,23 @@ def setup(subparsers, common=None) -> None:
363
440
  sp.add_argument("name", help='contact name, e.g. "John Doe"')
364
441
  sp.add_argument("phone")
365
442
  sp.set_defaults(func=cmd_contact_card)
443
+
444
+ sp = subparsers.add_parser(
445
+ "buttons", parents=parents, help="List a message's inline buttons (with press indexes)"
446
+ )
447
+ sp.add_argument("chat")
448
+ sp.add_argument("msg_id", type=int)
449
+ sp.set_defaults(func=cmd_buttons)
450
+
451
+ sp = subparsers.add_parser(
452
+ "press", parents=parents, help="Press an inline button by index (from `buttons`) or text"
453
+ )
454
+ sp.add_argument("chat")
455
+ sp.add_argument("msg_id", type=int)
456
+ sp.add_argument("button", help="button index (0-based, from `buttons`) or its text")
457
+ sp.set_defaults(func=cmd_press)
458
+
459
+ sp = subparsers.add_parser("reactions", parents=parents, help="Show reactions on a message")
460
+ sp.add_argument("chat")
461
+ sp.add_argument("msg_id", type=int)
462
+ sp.set_defaults(func=cmd_reactions)