@acedatacloud/skills 2026.722.2 → 2026.722.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.722.2",
3
+ "version": "2026.722.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",
@@ -1,74 +1,121 @@
1
1
  ---
2
2
  name: discord
3
- description: Read your Discord identity and the list of servers (guilds) you belong to via the Discord API. Use when the user mentions Discord, asks which servers/guilds they are in, or wants their Discord account info.
3
+ description: Work with the user's Discord account. Two modes, auto-selected by how they connected — OAuth (read-only identity + server list) or User Token / BYOC (full personal-account actions: list channels, read messages, send & reply). Use when the user mentions Discord, asks which servers they are in, or wants to read/post in a channel as themselves.
4
4
  when_to_use: |
5
- Trigger when the user wants to read their Discord account identity
6
- (username, avatar, email) or list the servers (guilds) their connected
7
- Discord account belongs to. This connection is read-only identity +
8
- guild list; it CANNOT read or send channel messages.
5
+ Trigger for anything on the user's connected Discord account. What you can do
6
+ depends on how they connected:
7
+ - OAuth connection → read-only: their identity (username, avatar, email) and
8
+ the list of servers (guilds) they belong to. No channel messages.
9
+ - User Token (BYOC) connection → full personal-account actions: list channels,
10
+ read recent messages, and send / reply in a channel as the user. Writes are
11
+ gated behind an explicit confirmation.
9
12
  connections: [discord]
10
13
  allowed_tools: [Bash]
11
14
  license: Apache-2.0
12
15
  metadata:
13
16
  author: acedatacloud
14
- version: "1.0"
17
+ version: "2.0"
15
18
  ---
16
19
 
