@acedatacloud/skills 2026.703.16 → 2026.704.0
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.
- package/package.json +1 -1
- package/skills/telegram/SKILL.md +34 -230
- package/skills/telegram/scripts/tg.py +270 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.704.0",
|
|
4
4
|
"description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-skills",
|
package/skills/telegram/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ allowed_tools: [Bash]
|
|
|
13
13
|
license: Apache-2.0
|
|
14
14
|
metadata:
|
|
15
15
|
author: acedatacloud
|
|
16
|
-
version: "1.
|
|
16
|
+
version: "1.2"
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
We drive **personal** Telegram over MTProto with [Telethon](https://docs.telethon.dev/) —
|
|
@@ -27,227 +27,26 @@ Credentials are injected as env vars by the connector:
|
|
|
27
27
|
- `TELEGRAM_SESSION_STRING` — Telethon `StringSession` = **full account access. Never log,
|
|
28
28
|
echo, or print it.** Treat it like the account password.
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## CLI
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
The skill ships [`scripts/tg.py`](scripts/tg.py) — self-contained (the only third-party dep is
|
|
33
|
+
`telethon`, preinstalled in the sandbox). Point a var at the shipped path and call it; no heredoc
|
|
34
|
+
to re-create per turn, so a multi-step flow (dry-run → confirm) can't lose the helper between calls:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
TG="$SKILL_DIR/scripts/tg.py"
|
|
38
|
+
python3 "$TG" whoami
|
|
39
|
+
```
|
|
35
40
|
|
|
36
41
|
Every state-changing command (`send`, `reply`, `send-file`, `forward`, `edit`, `delete`,
|
|
37
42
|
`react`, `mark-read`) is **gated**: without a trailing `--confirm` it only DRY-RUNS (prints what
|
|
38
43
|
it would do, changes nothing). Read commands run directly. `--confirm` is honored **only as the
|
|
39
44
|
last argument** so a message/caption that merely contains "--confirm" can never silently confirm.
|
|
40
45
|
|
|
41
|
-
```sh
|
|
42
|
-
python3 -c "import telethon" 2>/dev/null || pip install --user --quiet telethon 2>/dev/null || true
|
|
43
|
-
|
|
44
|
-
cat > ./tg.py <<'PY'
|
|
45
|
-
import os, sys, json, asyncio
|
|
46
|
-
from telethon import TelegramClient
|
|
47
|
-
from telethon.sessions import StringSession
|
|
48
|
-
from telethon.tl import functions
|
|
49
|
-
from telethon.tl.types import ReactionEmoji
|
|
50
|
-
|
|
51
|
-
API_ID = int(os.environ["TELEGRAM_API_ID"])
|
|
52
|
-
API_HASH = os.environ["TELEGRAM_API_HASH"]
|
|
53
|
-
SESSION = os.environ["TELEGRAM_SESSION_STRING"]
|
|
54
|
-
|
|
55
|
-
_raw = sys.argv[1:]
|
|
56
|
-
# --confirm is only honored as the LAST token, and only one is stripped, so a
|
|
57
|
-
# message/caption that merely contains "--confirm" cannot silently confirm a write.
|
|
58
|
-
CONFIRM = bool(_raw) and _raw[-1] == "--confirm"
|
|
59
|
-
a = _raw[:-1] if CONFIRM else list(_raw)
|
|
60
|
-
cmd = a[0] if a else "help"
|
|
61
|
-
args = a[1:]
|
|
62
|
-
GATED = {"send", "reply", "send-file", "forward", "edit", "delete", "react", "mark-read"}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def out(o):
|
|
66
|
-
print(json.dumps(o, ensure_ascii=False, default=str))
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
async def resolve(client, target):
|
|
70
|
-
# 1) try direct resolve; 2) fall back to scanning dialogs by id or exact name
|
|
71
|
-
# (StringSession doesn't persist the entity cache, so a numeric id from a
|
|
72
|
-
# previous invocation may need the dialog scan to recover its access hash).
|
|
73
|
-
for attempt in (lambda: client.get_entity(int(target)), lambda: client.get_entity(target)):
|
|
74
|
-
try:
|
|
75
|
-
return await attempt()
|
|
76
|
-
except Exception:
|
|
77
|
-
pass
|
|
78
|
-
ti = None
|
|
79
|
-
try:
|
|
80
|
-
ti = int(target)
|
|
81
|
-
except (ValueError, TypeError):
|
|
82
|
-
pass
|
|
83
|
-
async for d in client.iter_dialogs():
|
|
84
|
-
if (ti is not None and d.id == ti) or d.name == target:
|
|
85
|
-
return d.entity
|
|
86
|
-
raise ValueError(f"could not resolve target: {target}")
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def msg_row(m):
|
|
90
|
-
return {"id": m.id, "date": str(m.date), "out": m.out, "sender_id": m.sender_id,
|
|
91
|
-
"text": m.message, "media": bool(m.media)}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def need(n):
|
|
95
|
-
if len(args) < n:
|
|
96
|
-
raise ValueError(f"{cmd} needs {n} argument(s), got {len(args)}")
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
async def run():
|
|
100
|
-
if cmd in GATED and not CONFIRM:
|
|
101
|
-
out({"dry_run": True, "command": cmd, "args": args,
|
|
102
|
-
"note": "re-run with --confirm as the LAST argument to actually perform this write"})
|
|
103
|
-
return
|
|
104
|
-
async with TelegramClient(StringSession(SESSION), API_ID, API_HASH) as cl:
|
|
105
|
-
if cmd == "whoami":
|
|
106
|
-
me = await cl.get_me()
|
|
107
|
-
out({"id": me.id, "username": me.username,
|
|
108
|
-
"name": ((me.first_name or "") + " " + (me.last_name or "")).strip(), "phone": me.phone})
|
|
109
|
-
|
|
110
|
-
elif cmd == "list-chats":
|
|
111
|
-
limit = int(args[0]) if args and args[0].lstrip("-").isdigit() else 20
|
|
112
|
-
unread_only = "unread-only" in args
|
|
113
|
-
res = []
|
|
114
|
-
async for d in cl.iter_dialogs(limit=limit):
|
|
115
|
-
if unread_only and not d.unread_count:
|
|
116
|
-
continue
|
|
117
|
-
res.append({"name": d.name, "id": d.id, "group": d.is_group,
|
|
118
|
-
"channel": d.is_channel, "user": d.is_user, "unread": d.unread_count})
|
|
119
|
-
out(res)
|
|
120
|
-
|
|
121
|
-
elif cmd == "unread":
|
|
122
|
-
res = []
|
|
123
|
-
async for d in cl.iter_dialogs():
|
|
124
|
-
if d.unread_count:
|
|
125
|
-
res.append({"name": d.name, "id": d.id, "unread": d.unread_count,
|
|
126
|
-
"group": d.is_group, "channel": d.is_channel})
|
|
127
|
-
out(sorted(res, key=lambda x: -x["unread"]))
|
|
128
|
-
|
|
129
|
-
elif cmd == "get-messages":
|
|
130
|
-
need(1); ent = await resolve(cl, args[0])
|
|
131
|
-
n = int(args[1]) if len(args) > 1 else 50
|
|
132
|
-
rows = [msg_row(m) async for m in cl.iter_messages(ent, limit=n)]
|
|
133
|
-
rows.reverse()
|
|
134
|
-
out(rows)
|
|
135
|
-
|
|
136
|
-
elif cmd == "search":
|
|
137
|
-
need(2); ent = await resolve(cl, args[0])
|
|
138
|
-
q = args[1]; n = int(args[2]) if len(args) > 2 else 30
|
|
139
|
-
out([msg_row(m) async for m in cl.iter_messages(ent, search=q, limit=n)])
|
|
140
|
-
|
|
141
|
-
elif cmd == "search-global":
|
|
142
|
-
need(1); q = args[0]; n = int(args[1]) if len(args) > 1 else 30
|
|
143
|
-
rows = []
|
|
144
|
-
async for m in cl.iter_messages(None, search=q, limit=n):
|
|
145
|
-
r = msg_row(m); r["chat_id"] = m.chat_id
|
|
146
|
-
rows.append(r)
|
|
147
|
-
out(rows)
|
|
148
|
-
|
|
149
|
-
elif cmd == "contacts":
|
|
150
|
-
res = await cl(functions.contacts.GetContactsRequest(hash=0))
|
|
151
|
-
out([{"id": u.id, "username": u.username,
|
|
152
|
-
"name": ((u.first_name or "") + " " + (u.last_name or "")).strip(), "phone": u.phone}
|
|
153
|
-
for u in res.users])
|
|
154
|
-
|
|
155
|
-
elif cmd == "chat-info":
|
|
156
|
-
need(1); ent = await resolve(cl, args[0])
|
|
157
|
-
info = {"id": ent.id, "type": type(ent).__name__,
|
|
158
|
-
"title": getattr(ent, "title", None),
|
|
159
|
-
"name": ((getattr(ent, "first_name", "") or "") + " " + (getattr(ent, "last_name", "") or "")).strip() or None,
|
|
160
|
-
"username": getattr(ent, "username", None)}
|
|
161
|
-
try:
|
|
162
|
-
info["participants"] = (await cl.get_participants(ent, limit=1)).total
|
|
163
|
-
except Exception:
|
|
164
|
-
pass
|
|
165
|
-
out(info)
|
|
166
|
-
|
|
167
|
-
elif cmd == "message-link":
|
|
168
|
-
need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
|
|
169
|
-
try:
|
|
170
|
-
r = await cl(functions.channels.ExportMessageLinkRequest(channel=ent, id=mid))
|
|
171
|
-
out({"link": r.link})
|
|
172
|
-
except Exception as e:
|
|
173
|
-
out({"error": f"links only available for channels/supergroups: {e}"})
|
|
174
|
-
|
|
175
|
-
elif cmd == "download-media":
|
|
176
|
-
need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
|
|
177
|
-
outdir = args[2] if len(args) > 2 else "./tg_downloads"
|
|
178
|
-
os.makedirs(outdir, exist_ok=True)
|
|
179
|
-
m = await cl.get_messages(ent, ids=mid)
|
|
180
|
-
if not m or not m.media:
|
|
181
|
-
out({"error": "no media on that message"}); return
|
|
182
|
-
path = await cl.download_media(m, file=outdir)
|
|
183
|
-
out({"downloaded": path})
|
|
184
|
-
|
|
185
|
-
# ---- gated writes (need trailing --confirm) ----
|
|
186
|
-
elif cmd == "send":
|
|
187
|
-
need(2); ent = await resolve(cl, args[0])
|
|
188
|
-
m = await cl.send_message(ent, args[1])
|
|
189
|
-
out({"sent": True, "id": m.id})
|
|
190
|
-
|
|
191
|
-
elif cmd == "reply":
|
|
192
|
-
need(3); ent = await resolve(cl, args[0])
|
|
193
|
-
m = await cl.send_message(ent, args[2], reply_to=int(args[1]))
|
|
194
|
-
out({"sent": True, "id": m.id, "reply_to": int(args[1])})
|
|
195
|
-
|
|
196
|
-
elif cmd == "send-file":
|
|
197
|
-
need(2); ent = await resolve(cl, args[0])
|
|
198
|
-
caption = args[2] if len(args) > 2 else None
|
|
199
|
-
m = await cl.send_file(ent, args[1], caption=caption)
|
|
200
|
-
out({"sent": True, "id": m.id})
|
|
201
|
-
|
|
202
|
-
elif cmd == "forward":
|
|
203
|
-
need(3); src = await resolve(cl, args[0]); mid = int(args[1]); dst = await resolve(cl, args[2])
|
|
204
|
-
fwd = await cl.forward_messages(dst, mid, src)
|
|
205
|
-
out({"forwarded": True, "id": getattr(fwd, "id", None) or [x.id for x in fwd]})
|
|
206
|
-
|
|
207
|
-
elif cmd == "edit":
|
|
208
|
-
need(3); ent = await resolve(cl, args[0])
|
|
209
|
-
m = await cl.edit_message(ent, int(args[1]), args[2])
|
|
210
|
-
out({"edited": True, "id": m.id})
|
|
211
|
-
|
|
212
|
-
elif cmd == "delete":
|
|
213
|
-
need(2); ent = await resolve(cl, args[0])
|
|
214
|
-
await cl.delete_messages(ent, int(args[1]))
|
|
215
|
-
out({"deleted": True, "id": int(args[1])})
|
|
216
|
-
|
|
217
|
-
elif cmd == "react":
|
|
218
|
-
need(3); ent = await resolve(cl, args[0]); mid = int(args[1]); emoji = args[2]
|
|
219
|
-
await cl(functions.messages.SendReactionRequest(
|
|
220
|
-
peer=ent, msg_id=mid, reaction=[ReactionEmoji(emoticon=emoji)]))
|
|
221
|
-
out({"reacted": True, "id": mid, "emoji": emoji})
|
|
222
|
-
|
|
223
|
-
elif cmd == "mark-read":
|
|
224
|
-
need(1); ent = await resolve(cl, args[0])
|
|
225
|
-
await cl.send_read_acknowledge(ent)
|
|
226
|
-
out({"marked_read": True})
|
|
227
|
-
|
|
228
|
-
else:
|
|
229
|
-
out({"error": f"unknown command: {cmd}"}); sys.exit(1)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
async def main():
|
|
233
|
-
try:
|
|
234
|
-
await run()
|
|
235
|
-
except SystemExit:
|
|
236
|
-
raise
|
|
237
|
-
except Exception as e:
|
|
238
|
-
out({"error": f"{type(e).__name__}: {e}"})
|
|
239
|
-
sys.exit(1)
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
asyncio.run(main())
|
|
243
|
-
PY
|
|
244
|
-
echo "helper ready"
|
|
245
|
-
```
|
|
246
|
-
|
|
247
46
|
## Verify the connection first
|
|
248
47
|
|
|
249
48
|
```sh
|
|
250
|
-
python3
|
|
49
|
+
python3 "$TG" whoami
|
|
251
50
|
# → {"id": 8367450178, "username": "GermeyAce", "name": "Germey", "phone": "..."}
|
|
252
51
|
```
|
|
253
52
|
|
|
@@ -258,14 +57,14 @@ https://auth.acedata.cloud/user/connections.
|
|
|
258
57
|
|
|
259
58
|
| Goal | Command |
|
|
260
59
|
|---|---|
|
|
261
|
-
| Recent conversations | `python3
|
|
262
|
-
| Only chats with unread (ranked) | `python3
|
|
263
|
-
| A chat's history (oldest→newest) | `python3
|
|
264
|
-
| Search inside one chat | `python3
|
|
265
|
-
| Search across ALL chats | `python3
|
|
266
|
-
| List contacts | `python3
|
|
267
|
-
| Info about a chat/user | `python3
|
|
268
|
-
| t.me link to a message | `python3
|
|
60
|
+
| Recent conversations | `python3 "$TG" list-chats 20` |
|
|
61
|
+
| Only chats with unread (ranked) | `python3 "$TG" unread` |
|
|
62
|
+
| A chat's history (oldest→newest) | `python3 "$TG" get-messages <target> 50` |
|
|
63
|
+
| Search inside one chat | `python3 "$TG" search <target> "kw" 30` |
|
|
64
|
+
| Search across ALL chats | `python3 "$TG" search-global "kw" 30` |
|
|
65
|
+
| List contacts | `python3 "$TG" contacts` |
|
|
66
|
+
| Info about a chat/user | `python3 "$TG" chat-info <target>` |
|
|
67
|
+
| t.me link to a message | `python3 "$TG" message-link <target> <msg_id>` |
|
|
269
68
|
|
|
270
69
|
`<target>` = numeric id (most reliable — from `list-chats`), `@username`, phone, or exact chat
|
|
271
70
|
name. In message rows, `out:true` = sent by the user; `media:true` = has an attachment.
|
|
@@ -277,11 +76,16 @@ each → summarize. Don't dump 20k messages; sample the most-unread / most-relev
|
|
|
277
76
|
|
|
278
77
|
```sh
|
|
279
78
|
# Download an attachment from a message → returns the saved path
|
|
280
|
-
python3
|
|
281
|
-
# Send a local file
|
|
282
|
-
python3
|
|
79
|
+
python3 "$TG" download-media <target> <msg_id> ./tg_downloads
|
|
80
|
+
# Send a local file OR an http(s) URL (optional caption) — GATED
|
|
81
|
+
python3 "$TG" send-file <target> /path/or/https-url "caption" --confirm
|
|
283
82
|
```
|
|
284
83
|
|
|
84
|
+
An `http(s)` URL is downloaded to a real local file first (with the right
|
|
85
|
+
extension from the URL / `Content-Type`) and then uploaded, so a remote image
|
|
86
|
+
lands as a **photo** — not a document, and not a silent failure. This is the
|
|
87
|
+
reliable way to "发图": pass the CDN URL straight to `send-file`.
|
|
88
|
+
|
|
285
89
|
To hand a downloaded file back to the user as a link, upload it to the CDN (see the
|
|
286
90
|
`cos-upload` skill) after `download-media`.
|
|
287
91
|
|
|
@@ -292,13 +96,13 @@ exactly what will happen, get an explicit "yes", then re-run with `--confirm` as
|
|
|
292
96
|
argument**. Never bulk-send.
|
|
293
97
|
|
|
294
98
|
```sh
|
|
295
|
-
python3
|
|
296
|
-
python3
|
|
297
|
-
python3
|
|
298
|
-
python3
|
|
299
|
-
python3
|
|
300
|
-
python3
|
|
301
|
-
python3
|
|
99
|
+
python3 "$TG" send <target> "text" # → dry_run; add --confirm to send
|
|
100
|
+
python3 "$TG" reply <target> <msg_id> "text" --confirm
|
|
101
|
+
python3 "$TG" forward <from_target> <msg_id> <to_target> --confirm
|
|
102
|
+
python3 "$TG" edit <target> <msg_id> "new text" --confirm # own messages
|
|
103
|
+
python3 "$TG" delete <target> <msg_id> --confirm # destructive
|
|
104
|
+
python3 "$TG" react <target> <msg_id> "👍" --confirm
|
|
105
|
+
python3 "$TG" mark-read <target> --confirm # sends read receipts
|
|
302
106
|
```
|
|
303
107
|
|
|
304
108
|
The dry run returns `{"dry_run": true, "command": ..., "args": [...]}` — present that to the
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Personal Telegram (MTProto / Telethon) CLI — shipped with the `telegram` skill.
|
|
3
|
+
|
|
4
|
+
Self-contained: the only third-party dep is `telethon` (preinstalled in the
|
|
5
|
+
sandbox). URL downloads for `send-file` use the stdlib only, so a remote image
|
|
6
|
+
is fetched to a real local file before upload — Telethon sending a bare remote
|
|
7
|
+
URL is unreliable and often lands the media as a document (or fails outright).
|
|
8
|
+
|
|
9
|
+
Secrets (`TELEGRAM_API_HASH`, `TELEGRAM_SESSION_STRING`) are read from the env
|
|
10
|
+
and never printed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import mimetypes
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import urllib.request
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
from telethon import TelegramClient
|
|
23
|
+
from telethon.sessions import StringSession
|
|
24
|
+
from telethon.tl import functions
|
|
25
|
+
from telethon.tl.types import ReactionEmoji
|
|
26
|
+
|
|
27
|
+
API_ID = int(os.environ["TELEGRAM_API_ID"])
|
|
28
|
+
API_HASH = os.environ["TELEGRAM_API_HASH"]
|
|
29
|
+
SESSION = os.environ["TELEGRAM_SESSION_STRING"]
|
|
30
|
+
|
|
31
|
+
_raw = sys.argv[1:]
|
|
32
|
+
# --confirm is only honored as the LAST token, and only one is stripped, so a
|
|
33
|
+
# message/caption that merely contains "--confirm" cannot silently confirm a write.
|
|
34
|
+
CONFIRM = bool(_raw) and _raw[-1] == "--confirm"
|
|
35
|
+
a = _raw[:-1] if CONFIRM else list(_raw)
|
|
36
|
+
cmd = a[0] if a else "help"
|
|
37
|
+
args = a[1:]
|
|
38
|
+
GATED = {"send", "reply", "send-file", "forward", "edit", "delete", "react", "mark-read"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def out(o):
|
|
42
|
+
print(json.dumps(o, ensure_ascii=False, default=str))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ext_from_content_type(ct):
|
|
46
|
+
ct = (ct or "").split(";")[0].strip().lower()
|
|
47
|
+
if not ct:
|
|
48
|
+
return ""
|
|
49
|
+
return mimetypes.guess_extension(ct) or ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def materialize_file(src):
|
|
53
|
+
"""Return (local_path, cleanup) for `src`.
|
|
54
|
+
|
|
55
|
+
If `src` is an http(s) URL, download it to a temp file with a sensible
|
|
56
|
+
extension (so Telethon detects images/videos as media, not documents).
|
|
57
|
+
Local paths are returned unchanged with a no-op cleanup.
|
|
58
|
+
"""
|
|
59
|
+
parsed = urlparse(src)
|
|
60
|
+
if parsed.scheme not in ("http", "https"):
|
|
61
|
+
return src, (lambda: None)
|
|
62
|
+
|
|
63
|
+
# Pick an extension from the URL path first, then the response Content-Type.
|
|
64
|
+
_, url_ext = os.path.splitext(parsed.path)
|
|
65
|
+
req = urllib.request.Request(src, headers={"User-Agent": "Mozilla/5.0 (telegram-skill)"})
|
|
66
|
+
with urllib.request.urlopen(req, timeout=120) as resp: # noqa: S310 - user-supplied media URL
|
|
67
|
+
ext = url_ext or _ext_from_content_type(resp.headers.get("Content-Type"))
|
|
68
|
+
fd, path = tempfile.mkstemp(suffix=ext or ".bin", prefix="tg_send_")
|
|
69
|
+
|
|
70
|
+
def cleanup():
|
|
71
|
+
try:
|
|
72
|
+
os.remove(path)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
with os.fdopen(fd, "wb") as f:
|
|
78
|
+
while True:
|
|
79
|
+
chunk = resp.read(65536)
|
|
80
|
+
if not chunk:
|
|
81
|
+
break
|
|
82
|
+
f.write(chunk)
|
|
83
|
+
except BaseException:
|
|
84
|
+
cleanup() # don't orphan the temp file if the download fails mid-stream
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
return path, cleanup
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def resolve(client, target):
|
|
91
|
+
# 1) try direct resolve; 2) fall back to scanning dialogs by id or exact name
|
|
92
|
+
# (StringSession doesn't persist the entity cache, so a numeric id from a
|
|
93
|
+
# previous invocation may need the dialog scan to recover its access hash).
|
|
94
|
+
for attempt in (lambda: client.get_entity(int(target)), lambda: client.get_entity(target)):
|
|
95
|
+
try:
|
|
96
|
+
return await attempt()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
ti = None
|
|
100
|
+
try:
|
|
101
|
+
ti = int(target)
|
|
102
|
+
except (ValueError, TypeError):
|
|
103
|
+
pass
|
|
104
|
+
async for d in client.iter_dialogs():
|
|
105
|
+
if (ti is not None and d.id == ti) or d.name == target:
|
|
106
|
+
return d.entity
|
|
107
|
+
raise ValueError(f"could not resolve target: {target}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def msg_row(m):
|
|
111
|
+
return {"id": m.id, "date": str(m.date), "out": m.out, "sender_id": m.sender_id,
|
|
112
|
+
"text": m.message, "media": bool(m.media)}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def need(n):
|
|
116
|
+
if len(args) < n:
|
|
117
|
+
raise ValueError(f"{cmd} needs {n} argument(s), got {len(args)}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def run():
|
|
121
|
+
if cmd in GATED and not CONFIRM:
|
|
122
|
+
out({"dry_run": True, "command": cmd, "args": args,
|
|
123
|
+
"note": "re-run with --confirm as the LAST argument to actually perform this write"})
|
|
124
|
+
return
|
|
125
|
+
async with TelegramClient(StringSession(SESSION), API_ID, API_HASH) as cl:
|
|
126
|
+
if cmd == "whoami":
|
|
127
|
+
me = await cl.get_me()
|
|
128
|
+
out({"id": me.id, "username": me.username,
|
|
129
|
+
"name": ((me.first_name or "") + " " + (me.last_name or "")).strip(), "phone": me.phone})
|
|
130
|
+
|
|
131
|
+
elif cmd == "list-chats":
|
|
132
|
+
limit = int(args[0]) if args and args[0].lstrip("-").isdigit() else 20
|
|
133
|
+
unread_only = "unread-only" in args
|
|
134
|
+
res = []
|
|
135
|
+
async for d in cl.iter_dialogs(limit=limit):
|
|
136
|
+
if unread_only and not d.unread_count:
|
|
137
|
+
continue
|
|
138
|
+
res.append({"name": d.name, "id": d.id, "group": d.is_group,
|
|
139
|
+
"channel": d.is_channel, "user": d.is_user, "unread": d.unread_count})
|
|
140
|
+
out(res)
|
|
141
|
+
|
|
142
|
+
elif cmd == "unread":
|
|
143
|
+
res = []
|
|
144
|
+
async for d in cl.iter_dialogs():
|
|
145
|
+
if d.unread_count:
|
|
146
|
+
res.append({"name": d.name, "id": d.id, "unread": d.unread_count,
|
|
147
|
+
"group": d.is_group, "channel": d.is_channel})
|
|
148
|
+
out(sorted(res, key=lambda x: -x["unread"]))
|
|
149
|
+
|
|
150
|
+
elif cmd == "get-messages":
|
|
151
|
+
need(1); ent = await resolve(cl, args[0])
|
|
152
|
+
n = int(args[1]) if len(args) > 1 else 50
|
|
153
|
+
rows = [msg_row(m) async for m in cl.iter_messages(ent, limit=n)]
|
|
154
|
+
rows.reverse()
|
|
155
|
+
out(rows)
|
|
156
|
+
|
|
157
|
+
elif cmd == "search":
|
|
158
|
+
need(2); ent = await resolve(cl, args[0])
|
|
159
|
+
q = args[1]; n = int(args[2]) if len(args) > 2 else 30
|
|
160
|
+
out([msg_row(m) async for m in cl.iter_messages(ent, search=q, limit=n)])
|
|
161
|
+
|
|
162
|
+
elif cmd == "search-global":
|
|
163
|
+
need(1); q = args[0]; n = int(args[1]) if len(args) > 1 else 30
|
|
164
|
+
rows = []
|
|
165
|
+
async for m in cl.iter_messages(None, search=q, limit=n):
|
|
166
|
+
r = msg_row(m); r["chat_id"] = m.chat_id
|
|
167
|
+
rows.append(r)
|
|
168
|
+
out(rows)
|
|
169
|
+
|
|
170
|
+
elif cmd == "contacts":
|
|
171
|
+
res = await cl(functions.contacts.GetContactsRequest(hash=0))
|
|
172
|
+
out([{"id": u.id, "username": u.username,
|
|
173
|
+
"name": ((u.first_name or "") + " " + (u.last_name or "")).strip(), "phone": u.phone}
|
|
174
|
+
for u in res.users])
|
|
175
|
+
|
|
176
|
+
elif cmd == "chat-info":
|
|
177
|
+
need(1); ent = await resolve(cl, args[0])
|
|
178
|
+
info = {"id": ent.id, "type": type(ent).__name__,
|
|
179
|
+
"title": getattr(ent, "title", None),
|
|
180
|
+
"name": ((getattr(ent, "first_name", "") or "") + " " + (getattr(ent, "last_name", "") or "")).strip() or None,
|
|
181
|
+
"username": getattr(ent, "username", None)}
|
|
182
|
+
try:
|
|
183
|
+
info["participants"] = (await cl.get_participants(ent, limit=1)).total
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
out(info)
|
|
187
|
+
|
|
188
|
+
elif cmd == "message-link":
|
|
189
|
+
need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
|
|
190
|
+
try:
|
|
191
|
+
r = await cl(functions.channels.ExportMessageLinkRequest(channel=ent, id=mid))
|
|
192
|
+
out({"link": r.link})
|
|
193
|
+
except Exception as e:
|
|
194
|
+
out({"error": f"links only available for channels/supergroups: {e}"})
|
|
195
|
+
|
|
196
|
+
elif cmd == "download-media":
|
|
197
|
+
need(2); ent = await resolve(cl, args[0]); mid = int(args[1])
|
|
198
|
+
outdir = args[2] if len(args) > 2 else "./tg_downloads"
|
|
199
|
+
os.makedirs(outdir, exist_ok=True)
|
|
200
|
+
m = await cl.get_messages(ent, ids=mid)
|
|
201
|
+
if not m or not m.media:
|
|
202
|
+
out({"error": "no media on that message"}); return
|
|
203
|
+
path = await cl.download_media(m, file=outdir)
|
|
204
|
+
out({"downloaded": path})
|
|
205
|
+
|
|
206
|
+
# ---- gated writes (need trailing --confirm) ----
|
|
207
|
+
elif cmd == "send":
|
|
208
|
+
need(2); ent = await resolve(cl, args[0])
|
|
209
|
+
m = await cl.send_message(ent, args[1])
|
|
210
|
+
out({"sent": True, "id": m.id})
|
|
211
|
+
|
|
212
|
+
elif cmd == "reply":
|
|
213
|
+
need(3); ent = await resolve(cl, args[0])
|
|
214
|
+
m = await cl.send_message(ent, args[2], reply_to=int(args[1]))
|
|
215
|
+
out({"sent": True, "id": m.id, "reply_to": int(args[1])})
|
|
216
|
+
|
|
217
|
+
elif cmd == "send-file":
|
|
218
|
+
need(2); ent = await resolve(cl, args[0])
|
|
219
|
+
caption = args[2] if len(args) > 2 else None
|
|
220
|
+
# Download remote URLs to a real local file first so images/videos
|
|
221
|
+
# upload as media instead of failing or landing as a document.
|
|
222
|
+
path, cleanup = materialize_file(args[1])
|
|
223
|
+
try:
|
|
224
|
+
m = await cl.send_file(ent, path, caption=caption)
|
|
225
|
+
finally:
|
|
226
|
+
cleanup()
|
|
227
|
+
out({"sent": True, "id": m.id})
|
|
228
|
+
|
|
229
|
+
elif cmd == "forward":
|
|
230
|
+
need(3); src = await resolve(cl, args[0]); mid = int(args[1]); dst = await resolve(cl, args[2])
|
|
231
|
+
fwd = await cl.forward_messages(dst, mid, src)
|
|
232
|
+
out({"forwarded": True, "id": getattr(fwd, "id", None) or [x.id for x in fwd]})
|
|
233
|
+
|
|
234
|
+
elif cmd == "edit":
|
|
235
|
+
need(3); ent = await resolve(cl, args[0])
|
|
236
|
+
m = await cl.edit_message(ent, int(args[1]), args[2])
|
|
237
|
+
out({"edited": True, "id": m.id})
|
|
238
|
+
|
|
239
|
+
elif cmd == "delete":
|
|
240
|
+
need(2); ent = await resolve(cl, args[0])
|
|
241
|
+
await cl.delete_messages(ent, int(args[1]))
|
|
242
|
+
out({"deleted": True, "id": int(args[1])})
|
|
243
|
+
|
|
244
|
+
elif cmd == "react":
|
|
245
|
+
need(3); ent = await resolve(cl, args[0]); mid = int(args[1]); emoji = args[2]
|
|
246
|
+
await cl(functions.messages.SendReactionRequest(
|
|
247
|
+
peer=ent, msg_id=mid, reaction=[ReactionEmoji(emoticon=emoji)]))
|
|
248
|
+
out({"reacted": True, "id": mid, "emoji": emoji})
|
|
249
|
+
|
|
250
|
+
elif cmd == "mark-read":
|
|
251
|
+
need(1); ent = await resolve(cl, args[0])
|
|
252
|
+
await cl.send_read_acknowledge(ent)
|
|
253
|
+
out({"marked_read": True})
|
|
254
|
+
|
|
255
|
+
else:
|
|
256
|
+
out({"error": f"unknown command: {cmd}"}); sys.exit(1)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def main():
|
|
260
|
+
try:
|
|
261
|
+
await run()
|
|
262
|
+
except SystemExit:
|
|
263
|
+
raise
|
|
264
|
+
except Exception as e:
|
|
265
|
+
out({"error": f"{type(e).__name__}: {e}"})
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
if __name__ == "__main__":
|
|
270
|
+
asyncio.run(main())
|