telegram-userbot-cli 0.1.0__tar.gz → 0.2.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 (24) hide show
  1. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/PKG-INFO +39 -4
  2. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/README-zh.md +34 -3
  3. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/README.md +38 -3
  4. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/ROADMAP.md +21 -17
  5. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/pyproject.toml +1 -1
  6. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/__init__.py +1 -1
  7. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/chats.py +7 -1
  8. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/cli.py +2 -2
  9. telegram_userbot_cli-0.2.0/src/tgcli/dialogs.py +269 -0
  10. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/media.py +44 -1
  11. telegram_userbot_cli-0.2.0/src/tgcli/messages.py +365 -0
  12. telegram_userbot_cli-0.1.0/src/tgcli/messages.py +0 -122
  13. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.env.example +0 -0
  14. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  15. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.github/workflows/release.yml +0 -0
  16. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.gitignore +0 -0
  17. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/LICENSE +0 -0
  18. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/__main__.py +0 -0
  19. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/auth.py +0 -0
  20. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/client.py +0 -0
  21. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/config.py +0 -0
  22. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/output.py +0 -0
  23. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/resolve.py +0 -0
  24. {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: telegram-userbot-cli
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -25,9 +25,14 @@ no daemon, no external service**. Everything the CLI does lives in the CLI.
25
25
  ## Features
26
26
 
27
27
  - QR-code login (`tg login --qr`) with an independent session
28
- - Messaging: send / reply / edit / delete / forward / polls
29
- - Chats: list dialogs, history, pinned messages, in-chat & global search
30
- - Media: download, send files / albums, voice notes
28
+ - Messaging: send / reply / edit / delete / forward / polls / schedule /
29
+ drafts / reactions / pinning
30
+ - Chat management: archive, mute, edit title / description / photo,
31
+ invite links, clear history
32
+ - Media: download, send files / albums / voice notes / stickers / GIFs,
33
+ contact cards
34
+ - Chats: list dialogs, history, pinned messages, in-chat & global search,
35
+ online status, common chats
31
36
  - Contacts & profile management
32
37
  - Chat resolution by name fuzzy match, `@username`, numeric id, or `me`
33
38
  (Saved Messages), plus local aliases
@@ -79,6 +84,8 @@ tg logout # remove the session
79
84
 
80
85
  ## Commands
81
86
 
87
+ ### Core
88
+
82
89
  | Command | Description |
83
90
  |---|---|
84
91
  | `tg me` | Show your account info |
@@ -100,6 +107,34 @@ tg logout # remove the session
100
107
  | `tg profile --name/--bio/--photo` | Update your profile |
101
108
  | `tg alias set/list/rm` | Local chat aliases (`~/.config/tg/aliases.json`) |
102
109
 
110
+ ### Messaging & chat management (v2)
111
+
112
+ | Command | Description |
113
+ |---|---|
114
+ | `tg schedule <chat> <time> <text...>` | Schedule a message; time is `+30m`/`+2h`/`+1d`, `2026-09-07 14:30`, or a unix ts |
115
+ | `tg scheduled <chat>` | List scheduled messages (`--json` supported) |
116
+ | `tg unschedule <chat> <ids...>` | Delete scheduled messages |
117
+ | `tg draft <chat> [text]` | Save a draft (with text) or show the current draft |
118
+ | `tg draft-clear <chat>` | Clear the draft |
119
+ | `tg react <chat> <msg_id> <emoji>` | React to a message; `--big` for the big animation |
120
+ | `tg unreact <chat> <msg_id>` | Remove your reaction |
121
+ | `tg pin <chat> <msg_id>` / `tg unpin <chat> [msg_id]` | Pin / unpin (no id = unpin all) |
122
+ | `tg read <chat>` | Mark the chat as read (also clears mention badges) |
123
+ | `tg msg-link <chat> <msg_id>` | Export a t.me message link (channels/supergroups only) |
124
+ | `tg contact-card <chat> "<name>" <phone>` | Send a contact card |
125
+ | `tg sticker <chat> <file>` | Send a sticker file (`.webp` / `.tgs` / `.webm`) |
126
+ | `tg gif <chat> <query>` | Search GIFs and send the first match; `-n N` sends the Nth |
127
+ | `tg archive <chat>` / `tg unarchive <chat>` | Archive / unarchive a dialog |
128
+ | `tg mute <chat> [hours]` / `tg unmute <chat>` | Mute notifications (permanent unless hours given) / unmute |
129
+ | `tg chat-title <chat> <title>` | Change group/channel title |
130
+ | `tg chat-about <chat> <text>` | Change chat description |
131
+ | `tg chat-photo <chat> <file>` | Change the chat photo |
132
+ | `tg invite-link <chat>` | Export a chat invite link |
133
+ | `tg join <link>` | Join via `t.me/+hash`, `t.me/joinchat/…`, or `t.me/<username>` |
134
+ | `tg clear-history <chat>` | Delete all messages (both sides; `--self-only` keeps the other copy) |
135
+ | `tg status <user>` | Show a user's online status |
136
+ | `tg common-chats <user>` | List chats you share with a user |
137
+
103
138
  Global flags: `--account` (v1: default only), `--json` (raw JSON output).
104
139
  Exit codes: `0` ok, `1` command error, `2` config/connection/session error,
105
140
  `130` interrupted.
@@ -12,9 +12,10 @@
12
12
  ## 特性
13
13
 
14
14
  - 扫码登录(`tg login --qr`),使用独立会话
15
- - 消息:发送 / 引用回复 / 编辑 / 删除 / 转发 / 投票
16
- - 聊天:会话列表、历史记录、置顶消息、会话内搜索与全局搜索
17
- - 媒体:下载、发文件 / 相册、语音条
15
+ - 消息:发送 / 引用回复 / 编辑 / 删除 / 转发 / 投票 / 定时 / 草稿 / 表情回应 / 置顶
16
+ - 聊天管理:归档、免打扰、修改群名 / 简介 / 群头像、邀请链接、清空历史
17
+ - 媒体:下载、发文件 / 相册 / 语音条 / 贴纸 / GIF、发送名片
18
+ - 聊天:会话列表、历史记录、置顶消息、会话内与全局搜索、在线状态、共同群组
18
19
  - 联系人与个人资料管理
19
20
  - 会话解析:名称模糊匹配、`@username`、数字 id、`me`(已保存消息),以及本地别名
20
21
 
@@ -62,6 +63,8 @@ tg logout # 退出登录并删除会话
62
63
 
63
64
  ## 命令
64
65
 
66
+ ### 基础
67
+
65
68
  | 命令 | 说明 |
66
69
  |---|---|
67
70
  | `tg me` | 查看自己的账号信息 |
@@ -83,6 +86,34 @@ tg logout # 退出登录并删除会话
83
86
  | `tg profile --name/--bio/--photo` | 修改个人资料 |
84
87
  | `tg alias set/list/rm` | 本地会话别名(`~/.config/tg/aliases.json`) |
85
88
 
89
+ ### 消息增强与聊天管理(v2)
90
+
91
+ | 命令 | 说明 |
92
+ |---|---|
93
+ | `tg schedule <会话> <时间> <文本...>` | 定时发送;时间支持 `+30m`/`+2h`/`+1d`、`2026-09-07 14:30` 或 unix 时间戳 |
94
+ | `tg scheduled <会话>` | 列出定时消息(支持 `--json`) |
95
+ | `tg unschedule <会话> <id...>` | 撤销定时消息 |
96
+ | `tg draft <会话> [文本]` | 存草稿(带文本)或查看当前草稿 |
97
+ | `tg draft-clear <会话>` | 清空草稿 |
98
+ | `tg react <会话> <消息id> <emoji>` | 对消息发表情回应;`--big` 大动画 |
99
+ | `tg unreact <会话> <消息id>` | 撤销自己的表情回应 |
100
+ | `tg pin <会话> <消息id>` / `tg unpin <会话> [消息id]` | 置顶 / 取消置顶(不带 id = 全部取消) |
101
+ | `tg read <会话>` | 标记已读(同时清除 @ 提醒) |
102
+ | `tg msg-link <会话> <消息id>` | 导出 t.me 消息链接(仅频道/超级群组) |
103
+ | `tg contact-card <会话> "<姓名>" <电话>` | 发送联系人名片 |
104
+ | `tg sticker <会话> <文件>` | 发贴纸文件(`.webp` / `.tgs` / `.webm`) |
105
+ | `tg gif <会话> <关键词>` | 搜索 GIF 并发送第一条;`-n N` 发第 N 条 |
106
+ | `tg archive <会话>` / `tg unarchive <会话>` | 归档 / 取消归档会话 |
107
+ | `tg mute <会话> [小时]` / `tg unmute <会话>` | 免打扰(不带小时数 = 永久)/ 取消免打扰 |
108
+ | `tg chat-title <会话> <标题>` | 修改群/频道标题 |
109
+ | `tg chat-about <会话> <文本>` | 修改群简介 |
110
+ | `tg chat-photo <会话> <文件>` | 修改群头像 |
111
+ | `tg invite-link <会话>` | 导出群邀请链接 |
112
+ | `tg join <链接>` | 通过 `t.me/+hash`、`t.me/joinchat/…` 或 `t.me/<用户名>` 加群 |
113
+ | `tg clear-history <会话>` | 清空聊天记录(双方删除;`--self-only` 仅删自己一侧) |
114
+ | `tg status <用户>` | 查看用户在线状态 |
115
+ | `tg common-chats <用户>` | 列出与该用户的共同群组 |
116
+
86
117
  全局选项:`--account`(v1 仅 default)、`--json`(原始 JSON 输出)。
87
118
  退出码:`0` 成功、`1` 命令错误、`2` 配置/连接/会话错误、`130` 中断。
88
119
 
@@ -12,9 +12,14 @@ no daemon, no external service**. Everything the CLI does lives in the CLI.
12
12
  ## Features
13
13
 
14
14
  - QR-code login (`tg login --qr`) with an independent session
15
- - Messaging: send / reply / edit / delete / forward / polls
16
- - Chats: list dialogs, history, pinned messages, in-chat & global search
17
- - Media: download, send files / albums, voice notes
15
+ - Messaging: send / reply / edit / delete / forward / polls / schedule /
16
+ drafts / reactions / pinning
17
+ - Chat management: archive, mute, edit title / description / photo,
18
+ invite links, clear history
19
+ - Media: download, send files / albums / voice notes / stickers / GIFs,
20
+ contact cards
21
+ - Chats: list dialogs, history, pinned messages, in-chat & global search,
22
+ online status, common chats
18
23
  - Contacts & profile management
19
24
  - Chat resolution by name fuzzy match, `@username`, numeric id, or `me`
20
25
  (Saved Messages), plus local aliases
@@ -66,6 +71,8 @@ tg logout # remove the session
66
71
 
67
72
  ## Commands
68
73
 
74
+ ### Core
75
+
69
76
  | Command | Description |
70
77
  |---|---|
71
78
  | `tg me` | Show your account info |
@@ -87,6 +94,34 @@ tg logout # remove the session
87
94
  | `tg profile --name/--bio/--photo` | Update your profile |
88
95
  | `tg alias set/list/rm` | Local chat aliases (`~/.config/tg/aliases.json`) |
89
96
 
97
+ ### Messaging & chat management (v2)
98
+
99
+ | Command | Description |
100
+ |---|---|
101
+ | `tg schedule <chat> <time> <text...>` | Schedule a message; time is `+30m`/`+2h`/`+1d`, `2026-09-07 14:30`, or a unix ts |
102
+ | `tg scheduled <chat>` | List scheduled messages (`--json` supported) |
103
+ | `tg unschedule <chat> <ids...>` | Delete scheduled messages |
104
+ | `tg draft <chat> [text]` | Save a draft (with text) or show the current draft |
105
+ | `tg draft-clear <chat>` | Clear the draft |
106
+ | `tg react <chat> <msg_id> <emoji>` | React to a message; `--big` for the big animation |
107
+ | `tg unreact <chat> <msg_id>` | Remove your reaction |
108
+ | `tg pin <chat> <msg_id>` / `tg unpin <chat> [msg_id]` | Pin / unpin (no id = unpin all) |
109
+ | `tg read <chat>` | Mark the chat as read (also clears mention badges) |
110
+ | `tg msg-link <chat> <msg_id>` | Export a t.me message link (channels/supergroups only) |
111
+ | `tg contact-card <chat> "<name>" <phone>` | Send a contact card |
112
+ | `tg sticker <chat> <file>` | Send a sticker file (`.webp` / `.tgs` / `.webm`) |
113
+ | `tg gif <chat> <query>` | Search GIFs and send the first match; `-n N` sends the Nth |
114
+ | `tg archive <chat>` / `tg unarchive <chat>` | Archive / unarchive a dialog |
115
+ | `tg mute <chat> [hours]` / `tg unmute <chat>` | Mute notifications (permanent unless hours given) / unmute |
116
+ | `tg chat-title <chat> <title>` | Change group/channel title |
117
+ | `tg chat-about <chat> <text>` | Change chat description |
118
+ | `tg chat-photo <chat> <file>` | Change the chat photo |
119
+ | `tg invite-link <chat>` | Export a chat invite link |
120
+ | `tg join <link>` | Join via `t.me/+hash`, `t.me/joinchat/…`, or `t.me/<username>` |
121
+ | `tg clear-history <chat>` | Delete all messages (both sides; `--self-only` keeps the other copy) |
122
+ | `tg status <user>` | Show a user's online status |
123
+ | `tg common-chats <user>` | List chats you share with a user |
124
+
90
125
  Global flags: `--account` (v1: default only), `--json` (raw JSON output).
91
126
  Exit codes: `0` ok, `1` command error, `2` config/connection/session error,
92
127
  `130` interrupted.
@@ -4,34 +4,38 @@ The v1 release covers the core daily workflow. The goal is full coverage of
4
4
  the Telegram feature surface reachable from a userbot, roughly matching the
5
5
  126-tool scope of [chigwell/telegram-mcp](https://github.com/chigwell/telegram-mcp).
6
6
 
7
- Status legend: ✅ done (v1) · 🚧 planned · ❌ deliberately out of scope.
7
+ Status legend: ✅ done · 🚧 planned · ❌ deliberately out of scope.
8
8
 
9
9
  ## Milestones
10
10
 
11
- ### v2 — Messaging enhancements & chat management
11
+ ### v2 — Messaging enhancements & chat management (shipped)
12
12
 
13
13
  Messaging:
14
14
 
15
- - 🚧 `schedule <chat> <time> <text...>` — send scheduled messages
15
+ - `schedule <chat> <time> <text...>` — send scheduled messages
16
16
  (`send_message(..., schedule=...)`), list/delete scheduled
17
- - 🚧 `draft <chat> [text]` / `draft-clear <chat>` — save/get/clear drafts
18
- - 🚧 `react <chat> <msg_id> <emoji>` / `unreact`send/remove reactions
19
- - 🚧 `sticker <chat> <file.webp>` — send stickers (telethon handles via `send_file`)
20
- - 🚧 `gif <chat> <query>` search GIFs and send
21
- - 🚧 `contact-card <chat> <name> <phone>` — send a contact card
22
- - 🚧 `pin <chat> <msg_id>` / `unpin <chat> [msg_id]` pin/unpin messages
23
- - 🚧 `read <chat>` mark a chat as read
24
- - 🚧 `msg-link <chat> <msg_id>` — export a t.me message link (channels/supergroups)
17
+ (`scheduled <chat>` / `unschedule <chat> <ids...>`)
18
+ - `draft <chat> [text]` / `draft-clear <chat>` save/get/clear drafts
19
+ - `react <chat> <msg_id> <emoji>` / `unreact` — send/remove reactions
20
+ (note: reacting to Saved Messages is premium-only on Telegram's side)
21
+ - `sticker <chat> <file.webp>` — send stickers (telethon handles via `send_file`)
22
+ - `gif <chat> <query>` search GIFs (inline query to the built-in @gif bot;
23
+ `messages.searchGifs` was removed from the TL layer) and send
24
+ - `contact-card <chat> <name> <phone>` — send a contact card
25
+ - ✅ `pin <chat> <msg_id>` / `unpin <chat> [msg_id]` — pin/unpin messages
26
+ - ✅ `read <chat>` — mark a chat as read
27
+ - ✅ `msg-link <chat> <msg_id>` — export a t.me message link (channels/supergroups)
25
28
 
26
29
  Chat management:
27
30
 
28
- - 🚧 `archive <chat>` / `unarchive <chat>` — archive/unarchive dialogs
29
- - 🚧 `mute <chat> [hours]` / `unmute <chat>` — mute/unmute notifications
30
- - 🚧 `chat-title <chat> <title>` / `chat-about <chat> <text>` /
31
+ - `archive <chat>` / `unarchive <chat>` — archive/unarchive dialogs
32
+ - `mute <chat> [hours]` / `unmute <chat>` — mute/unmute notifications
33
+ - `chat-title <chat> <title>` / `chat-about <chat> <text>` /
31
34
  `chat-photo <chat> <file>` — edit chat info
32
- - 🚧 `invite-link <chat>` / `join <link>` — export/import invite links
33
- - 🚧 `clear-history <chat>` — delete all messages in a chat
34
- - 🚧 `status <user>` / `common-chats <user>` — online status, common chats
35
+ - `invite-link <chat>` / `join <link>` — export/import invite links
36
+ - `clear-history <chat>` — delete all messages in a chat (`--self-only` to
37
+ keep the other side's copy)
38
+ - ✅ `status <user>` / `common-chats <user>` — online status, common chats
35
39
 
36
40
  ### v3 — Group administration
37
41
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "telegram-userbot-cli"
7
- version = "0.1.0"
7
+ version = "0.2.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.1.0"
3
+ __version__ = "0.2.0"
@@ -1,5 +1,7 @@
1
1
  """Chat / search / contacts / profile commands."""
2
2
 
3
+ from datetime import datetime
4
+
3
5
  from telethon import functions, types
4
6
 
5
7
  from .client import run_with_client
@@ -70,7 +72,11 @@ def muted_of(d) -> bool:
70
72
  # The custom Dialog wrapper drops the mute state; read it from the raw dialog.
71
73
  notify = getattr(getattr(d, "dialog", None), "notify_settings", None)
72
74
  mute_until = getattr(notify, "mute_until", None)
73
- return bool(mute_until)
75
+ if not mute_until:
76
+ return False
77
+ # an unmuted chat sends mute_until=0, which telethon deserializes as
78
+ # datetime(1970, ...) — truthy but in the past, i.e. not muted
79
+ return mute_until > datetime.now(mute_until.tzinfo)
74
80
 
75
81
 
76
82
  async def cmd_hist(args) -> None:
@@ -6,7 +6,7 @@ import sys
6
6
 
7
7
  from telethon import errors
8
8
 
9
- from . import __version__, auth, chats, media, messages, resolve
9
+ from . import __version__, auth, chats, dialogs, media, messages, resolve
10
10
  from .output import TgError
11
11
 
12
12
 
@@ -41,7 +41,7 @@ def build_parser() -> argparse.ArgumentParser:
41
41
  sp2 = sub.add_parser("logout", parents=[common], help="Log out and remove the session")
42
42
  sp2.set_defaults(func=auth.cmd_logout)
43
43
 
44
- for mod in (messages, chats, media):
44
+ for mod in (messages, chats, media, dialogs):
45
45
  mod.setup(sub, common)
46
46
 
47
47
  resolve.setup_alias(sub, common)
@@ -0,0 +1,269 @@
1
+ """Chat management: archive, mute, chat info, invite links, history, user status."""
2
+
3
+ import os
4
+ import re
5
+ import time
6
+ from datetime import datetime
7
+
8
+ from telethon import functions, types
9
+
10
+ from .client import run_with_client
11
+ from .output import TgError, fmt_date, json_pp
12
+ from .resolve import resolve_chat
13
+
14
+ # "forever" mute: the official clients pin mute_until to a far-future timestamp
15
+ _MUTE_FOREVER = 2**31 - 1
16
+
17
+
18
+ async def _input_peer(client, chat):
19
+ return await client.get_input_entity(await resolve_chat(client, chat))
20
+
21
+
22
+ async def cmd_archive(args) -> None:
23
+ await _set_folder(args, 1, "Archived")
24
+
25
+
26
+ async def cmd_unarchive(args) -> None:
27
+ await _set_folder(args, 0, "Unarchived")
28
+
29
+
30
+ async def _set_folder(args, folder: int, done: str) -> None:
31
+ async with run_with_client(args) as client:
32
+ entity = await resolve_chat(client, args.chat)
33
+ await client.edit_folder(entity, folder=folder)
34
+ print(done)
35
+
36
+
37
+ async def cmd_mute(args) -> None:
38
+ if args.hours:
39
+ until = int(time.time()) + args.hours * 3600
40
+ human = f"muted for {args.hours}h"
41
+ else:
42
+ until = _MUTE_FOREVER
43
+ human = "muted permanently"
44
+ await _update_notify(args, until, human)
45
+
46
+
47
+ async def cmd_unmute(args) -> None:
48
+ await _update_notify(args, 0, "unmuted")
49
+
50
+
51
+ async def _update_notify(args, mute_until: int, done: str) -> None:
52
+ async with run_with_client(args) as client:
53
+ entity = await resolve_chat(client, args.chat)
54
+ await client(
55
+ functions.account.UpdateNotifySettingsRequest(
56
+ peer=entity,
57
+ settings=types.InputPeerNotifySettings(mute_until=mute_until),
58
+ )
59
+ )
60
+ print(done)
61
+
62
+
63
+ async def cmd_chat_title(args) -> None:
64
+ async with run_with_client(args) as client:
65
+ peer = await _input_peer(client, args.chat)
66
+ if isinstance(peer, types.InputPeerChannel):
67
+ await client(functions.channels.EditTitleRequest(channel=peer, title=args.title))
68
+ elif isinstance(peer, types.InputPeerChat):
69
+ await client(
70
+ functions.messages.EditChatTitleRequest(chat_id=peer.chat_id, title=args.title)
71
+ )
72
+ else:
73
+ raise TgError("chat titles can only be changed for groups and channels")
74
+ print("Chat title updated")
75
+
76
+
77
+ async def cmd_chat_about(args) -> None:
78
+ async with run_with_client(args) as client:
79
+ entity = await resolve_chat(client, args.chat)
80
+ await client(functions.messages.EditChatAboutRequest(peer=entity, about=args.text))
81
+ print("Chat description updated")
82
+
83
+
84
+ async def cmd_chat_photo(args) -> None:
85
+ path = os.path.expanduser(args.file)
86
+ if not os.path.exists(path):
87
+ raise TgError(f"file not found: {path}")
88
+ async with run_with_client(args) as client:
89
+ peer = await _input_peer(client, args.chat)
90
+ uploaded = await client.upload_file(path)
91
+ photo = types.InputChatUploadedPhoto(file=uploaded)
92
+ if isinstance(peer, types.InputPeerChannel):
93
+ await client(functions.channels.EditPhotoRequest(channel=peer, photo=photo))
94
+ elif isinstance(peer, types.InputPeerChat):
95
+ await client(functions.messages.EditChatPhotoRequest(chat_id=peer.chat_id, photo=photo))
96
+ else:
97
+ raise TgError("chat photos can only be changed for groups and channels")
98
+ print("Chat photo updated")
99
+
100
+
101
+ async def cmd_invite_link(args) -> None:
102
+ async with run_with_client(args) as client:
103
+ entity = await resolve_chat(client, args.chat)
104
+ res = await client(functions.messages.ExportChatInviteRequest(peer=entity))
105
+ link = getattr(res, "link", None)
106
+ if not link:
107
+ raise TgError("no invite link returned (check your admin rights)")
108
+ print(link)
109
+
110
+
111
+ def _invite_hash(link: str) -> str | None:
112
+ m = re.search(r"t\.me/(?:\+|joinchat/)([A-Za-z0-9_-]+)", link)
113
+ return m.group(1) if m else None
114
+
115
+
116
+ def _invite_username(link: str) -> str | None:
117
+ m = re.search(r"t\.me/([A-Za-z0-9_]{4,})", link)
118
+ return m.group(1) if m else link.lstrip("@")
119
+
120
+
121
+ async def cmd_join(args) -> None:
122
+ async with run_with_client(args) as client:
123
+ h = _invite_hash(args.link)
124
+ if h:
125
+ await client(functions.messages.ImportChatInviteRequest(hash=h))
126
+ print("Joined private group")
127
+ return
128
+ name = _invite_username(args.link)
129
+ await client(functions.channels.JoinChannelRequest(channel=name))
130
+ print(f"Joined @{name}")
131
+
132
+
133
+ async def cmd_clear_history(args) -> None:
134
+ async with run_with_client(args) as client:
135
+ peer = await client.get_input_entity(await resolve_chat(client, args.chat))
136
+ if isinstance(peer, types.InputPeerChannel):
137
+ # supergroups/channels reject messages.DeleteHistoryRequest
138
+ await client(
139
+ functions.channels.DeleteHistoryRequest(
140
+ channel=peer, max_id=0, for_everyone=not args.self_only
141
+ )
142
+ )
143
+ else:
144
+ await client(
145
+ functions.messages.DeleteHistoryRequest(
146
+ peer=peer,
147
+ max_id=0,
148
+ revoke=not args.self_only,
149
+ )
150
+ )
151
+ scope = "for both sides" if not args.self_only else "locally only"
152
+ print(f"History cleared ({scope})")
153
+
154
+
155
+ def _status_text(status) -> str:
156
+ if isinstance(status, types.UserStatusOnline):
157
+ if status.expires > datetime.now(status.expires.tzinfo):
158
+ return "online"
159
+ return f"last seen {fmt_date(status.expires)}"
160
+ if isinstance(status, types.UserStatusOffline):
161
+ return f"last seen {fmt_date(status.was_online)}"
162
+ if isinstance(status, types.UserStatusRecently):
163
+ return "last seen recently"
164
+ if isinstance(status, types.UserStatusLastWeek):
165
+ return "last seen within a week"
166
+ if isinstance(status, types.UserStatusLastMonth):
167
+ return "last seen within a month"
168
+ if isinstance(status, types.UserStatusLongAgo):
169
+ return "last seen a long time ago"
170
+ return str(status)
171
+
172
+
173
+ async def cmd_status(args) -> None:
174
+ async with run_with_client(args) as client:
175
+ user = await client.get_entity(await resolve_chat(client, args.user))
176
+ name = " ".join(x for x in (user.first_name, user.last_name) if x)
177
+ print(f"{name} ({user.id}): {_status_text(user.status)}")
178
+
179
+
180
+ async def cmd_common_chats(args) -> None:
181
+ async with run_with_client(args) as client:
182
+ peer = await _input_peer(client, args.user)
183
+ if isinstance(peer, types.InputPeerSelf):
184
+ raise TgError("cannot list common chats with yourself")
185
+ if not isinstance(peer, types.InputPeerUser):
186
+ raise TgError(f"'{args.user}' is not a user")
187
+ user = types.InputUser(user_id=peer.user_id, access_hash=peer.access_hash)
188
+ res = await client(
189
+ functions.messages.GetCommonChatsRequest(user_id=user, max_id=0, limit=100)
190
+ )
191
+ chats = getattr(res, "chats", []) or []
192
+ if args.json:
193
+ print(json_pp([c.to_dict() for c in chats]))
194
+ return
195
+ if not chats:
196
+ print("(no common chats)")
197
+ return
198
+ for c in chats:
199
+ title = getattr(c, "title", None) or "?"
200
+ uname = getattr(c, "username", None) or ""
201
+ print(f"{c.id:>14} {title} {'@' + uname if uname else ''}")
202
+
203
+
204
+ def setup(subparsers, common=None) -> None:
205
+ parents = [common] if common else []
206
+ sp = subparsers.add_parser("archive", parents=parents, help="Archive a chat")
207
+ sp.add_argument("chat")
208
+ sp.set_defaults(func=cmd_archive)
209
+
210
+ sp = subparsers.add_parser("unarchive", parents=parents, help="Unarchive a chat")
211
+ sp.add_argument("chat")
212
+ sp.set_defaults(func=cmd_unarchive)
213
+
214
+ sp = subparsers.add_parser(
215
+ "mute", parents=parents, help="Mute notifications (permanent unless [hours] given)"
216
+ )
217
+ sp.add_argument("chat")
218
+ sp.add_argument("hours", nargs="?", type=int, default=None)
219
+ sp.set_defaults(func=cmd_mute)
220
+
221
+ sp = subparsers.add_parser("unmute", parents=parents, help="Unmute notifications")
222
+ sp.add_argument("chat")
223
+ sp.set_defaults(func=cmd_unmute)
224
+
225
+ sp = subparsers.add_parser("chat-title", parents=parents, help="Change group/channel title")
226
+ sp.add_argument("chat")
227
+ sp.add_argument("title")
228
+ sp.set_defaults(func=cmd_chat_title)
229
+
230
+ sp = subparsers.add_parser("chat-about", parents=parents, help="Change chat description")
231
+ sp.add_argument("chat")
232
+ sp.add_argument("text")
233
+ sp.set_defaults(func=cmd_chat_about)
234
+
235
+ sp = subparsers.add_parser("chat-photo", parents=parents, help="Change the chat photo")
236
+ sp.add_argument("chat")
237
+ sp.add_argument("file", help="path to an image")
238
+ sp.set_defaults(func=cmd_chat_photo)
239
+
240
+ sp = subparsers.add_parser("invite-link", parents=parents, help="Export a chat invite link")
241
+ sp.add_argument("chat")
242
+ sp.set_defaults(func=cmd_invite_link)
243
+
244
+ sp = subparsers.add_parser(
245
+ "join", parents=parents, help="Join via t.me link, @username, or invite hash"
246
+ )
247
+ sp.add_argument("link", help="e.g. t.me/+abc123, t.me/joinchat/abc123, or t.me/somechannel")
248
+ sp.set_defaults(func=cmd_join)
249
+
250
+ sp = subparsers.add_parser(
251
+ "clear-history",
252
+ parents=parents,
253
+ help="Delete all messages in a chat (both sides; --self-only keeps the other copy)",
254
+ )
255
+ sp.add_argument("chat")
256
+ sp.add_argument(
257
+ "--self-only", action="store_true", help="delete only your own copy (private chats)"
258
+ )
259
+ sp.set_defaults(func=cmd_clear_history)
260
+
261
+ sp = subparsers.add_parser("status", parents=parents, help="Show a user's online status")
262
+ sp.add_argument("user")
263
+ sp.set_defaults(func=cmd_status)
264
+
265
+ sp = subparsers.add_parser(
266
+ "common-chats", parents=parents, help="List chats you share with a user"
267
+ )
268
+ sp.add_argument("user")
269
+ sp.set_defaults(func=cmd_common_chats)
@@ -1,8 +1,10 @@
1
- """Media commands: dl (download), sf (send file/album), voice."""
1
+ """Media commands: dl (download), sf (send file/album), voice, sticker, gif."""
2
2
 
3
3
  import os
4
4
  import re
5
5
 
6
+ from telethon import functions
7
+
6
8
  from .client import run_with_client
7
9
  from .output import TgError
8
10
  from .resolve import resolve_chat
@@ -62,6 +64,34 @@ async def cmd_voice(args) -> None:
62
64
  print("Voice note sent")
63
65
 
64
66
 
67
+ async def cmd_sticker(args) -> None:
68
+ path = os.path.expanduser(args.file)
69
+ if not os.path.exists(path):
70
+ raise TgError(f"file not found: {path}")
71
+ async with run_with_client(args) as client:
72
+ entity = await resolve_chat(client, args.chat)
73
+ await client.send_file(entity, path, force_document=False)
74
+ print("Sticker sent")
75
+
76
+
77
+ async def cmd_gif(args) -> None:
78
+ async with run_with_client(args) as client:
79
+ entity = await resolve_chat(client, args.chat)
80
+ # GIF search = inline query to the built-in @gif bot (searchGifs was
81
+ # removed from the TL layer; this is what official clients use)
82
+ res = await client(
83
+ functions.messages.GetInlineBotResultsRequest(
84
+ bot="gif", peer=entity, query=args.query, offset=""
85
+ )
86
+ )
87
+ docs = [r.document for r in res.results if getattr(r, "document", None) is not None]
88
+ if not docs:
89
+ raise TgError(f"no GIFs found for {args.query!r}")
90
+ idx = min(max(args.index, 1), len(docs)) - 1
91
+ await client.send_file(entity, docs[idx])
92
+ print(f"GIF sent (match {idx + 1} of {len(docs)} for {args.query!r})")
93
+
94
+
65
95
  def setup(subparsers, common=None) -> None:
66
96
  parents = [common] if common else []
67
97
  sp = subparsers.add_parser(
@@ -89,3 +119,16 @@ def setup(subparsers, common=None) -> None:
89
119
  sp.add_argument("chat")
90
120
  sp.add_argument("file")
91
121
  sp.set_defaults(func=cmd_voice)
122
+
123
+ sp = subparsers.add_parser(
124
+ "sticker", parents=parents, help="Send a sticker file (.webp / .tgs / .webm)"
125
+ )
126
+ sp.add_argument("chat")
127
+ sp.add_argument("file")
128
+ sp.set_defaults(func=cmd_sticker)
129
+
130
+ sp = subparsers.add_parser("gif", parents=parents, help="Search GIFs and send the first match")
131
+ sp.add_argument("chat")
132
+ sp.add_argument("query")
133
+ sp.add_argument("-n", "--index", type=int, default=1, help="send the Nth match (default 1)")
134
+ sp.set_defaults(func=cmd_gif)
@@ -0,0 +1,365 @@
1
+ """Messaging commands: send, reply, edit, del, fwd, poll, schedule, draft,
2
+ reactions, pinning, read state, message links, contact cards."""
3
+
4
+ import random
5
+ import re
6
+ from datetime import datetime, timedelta
7
+
8
+ from telethon import functions, types
9
+
10
+ from .client import run_with_client
11
+ from .output import TgError, fmt_date, json_pp
12
+ from .resolve import resolve_chat
13
+
14
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
15
+
16
+
17
+ def parse_when(spec: str) -> datetime:
18
+ """Parse a schedule time: relative (+30m/+2h/+1d), unix ts, or ISO date."""
19
+ s = spec.strip()
20
+ m = re.fullmatch(r"\+(\d+)([smhd])", s.lower())
21
+ if m:
22
+ return datetime.now().astimezone() + timedelta(
23
+ seconds=int(m.group(1)) * _UNIT_SECONDS[m.group(2)]
24
+ )
25
+ if re.fullmatch(r"\d{9,12}", s):
26
+ return datetime.fromtimestamp(int(s)).astimezone()
27
+ try:
28
+ dt = datetime.fromisoformat(s)
29
+ except ValueError:
30
+ pass
31
+ else:
32
+ return dt.astimezone() if dt.tzinfo is None else dt
33
+ raise TgError(
34
+ f"cannot parse time: {spec!r}",
35
+ 'use "+30m" / "+2h", "2026-09-07 14:30", or a unix timestamp',
36
+ )
37
+
38
+
39
+ async def cmd_send(args) -> None:
40
+ async with run_with_client(args) as client:
41
+ entity = await resolve_chat(client, args.chat)
42
+ msg = await client.send_message(entity, " ".join(args.text), parse_mode=args.parse_mode)
43
+ print(f"Message sent (id: {msg.id})")
44
+
45
+
46
+ async def cmd_reply(args) -> None:
47
+ async with run_with_client(args) as client:
48
+ entity = await resolve_chat(client, args.chat)
49
+ msg = await client.send_message(entity, " ".join(args.text), reply_to=args.msg_id)
50
+ print(f"Reply sent (id: {msg.id})")
51
+
52
+
53
+ async def cmd_edit(args) -> None:
54
+ async with run_with_client(args) as client:
55
+ entity = await resolve_chat(client, args.chat)
56
+ await client.edit_message(entity, args.msg_id, args.new_text)
57
+ print("Message edited")
58
+
59
+
60
+ async def cmd_del(args) -> None:
61
+ async with run_with_client(args) as client:
62
+ entity = await resolve_chat(client, args.chat)
63
+ await client.delete_messages(entity, args.ids, revoke=True)
64
+ print(f"Deleted {len(args.ids)} message(s)")
65
+
66
+
67
+ async def cmd_fwd(args) -> None:
68
+ async with run_with_client(args) as client:
69
+ from_entity = await resolve_chat(client, args.from_chat)
70
+ to_entity = await resolve_chat(client, args.to)
71
+ sent = await client.forward_messages(to_entity, args.msg_ids, from_peer=from_entity)
72
+ print(f"Forwarded {len(sent)} message(s)")
73
+
74
+
75
+ async def cmd_poll(args) -> None:
76
+ if len(args.options) < 2:
77
+ raise TgError("a poll needs at least 2 options")
78
+ async with run_with_client(args) as client:
79
+ entity = await resolve_chat(client, args.chat)
80
+ answers = [
81
+ types.PollAnswer(
82
+ text=types.TextWithEntities(text=a, entities=[]),
83
+ option=bytes([i]),
84
+ )
85
+ for i, a in enumerate(args.options)
86
+ ]
87
+ poll = types.Poll(
88
+ id=random.randint(1, 2**31),
89
+ question=types.TextWithEntities(text=args.question, entities=[]),
90
+ answers=answers,
91
+ hash=0,
92
+ closed=False,
93
+ public_voters=args.public,
94
+ quiz=args.quiz,
95
+ multiple_choice=args.multiple,
96
+ )
97
+ msg = await client.send_message(entity, file=types.InputMediaPoll(poll=poll))
98
+ print(f"Poll sent (id: {msg.id})")
99
+
100
+
101
+ async def cmd_schedule(args) -> None:
102
+ when = parse_when(args.time)
103
+ if when <= datetime.now().astimezone():
104
+ raise TgError("schedule time must be in the future")
105
+ async with run_with_client(args) as client:
106
+ entity = await resolve_chat(client, args.chat)
107
+ msg = await client.send_message(entity, " ".join(args.text), schedule=when)
108
+ print(f"Scheduled (id: {msg.id}) for {fmt_date(when)}")
109
+
110
+
111
+ async def cmd_scheduled(args) -> None:
112
+ async with run_with_client(args) as client:
113
+ entity = await resolve_chat(client, args.chat)
114
+ res = await client(functions.messages.GetScheduledHistoryRequest(peer=entity, hash=0))
115
+ msgs = getattr(res, "messages", []) or []
116
+ if not msgs:
117
+ print("(no scheduled messages)")
118
+ return
119
+ if args.json:
120
+ print(json_pp([m.to_dict() for m in msgs]))
121
+ return
122
+ for m in msgs:
123
+ body = " ".join((m.message or "").split())[:100] or "[media]"
124
+ print(f"[{m.id}] {fmt_date(m.date)} {body}")
125
+
126
+
127
+ async def cmd_unschedule(args) -> None:
128
+ async with run_with_client(args) as client:
129
+ entity = await resolve_chat(client, args.chat)
130
+ await client(functions.messages.DeleteScheduledMessagesRequest(peer=entity, id=args.ids))
131
+ print(f"Unscheduled {len(args.ids)} message(s)")
132
+
133
+
134
+ async def cmd_draft(args) -> None:
135
+ async with run_with_client(args) as client:
136
+ entity = await resolve_chat(client, args.chat)
137
+ if args.text:
138
+ text = " ".join(args.text)
139
+ await client(functions.messages.SaveDraftRequest(peer=entity, message=text))
140
+ print("Draft saved")
141
+ return
142
+ text = ""
143
+ async for d in client.iter_drafts(entity):
144
+ text = d.raw_text or ""
145
+ break
146
+ print(text if text else "(no draft)")
147
+
148
+
149
+ async def cmd_draft_clear(args) -> None:
150
+ async with run_with_client(args) as client:
151
+ entity = await resolve_chat(client, args.chat)
152
+ # an empty message clears the draft
153
+ await client(functions.messages.SaveDraftRequest(peer=entity, message=""))
154
+ print("Draft cleared")
155
+
156
+
157
+ async def cmd_react(args) -> None:
158
+ async with run_with_client(args) as client:
159
+ entity = await resolve_chat(client, args.chat)
160
+ await client(
161
+ functions.messages.SendReactionRequest(
162
+ peer=entity,
163
+ msg_id=args.msg_id,
164
+ big=args.big,
165
+ reaction=[types.ReactionEmoji(emoticon=args.emoji)],
166
+ )
167
+ )
168
+ print(f"Reacted {args.emoji} to message {args.msg_id}")
169
+
170
+
171
+ async def cmd_unreact(args) -> None:
172
+ async with run_with_client(args) as client:
173
+ entity = await resolve_chat(client, args.chat)
174
+ # an empty reaction list removes our reaction
175
+ await client(
176
+ functions.messages.SendReactionRequest(peer=entity, msg_id=args.msg_id, reaction=[])
177
+ )
178
+ print(f"Reaction removed from message {args.msg_id}")
179
+
180
+
181
+ async def cmd_pin(args) -> None:
182
+ async with run_with_client(args) as client:
183
+ entity = await resolve_chat(client, args.chat)
184
+ await client.pin_message(entity, args.msg_id)
185
+ print(f"Pinned message {args.msg_id}")
186
+
187
+
188
+ async def cmd_unpin(args) -> None:
189
+ async with run_with_client(args) as client:
190
+ entity = await resolve_chat(client, args.chat)
191
+ if args.msg_id is None:
192
+ await client.unpin_message(entity)
193
+ print("Unpinned all messages")
194
+ else:
195
+ await client.unpin_message(entity, args.msg_id)
196
+ print(f"Unpinned message {args.msg_id}")
197
+
198
+
199
+ async def cmd_read(args) -> None:
200
+ async with run_with_client(args) as client:
201
+ entity = await resolve_chat(client, args.chat)
202
+ await client.send_read_acknowledge(entity, clear_mentions=True)
203
+ print("Marked as read")
204
+
205
+
206
+ async def cmd_msg_link(args) -> None:
207
+ async with run_with_client(args) as client:
208
+ peer = await client.get_input_entity(await resolve_chat(client, args.chat))
209
+ if not isinstance(peer, types.InputPeerChannel):
210
+ raise TgError(
211
+ "message links only exist for channels and supergroups",
212
+ "basic groups and private chats have no t.me message links",
213
+ )
214
+ res = await client(
215
+ functions.channels.ExportMessageLinkRequest(
216
+ channel=types.InputChannel(
217
+ channel_id=peer.channel_id, access_hash=peer.access_hash
218
+ ),
219
+ id=args.msg_id,
220
+ )
221
+ )
222
+ print(res.link)
223
+
224
+
225
+ async def cmd_contact_card(args) -> None:
226
+ first, _, last = args.name.partition(" ")
227
+ async with run_with_client(args) as client:
228
+ entity = await resolve_chat(client, args.chat)
229
+ msg = await client.send_message(
230
+ entity,
231
+ file=types.InputMediaContact(
232
+ phone_number=args.phone, first_name=first, last_name=last, vcard=""
233
+ ),
234
+ )
235
+ print(f"Contact card sent (id: {msg.id})")
236
+
237
+
238
+ def setup(subparsers, common=None) -> None:
239
+ parents = [common] if common else []
240
+ sp = subparsers.add_parser(
241
+ "send", parents=parents, help="Send a message, e.g. tg send me hello"
242
+ )
243
+ sp.add_argument("chat")
244
+ sp.add_argument("text", nargs="+")
245
+ sp.add_argument("-p", "--parse-mode", default=None, choices=["md", "markdown", "html"])
246
+ sp.set_defaults(func=cmd_send)
247
+
248
+ sp = subparsers.add_parser(
249
+ "reply", parents=parents, help="Reply to a message, e.g. tg reply me 12 got it"
250
+ )
251
+ sp.add_argument("chat")
252
+ sp.add_argument("msg_id", type=int)
253
+ sp.add_argument("text", nargs="+")
254
+ sp.set_defaults(func=cmd_reply)
255
+
256
+ sp = subparsers.add_parser("edit", parents=parents, help="Edit your own message")
257
+ sp.add_argument("chat")
258
+ sp.add_argument("msg_id", type=int)
259
+ sp.add_argument("new_text")
260
+ sp.set_defaults(func=cmd_edit)
261
+
262
+ sp = subparsers.add_parser(
263
+ "del", parents=parents, help="Delete messages (revoke for both sides)"
264
+ )
265
+ sp.add_argument("chat")
266
+ sp.add_argument("ids", nargs="+", type=int)
267
+ sp.set_defaults(func=cmd_del)
268
+
269
+ sp = subparsers.add_parser(
270
+ "fwd", parents=parents, help="Forward messages, e.g. tg fwd SRC 1 2 3 DST (last is target)"
271
+ )
272
+ sp.add_argument("from_chat")
273
+ sp.add_argument("msg_ids", nargs="+", type=int)
274
+ sp.add_argument("to")
275
+ sp.set_defaults(func=cmd_fwd)
276
+
277
+ sp = subparsers.add_parser(
278
+ "poll", parents=parents, help='Create a poll, e.g. tg poll me "Q?" a b c'
279
+ )
280
+ sp.add_argument("chat")
281
+ sp.add_argument("question")
282
+ sp.add_argument("options", nargs="+")
283
+ sp.add_argument("--multiple", action="store_true", help="allow multiple answers")
284
+ sp.add_argument("--quiz", action="store_true", help="quiz mode")
285
+ sp.add_argument("--public", action="store_true", help="public votes")
286
+ sp.set_defaults(func=cmd_poll)
287
+
288
+ sp = subparsers.add_parser(
289
+ "schedule",
290
+ parents=parents,
291
+ help='Schedule a message, e.g. tg schedule me "+2h" ping',
292
+ )
293
+ sp.add_argument("chat")
294
+ sp.add_argument("time", help='+30m / +2h / +1d, "2026-09-07 14:30", or a unix timestamp')
295
+ sp.add_argument("text", nargs="+")
296
+ sp.set_defaults(func=cmd_schedule)
297
+
298
+ sp = subparsers.add_parser("scheduled", parents=parents, help="List scheduled messages")
299
+ sp.add_argument("chat")
300
+ sp.set_defaults(func=cmd_scheduled)
301
+
302
+ sp = subparsers.add_parser(
303
+ "unschedule", parents=parents, help="Delete scheduled messages (ids from `scheduled`)"
304
+ )
305
+ sp.add_argument("chat")
306
+ sp.add_argument("ids", nargs="+", type=int)
307
+ sp.set_defaults(func=cmd_unschedule)
308
+
309
+ sp = subparsers.add_parser(
310
+ "draft",
311
+ parents=parents,
312
+ help="Save a draft (with text) or show the current draft (without)",
313
+ )
314
+ sp.add_argument("chat")
315
+ sp.add_argument("text", nargs="*", default=[])
316
+ sp.set_defaults(func=cmd_draft)
317
+
318
+ sp = subparsers.add_parser("draft-clear", parents=parents, help="Clear the draft")
319
+ sp.add_argument("chat")
320
+ sp.set_defaults(func=cmd_draft_clear)
321
+
322
+ sp = subparsers.add_parser("react", parents=parents, help="React to a message")
323
+ sp.add_argument("chat")
324
+ sp.add_argument("msg_id", type=int)
325
+ sp.add_argument("emoji", help='e.g. "👍", "❤️", "🎉"')
326
+ sp.add_argument("--big", action="store_true", help="big animation")
327
+ sp.set_defaults(func=cmd_react)
328
+
329
+ sp = subparsers.add_parser("unreact", parents=parents, help="Remove your reaction")
330
+ sp.add_argument("chat")
331
+ sp.add_argument("msg_id", type=int)
332
+ sp.set_defaults(func=cmd_unreact)
333
+
334
+ sp = subparsers.add_parser("pin", parents=parents, help="Pin a message")
335
+ sp.add_argument("chat")
336
+ sp.add_argument("msg_id", type=int)
337
+ sp.set_defaults(func=cmd_pin)
338
+
339
+ sp = subparsers.add_parser(
340
+ "unpin", parents=parents, help="Unpin a message (or all if no id given)"
341
+ )
342
+ sp.add_argument("chat")
343
+ sp.add_argument("msg_id", nargs="?", type=int, default=None)
344
+ sp.set_defaults(func=cmd_unpin)
345
+
346
+ sp = subparsers.add_parser("read", parents=parents, help="Mark the chat as read")
347
+ sp.add_argument("chat")
348
+ sp.set_defaults(func=cmd_read)
349
+
350
+ sp = subparsers.add_parser(
351
+ "msg-link", parents=parents, help="Export a t.me link (channels/supergroups)"
352
+ )
353
+ sp.add_argument("chat")
354
+ sp.add_argument("msg_id", type=int)
355
+ sp.set_defaults(func=cmd_msg_link)
356
+
357
+ sp = subparsers.add_parser(
358
+ "contact-card",
359
+ parents=parents,
360
+ help='Send a contact card, e.g. tg contact-card me "Jo Do" +8613800000000',
361
+ )
362
+ sp.add_argument("chat")
363
+ sp.add_argument("name", help='contact name, e.g. "John Doe"')
364
+ sp.add_argument("phone")
365
+ sp.set_defaults(func=cmd_contact_card)
@@ -1,122 +0,0 @@
1
- """Messaging commands: send, reply, edit, del, fwd, poll."""
2
-
3
- import random
4
-
5
- from telethon import types
6
-
7
- from .client import run_with_client
8
- from .output import TgError
9
- from .resolve import resolve_chat
10
-
11
-
12
- async def cmd_send(args) -> None:
13
- async with run_with_client(args) as client:
14
- entity = await resolve_chat(client, args.chat)
15
- msg = await client.send_message(entity, " ".join(args.text), parse_mode=args.parse_mode)
16
- print(f"Message sent (id: {msg.id})")
17
-
18
-
19
- async def cmd_reply(args) -> None:
20
- async with run_with_client(args) as client:
21
- entity = await resolve_chat(client, args.chat)
22
- msg = await client.send_message(entity, " ".join(args.text), reply_to=args.msg_id)
23
- print(f"Reply sent (id: {msg.id})")
24
-
25
-
26
- async def cmd_edit(args) -> None:
27
- async with run_with_client(args) as client:
28
- entity = await resolve_chat(client, args.chat)
29
- await client.edit_message(entity, args.msg_id, args.new_text)
30
- print("Message edited")
31
-
32
-
33
- async def cmd_del(args) -> None:
34
- async with run_with_client(args) as client:
35
- entity = await resolve_chat(client, args.chat)
36
- await client.delete_messages(entity, args.ids, revoke=True)
37
- print(f"Deleted {len(args.ids)} message(s)")
38
-
39
-
40
- async def cmd_fwd(args) -> None:
41
- async with run_with_client(args) as client:
42
- from_entity = await resolve_chat(client, args.from_chat)
43
- to_entity = await resolve_chat(client, args.to)
44
- sent = await client.forward_messages(to_entity, args.msg_ids, from_peer=from_entity)
45
- print(f"Forwarded {len(sent)} message(s)")
46
-
47
-
48
- async def cmd_poll(args) -> None:
49
- if len(args.options) < 2:
50
- raise TgError("a poll needs at least 2 options")
51
- async with run_with_client(args) as client:
52
- entity = await resolve_chat(client, args.chat)
53
- answers = [
54
- types.PollAnswer(
55
- text=types.TextWithEntities(text=a, entities=[]),
56
- option=bytes([i]),
57
- )
58
- for i, a in enumerate(args.options)
59
- ]
60
- poll = types.Poll(
61
- id=random.randint(1, 2**31),
62
- question=types.TextWithEntities(text=args.question, entities=[]),
63
- answers=answers,
64
- hash=0,
65
- closed=False,
66
- public_voters=args.public,
67
- quiz=args.quiz,
68
- multiple_choice=args.multiple,
69
- )
70
- msg = await client.send_message(entity, file=types.InputMediaPoll(poll=poll))
71
- print(f"Poll sent (id: {msg.id})")
72
-
73
-
74
- def setup(subparsers, common=None) -> None:
75
- parents = [common] if common else []
76
- sp = subparsers.add_parser(
77
- "send", parents=parents, help="Send a message, e.g. tg send me hello"
78
- )
79
- sp.add_argument("chat")
80
- sp.add_argument("text", nargs="+")
81
- sp.add_argument("-p", "--parse-mode", default=None, choices=["md", "markdown", "html"])
82
- sp.set_defaults(func=cmd_send)
83
-
84
- sp = subparsers.add_parser(
85
- "reply", parents=parents, help="Reply to a message, e.g. tg reply me 12 got it"
86
- )
87
- sp.add_argument("chat")
88
- sp.add_argument("msg_id", type=int)
89
- sp.add_argument("text", nargs="+")
90
- sp.set_defaults(func=cmd_reply)
91
-
92
- sp = subparsers.add_parser("edit", parents=parents, help="Edit your own message")
93
- sp.add_argument("chat")
94
- sp.add_argument("msg_id", type=int)
95
- sp.add_argument("new_text")
96
- sp.set_defaults(func=cmd_edit)
97
-
98
- sp = subparsers.add_parser(
99
- "del", parents=parents, help="Delete messages (revoke for both sides)"
100
- )
101
- sp.add_argument("chat")
102
- sp.add_argument("ids", nargs="+", type=int)
103
- sp.set_defaults(func=cmd_del)
104
-
105
- sp = subparsers.add_parser(
106
- "fwd", parents=parents, help="Forward messages, e.g. tg fwd SRC 1 2 3 DST (last is target)"
107
- )
108
- sp.add_argument("from_chat")
109
- sp.add_argument("msg_ids", nargs="+", type=int)
110
- sp.add_argument("to")
111
- sp.set_defaults(func=cmd_fwd)
112
-
113
- sp = subparsers.add_parser(
114
- "poll", parents=parents, help='Create a poll, e.g. tg poll me "Q?" a b c'
115
- )
116
- sp.add_argument("chat")
117
- sp.add_argument("question")
118
- sp.add_argument("options", nargs="+")
119
- sp.add_argument("--multiple", action="store_true", help="allow multiple answers")
120
- sp.add_argument("--quiz", action="store_true", help="quiz mode")
121
- sp.add_argument("--public", action="store_true", help="public votes")
122
- sp.set_defaults(func=cmd_poll)