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.
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/PKG-INFO +21 -1
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/README-zh.md +20 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/README.md +20 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/ROADMAP.md +26 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/pyproject.toml +1 -1
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/__init__.py +1 -1
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/cli.py +13 -2
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/dialogs.py +28 -0
- telegram_userbot_cli-0.5.0/src/tgcli/discovery.py +197 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/groups.py +170 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/media.py +19 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/messages.py +98 -1
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.env.example +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.github/workflows/ci.yml +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.github/workflows/release.yml +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/.gitignore +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/LICENSE +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/__main__.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/auth.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/chats.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/client.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/config.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/contacts.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/output.py +0 -0
- {telegram_userbot_cli-0.4.0 → telegram_userbot_cli-0.5.0}/src/tgcli/resolve.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -6,7 +6,18 @@ import sys
|
|
|
6
6
|
|
|
7
7
|
from telethon import errors
|
|
8
8
|
|
|
9
|
-
from . import
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|