telegram-userbot-cli 0.3.0__tar.gz → 0.4.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.3.0 → telegram_userbot_cli-0.4.0}/PKG-INFO +24 -1
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/README-zh.md +22 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/README.md +23 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/ROADMAP.md +19 -12
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/pyproject.toml +1 -1
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/__init__.py +1 -1
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/auth.py +38 -15
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/chats.py +212 -1
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/cli.py +5 -2
- telegram_userbot_cli-0.4.0/src/tgcli/contacts.py +173 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/dialogs.py +117 -1
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/media.py +40 -2
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/.env.example +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/.github/workflows/ci.yml +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/.github/workflows/release.yml +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/.gitignore +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/LICENSE +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/__main__.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/client.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/config.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/groups.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/messages.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/output.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.0}/src/tgcli/resolve.py +0 -0
- {telegram_userbot_cli-0.3.0 → telegram_userbot_cli-0.4.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.4.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
|
|
@@ -31,6 +31,8 @@ no daemon, no external service**. Everything the CLI does lives in the CLI.
|
|
|
31
31
|
admin promotion with granular rights, slow mode, forum topics, admin log
|
|
32
32
|
- Chat management: archive, mute, edit title / description / photo,
|
|
33
33
|
invite links, clear history
|
|
34
|
+
- Contacts, privacy settings, blocking, dialog folders, multi-account,
|
|
35
|
+
voice transcription
|
|
34
36
|
- Media: download, send files / albums / voice notes / stickers / GIFs,
|
|
35
37
|
contact cards
|
|
36
38
|
- Chats: list dialogs, history, pinned messages, in-chat & global search,
|
|
@@ -158,6 +160,27 @@ tg logout # remove the session
|
|
|
158
160
|
| `tg topic-create <chat> <title> [text]` | Create a forum topic, optionally with a first message |
|
|
159
161
|
| `tg admin-log <chat> [-n]` | Recent admin actions |
|
|
160
162
|
|
|
163
|
+
### Contacts, privacy & misc (v4)
|
|
164
|
+
|
|
165
|
+
| Command | Description |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `tg contact add <user> [name] [--phone]` | Add/update a contact |
|
|
168
|
+
| `tg contact del <user...>` | Delete contacts (by name/id or `+phone`) |
|
|
169
|
+
| `tg contact import <file>` | Bulk import from `.json` / `.csv` (`phone,first_name,last_name`) |
|
|
170
|
+
| `tg contact export [file]` | Export contacts as re-importable JSON (default stdout) |
|
|
171
|
+
| `tg photo-del [--all]` | Delete your most recent profile photo (or all) |
|
|
172
|
+
| `tg privacy get [key...]` | Show privacy settings (default: all) |
|
|
173
|
+
| `tg privacy set <key> all\|contacts\|nobody` | Change a privacy setting; `--allow USER` / `--deny USER` refine it |
|
|
174
|
+
| `tg block <user>` / `tg unblock <user>` / `tg blocked` | Block management |
|
|
175
|
+
| `tg folders` | List dialog folders |
|
|
176
|
+
| `tg folder-create <name> --chat <c>...` | Create a folder with chats |
|
|
177
|
+
| `tg folder-assign <folder> <chat...>` | Add chats to a folder |
|
|
178
|
+
| `tg folder-del <folder>` | Delete a folder |
|
|
179
|
+
| `tg folder-order <folder...>` | Reorder folders (pass all in the wanted order) |
|
|
180
|
+
| `tg accounts` | List configured accounts |
|
|
181
|
+
| `tg login --account <label>` | Log in an additional account |
|
|
182
|
+
| `tg vtr <chat> <msg_id>` | Transcribe a voice note (Telegram Premium required) |
|
|
183
|
+
|
|
161
184
|
Global flags: `--account` (v1: default only), `--json` (raw JSON output).
|
|
162
185
|
Exit codes: `0` ok, `1` command error, `2` config/connection/session error,
|
|
163
186
|
`130` interrupted.
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
- 消息:发送 / 引用回复 / 编辑 / 删除 / 转发 / 投票 / 定时 / 草稿 / 表情回应 / 置顶
|
|
16
16
|
- 群组管理:建群 / 建频道、邀请 / 踢人 / 封禁、管理员升降级(细粒度权限)、慢速模式、论坛话题、管理日志
|
|
17
17
|
- 聊天管理:归档、免打扰、修改群名 / 简介 / 群头像、邀请链接、清空历史
|
|
18
|
+
- 联系人、隐私设置、封锁管理、会话文件夹、多账号、语音转写
|
|
18
19
|
- 媒体:下载、发文件 / 相册 / 语音条 / 贴纸 / GIF、发送名片
|
|
19
20
|
- 聊天:会话列表、历史记录、置顶消息、会话内与全局搜索、在线状态、共同群组
|
|
20
21
|
- 联系人与个人资料管理
|
|
@@ -136,6 +137,27 @@ tg logout # 退出登录并删除会话
|
|
|
136
137
|
| `tg topic-create <会话> <标题> [首条消息]` | 创建论坛话题 |
|
|
137
138
|
| `tg admin-log <会话> [-n]` | 近期管理操作日志 |
|
|
138
139
|
|
|
140
|
+
### 联系人、隐私与其他(v4)
|
|
141
|
+
|
|
142
|
+
| 命令 | 说明 |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `tg contact add <用户> [姓名] [--phone]` | 新增/更新联系人 |
|
|
145
|
+
| `tg contact del <用户...>` | 删除联系人(按名称/id 或 `+手机号`) |
|
|
146
|
+
| `tg contact import <文件>` | 从 `.json` / `.csv` 批量导入(`phone,first_name,last_name`) |
|
|
147
|
+
| `tg contact export [文件]` | 导出为可再导入的 JSON(默认输出到终端) |
|
|
148
|
+
| `tg photo-del [--all]` | 删除最近一张头像(`--all` 删除全部) |
|
|
149
|
+
| `tg privacy get [键...]` | 查看隐私设置(默认全部) |
|
|
150
|
+
| `tg privacy set <键> all\|contacts\|nobody` | 修改隐私设置;`--allow 用户` / `--deny 用户` 精细化 |
|
|
151
|
+
| `tg block <用户>` / `tg unblock <用户>` / `tg blocked` | 封锁管理 |
|
|
152
|
+
| `tg folders` | 列出会话文件夹 |
|
|
153
|
+
| `tg folder-create <名称> --chat <会话>...` | 创建文件夹并加入会话 |
|
|
154
|
+
| `tg folder-assign <文件夹> <会话...>` | 向文件夹追加会话 |
|
|
155
|
+
| `tg folder-del <文件夹>` | 删除文件夹 |
|
|
156
|
+
| `tg folder-order <文件夹...>` | 重排文件夹(按想要的顺序全部列出) |
|
|
157
|
+
| `tg accounts` | 列出已配置的账号 |
|
|
158
|
+
| `tg login --account <标签>` | 登录额外账号 |
|
|
159
|
+
| `tg vtr <会话> <消息id>` | 语音转文字(需要 Telegram Premium) |
|
|
160
|
+
|
|
139
161
|
全局选项:`--account`(v1 仅 default)、`--json`(原始 JSON 输出)。
|
|
140
162
|
退出码:`0` 成功、`1` 命令错误、`2` 配置/连接/会话错误、`130` 中断。
|
|
141
163
|
|
|
@@ -18,6 +18,8 @@ no daemon, no external service**. Everything the CLI does lives in the CLI.
|
|
|
18
18
|
admin promotion with granular rights, slow mode, forum topics, admin log
|
|
19
19
|
- Chat management: archive, mute, edit title / description / photo,
|
|
20
20
|
invite links, clear history
|
|
21
|
+
- Contacts, privacy settings, blocking, dialog folders, multi-account,
|
|
22
|
+
voice transcription
|
|
21
23
|
- Media: download, send files / albums / voice notes / stickers / GIFs,
|
|
22
24
|
contact cards
|
|
23
25
|
- Chats: list dialogs, history, pinned messages, in-chat & global search,
|
|
@@ -145,6 +147,27 @@ tg logout # remove the session
|
|
|
145
147
|
| `tg topic-create <chat> <title> [text]` | Create a forum topic, optionally with a first message |
|
|
146
148
|
| `tg admin-log <chat> [-n]` | Recent admin actions |
|
|
147
149
|
|
|
150
|
+
### Contacts, privacy & misc (v4)
|
|
151
|
+
|
|
152
|
+
| Command | Description |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `tg contact add <user> [name] [--phone]` | Add/update a contact |
|
|
155
|
+
| `tg contact del <user...>` | Delete contacts (by name/id or `+phone`) |
|
|
156
|
+
| `tg contact import <file>` | Bulk import from `.json` / `.csv` (`phone,first_name,last_name`) |
|
|
157
|
+
| `tg contact export [file]` | Export contacts as re-importable JSON (default stdout) |
|
|
158
|
+
| `tg photo-del [--all]` | Delete your most recent profile photo (or all) |
|
|
159
|
+
| `tg privacy get [key...]` | Show privacy settings (default: all) |
|
|
160
|
+
| `tg privacy set <key> all\|contacts\|nobody` | Change a privacy setting; `--allow USER` / `--deny USER` refine it |
|
|
161
|
+
| `tg block <user>` / `tg unblock <user>` / `tg blocked` | Block management |
|
|
162
|
+
| `tg folders` | List dialog folders |
|
|
163
|
+
| `tg folder-create <name> --chat <c>...` | Create a folder with chats |
|
|
164
|
+
| `tg folder-assign <folder> <chat...>` | Add chats to a folder |
|
|
165
|
+
| `tg folder-del <folder>` | Delete a folder |
|
|
166
|
+
| `tg folder-order <folder...>` | Reorder folders (pass all in the wanted order) |
|
|
167
|
+
| `tg accounts` | List configured accounts |
|
|
168
|
+
| `tg login --account <label>` | Log in an additional account |
|
|
169
|
+
| `tg vtr <chat> <msg_id>` | Transcribe a voice note (Telegram Premium required) |
|
|
170
|
+
|
|
148
171
|
Global flags: `--account` (v1: default only), `--json` (raw JSON output).
|
|
149
172
|
Exit codes: `0` ok, `1` command error, `2` config/connection/session error,
|
|
150
173
|
`130` interrupted.
|
|
@@ -55,19 +55,26 @@ Chat management:
|
|
|
55
55
|
(list/create)
|
|
56
56
|
- ✅ `admin-log <chat>` — recent admin actions
|
|
57
57
|
|
|
58
|
-
### v4 — Contacts, profile & misc
|
|
58
|
+
### v4 — Contacts, profile & misc (shipped)
|
|
59
59
|
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
60
|
+
- ✅ `contact add/del` — add/delete contacts (`contact del` also matches `+phone`)
|
|
61
|
+
- ✅ `contact import <file>` / `contact export` — bulk JSON/CSV import,
|
|
62
|
+
re-importable JSON export (note: Telegram no longer creates contacts for
|
|
63
|
+
unregistered numbers — unmatched imports return 0)
|
|
64
|
+
- ✅ `photo-del` — delete the most recent profile photo (`--all` for all)
|
|
65
|
+
- ✅ `privacy get/set` — 11 keys (lastseen, phone, calls, p2p, groups, photo,
|
|
66
|
+
forwards, voicemail, about, birthday, addedby); set rules must use the
|
|
67
|
+
`InputPrivacyValue*` classes — the output `PrivacyValue*` classes are
|
|
68
|
+
silently ignored by the server
|
|
69
|
+
- ✅ `block <user>` / `unblock <user>` / `blocked` — block management
|
|
70
|
+
- ✅ `folders` / `folder-create` / `folder-assign` / `folder-del` /
|
|
71
|
+
`folder-order` — dialog folders (list/create/assign/reorder)
|
|
72
|
+
- ✅ `accounts` — multi-account support (`accounts` lists every
|
|
73
|
+
`TELEGRAM_SESSION_STRING*`; `login --account LABEL` and
|
|
74
|
+
`<command> --account LABEL` both work)
|
|
75
|
+
- ✅ `vtr <chat> <msg_id>` — voice transcription via raw
|
|
76
|
+
`messages.TranscribeAudioRequest` (telethon has no wrapper). Telegram
|
|
77
|
+
Premium is required server-side; free accounts get a clean error
|
|
71
78
|
|
|
72
79
|
### Out of scope
|
|
73
80
|
|
|
@@ -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.4.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,8 +1,10 @@
|
|
|
1
|
-
"""Login / logout flows."""
|
|
1
|
+
"""Login / logout / multi-account flows."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import getpass
|
|
5
5
|
import io
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
6
8
|
import sys
|
|
7
9
|
from datetime import date, datetime, timezone
|
|
8
10
|
|
|
@@ -14,6 +16,7 @@ from .client import build_client
|
|
|
14
16
|
from .output import TgError
|
|
15
17
|
|
|
16
18
|
_MAX_QR_REFRESHES = 5
|
|
19
|
+
_SESSION_ENV_RE = re.compile(r"TELEGRAM_SESSION_STRING(?:_([A-Z0-9_]+))?$")
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def render_qr(url: str) -> None:
|
|
@@ -38,6 +41,19 @@ def _seconds_until(qr) -> float:
|
|
|
38
41
|
return 120.0
|
|
39
42
|
|
|
40
43
|
|
|
44
|
+
def _save_session(args, session_str: str) -> None:
|
|
45
|
+
account = getattr(args, "account", None)
|
|
46
|
+
key = config.session_env_var(account)
|
|
47
|
+
config.write_env(
|
|
48
|
+
key,
|
|
49
|
+
session_str,
|
|
50
|
+
comment=f"generated by tg login on {date.today()}; keep secret, "
|
|
51
|
+
"do NOT share with the MCP daemon session",
|
|
52
|
+
)
|
|
53
|
+
where = f" for account '{account}'" if account else ""
|
|
54
|
+
print(f"Login successful{where}. Session written to .env — verify with: tg me")
|
|
55
|
+
|
|
56
|
+
|
|
41
57
|
async def _qr_login(args) -> None:
|
|
42
58
|
api_id, api_hash = config.require_credentials()
|
|
43
59
|
client = build_client(StringSession(), api_id, api_hash)
|
|
@@ -65,13 +81,7 @@ async def _qr_login(args) -> None:
|
|
|
65
81
|
session_str = StringSession.save(client.session)
|
|
66
82
|
finally:
|
|
67
83
|
await client.disconnect()
|
|
68
|
-
|
|
69
|
-
"TELEGRAM_SESSION_STRING",
|
|
70
|
-
session_str,
|
|
71
|
-
comment=f"generated by tg login on {date.today()}; keep secret, "
|
|
72
|
-
"do NOT share with the MCP daemon session",
|
|
73
|
-
)
|
|
74
|
-
print("Login successful. Session written to .env — verify with: tg me")
|
|
84
|
+
_save_session(args, session_str)
|
|
75
85
|
|
|
76
86
|
|
|
77
87
|
async def _phone_login(args) -> None:
|
|
@@ -90,13 +100,7 @@ async def _phone_login(args) -> None:
|
|
|
90
100
|
session_str = StringSession.save(client.session)
|
|
91
101
|
finally:
|
|
92
102
|
await client.disconnect()
|
|
93
|
-
|
|
94
|
-
"TELEGRAM_SESSION_STRING",
|
|
95
|
-
session_str,
|
|
96
|
-
comment=f"generated by tg login on {date.today()}; keep secret, "
|
|
97
|
-
"do NOT share with the MCP daemon session",
|
|
98
|
-
)
|
|
99
|
-
print("Login successful. Session written to .env — verify with: tg me")
|
|
103
|
+
_save_session(args, session_str)
|
|
100
104
|
|
|
101
105
|
|
|
102
106
|
async def cmd_login(args) -> None:
|
|
@@ -117,3 +121,22 @@ async def cmd_logout(args) -> None:
|
|
|
117
121
|
await client.disconnect()
|
|
118
122
|
config.clear_env(config.session_env_var(getattr(args, "account", None)))
|
|
119
123
|
print("Logged out; session removed from .env. The MCP daemon's session is untouched.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def cmd_accounts(args) -> None:
|
|
127
|
+
"""List every configured account (TELEGRAM_SESSION_STRING* entries)."""
|
|
128
|
+
rows = []
|
|
129
|
+
for key in os.environ:
|
|
130
|
+
m = _SESSION_ENV_RE.fullmatch(key)
|
|
131
|
+
if m and os.environ[key].strip():
|
|
132
|
+
label = m.group(1).lower() if m.group(1) else "default"
|
|
133
|
+
rows.append((label, key))
|
|
134
|
+
rows.sort(key=lambda r: (r[0] != "default", r[0]))
|
|
135
|
+
if not rows:
|
|
136
|
+
print("(no accounts configured — run: tg login)")
|
|
137
|
+
return
|
|
138
|
+
print(f"{'account':<16} env variable")
|
|
139
|
+
for label, key in rows:
|
|
140
|
+
note = " (default)" if label == "default" else ""
|
|
141
|
+
print(f"{label:<16} {key}{note}")
|
|
142
|
+
print("\nuse with: tg <command> --account <label> | add one: tg login --account <label>")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Chat / search / contacts / profile commands."""
|
|
1
|
+
"""Chat / search / contacts / profile / privacy / block commands."""
|
|
2
2
|
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
|
|
@@ -8,6 +8,21 @@ from .client import run_with_client
|
|
|
8
8
|
from .output import TgError, fmt_message_row, json_pp
|
|
9
9
|
from .resolve import resolve_chat
|
|
10
10
|
|
|
11
|
+
# friendly names for the privacy keys (value -> InputPrivacyKey class name)
|
|
12
|
+
_PRIVACY_KEYS = {
|
|
13
|
+
"lastseen": "InputPrivacyKeyStatusTimestamp",
|
|
14
|
+
"phone": "InputPrivacyKeyPhoneNumber",
|
|
15
|
+
"calls": "InputPrivacyKeyPhoneCall",
|
|
16
|
+
"p2p": "InputPrivacyKeyPhoneP2P",
|
|
17
|
+
"groups": "InputPrivacyKeyChatInvite",
|
|
18
|
+
"photo": "InputPrivacyKeyProfilePhoto",
|
|
19
|
+
"forwards": "InputPrivacyKeyForwards",
|
|
20
|
+
"voicemail": "InputPrivacyKeyVoiceMessages",
|
|
21
|
+
"about": "InputPrivacyKeyAbout",
|
|
22
|
+
"birthday": "InputPrivacyKeyBirthday",
|
|
23
|
+
"addedby": "InputPrivacyKeyAddedByPhone",
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
|
|
12
27
|
async def cmd_me(args) -> None:
|
|
13
28
|
async with run_with_client(args) as client:
|
|
@@ -186,6 +201,171 @@ async def cmd_profile(args) -> None:
|
|
|
186
201
|
print(json_pp(me.to_dict() if me else {}))
|
|
187
202
|
|
|
188
203
|
|
|
204
|
+
async def cmd_photo_del(args) -> None:
|
|
205
|
+
async with run_with_client(args) as client:
|
|
206
|
+
photos = await client.get_profile_photos("me")
|
|
207
|
+
if not photos:
|
|
208
|
+
print("(no profile photos)")
|
|
209
|
+
return
|
|
210
|
+
if not args.all:
|
|
211
|
+
photos = photos[:1] # just the most recent one
|
|
212
|
+
await client(
|
|
213
|
+
functions.photos.DeletePhotosRequest(
|
|
214
|
+
id=[
|
|
215
|
+
types.InputPhoto(
|
|
216
|
+
id=p.id, access_hash=p.access_hash, file_reference=p.file_reference
|
|
217
|
+
)
|
|
218
|
+
for p in photos
|
|
219
|
+
]
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
print(f"Deleted {len(photos)} profile photo(s)")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _privacy_key(name: str) -> types.TypeInputPrivacyKey:
|
|
226
|
+
cls_name = _PRIVACY_KEYS.get(name.lower())
|
|
227
|
+
if not cls_name:
|
|
228
|
+
raise TgError(
|
|
229
|
+
f"unknown privacy key: {name!r}",
|
|
230
|
+
f"choose from: {', '.join(sorted(_PRIVACY_KEYS))}",
|
|
231
|
+
)
|
|
232
|
+
return getattr(types, cls_name)()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _privacy_rule_text(rule, users: dict) -> str:
|
|
236
|
+
kind = type(rule).__name__.removeprefix("PrivacyValue")
|
|
237
|
+
if kind == "AllowAll":
|
|
238
|
+
return "everyone"
|
|
239
|
+
if kind == "AllowContacts":
|
|
240
|
+
return "contacts"
|
|
241
|
+
if kind == "AllowCloseFriends":
|
|
242
|
+
return "close friends"
|
|
243
|
+
if kind == "AllowBots":
|
|
244
|
+
return "bots"
|
|
245
|
+
if kind == "AllowPremium":
|
|
246
|
+
return "premium"
|
|
247
|
+
if kind == "AllowChatParticipants":
|
|
248
|
+
return "members of " + ", ".join(_display_chat(c, users) for c in rule.chats)
|
|
249
|
+
if kind == "AllowUsers":
|
|
250
|
+
return "+" + ", ".join(_display_chat(users.get(u), users) or str(u) for u in rule.users)
|
|
251
|
+
if kind == "DisallowAll":
|
|
252
|
+
return "nobody"
|
|
253
|
+
if kind == "DisallowContacts":
|
|
254
|
+
return "no contacts"
|
|
255
|
+
if kind == "DisallowUsers":
|
|
256
|
+
return "-" + ", ".join(_display_chat(users.get(u), users) or str(u) for u in rule.users)
|
|
257
|
+
return kind
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _display_chat(chat, users: dict) -> str:
|
|
261
|
+
if chat is None:
|
|
262
|
+
return "?"
|
|
263
|
+
title = getattr(chat, "title", None)
|
|
264
|
+
if title:
|
|
265
|
+
return title
|
|
266
|
+
name = " ".join(
|
|
267
|
+
x for x in (getattr(chat, "first_name", None), getattr(chat, "last_name", None)) if x
|
|
268
|
+
)
|
|
269
|
+
return name or "?"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def cmd_privacy_get(args) -> None:
|
|
273
|
+
keys = args.key or sorted(_PRIVACY_KEYS)
|
|
274
|
+
async with run_with_client(args) as client:
|
|
275
|
+
rows = []
|
|
276
|
+
for name in keys:
|
|
277
|
+
res = await client(functions.account.GetPrivacyRequest(key=_privacy_key(name)))
|
|
278
|
+
users = {u.id: u for u in getattr(res, "users", []) or []}
|
|
279
|
+
rules = " + ".join(_privacy_rule_text(r, users) for r in res.rules) or "(unset)"
|
|
280
|
+
rows.append((name, rules))
|
|
281
|
+
if args.json:
|
|
282
|
+
print(json_pp([{"key": k, "rules": v} for k, v in rows]))
|
|
283
|
+
return
|
|
284
|
+
for name, rules in rows:
|
|
285
|
+
print(f"{name:<10} {rules}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _input_user(client, peer) -> types.TypeInputUser:
|
|
289
|
+
if isinstance(peer, types.InputPeerUser):
|
|
290
|
+
return types.InputUser(user_id=peer.user_id, access_hash=peer.access_hash)
|
|
291
|
+
if isinstance(peer, types.InputPeerSelf):
|
|
292
|
+
return types.InputUserSelf()
|
|
293
|
+
raise TgError("only users can appear in privacy rules")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def cmd_privacy_set(args) -> None:
|
|
297
|
+
base = args.value.lower()
|
|
298
|
+
base_map = {"all": "everyone", "contacts": "contacts", "nobody": "nobody"}
|
|
299
|
+
if base not in base_map:
|
|
300
|
+
raise TgError(
|
|
301
|
+
f"unknown privacy value: {args.value!r}",
|
|
302
|
+
"use all / contacts / nobody, with optional --allow USER / --deny USER",
|
|
303
|
+
)
|
|
304
|
+
async with run_with_client(args) as client:
|
|
305
|
+
# rules need Input* rule classes and InputUser entries
|
|
306
|
+
resolved_allow = [
|
|
307
|
+
_input_user(client, await client.get_input_entity(await resolve_chat(client, u)))
|
|
308
|
+
for u in args.allow
|
|
309
|
+
]
|
|
310
|
+
resolved_deny = [
|
|
311
|
+
_input_user(client, await client.get_input_entity(await resolve_chat(client, u)))
|
|
312
|
+
for u in args.deny
|
|
313
|
+
]
|
|
314
|
+
rules: list = []
|
|
315
|
+
if base == "all":
|
|
316
|
+
rules.append(types.InputPrivacyValueAllowAll())
|
|
317
|
+
elif base == "contacts":
|
|
318
|
+
rules.append(types.InputPrivacyValueAllowContacts())
|
|
319
|
+
else:
|
|
320
|
+
rules.append(types.InputPrivacyValueDisallowAll())
|
|
321
|
+
if resolved_allow:
|
|
322
|
+
rules.append(types.InputPrivacyValueAllowUsers(users=resolved_allow))
|
|
323
|
+
if resolved_deny:
|
|
324
|
+
rules.append(types.InputPrivacyValueDisallowUsers(users=resolved_deny))
|
|
325
|
+
await client(functions.account.SetPrivacyRequest(key=_privacy_key(args.key), rules=rules))
|
|
326
|
+
desc = base_map[base]
|
|
327
|
+
if resolved_allow:
|
|
328
|
+
desc += ", allow " + ", ".join(args.allow)
|
|
329
|
+
if resolved_deny:
|
|
330
|
+
desc += ", deny " + ", ".join(args.deny)
|
|
331
|
+
print(f"{args.key} set to {desc}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def cmd_block(args) -> None:
|
|
335
|
+
async with run_with_client(args) as client:
|
|
336
|
+
entity = await resolve_chat(client, args.user)
|
|
337
|
+
await client(functions.contacts.BlockRequest(id=entity))
|
|
338
|
+
print(f"Blocked {args.user}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def cmd_unblock(args) -> None:
|
|
342
|
+
async with run_with_client(args) as client:
|
|
343
|
+
entity = await resolve_chat(client, args.user)
|
|
344
|
+
await client(functions.contacts.UnblockRequest(id=entity))
|
|
345
|
+
print(f"Unblocked {args.user}")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
async def cmd_blocked(args) -> None:
|
|
349
|
+
async with run_with_client(args) as client:
|
|
350
|
+
res = await client(functions.contacts.GetBlockedRequest(offset=0, limit=100))
|
|
351
|
+
users = {u.id: u for u in getattr(res, "users", []) or []}
|
|
352
|
+
rows = []
|
|
353
|
+
for pb in getattr(res, "blocked", []) or []:
|
|
354
|
+
pid = pb.peer_id.user_id if isinstance(pb.peer_id, types.PeerUser) else pb.peer_id
|
|
355
|
+
rows.append((pid, getattr(pb, "date", None)))
|
|
356
|
+
if args.json:
|
|
357
|
+
print(json_pp([p.to_dict() for p in getattr(res, "blocked", []) or []]))
|
|
358
|
+
return
|
|
359
|
+
if not rows:
|
|
360
|
+
print("(nobody is blocked)")
|
|
361
|
+
return
|
|
362
|
+
for pid, date in rows:
|
|
363
|
+
u = users.get(pid)
|
|
364
|
+
name = _display_chat(u, users) if u else str(pid)
|
|
365
|
+
when = date.astimezone().strftime("%Y-%m-%d") if date else ""
|
|
366
|
+
print(f"{pid:>14} {name} {when}")
|
|
367
|
+
|
|
368
|
+
|
|
189
369
|
def setup(subparsers, common=None) -> None:
|
|
190
370
|
parents = [common] if common else []
|
|
191
371
|
sp = subparsers.add_parser("me", parents=parents, help="Show your account info")
|
|
@@ -233,3 +413,34 @@ def setup(subparsers, common=None) -> None:
|
|
|
233
413
|
sp.add_argument("--bio", default=None)
|
|
234
414
|
sp.add_argument("--photo", default=None, help="path to an image")
|
|
235
415
|
sp.set_defaults(func=cmd_profile)
|
|
416
|
+
|
|
417
|
+
sp = subparsers.add_parser(
|
|
418
|
+
"photo-del", parents=parents, help="Delete your most recent profile photo (--all for all)"
|
|
419
|
+
)
|
|
420
|
+
sp.add_argument("--all", action="store_true", help="delete every profile photo")
|
|
421
|
+
sp.set_defaults(func=cmd_photo_del)
|
|
422
|
+
|
|
423
|
+
sp = subparsers.add_parser("privacy", parents=parents, help="Show privacy settings")
|
|
424
|
+
sub = sp.add_subparsers(dest="privacy_cmd", required=True)
|
|
425
|
+
g = sub.add_parser("get", help="privacy get [key...] (default: all)")
|
|
426
|
+
g.add_argument("key", nargs="*", default=[])
|
|
427
|
+
g.set_defaults(func=cmd_privacy_get)
|
|
428
|
+
s = sub.add_parser(
|
|
429
|
+
"set", help="privacy set KEY all|contacts|nobody [--allow USER] [--deny USER]"
|
|
430
|
+
)
|
|
431
|
+
s.add_argument("key", help=f"one of: {', '.join(sorted(_PRIVACY_KEYS))}")
|
|
432
|
+
s.add_argument("value")
|
|
433
|
+
s.add_argument("-a", "--allow", action="append", default=[], metavar="USER")
|
|
434
|
+
s.add_argument("-d", "--deny", action="append", default=[], metavar="USER")
|
|
435
|
+
s.set_defaults(func=cmd_privacy_set)
|
|
436
|
+
|
|
437
|
+
sp = subparsers.add_parser("block", parents=parents, help="Block a user")
|
|
438
|
+
sp.add_argument("user")
|
|
439
|
+
sp.set_defaults(func=cmd_block)
|
|
440
|
+
|
|
441
|
+
sp = subparsers.add_parser("unblock", parents=parents, help="Unblock a user")
|
|
442
|
+
sp.add_argument("user")
|
|
443
|
+
sp.set_defaults(func=cmd_unblock)
|
|
444
|
+
|
|
445
|
+
sp = subparsers.add_parser("blocked", parents=parents, help="List blocked users")
|
|
446
|
+
sp.set_defaults(func=cmd_blocked)
|
|
@@ -6,7 +6,7 @@ import sys
|
|
|
6
6
|
|
|
7
7
|
from telethon import errors
|
|
8
8
|
|
|
9
|
-
from . import __version__, auth, chats, dialogs, groups, media, messages, resolve
|
|
9
|
+
from . import __version__, auth, chats, contacts, dialogs, groups, media, messages, resolve
|
|
10
10
|
from .output import TgError
|
|
11
11
|
|
|
12
12
|
|
|
@@ -41,7 +41,10 @@ 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
|
-
|
|
44
|
+
sp3 = sub.add_parser("accounts", parents=[common], help="List configured accounts")
|
|
45
|
+
sp3.set_defaults(func=auth.cmd_accounts)
|
|
46
|
+
|
|
47
|
+
for mod in (messages, chats, media, dialogs, groups, contacts):
|
|
45
48
|
mod.setup(sub, common)
|
|
46
49
|
|
|
47
50
|
resolve.setup_alias(sub, common)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Contact management: add, delete, bulk import/export."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from telethon import functions, types
|
|
8
|
+
|
|
9
|
+
from .client import run_with_client
|
|
10
|
+
from .output import TgError, json_pp
|
|
11
|
+
from .resolve import resolve_chat
|
|
12
|
+
|
|
13
|
+
_FIELDS = ("phone", "first_name", "last_name")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _row_from(entry: dict) -> dict:
|
|
17
|
+
row = {}
|
|
18
|
+
for key in _FIELDS:
|
|
19
|
+
for alias in (key, key.replace("_", ""), {"phone": "tel"}.get(key, key)):
|
|
20
|
+
if alias in entry and str(entry[alias]).strip():
|
|
21
|
+
row[key] = str(entry[alias]).strip()
|
|
22
|
+
break
|
|
23
|
+
else:
|
|
24
|
+
row[key] = ""
|
|
25
|
+
if not row["first_name"]:
|
|
26
|
+
raise TgError(f"contact entry needs at least a first_name: {entry}")
|
|
27
|
+
return row
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_contacts_file(path: str) -> list[dict]:
|
|
31
|
+
p = Path(path).expanduser()
|
|
32
|
+
if not p.exists():
|
|
33
|
+
raise TgError(f"file not found: {p}")
|
|
34
|
+
rows: list[dict] = []
|
|
35
|
+
if p.suffix.lower() == ".csv":
|
|
36
|
+
with p.open(newline="", encoding="utf-8-sig") as fh:
|
|
37
|
+
for i, raw in enumerate(csv.DictReader(fh), start=2):
|
|
38
|
+
entry = {k.strip().lower(): v for k, v in raw.items() if k}
|
|
39
|
+
try:
|
|
40
|
+
rows.append(_row_from(entry))
|
|
41
|
+
except TgError as e:
|
|
42
|
+
raise TgError(f"{p.name} line {i}: {e}") from None
|
|
43
|
+
else:
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
46
|
+
except json.JSONDecodeError as e:
|
|
47
|
+
raise TgError(
|
|
48
|
+
f"{p} is not valid JSON: {e}", "expected a list of contact objects"
|
|
49
|
+
) from None
|
|
50
|
+
if not isinstance(data, list):
|
|
51
|
+
raise TgError(f"{p} must contain a JSON list of contact objects")
|
|
52
|
+
for i, entry in enumerate(data, start=1):
|
|
53
|
+
if not isinstance(entry, dict):
|
|
54
|
+
raise TgError(f"{p} entry {i} is not an object")
|
|
55
|
+
rows.append(_row_from(entry))
|
|
56
|
+
if not rows:
|
|
57
|
+
raise TgError(f"no contacts found in {p}")
|
|
58
|
+
return rows
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def cmd_contact_add(args) -> None:
|
|
62
|
+
first, _, last = args.name.partition(" ")
|
|
63
|
+
async with run_with_client(args) as client:
|
|
64
|
+
entity = await resolve_chat(client, args.user)
|
|
65
|
+
res = await client(
|
|
66
|
+
functions.contacts.AddContactRequest(
|
|
67
|
+
id=entity,
|
|
68
|
+
first_name=first,
|
|
69
|
+
last_name=last,
|
|
70
|
+
phone=args.phone or "",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
status = getattr(res, "status", None)
|
|
74
|
+
print(f"Contact added: {args.name!r} ({args.user})" + (f" [{status}]" if status else ""))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _resolve_contact_refs(client, users: list[str]) -> list:
|
|
78
|
+
"""Resolve users by name/@username/id, or by phone when prefixed with +."""
|
|
79
|
+
res = await client(functions.contacts.GetContactsRequest(hash=0))
|
|
80
|
+
by_phone = {}
|
|
81
|
+
for u in getattr(res, "users", []) or []:
|
|
82
|
+
phone = getattr(u, "phone", None)
|
|
83
|
+
if phone:
|
|
84
|
+
by_phone["+" + phone.lstrip("+")] = u
|
|
85
|
+
refs = []
|
|
86
|
+
for spec in users:
|
|
87
|
+
if spec.startswith("+"):
|
|
88
|
+
u = by_phone.get(spec)
|
|
89
|
+
if u is None:
|
|
90
|
+
raise TgError(f"no contact with phone {spec}")
|
|
91
|
+
refs.append(u)
|
|
92
|
+
else:
|
|
93
|
+
refs.append(await resolve_chat(client, spec))
|
|
94
|
+
return refs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def cmd_contact_del(args) -> None:
|
|
98
|
+
async with run_with_client(args) as client:
|
|
99
|
+
refs = await _resolve_contact_refs(client, args.users)
|
|
100
|
+
await client(functions.contacts.DeleteContactsRequest(id=refs))
|
|
101
|
+
print(f"Deleted {len(refs)} contact(s)")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def cmd_contact_import(args) -> None:
|
|
105
|
+
rows = _load_contacts_file(args.file)
|
|
106
|
+
contacts = [
|
|
107
|
+
types.InputPhoneContact(
|
|
108
|
+
client_id=i,
|
|
109
|
+
phone=r["phone"] or "+10000000000",
|
|
110
|
+
first_name=r["first_name"],
|
|
111
|
+
last_name=r["last_name"],
|
|
112
|
+
)
|
|
113
|
+
for i, r in enumerate(rows)
|
|
114
|
+
]
|
|
115
|
+
async with run_with_client(args) as client:
|
|
116
|
+
res = await client(functions.contacts.ImportContactsRequest(contacts=contacts))
|
|
117
|
+
imported = len(getattr(res, "imported", []) or [])
|
|
118
|
+
retry = len(getattr(res, "retry_contacts", []) or [])
|
|
119
|
+
print(
|
|
120
|
+
f"Imported {imported} new contact(s) from {args.file}"
|
|
121
|
+
+ (f"; {retry} could not be matched" if retry else "")
|
|
122
|
+
)
|
|
123
|
+
if args.json:
|
|
124
|
+
print(json_pp([u.to_dict() for u in getattr(res, "users", []) or []]))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def cmd_contact_export(args) -> None:
|
|
128
|
+
async with run_with_client(args) as client:
|
|
129
|
+
res = await client(functions.contacts.GetContactsRequest(hash=0))
|
|
130
|
+
out = []
|
|
131
|
+
for u in getattr(res, "users", []) or []:
|
|
132
|
+
if not getattr(u, "contact", False):
|
|
133
|
+
continue # GetContacts may return non-contact users; keep true contacts
|
|
134
|
+
out.append(
|
|
135
|
+
{
|
|
136
|
+
"id": u.id,
|
|
137
|
+
"phone": f"+{u.phone}" if getattr(u, "phone", None) else "",
|
|
138
|
+
"first_name": u.first_name or "",
|
|
139
|
+
"last_name": u.last_name or "",
|
|
140
|
+
"username": getattr(u, "username", None) or "",
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
payload = json.dumps(out, ensure_ascii=False, indent=2) + "\n"
|
|
144
|
+
if args.file:
|
|
145
|
+
path = Path(args.file).expanduser()
|
|
146
|
+
path.write_text(payload, encoding="utf-8")
|
|
147
|
+
print(f"Exported {len(out)} contact(s) to {path}")
|
|
148
|
+
else:
|
|
149
|
+
print(payload, end="")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def setup(subparsers, common=None) -> None:
|
|
153
|
+
parents = [common] if common else []
|
|
154
|
+
sp = subparsers.add_parser("contact", parents=parents, help="Manage contacts")
|
|
155
|
+
sub = sp.add_subparsers(dest="contact_cmd", required=True)
|
|
156
|
+
|
|
157
|
+
s1 = sub.add_parser("add", help="contact add USER [NAME] [--phone +86...]")
|
|
158
|
+
s1.add_argument("user")
|
|
159
|
+
s1.add_argument("name", nargs="?", default=None, help='display name, e.g. "John Doe"')
|
|
160
|
+
s1.add_argument("--phone", default="")
|
|
161
|
+
s1.set_defaults(func=cmd_contact_add)
|
|
162
|
+
|
|
163
|
+
s2 = sub.add_parser("del", help="contact del USER...")
|
|
164
|
+
s2.add_argument("users", nargs="+")
|
|
165
|
+
s2.set_defaults(func=cmd_contact_del)
|
|
166
|
+
|
|
167
|
+
s3 = sub.add_parser("import", help="contact import FILE (.json or .csv)")
|
|
168
|
+
s3.add_argument("file")
|
|
169
|
+
s3.set_defaults(func=cmd_contact_import)
|
|
170
|
+
|
|
171
|
+
s4 = sub.add_parser("export", help="contact export [FILE] (JSON, re-importable)")
|
|
172
|
+
s4.add_argument("file", nargs="?", default=None)
|
|
173
|
+
s4.set_defaults(func=cmd_contact_export)
|
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
import time
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
|
|
8
|
-
from telethon import functions, types
|
|
8
|
+
from telethon import functions, types, utils
|
|
9
9
|
|
|
10
10
|
from .client import run_with_client
|
|
11
11
|
from .output import TgError, fmt_date, json_pp
|
|
@@ -201,6 +201,93 @@ async def cmd_common_chats(args) -> None:
|
|
|
201
201
|
print(f"{c.id:>14} {title} {'@' + uname if uname else ''}")
|
|
202
202
|
|
|
203
203
|
|
|
204
|
+
# ---- dialog folders ----
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _filter_title(f) -> str:
|
|
208
|
+
t = getattr(f, "title", None)
|
|
209
|
+
return getattr(t, "text", None) or str(t or "?")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _get_filters(client) -> list:
|
|
213
|
+
res = await client(functions.messages.GetDialogFiltersRequest())
|
|
214
|
+
return [f for f in (getattr(res, "filters", None) or []) if isinstance(f, types.DialogFilter)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def _find_filter(client, spec: str):
|
|
218
|
+
for f in await _get_filters(client):
|
|
219
|
+
if str(f.id) == spec or _filter_title(f).lower() == spec.lower():
|
|
220
|
+
return f
|
|
221
|
+
raise TgError(f"folder not found: {spec!r}", "list folders with: tg folders")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def cmd_folders(args) -> None:
|
|
225
|
+
async with run_with_client(args) as client:
|
|
226
|
+
filters = await _get_filters(client)
|
|
227
|
+
if args.json:
|
|
228
|
+
print(json_pp([f.to_dict() for f in filters]))
|
|
229
|
+
return
|
|
230
|
+
if not filters:
|
|
231
|
+
print("(no folders — create one with: tg folder-create <name> --chat <chat>)")
|
|
232
|
+
return
|
|
233
|
+
for f in filters:
|
|
234
|
+
print(f"[{f.id:>2}] {_filter_title(f)} ({len(f.include_peers)} chats)")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def cmd_folder_create(args) -> None:
|
|
238
|
+
if not args.chat:
|
|
239
|
+
raise TgError("pass at least one chat: --chat <chat>")
|
|
240
|
+
async with run_with_client(args) as client:
|
|
241
|
+
existing = await _get_filters(client)
|
|
242
|
+
new_id = max((f.id for f in existing), default=1) + 1
|
|
243
|
+
peers = [await _input_peer(client, c) for c in args.chat]
|
|
244
|
+
filt = types.DialogFilter(
|
|
245
|
+
id=new_id,
|
|
246
|
+
title=types.TextWithEntities(text=args.name, entities=[]),
|
|
247
|
+
pinned_peers=[],
|
|
248
|
+
include_peers=peers,
|
|
249
|
+
exclude_peers=[],
|
|
250
|
+
)
|
|
251
|
+
await client(functions.messages.UpdateDialogFilterRequest(id=new_id, filter=filt))
|
|
252
|
+
print(f"Folder created: [{new_id}] {args.name} ({len(peers)} chats)")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def cmd_folder_assign(args) -> None:
|
|
256
|
+
async with run_with_client(args) as client:
|
|
257
|
+
filt = await _find_filter(client, args.folder)
|
|
258
|
+
known = {utils.get_peer_id(p) for p in filt.include_peers}
|
|
259
|
+
added = 0
|
|
260
|
+
for c in args.chat:
|
|
261
|
+
peer = await _input_peer(client, c)
|
|
262
|
+
if utils.get_peer_id(peer) not in known:
|
|
263
|
+
filt.include_peers.append(peer)
|
|
264
|
+
added += 1
|
|
265
|
+
await client(functions.messages.UpdateDialogFilterRequest(id=filt.id, filter=filt))
|
|
266
|
+
print(f"Added {added} chat(s) to folder [{filt.id}] {_filter_title(filt)}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
async def cmd_folder_del(args) -> None:
|
|
270
|
+
async with run_with_client(args) as client:
|
|
271
|
+
filt = await _find_filter(client, args.folder)
|
|
272
|
+
# no filter object with the id = delete the folder
|
|
273
|
+
await client(functions.messages.UpdateDialogFilterRequest(id=filt.id))
|
|
274
|
+
print(f"Folder deleted: [{filt.id}] {_filter_title(filt)}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
async def cmd_folder_order(args) -> None:
|
|
278
|
+
async with run_with_client(args) as client:
|
|
279
|
+
ids = []
|
|
280
|
+
for spec in args.folder:
|
|
281
|
+
filt = await _find_filter(client, spec)
|
|
282
|
+
if filt.id in ids:
|
|
283
|
+
raise TgError(f"folder {spec!r} listed twice")
|
|
284
|
+
ids.append(filt.id)
|
|
285
|
+
all_ids = [f.id for f in await _get_filters(client)]
|
|
286
|
+
missing = [i for i in all_ids if i not in ids]
|
|
287
|
+
await client(functions.messages.UpdateDialogFiltersOrderRequest(order=ids + missing))
|
|
288
|
+
print("Folder order updated: " + " → ".join(str(i) for i in ids))
|
|
289
|
+
|
|
290
|
+
|
|
204
291
|
def setup(subparsers, common=None) -> None:
|
|
205
292
|
parents = [common] if common else []
|
|
206
293
|
sp = subparsers.add_parser("archive", parents=parents, help="Archive a chat")
|
|
@@ -267,3 +354,32 @@ def setup(subparsers, common=None) -> None:
|
|
|
267
354
|
)
|
|
268
355
|
sp.add_argument("user")
|
|
269
356
|
sp.set_defaults(func=cmd_common_chats)
|
|
357
|
+
|
|
358
|
+
sp = subparsers.add_parser("folders", parents=parents, help="List dialog folders")
|
|
359
|
+
sp.set_defaults(func=cmd_folders)
|
|
360
|
+
|
|
361
|
+
sp = subparsers.add_parser(
|
|
362
|
+
"folder-create",
|
|
363
|
+
parents=parents,
|
|
364
|
+
help='Create a folder, e.g. tg folder-create Work --chat "@boss"',
|
|
365
|
+
)
|
|
366
|
+
sp.add_argument("name")
|
|
367
|
+
sp.add_argument("--chat", action="append", default=[], help="chat to include (repeatable)")
|
|
368
|
+
sp.set_defaults(func=cmd_folder_create)
|
|
369
|
+
|
|
370
|
+
sp = subparsers.add_parser(
|
|
371
|
+
"folder-assign", parents=parents, help="Add chats to an existing folder"
|
|
372
|
+
)
|
|
373
|
+
sp.add_argument("folder", help="folder name or id")
|
|
374
|
+
sp.add_argument("chat", nargs="+")
|
|
375
|
+
sp.set_defaults(func=cmd_folder_assign)
|
|
376
|
+
|
|
377
|
+
sp = subparsers.add_parser("folder-del", parents=parents, help="Delete a folder")
|
|
378
|
+
sp.add_argument("folder", help="folder name or id")
|
|
379
|
+
sp.set_defaults(func=cmd_folder_del)
|
|
380
|
+
|
|
381
|
+
sp = subparsers.add_parser(
|
|
382
|
+
"folder-order", parents=parents, help="Reorder folders (pass all of them in order)"
|
|
383
|
+
)
|
|
384
|
+
sp.add_argument("folder", nargs="+", help="folder names/ids in the wanted order")
|
|
385
|
+
sp.set_defaults(func=cmd_folder_order)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
"""Media commands: dl (download), sf (send file/album), voice, sticker, gif."""
|
|
1
|
+
"""Media commands: dl (download), sf (send file/album), voice, sticker, gif, vtr."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
5
6
|
|
|
6
|
-
from telethon import functions
|
|
7
|
+
from telethon import functions, types
|
|
7
8
|
|
|
8
9
|
from .client import run_with_client
|
|
9
10
|
from .output import TgError
|
|
@@ -92,6 +93,36 @@ async def cmd_gif(args) -> None:
|
|
|
92
93
|
print(f"GIF sent (match {idx + 1} of {len(docs)} for {args.query!r})")
|
|
93
94
|
|
|
94
95
|
|
|
96
|
+
async def cmd_vtr(args) -> None:
|
|
97
|
+
async with run_with_client(args) as client:
|
|
98
|
+
entity = await resolve_chat(client, args.chat)
|
|
99
|
+
msg = await client.get_messages(entity, ids=args.msg_id)
|
|
100
|
+
doc = getattr(getattr(msg, "media", None), "document", None)
|
|
101
|
+
is_voice = any(
|
|
102
|
+
isinstance(a, types.DocumentAttributeAudio) and a.voice
|
|
103
|
+
for a in getattr(doc, "attributes", [])
|
|
104
|
+
)
|
|
105
|
+
if not is_voice:
|
|
106
|
+
raise TgError(
|
|
107
|
+
f"message {args.msg_id} has no voice note",
|
|
108
|
+
hint=f"check with: tg hist {args.chat} 5",
|
|
109
|
+
)
|
|
110
|
+
text, pending = "", True
|
|
111
|
+
for _ in range(10):
|
|
112
|
+
res = await client(
|
|
113
|
+
functions.messages.TranscribeAudioRequest(peer=entity, msg_id=args.msg_id)
|
|
114
|
+
)
|
|
115
|
+
text, pending = res.text or "", bool(res.pending)
|
|
116
|
+
if not pending:
|
|
117
|
+
break
|
|
118
|
+
await asyncio.sleep(1.5)
|
|
119
|
+
if pending:
|
|
120
|
+
raise TgError("transcription is still processing; try again in a moment")
|
|
121
|
+
if not text:
|
|
122
|
+
raise TgError("no transcription returned (Telegram Premium required for this feature)")
|
|
123
|
+
print(text)
|
|
124
|
+
|
|
125
|
+
|
|
95
126
|
def setup(subparsers, common=None) -> None:
|
|
96
127
|
parents = [common] if common else []
|
|
97
128
|
sp = subparsers.add_parser(
|
|
@@ -132,3 +163,10 @@ def setup(subparsers, common=None) -> None:
|
|
|
132
163
|
sp.add_argument("query")
|
|
133
164
|
sp.add_argument("-n", "--index", type=int, default=1, help="send the Nth match (default 1)")
|
|
134
165
|
sp.set_defaults(func=cmd_gif)
|
|
166
|
+
|
|
167
|
+
sp = subparsers.add_parser(
|
|
168
|
+
"vtr", parents=parents, help="Transcribe a voice note (Telegram Premium)"
|
|
169
|
+
)
|
|
170
|
+
sp.add_argument("chat")
|
|
171
|
+
sp.add_argument("msg_id", type=int)
|
|
172
|
+
sp.set_defaults(func=cmd_vtr)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|