17
- We drive the [Discord API](https://discord.com/developers/docs/reference)
18
- with `curl + jq`. The user's OAuth bearer token is in `$DISCORD_TOKEN`;
19
- every call needs it as `Authorization: Bearer $DISCORD_TOKEN`. Use the
20
- versioned base URL `https://discord.com/api/v10`.
20
+ # discord — work with the user's Discord account
21
21
 
22
- Discord returns standard JSON. Errors look like
23
- `{"code": <n>, "message": "<reason>"}`. A `401 Unauthorized` means the
24
- token expired or the connection was revoked — tell the user to re-connect
25
- Discord at `auth.acedata.cloud/user/connections`. A `429` carries a
26
- `retry_after` (seconds) field — sleep that long, then retry; never
27
- parallelize.
22
+ This skill has **two modes**, picked automatically by which credential the
23
+ connector injected. Check the environment first and follow the matching section:
28
24
 
29
- **Scope is read-only `identify` + `email` + `guilds`.** This OAuth
30
- connection can ONLY read the account's identity and the list of guilds it
31
- belongs to. It CANNOT read/send channel messages, list a guild's channels
32
- or members, or manage anything — those require a **Discord Bot** (bot
33
- token + gateway), which this connector does not provide. Do not call
34
- `/guilds/{id}/channels`, `/channels/...`, or `/guilds/{id}/members` — they
35
- return 401/403 with a user OAuth token. If the user asks for those, say it
36
- needs a Discord bot integration, which isn't set up.
37
-
38
- ## Recipes
39
-
40
- ### Verify auth + identity (always run first)
25
+ - **`$DISCORD_USER_TOKEN` is set** → **User Token (BYOC) mode**: full
26
+ personal-account actions via the `scripts/discord_user.py` CLI (list channels, read
27
+ messages, send / reply). This acts as the user's **real account**.
28
+ - **only `$DISCORD_TOKEN` is set** → **OAuth mode**: read-only identity + server
29
+ list via `curl`. Channel messages are **not** available in this mode.
41
30
 
42
31
  ```sh
43
- curl -sS -H "Authorization: Bearer $DISCORD_TOKEN" \
44
- "https://discord.com/api/v10/users/@me" \
45
- | jq '{id, username, global_name, email, avatar}'
32
+ if [ -n "$DISCORD_USER_TOKEN" ]; then echo "mode: user-token (full)"; \
33
+ elif [ -n "$DISCORD_TOKEN" ]; then echo "mode: oauth (read-only)"; \
34
+ else echo "no Discord connection — connect at https://auth.acedata.cloud/user/connections"; fi
46
35
  ```
47
36
 
48
- ### List the servers (guilds) the user is in
37
+ Both tokens are **secret — full account access. Never echo or print them.** On
38
+ `401`, the token expired or was revoked — tell the user to reconnect at
39
+ `auth.acedata.cloud/user/connections`. On `429`, sleep the `retry_after`
40
+ seconds, then retry; never parallelize.
41
+
42
+ ---
43
+
44
+ ## User Token (BYOC) mode — full actions
45
+
46
+ When `$DISCORD_USER_TOKEN` is set, drive the user's real account through
47
+ [`discord.py-self`](https://github.com/dolfies/discord.py-self) via the CLI at
48
+ `scripts/discord_user.py` (run with `python3`). It logs in with the token only (no
49
+ gateway) and prints JSON.
50
+
51
+ `discord.py-self` is preinstalled in the hosted sandbox (import name `discord`).
52
+ Do **not** `pip install` it at runtime; if import fails, report that the sandbox
53
+ image is missing the dependency and stop.
54
+
55
+ **Writes (`send` / `reply`) are gated by a trailing `--confirm`.** Without it
56
+ they dry-run and print what they *would* send. **Confirm the exact channel and
57
+ content with the user before sending** — it's irreversible and public. Only
58
+ append `--confirm` (as the very last argument) once the user approves.
49
59
 
50
60
  ```sh
51
- curl -sS -H "Authorization: Bearer $DISCORD_TOKEN" \
52
- "https://discord.com/api/v10/users/@me/guilds" \
53
- | jq 'map({id, name, owner, approximate_member_count})'
61
+ # identity (always run first)
62
+ python3 scripts/discord_user.py whoami
63
+ # list the servers (guilds) the account is in
64
+ python3 scripts/discord_user.py guilds
65
+ # list text channels in a server
66
+ python3 scripts/discord_user.py channels --guild 1133012399448928276
67
+ # read recent messages in a channel
68
+ python3 scripts/discord_user.py messages --channel 1133012400174534792 --limit 20
69
+ # send — dry-run first, then re-run with a trailing --confirm
70
+ python3 scripts/discord_user.py send --channel 1133012400174534792 --text "hello"
71
+ python3 scripts/discord_user.py send --channel 1133012400174534792 --text "hello" --confirm
72
+ # reply to a specific message
73
+ python3 scripts/discord_user.py reply \
74
+ --channel 1133012400174534792 --message 987654321098765432 --text "on it" --confirm
54
75
  ```
55
76
 
56
- Add `?with_counts=true` to include `approximate_member_count` /
57
- `approximate_presence_count`:
77
+ `--confirm` is honored ONLY as the final argument (one strip), so a message body
78
+ that merely contains the text `--confirm` can never silently trigger a send. IDs
79
+ are numeric Discord snowflakes. A proxy can be forced via `DISCORD_PROXY` (falls
80
+ back to `HTTPS_PROXY` / `ALL_PROXY`) if the sandbox needs one to reach
81
+ `discord.com`.
82
+
83
+ ---
84
+
85
+ ## OAuth mode — read-only
86
+
87
+ When only `$DISCORD_TOKEN` is set, drive the
88
+ [Discord API](https://discord.com/developers/docs/reference) with `curl + jq`.
89
+ Auth header is `Authorization: Bearer $DISCORD_TOKEN`; base URL
90
+ `https://discord.com/api/v10`.
91
+
92
+ **Scope is read-only `identify` + `email` + `guilds`.** This mode can ONLY read
93
+ the account's identity and its guild list. It CANNOT read/send channel messages,
94
+ list a guild's channels or members, or manage anything — `/guilds/{id}/channels`,
95
+ `/channels/...`, `/guilds/{id}/members` return 401/403 with an OAuth token. If the
96
+ user needs those, they must connect Discord with a **User Token** (the BYOC mode
97
+ above), or use a **Discord Bot** (`discordbot` connection) to act as a bot.
58
98
 
59
99
  ```sh
100
+ # identity (always run first)
101
+ curl -sS -H "Authorization: Bearer $DISCORD_TOKEN" \
102
+ "https://discord.com/api/v10/users/@me" \
103
+ | jq '{id, username, global_name, email, avatar}'
104
+
105
+ # the servers (guilds) the user is in (add ?with_counts=true for member counts)
60
106
  curl -sS -H "Authorization: Bearer $DISCORD_TOKEN" \
61
107
  "https://discord.com/api/v10/users/@me/guilds?with_counts=true" \
62
108
  | jq 'map({id, name, owner, members: .approximate_member_count})'
63
109
  ```
64
110
 
111
+ ---
112
+
65
113
  ## Notes
66
114
 
67
- - A "server" in the UI is a "guild" in the API. `owner: true` means the
68
- user owns that guild.
69
- - Guild icon URL (when `icon` is non-null):
70
- `https://cdn.discordapp.com/icons/<guild_id>/<icon>.png` (use `.gif` if
71
- the icon hash starts with `a_`).
72
- - The guild list paginates at 200; the typical user is in far fewer, so a
73
- single call is usually enough. If you ever hit 200, paginate with
74
- `?after=<last_guild_id>`.
115
+ - A "server" in the UI is a "guild" in the API; messages live in channels inside
116
+ guilds. In User Token mode: `guilds` → `channels --guild <id>` → act on a
117
+ channel id. Don't invent ids.
118
+ - In OAuth mode, `owner: true` means the user owns that guild. Guild icon URL
119
+ (when `icon` is non-null): `https://cdn.discordapp.com/icons/<guild_id>/<icon>.png`
120
+ (use `.gif` if the hash starts with `a_`). The guild list paginates at 200;
121
+ paginate with `?after=<last_guild_id>` if you ever hit it.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ discord_user.py — act on Discord with the user's own account (BYOC user-token path).
4
+
5
+ This is the **BYOC** half of the `discord` skill. It runs only when the user
6
+ connected Discord with a personal user token (env ``DISCORD_USER_TOKEN``); the
7
+ OAuth half of the skill uses plain ``curl`` and is documented in SKILL.md.
8
+
9
+ Drives Discord's user API through `discord.py-self`
10
+ (https://github.com/dolfies/discord.py-self). This acts as the user's REAL
11
+ account, so every state-changing command (send / reply) is GATED by a trailing
12
+ ``--confirm`` — without it, the command dry-runs.
13
+
14
+ The connector injects the token as env var ``DISCORD_USER_TOKEN``. It is full
15
+ account access — NEVER echo or print it.
16
+
17
+ Examples:
18
+ python3 discord_user.py whoami
19
+ python3 discord_user.py guilds
20
+ python3 discord_user.py channels --guild 1133012399448928276
21
+ python3 discord_user.py messages --channel 1133012400174534792 --limit 20
22
+ python3 discord_user.py send --channel 1133012400174534792 --text "hello" --confirm
23
+ python3 discord_user.py reply --channel 1133012400174534792 --message 987654321 --text "on it" --confirm
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import asyncio
30
+ import json
31
+ import os
32
+ import sys
33
+
34
+ _RAW = sys.argv[1:]
35
+ # --confirm is honored ONLY as the last token, and only one is stripped, so a
36
+ # message body that merely contains "--confirm" can never silently confirm a write.
37
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
38
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
39
+
40
+ # State-changing commands — dry-run unless the invocation ends with --confirm.
41
+ GATED = {"send", "reply"}
42
+
43
+
44
+ def out(obj) -> None:
45
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
46
+
47
+
48
+ def die(msg: str, code: int = 1) -> None:
49
+ out({"error": msg})
50
+ sys.exit(code)
51
+
52
+
53
+ def load_token() -> str:
54
+ tok = os.environ.get("DISCORD_USER_TOKEN")
55
+ if not tok:
56
+ die("DISCORD_USER_TOKEN is not set — connect Discord (User Token) at "
57
+ "https://auth.acedata.cloud/user/connections, then retry.")
58
+ return tok.strip()
59
+
60
+
61
+ def proxy() -> str | None:
62
+ return (
63
+ os.environ.get("DISCORD_PROXY")
64
+ or os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")
65
+ or os.environ.get("ALL_PROXY") or os.environ.get("all_proxy")
66
+ or None
67
+ )
68
+
69
+
70
+ def channel_id(args) -> int:
71
+ try:
72
+ return int(args.channel)
73
+ except (TypeError, ValueError):
74
+ die("--channel must be a numeric Discord channel id")
75
+
76
+
77
+ def guild_id(args) -> int:
78
+ try:
79
+ return int(args.guild)
80
+ except (TypeError, ValueError):
81
+ die("--guild must be a numeric Discord guild (server) id")
82
+
83
+
84
+ async def messageable_channel(client, args):
85
+ """Fetch a channel and confirm it can hold messages (not a category/forum)."""
86
+ import discord.abc
87
+ ch = await client.fetch_channel(channel_id(args))
88
+ if not isinstance(ch, discord.abc.Messageable):
89
+ die(f"channel {ch.id} is a {ch.type} channel, which can't hold messages — "
90
+ "pass a text/voice channel id (see `channels --guild <id>`)")
91
+ return ch
92
+
93
+
94
+ # ── commands ──────────────────────────────────────────────────────────────
95
+ async def cmd_whoami(client, args):
96
+ u = client.user
97
+ out({"id": str(u.id), "username": u.name, "global_name": getattr(u, "global_name", None),
98
+ "bot": u.bot})
99
+
100
+
101
+ async def cmd_guilds(client, args):
102
+ guilds = await client.fetch_guilds(with_counts=True)
103
+ out([{"id": str(g.id), "name": g.name,
104
+ "members": getattr(g, "approximate_member_count", None)} for g in guilds])
105
+
106
+
107
+ async def cmd_channels(client, args):
108
+ import discord
109
+ guild = await client.fetch_guild(guild_id(args))
110
+ channels = await guild.fetch_channels()
111
+ text = [c for c in channels if isinstance(c, (discord.TextChannel,))]
112
+ out([{"id": str(c.id), "name": c.name, "type": str(c.type)} for c in text])
113
+
114
+
115
+ async def cmd_messages(client, args):
116
+ ch = await messageable_channel(client, args)
117
+ msgs = [m async for m in ch.history(limit=args.limit)]
118
+ out([{"id": str(m.id), "author": m.author.name, "author_id": str(m.author.id),
119
+ "ts": m.created_at, "content": m.content} for m in msgs])
120
+
121
+
122
+ async def cmd_send(client, args):
123
+ ch = await messageable_channel(client, args)
124
+ if not CONFIRM:
125
+ out({"dry_run": True, "would_send": {"channel": str(ch.id),
126
+ "channel_name": getattr(ch, "name", None), "text": args.text},
127
+ "hint": "re-run with a trailing --confirm to actually send"})
128
+ return
129
+ msg = await ch.send(args.text)
130
+ out({"sent": True, "channel": str(ch.id), "message_id": str(msg.id)})
131
+
132
+
133
+ async def cmd_reply(client, args):
134
+ import discord
135
+ ch = await messageable_channel(client, args)
136
+ try:
137
+ target = int(args.message)
138
+ except (TypeError, ValueError):
139
+ die("--message must be a numeric Discord message id")
140
+ if not CONFIRM:
141
+ out({"dry_run": True, "would_reply": {"channel": str(ch.id),
142
+ "reply_to": str(target), "text": args.text},
143
+ "hint": "re-run with a trailing --confirm to actually send"})
144
+ return
145
+ ref = discord.MessageReference(message_id=target, channel_id=ch.id,
146
+ guild_id=getattr(ch, "guild", None) and ch.guild.id)
147
+ msg = await ch.send(args.text, reference=ref)
148
+ out({"sent": True, "channel": str(ch.id), "message_id": str(msg.id),
149
+ "reply_to": str(target)})
150
+
151
+
152
+ HANDLERS = {
153
+ "whoami": cmd_whoami, "guilds": cmd_guilds, "channels": cmd_channels,
154
+ "messages": cmd_messages, "send": cmd_send, "reply": cmd_reply,
155
+ }
156
+
157
+
158
+ def build_parser() -> argparse.ArgumentParser:
159
+ p = argparse.ArgumentParser(prog="discord_user.py", description="Discord user-token CLI")
160
+ sub = p.add_subparsers(dest="cmd", required=True)
161
+
162
+ sub.add_parser("whoami", help="verify the token and show the logged-in account")
163
+ sub.add_parser("guilds", help="list the servers (guilds) the account is in")
164
+
165
+ sp = sub.add_parser("channels", help="list text channels in a server")
166
+ sp.add_argument("--guild", required=True)
167
+
168
+ sp = sub.add_parser("messages", help="read recent messages in a channel")
169
+ sp.add_argument("--channel", required=True)
170
+ sp.add_argument("--limit", type=int, default=20)
171
+
172
+ sp = sub.add_parser("send", help="send a message to a channel (GATED by trailing --confirm)")
173
+ sp.add_argument("--channel", required=True)
174
+ sp.add_argument("--text", required=True)
175
+
176
+ sp = sub.add_parser("reply", help="reply to a message (GATED by trailing --confirm)")
177
+ sp.add_argument("--channel", required=True)
178
+ sp.add_argument("--message", required=True)
179
+ sp.add_argument("--text", required=True)
180
+ return p
181
+
182
+
183
+ async def run(args) -> None:
184
+ import discord
185
+ client = discord.Client(proxy=proxy())
186
+ try:
187
+ await client.login(load_token())
188
+ await HANDLERS[args.cmd](client, args)
189
+ finally:
190
+ # close() is safe even if login() failed (no session opened).
191
+ await client.close()
192
+
193
+
194
+ def main() -> None:
195
+ args = build_parser().parse_args(ARGV)
196
+ if args.cmd in GATED and not CONFIRM:
197
+ # Still resolve the channel for the dry-run preview, but never write.
198
+ pass
199
+ try:
200
+ import discord
201
+ from discord.errors import (
202
+ LoginFailure, Forbidden, NotFound, HTTPException,
203
+ )
204
+ except Exception as e: # discord.py-self not importable
205
+ die(f"discord.py-self is not available in the sandbox image: {e}. "
206
+ "Deploy the sandbox skill dependencies image; do not pip-install it at runtime.")
207
+ try:
208
+ asyncio.run(run(args))
209
+ except LoginFailure as e:
210
+ die("auth failed — the Discord user token is wrong or expired. Reconnect "
211
+ f"at https://auth.acedata.cloud/user/connections. ({e})")
212
+ except Forbidden as e:
213
+ die(f"forbidden by Discord (no access to that channel/server, or blocked): {e}")
214
+ except NotFound as e:
215
+ die(f"not found — check the channel / guild / message id: {e}")
216
+ except HTTPException as e:
217
+ # 429 rate limits and other API errors land here.
218
+ status = getattr(e, "status", None)
219
+ if status == 429:
220
+ retry = getattr(e, "retry_after", None)
221
+ die(f"rate limited by Discord — wait {retry or 'a bit'}s and retry; never parallelize. ({e})")
222
+ die(f"Discord API error (HTTP {status}): {e}")
223
+ except Exception as e:
224
+ die(f"Discord request failed ({type(e).__name__}: {e}). Likely an expired "
225
+ "token — reconnect at https://auth.acedata.cloud/user/connections.")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()