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.
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/PKG-INFO +39 -4
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/README-zh.md +34 -3
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/README.md +38 -3
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/ROADMAP.md +21 -17
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/pyproject.toml +1 -1
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/__init__.py +1 -1
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/chats.py +7 -1
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/cli.py +2 -2
- telegram_userbot_cli-0.2.0/src/tgcli/dialogs.py +269 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/media.py +44 -1
- telegram_userbot_cli-0.2.0/src/tgcli/messages.py +365 -0
- telegram_userbot_cli-0.1.0/src/tgcli/messages.py +0 -122
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.env.example +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.github/workflows/release.yml +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/.gitignore +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/LICENSE +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/__main__.py +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/auth.py +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/client.py +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/config.py +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/output.py +0 -0
- {telegram_userbot_cli-0.1.0 → telegram_userbot_cli-0.2.0}/src/tgcli/resolve.py +0 -0
- {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.
|
|
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
|
-
|
|
30
|
-
-
|
|
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
|
-
|
|
17
|
-
-
|
|
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
|
|
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
|
-
-
|
|
15
|
+
- ✅ `schedule <chat> <time> <text...>` — send scheduled messages
|
|
16
16
|
(`send_message(..., schedule=...)`), list/delete scheduled
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
-
|
|
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
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
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
|
-
-
|
|
33
|
-
-
|
|
34
|
-
|
|
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.
|
|
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,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
|
-
|
|
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)
|
|
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
|