@acedatacloud/skills 2026.621.1 → 2026.621.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.621.1",
3
+ "version": "2026.621.3",
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",
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: discordbot
3
+ description: List channels, read recent messages, and send messages on Discord using the user's own bot, via the Discord REST API. Use when the user wants their Discord BOT to post a message, read a channel, or list servers/channels — anything that acts in a server the bot was invited to.
4
+ when_to_use: |
5
+ Trigger when the user wants to send, read, or list things on Discord
6
+ through their bot: list the servers/channels the bot can see, read recent
7
+ messages in a channel, or post / reply in a channel. Messages are sent as
8
+ the BOT, not the user's personal account, and only in servers the bot has
9
+ been invited to with the right permissions.
10
+ connections: [discordbot]
11
+ allowed_tools: [Bash]
12
+ license: Apache-2.0
13
+ metadata:
14
+ author: acedatacloud
15
+ version: "1.0"
16
+ ---
17
+
18
+ We drive the [Discord API](https://discord.com/developers/docs/reference)
19
+ with `curl + jq` using the user's **bot** token in `$DISCORDBOT_TOKEN`. The
20
+ auth header is `Authorization: Bot $DISCORDBOT_TOKEN` — note the literal
21
+ `Bot ` prefix (NOT `Bearer`). Base URL is `https://discord.com/api/v10`.
22
+
23
+ This acts as the user's registered **bot**, so it can only see and act in
24
+ servers (guilds) the bot has been **invited to** and only where it has the
25
+ relevant permission (View Channels / Send Messages / Read Message History).
26
+ A `403 Forbidden` (code 50001 "Missing Access" / 50013 "Missing
27
+ Permissions") almost always means the bot isn't in that server or lacks the
28
+ permission — tell the user to invite the bot or grant the permission rather
29
+ than retrying.
30
+
31
+ Errors are JSON `{"code": <n>, "message": "<reason>"}`. A `401` means the
32
+ bot token is wrong/reset — ask the user to re-paste it at
33
+ `auth.acedata.cloud/user/connections`. A `429` carries `retry_after`
34
+ (seconds) — sleep that long, then retry; never parallelize.
35
+
36
+ **Before sending a message, confirm the exact channel and content with the
37
+ user.** Sending is irreversible and public to that channel.
38
+
39
+ ## Recipes
40
+
41
+ ### Verify the bot (always run first)
42
+
43
+ ```sh
44
+ curl -sS -H "Authorization: Bot $DISCORDBOT_TOKEN" \
45
+ "https://discord.com/api/v10/users/@me" \
46
+ | jq '{id, username, bot}'
47
+ ```
48
+
49
+ ### List servers (guilds) the bot is in
50
+
51
+ ```sh
52
+ curl -sS -H "Authorization: Bot $DISCORDBOT_TOKEN" \
53
+ "https://discord.com/api/v10/users/@me/guilds" \
54
+ | jq 'map({id, name})'
55
+ ```
56
+
57
+ ### List text channels in a server
58
+
59
+ Channel `type` 0 = text, 5 = announcement; 2 = voice, 4 = category (skip
60
+ those for messaging). You need a guild id from the call above.
61
+
62
+ ```sh
63
+ GUILD_ID="123456789012345678"
64
+ curl -sS -H "Authorization: Bot $DISCORDBOT_TOKEN" \
65
+ "https://discord.com/api/v10/guilds/$GUILD_ID/channels" \
66
+ | jq 'map(select(.type==0 or .type==5) | {id, name, type})'
67
+ ```
68
+
69
+ ### Read recent messages in a channel
70
+
71
+ Reading message **content** requires the **Message Content Intent** to be
72
+ enabled on the bot (Developer Portal → Bot → Privileged Gateway Intents).
73
+ Without it, `content` comes back empty for messages that don't mention the
74
+ bot. Needs the *Read Message History* permission in that channel.
75
+
76
+ ```sh
77
+ CHANNEL_ID="123456789012345678"
78
+ curl -sS -H "Authorization: Bot $DISCORDBOT_TOKEN" \
79
+ "https://discord.com/api/v10/channels/$CHANNEL_ID/messages?limit=20" \
80
+ | jq 'map({author: .author.username, ts: .timestamp, content})'
81
+ ```
82
+
83
+ ### Send a message to a channel
84
+
85
+ ```sh
86
+ CHANNEL_ID="123456789012345678"
87
+ curl -sS -X POST \
88
+ -H "Authorization: Bot $DISCORDBOT_TOKEN" \
89
+ -H "Content-Type: application/json" \
90
+ "https://discord.com/api/v10/channels/$CHANNEL_ID/messages" \
91
+ -d "$(jq -nc --arg c "Hello from the bot." '{content: $c}')"
92
+ ```
93
+
94
+ ### Reply to a specific message
95
+
96
+ ```sh
97
+ CHANNEL_ID="123456789012345678"; MESSAGE_ID="987654321098765432"
98
+ curl -sS -X POST \
99
+ -H "Authorization: Bot $DISCORDBOT_TOKEN" \
100
+ -H "Content-Type: application/json" \
101
+ "https://discord.com/api/v10/channels/$CHANNEL_ID/messages" \
102
+ -d "$(jq -nc --arg c "On it!" --arg m "$MESSAGE_ID" \
103
+ '{content: $c, message_reference: {message_id: $m}}')"
104
+ ```
105
+
106
+ ## Notes
107
+
108
+ - A "server" in the UI is a "guild" in the API; messages live in channels
109
+ inside guilds. Always: list guilds → list that guild's channels → act on a
110
+ channel id. Don't invent ids.
111
+ - The bot only sees servers it was invited to. To add it: Developer Portal →
112
+ OAuth2 → URL Generator → scope `bot` + the permissions you need → open the
113
+ URL → pick a server (the user needs *Manage Server* there).
114
+ - This is a bot, not the user's account — it cannot read the user's DMs or
115
+ the user's full server list, only what the bot itself can access.
116
+ - Mentions: `<@USER_ID>` pings a user, `<#CHANNEL_ID>` links a channel. Plain
117
+ text is fine for normal messages.
@@ -1,118 +1,242 @@
1
1
  ---
2
2
  name: telegram
3
- description: Read, search and send personal Telegram messages list recent chats / contacts / groups, pull a conversation's history, search messages, and send a message driven by the Telethon MTProto client with the user's own account. Use when the user mentions Telegram, a Telegram chat/group/contact, "我的 Telegram", reading or replying to Telegram messages, or summarizing Telegram conversations.
3
+ description: Full personal Telegram control over MTProto (Telethon) with the user's own account list/search chats, read & summarize history, see unread, look up contacts & chat info, download media, and send / reply / forward / edit / delete / react / send files / mark read. Use when the user mentions Telegram, a Telegram chat/group/contact, "我的 Telegram", reading/replying/forwarding/summarizing Telegram messages, their unread Telegram, or sending a file/message on Telegram.
4
4
  when_to_use: |
5
- Trigger when the user wants to do anything with their personal Telegram
6
- account: list recent conversations, read / summarize the history of a chat
7
- or group, search their messages for a keyword, look up a contact, or send /
8
- reply to a message. This drives the user's OWN account over MTProto (not a
9
- bot), so it can see everything the user can see.
5
+ Trigger for anything on the user's personal Telegram account: list recent
6
+ conversations or just the unread ones, read / summarize a chat or group,
7
+ search one chat or across all chats, look up a contact or a chat's info,
8
+ download a photo/file from a message, or take an action send, reply,
9
+ forward, edit, delete, react, send a file, or mark a chat read. This drives
10
+ the user's OWN account over MTProto (not a bot), so it sees everything they see.
10
11
  connections: [telegram]
11
12
  allowed_tools: [Bash]
12
13
  license: Apache-2.0
13
14
  metadata:
14
15
  author: acedatacloud
15
- version: "1.0"
16
+ version: "1.1"
16
17
  ---
17
18
 
18
- We drive **personal** Telegram over the MTProto protocol with the
19
- [Telethon](https://docs.telethon.dev/) Python library — this acts as the user's own
20
- account (a "userbot"), so unlike the Bot API it can read full chat history, list every
21
- conversation, and message anyone the user can message.
19
+ We drive **personal** Telegram over MTProto with [Telethon](https://docs.telethon.dev/) —
20
+ this acts as the user's own account (a "userbot"), so unlike the Bot API it can read full
21
+ history, list every conversation, and act on anyone the user can reach.
22
22
 
23
- The user's credentials are injected as environment variables by the connector:
23
+ Credentials are injected as env vars by the connector:
24
24
 
25
- - `TELEGRAM_API_ID` — the app id (from my.telegram.org)
26
- - `TELEGRAM_API_HASH` — the app hash — **secret, never echo it**
27
- - `TELEGRAM_SESSION_STRING` — a Telethon `StringSession` = **full account access. Never log,
25
+ - `TELEGRAM_API_ID` — app id
26
+ - `TELEGRAM_API_HASH` — app hash — **secret, never echo**
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
  ## Setup — write the helper once per session
31
31
 
32
- `telethon` is preinstalled in the sandbox image. The helper is written to `./tg.py` **in the
33
- current working directory** (the per-session workdir) — not a shared global path like `/tmp`
34
- so concurrent sessions never race on or reuse each other's file.
32
+ `telethon` is preinstalled in the sandbox. The helper is written to `./tg.py` **in the current
33
+ working directory** (the per-session workdir) — not a shared global path so concurrent
34
+ sessions never race.
35
+
36
+ Every state-changing command (`send`, `reply`, `send-file`, `forward`, `edit`, `delete`,
37
+ `react`, `mark-read`) is **gated**: without a trailing `--confirm` it only DRY-RUNS (prints what
38
+ it would do, changes nothing). Read commands run directly. `--confirm` is honored **only as the
39
+ last argument** so a message/caption that merely contains "--confirm" can never silently confirm.
35
40
 
36
41
  ```sh
37
- # telethon is preinstalled; the `|| pip install` is a best-effort fallback only
38
- # (the sandbox is non-root, so a runtime install may not succeed — rely on the
39
- # preinstalled package).
40
42
  python3 -c "import telethon" 2>/dev/null || pip install --user --quiet telethon 2>/dev/null || true
41
43
 
42
44
  cat > ./tg.py <<'PY'
43
45
  import os, sys, json, asyncio
44
46
  from telethon import TelegramClient
45
47
  from telethon.sessions import StringSession
48
+ from telethon.tl import functions
49
+ from telethon.tl.types import ReactionEmoji
46
50
 
47
51
  API_ID = int(os.environ["TELEGRAM_API_ID"])
48
52
  API_HASH = os.environ["TELEGRAM_API_HASH"]
49
53
  SESSION = os.environ["TELEGRAM_SESSION_STRING"]
50
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
+
51
68
 
52
69
  async def resolve(client, target):
53
- # Accept a numeric id, @username, phone, or an exact chat display name.
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
54
79
  try:
55
- return await client.get_entity(int(target))
80
+ ti = int(target)
56
81
  except (ValueError, TypeError):
57
82
  pass
58
- try:
59
- return await client.get_entity(target)
60
- except Exception:
61
- async for d in client.iter_dialogs():
62
- if d.name == target:
63
- return d.entity
64
- raise ValueError(f"could not resolve target: {target}")
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}")
65
87
 
66
88
 
67
- async def main():
68
- cmd = sys.argv[1]
69
- async with TelegramClient(StringSession(SESSION), API_ID, API_HASH) as client:
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:
70
105
  if cmd == "whoami":
71
- me = await client.get_me()
72
- print(json.dumps({"id": me.id, "username": me.username,
73
- "name": ((me.first_name or "") + " " + (me.last_name or "")).strip()},
74
- ensure_ascii=False))
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
+
75
110
  elif cmd == "list-chats":
76
- limit = int(sys.argv[2]) if len(sys.argv) > 2 else 20
77
- out = []
78
- async for d in client.iter_dialogs(limit=limit):
79
- out.append({"name": d.name, "id": d.id, "group": d.is_group,
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,
80
118
  "channel": d.is_channel, "user": d.is_user, "unread": d.unread_count})
81
- print(json.dumps(out, ensure_ascii=False))
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
+
82
129
  elif cmd == "get-messages":
83
- target = sys.argv[2]
84
- n = int(sys.argv[3]) if len(sys.argv) > 3 else 50
85
- ent = await resolve(client, target)
86
- out = []
87
- async for m in client.iter_messages(ent, limit=n):
88
- out.append({"id": m.id, "date": str(m.date), "out": m.out,
89
- "sender_id": m.sender_id, "text": m.message})
90
- out.reverse()
91
- print(json.dumps(out, ensure_ascii=False))
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
+
92
136
  elif cmd == "search":
93
- target, query = sys.argv[2], sys.argv[3]
94
- n = int(sys.argv[4]) if len(sys.argv) > 4 else 30
95
- ent = await resolve(client, target)
96
- out = []
97
- async for m in client.iter_messages(ent, search=query, limit=n):
98
- out.append({"id": m.id, "date": str(m.date), "sender_id": m.sender_id, "text": m.message})
99
- print(json.dumps(out, ensure_ascii=False))
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) ----
100
186
  elif cmd == "send":
101
- # Gated: without --confirm this only DRY-RUNS (prints the intended
102
- # target + text and sends nothing). Pass --confirm to actually send.
103
- target, text = sys.argv[2], sys.argv[3]
104
- confirm = "--confirm" in sys.argv[4:]
105
- ent = await resolve(client, target)
106
- if not confirm:
107
- name = getattr(ent, "title", None) or getattr(ent, "first_name", None) or str(target)
108
- print(json.dumps({"dry_run": True, "would_send_to": name, "text": text,
109
- "note": "re-run with --confirm to actually send"}, ensure_ascii=False))
110
- return
111
- msg = await client.send_message(ent, text)
112
- print(json.dumps({"sent": True, "id": msg.id}, ensure_ascii=False))
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
+
113
228
  else:
114
- print(json.dumps({"error": f"unknown command: {cmd}"}))
115
- sys.exit(1)
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)
116
240
 
117
241
 
118
242
  asyncio.run(main())
@@ -120,76 +244,77 @@ PY
120
244
  echo "helper ready"
121
245
  ```
122
246
 
123
- ## Verify the connection (run this first)
247
+ ## Verify the connection first
124
248
 
125
249
  ```sh
126
250
  python3 ./tg.py whoami
127
- # → {"id": 8367450178, "username": "GermeyAce", "name": "Germey"}
251
+ # → {"id": 8367450178, "username": "GermeyAce", "name": "Germey", "phone": "..."}
128
252
  ```
129
253
 
130
- If this errors with an auth/session message, the stored session is dead (revoked or expired)
131
- tell the user to reconnect the Telegram connector at https://auth.acedata.cloud/user/connections.
254
+ On an auth/session error the stored session is dead tell the user to reconnect at
255
+ https://auth.acedata.cloud/user/connections.
132
256
 
133
- ## Recipes
257
+ ## Read recipes
134
258
 
135
- ### List recent conversations
259
+ | Goal | Command |
260
+ |---|---|
261
+ | Recent conversations | `python3 ./tg.py list-chats 20` |
262
+ | Only chats with unread (ranked) | `python3 ./tg.py unread` |
263
+ | A chat's history (oldest→newest) | `python3 ./tg.py get-messages <target> 50` |
264
+ | Search inside one chat | `python3 ./tg.py search <target> "kw" 30` |
265
+ | Search across ALL chats | `python3 ./tg.py search-global "kw" 30` |
266
+ | List contacts | `python3 ./tg.py contacts` |
267
+ | Info about a chat/user | `python3 ./tg.py chat-info <target>` |
268
+ | t.me link to a message | `python3 ./tg.py message-link <target> <msg_id>` |
136
269
 
137
- ```sh
138
- python3 ./tg.py list-chats 20
139
- # → [{"name":"Ace <> ConduitOS","id":-5287630726,"group":true,...,"unread":0}, ...]
140
- ```
270
+ `<target>` = numeric id (most reliable — from `list-chats`), `@username`, phone, or exact chat
271
+ name. In message rows, `out:true` = sent by the user; `media:true` = has an attachment.
141
272
 
142
- Use the returned `id` (or the exact `name`) as the target for the next calls.
273
+ **Summarize-unread pattern**: `unread` pick the chats that matter `get-messages <id> N` on
274
+ each → summarize. Don't dump 20k messages; sample the most-unread / most-relevant.
143
275
 
144
- ### Read a conversation's history (oldest→newest)
276
+ ## Media
145
277
 
146
278
  ```sh
147
- # target = numeric id, @username, phone, or exact chat name; second arg = how many messages
148
- python3 ./tg.py get-messages -5287630726 50
149
- python3 ./tg.py get-messages @some_username 30
279
+ # Download an attachment from a message returns the saved path
280
+ python3 ./tg.py download-media <target> <msg_id> ./tg_downloads
281
+ # Send a local file or a URL (optional caption) — GATED
282
+ python3 ./tg.py send-file <target> /path/or/https-url "caption" --confirm
150
283
  ```
151
284
 
152
- `out: true` means the message was sent BY the user; `sender_id` is the author. Summarize from
153
- the returned JSON.
285
+ To hand a downloaded file back to the user as a link, upload it to the CDN (see the
286
+ `cos-upload` skill) after `download-media`.
154
287
 
155
- ### Search inside a conversation
288
+ ## Write recipes all GATED (dry-run unless trailing `--confirm`)
156
289
 
157
- ```sh
158
- python3 ./tg.py search -5287630726 "keyword" 30
159
- ```
160
-
161
- (Server-side search is scoped to one chat. To search broadly, list chats first, then search the
162
- relevant ones.)
163
-
164
- ### Send / reply to a message — TWO-STEP, confirm first
165
-
166
- Sending posts a **real message as the user**, so it is gated:
290
+ Sending/editing/deleting acts as the **real user**. Always run the dry run first, show the user
291
+ exactly what will happen, get an explicit "yes", then re-run with `--confirm` as the **last
292
+ argument**. Never bulk-send.
167
293
 
168
294
  ```sh
169
- # Step 1 DRY RUN (default, sends nothing). Show this preview to the user.
170
- python3 ./tg.py send -5287630726 "Hi, following up on this."
171
- # {"dry_run": true, "would_send_to": "Ace <> ConduitOS", "text": "Hi, following up on this.", ...}
172
-
173
- # Step 2 only after the user explicitly says yes in the conversation, add --confirm:
174
- python3 ./tg.py send -5287630726 "Hi, following up on this." --confirm
175
- # {"sent": true, "id": 4502}
295
+ python3 ./tg.py send <target> "text" # dry_run; add --confirm to send
296
+ python3 ./tg.py reply <target> <msg_id> "text" --confirm
297
+ python3 ./tg.py forward <from_target> <msg_id> <to_target> --confirm
298
+ python3 ./tg.py edit <target> <msg_id> "new text" --confirm # own messages
299
+ python3 ./tg.py delete <target> <msg_id> --confirm # destructive
300
+ python3 ./tg.py react <target> <msg_id> "👍" --confirm
301
+ python3 ./tg.py mark-read <target> --confirm # sends read receipts
176
302
  ```
177
303
 
178
- **Always run the dry run first, show the user exactly who + what, and require an explicit "yes"
179
- before re-running with `--confirm`** — even if the original instruction said "just send it".
180
- Never bulk-send.
181
-
182
- ## Gotchas — surface these before the user is surprised
183
-
184
- - **This is the user's real account.** Sending posts as them; reading exposes all their private
185
- chats. Be conservative.
186
- - **`FloodWaitError`**: Telegram rate-limits userbots. If a call fails with a flood-wait of N
187
- seconds, tell the user to retry after N seconds — do not loop/retry aggressively (it escalates
188
- toward an account ban).
189
- - **Dead session**: a `session_string` can be revoked by the user from Telegram Settings
190
- Devices. On an `AuthKeyError` / unauthorized error, the fix is reconnecting the connector, not
191
- retrying.
192
- - **Never print `TELEGRAM_SESSION_STRING` or `TELEGRAM_API_HASH`** — they are full-account
193
- secrets. The helper never prints them; keep it that way.
194
- - **Resolving targets**: prefer the numeric `id` from `list-chats` (most reliable). Names work
195
- only on an exact match; usernames need the leading `@`.
304
+ The dry run returns `{"dry_run": true, "command": ..., "args": [...]}` present that to the
305
+ user verbatim as the confirmation prompt.
306
+
307
+ ## Gotchas — surface before the user is surprised
308
+
309
+ - **This is the user's real account.** Confirm before any write; reading exposes private chats.
310
+ - **`FloodWaitError`**: Telegram rate-limits userbots. On a flood-wait of N seconds, tell the
311
+ user to retry after N — never loop/retry aggressively (escalates toward a ban).
312
+ - **Dead session**: revoked from Telegram Settings Devices, or ~6-month inactivity. On
313
+ `AuthKeyError`/unauthorized, reconnect the connector (don't retry).
314
+ - **Never print `TELEGRAM_SESSION_STRING` / `TELEGRAM_API_HASH`** — full-account secrets.
315
+ - **Targets**: prefer the numeric `id` from `list-chats` (the helper recovers its access hash by
316
+ scanning dialogs); names need an exact match, usernames need a leading `@`.
317
+ - **`message-link`** only works for public channels/supergroups; private 1:1 / basic groups
318
+ return an error (no shareable link exists).
319
+ - **`edit`/`delete`** generally only apply to the user's own messages (admins can delete others
320
+ in groups they manage).