@acedatacloud/skills 2026.721.3 → 2026.722.1

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.721.3",
3
+ "version": "2026.722.1",
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",
@@ -56,11 +56,11 @@ models include:
56
56
 
57
57
  | Family | Current examples |
58
58
  |--------|------------------|
59
- | OpenAI / reasoning | `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o1`, `o3`, `o4-mini` |
59
+ | OpenAI / reasoning | `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.2`, `gpt-5.1`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o1`, `o3`, `o4-mini` |
60
60
  | OpenAI free-tier chat-completions | `gpt-5.5:free`, `gpt-5:free`, `gpt-4.1:free`, `gpt-4o:free`, `gpt-4o-mini:free`, `gpt-oss:free` |
61
61
  | Claude | `claude-fable-5`, `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-opus-4-5-20251101`, `claude-sonnet-4-6`, `claude-sonnet-4-5-20250929`, `claude-sonnet-4-20250514`, `claude-haiku-4-5-20251001`, `claude-3-7-sonnet-20250219` |
62
- | Gemini | `gemini-3.1-pro`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-image-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-pro-preview`, `gemini-2.5-flash-lite`, `gemini-2.0-flash-lite` |
63
- | Grok | `grok-4`, `grok-4-0709`, `grok-3`, `grok-3-fast` |
62
+ | Gemini | `gemini-3.5-flash`, `gemini-3.1-pro`, `gemini-3.1-pro-preview`, `gemini-3.1-flash-image-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-pro-preview`, `gemini-2.5-flash-lite`, `gemini-2.0-flash-lite` |
63
+ | Grok | `grok-4.5`, `grok-4`, `grok-4-0709`, `grok-3`, `grok-3-fast` |
64
64
  | DeepSeek | `deepseek-r1`, `deepseek-r1-0528`, `deepseek-v3`, `deepseek-v3-250324`, `deepseek-v3.2-exp`, `deepseek-v4-flash` |
65
65
  | Kimi | `kimi-k3`, `kimi-k2.6`, `kimi-k2.5`, `kimi-k2-thinking-turbo`, `kimi-k2-thinking`, `kimi-k2-instruct-0905`, `kimi-k2-0905-preview`, `kimi-k2-turbo-preview`, `kimi-k2-0711-preview` |
66
66
  | GLM | `glm-5.2`, `glm-5.1`, `glm-5`, `glm-5-turbo`, `glm-4.7`, `glm-4.6`, `glm-4.5`, `glm-4.5v`, `glm-3-turbo` |
@@ -1,18 +1,18 @@
1
1
  ---
2
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.
3
+ description: List channels, read recent messages, send channel messages, and send a single user-initiated DM on Discord using the user's own bot via the Discord REST API.
4
4
  when_to_use: |
5
5
  Trigger when the user wants to send, read, or list things on Discord
6
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.
7
+ messages in a channel, post / reply in a channel, or DM one user who has
8
+ explicitly initiated the interaction. Messages are sent as the BOT, not
9
+ the user's personal account, and only where the bot has access.
10
10
  connections: [discordbot]
11
11
  allowed_tools: [Bash]
12
12
  license: Apache-2.0
13
13
  metadata:
14
14
  author: acedatacloud
15
- version: "1.0"
15
+ version: "1.1"
16
16
  ---
17
17
 
18
18
  We drive the [Discord API](https://discord.com/developers/docs/reference)
@@ -33,8 +33,21 @@ bot token is wrong/reset — ask the user to re-paste it at
33
33
  `auth.acedata.cloud/user/connections`. A `429` carries `retry_after`
34
34
  (seconds) — sleep that long, then retry; never parallelize.
35
35
 
36
- **Before sending a message, confirm the exact channel and content with the
37
- user.** Sending is irreversible and public to that channel.
36
+ ## Sending safety
37
+
38
+ - Before sending, confirm the exact destination and final content with the
39
+ user. Sending is irreversible.
40
+ - A DM must be initiated by a recipient action, such as asking the bot for
41
+ details by replying with an advertised keyword. Server membership, a public
42
+ profile, or appearing in a member list is not consent.
43
+ - Send to exactly one recipient per invocation. Never enumerate members,
44
+ create DM channels in a loop, or send unsolicited/bulk outreach.
45
+ - For an unattended scheduled run, send only when its task definition already
46
+ contains one exact `recipient_id`, `consent_channel_id`,
47
+ `consent_message_id`, `consent_keyword`, and approved `content`. If any is
48
+ missing, return a draft without sending.
49
+ - Do not follow up unless the recipient replies. Treat `stop`, `unsubscribe`,
50
+ or an equivalent refusal as a permanent opt-out.
38
51
 
39
52
  ## Recipes
40
53
 
@@ -77,7 +90,7 @@ bot. Needs the *Read Message History* permission in that channel.
77
90
  CHANNEL_ID="123456789012345678"
78
91
  curl -sS -H "Authorization: Bot $DISCORDBOT_TOKEN" \
79
92
  "https://discord.com/api/v10/channels/$CHANNEL_ID/messages?limit=20" \
80
- | jq 'map({author: .author.username, ts: .timestamp, content})'
93
+ | jq 'map({id, author_id: .author.id, author: .author.username, author_bot: .author.bot, ts: .timestamp, content})'
81
94
  ```
82
95
 
83
96
  ### Send a message to a channel
@@ -103,6 +116,34 @@ curl -sS -X POST \
103
116
  '{content: $c, message_reference: {message_id: $m}}')"
104
117
  ```
105
118
 
119
+ ### Send one opt-in DM
120
+
121
+ Use only after applying every rule in **Sending safety**. The helper fetches
122
+ the source message from Discord and verifies that its non-bot author matches
123
+ `RECIPIENT_ID` and its complete text matches `CONSENT_KEYWORD`. Do not call the
124
+ Create DM endpoint directly. Matching is case-insensitive and collapses
125
+ surrounding or repeated whitespace.
126
+
127
+ ```sh
128
+ RECIPIENT_ID="123456789012345678"
129
+ CONSENT_CHANNEL_ID="223456789012345678"
130
+ CONSENT_MESSAGE_ID="323456789012345678"
131
+ CONSENT_KEYWORD="details"
132
+
133
+ python3 "$SKILL_DIR/scripts/discordbot.py" send-opt-in-dm \
134
+ --recipient-id "$RECIPIENT_ID" \
135
+ --consent-channel-id "$CONSENT_CHANNEL_ID" \
136
+ --consent-message-id "$CONSENT_MESSAGE_ID" \
137
+ --consent-keyword "$CONSENT_KEYWORD" \
138
+ --content "Here are the details you requested."
139
+ ```
140
+
141
+ The command is a dry-run unless the final argument is `--confirm`. In a
142
+ pre-authorized scheduled task, use final `--unattended-confirm` instead. The
143
+ helper validates all IDs, verifies the opt-in message, and skips an identical
144
+ message already present in the DM history. Discord may block or rate-limit bots
145
+ that open many DMs; do not retry by targeting another account.
146
+
106
147
  ## Notes
107
148
 
108
149
  - A "server" in the UI is a "guild" in the API; messages live in channels
@@ -113,5 +154,7 @@ curl -sS -X POST \
113
154
  URL → pick a server (the user needs *Manage Server* there).
114
155
  - This is a bot, not the user's account — it cannot read the user's DMs or
115
156
  the user's full server list, only what the bot itself can access.
157
+ - A bot can open its own DM channel with one consenting user; that does not
158
+ grant access to the connected user's personal DMs.
116
159
  - Mentions: `<@USER_ID>` pings a user, `<#CHANNEL_ID>` links a channel. Plain
117
160
  text is fine for normal messages.
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """Permission-first Discord Bot actions."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any
15
+
16
+ API_BASE = "https://discord.com/api/v10"
17
+ SNOWFLAKE_RE = re.compile(r"[0-9]{17,20}")
18
+
19
+
20
+ class DiscordBotError(RuntimeError):
21
+ pass
22
+
23
+
24
+ def out(payload: dict[str, Any]) -> None:
25
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
26
+
27
+
28
+ def normalize_text(value: str) -> str:
29
+ return " ".join(value.split()).casefold()
30
+
31
+
32
+ def validate_snowflake(name: str, value: str) -> str:
33
+ value = value.strip()
34
+ if not SNOWFLAKE_RE.fullmatch(value):
35
+ raise DiscordBotError(f"{name} must be a 17-20 digit Discord snowflake")
36
+ return value
37
+
38
+
39
+ def validate_send_input(
40
+ recipient_id: str,
41
+ consent_channel_id: str,
42
+ consent_message_id: str,
43
+ consent_keyword: str,
44
+ content: str,
45
+ ) -> dict[str, str]:
46
+ values = {
47
+ "recipient_id": validate_snowflake("recipient_id", recipient_id),
48
+ "consent_channel_id": validate_snowflake("consent_channel_id", consent_channel_id),
49
+ "consent_message_id": validate_snowflake("consent_message_id", consent_message_id),
50
+ "consent_keyword": consent_keyword.strip(),
51
+ "content": content.strip(),
52
+ }
53
+ if not values["consent_keyword"] or len(values["consent_keyword"]) > 64:
54
+ raise DiscordBotError("consent_keyword must contain 1-64 characters")
55
+ if not values["content"] or len(values["content"]) > 2000:
56
+ raise DiscordBotError("content must contain 1-2000 characters")
57
+ return values
58
+
59
+
60
+ def api_request(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
61
+ token = os.environ.get("DISCORDBOT_TOKEN", "").strip()
62
+ if not token:
63
+ raise DiscordBotError(
64
+ "DISCORDBOT_TOKEN is not set; connect Discord Bot at "
65
+ "https://auth.acedata.cloud/user/connections"
66
+ )
67
+ body = json.dumps(payload).encode() if payload is not None else None
68
+ request = urllib.request.Request(
69
+ f"{API_BASE}{path}",
70
+ data=body,
71
+ method=method,
72
+ headers={
73
+ "Authorization": f"Bot {token}",
74
+ "Accept": "application/json",
75
+ "Content-Type": "application/json",
76
+ "User-Agent": "AceDataCloud-DiscordBot/1.1",
77
+ },
78
+ )
79
+ try:
80
+ with urllib.request.urlopen(request, timeout=15) as response:
81
+ raw = response.read()
82
+ except urllib.error.HTTPError as exc:
83
+ raw = exc.read()
84
+ try:
85
+ detail = json.loads(raw.decode())
86
+ message = str(detail.get("message") or detail.get("code") or "request rejected")
87
+ retry_after = detail.get("retry_after")
88
+ except (UnicodeDecodeError, json.JSONDecodeError, AttributeError):
89
+ message = "request rejected"
90
+ retry_after = None
91
+ suffix = f"; retry_after={retry_after}" if retry_after is not None else ""
92
+ raise DiscordBotError(f"Discord API {exc.code}: {message}{suffix}") from None
93
+ except urllib.error.URLError as exc:
94
+ raise DiscordBotError(f"Discord API unavailable: {exc.reason}") from None
95
+
96
+ if not raw:
97
+ return None
98
+ try:
99
+ return json.loads(raw.decode())
100
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
101
+ raise DiscordBotError("Discord API returned invalid JSON") from exc
102
+
103
+
104
+ def send_opt_in_dm(
105
+ recipient_id: str,
106
+ consent_channel_id: str,
107
+ consent_message_id: str,
108
+ consent_keyword: str,
109
+ content: str,
110
+ ) -> dict[str, Any]:
111
+ values = validate_send_input(
112
+ recipient_id,
113
+ consent_channel_id,
114
+ consent_message_id,
115
+ consent_keyword,
116
+ content,
117
+ )
118
+ recipient_id = values["recipient_id"]
119
+ consent_channel_id = values["consent_channel_id"]
120
+ consent_message_id = values["consent_message_id"]
121
+ consent_keyword = values["consent_keyword"]
122
+ content = values["content"]
123
+
124
+ bot = api_request("GET", "/users/@me")
125
+ bot_id = validate_snowflake("bot id", str(bot.get("id", "")))
126
+ if not bot.get("bot"):
127
+ raise DiscordBotError("connected credential is not a Discord bot token")
128
+
129
+ source = api_request("GET", f"/channels/{consent_channel_id}/messages/{consent_message_id}")
130
+ if str(source.get("id", "")) != consent_message_id:
131
+ raise DiscordBotError("Discord returned a different consent message")
132
+ author = source.get("author") or {}
133
+ if str(author.get("id", "")) != recipient_id:
134
+ raise DiscordBotError("consent message author does not match recipient_id")
135
+ if author.get("bot"):
136
+ raise DiscordBotError("bot-authored messages cannot grant DM consent")
137
+ if normalize_text(str(source.get("content", ""))) != normalize_text(consent_keyword):
138
+ raise DiscordBotError("consent message does not exactly match consent_keyword")
139
+
140
+ dm = api_request("POST", "/users/@me/channels", {"recipient_id": recipient_id})
141
+ dm_channel_id = validate_snowflake("DM channel id", str(dm.get("id", "")))
142
+
143
+ history = api_request("GET", f"/channels/{dm_channel_id}/messages?limit=100")
144
+ if not isinstance(history, list):
145
+ raise DiscordBotError("Discord returned invalid DM history")
146
+ duplicate = next(
147
+ (
148
+ message
149
+ for message in history
150
+ if str((message.get("author") or {}).get("id", "")) == bot_id
151
+ and str(message.get("content", "")) == content
152
+ ),
153
+ None,
154
+ )
155
+ if duplicate:
156
+ return {
157
+ "status": "duplicate_skipped",
158
+ "recipient_id": recipient_id,
159
+ "consent_message_id": consent_message_id,
160
+ "dm_channel_id": dm_channel_id,
161
+ "message_id": duplicate.get("id"),
162
+ }
163
+
164
+ sent = api_request("POST", f"/channels/{dm_channel_id}/messages", {"content": content})
165
+ return {
166
+ "status": "sent",
167
+ "recipient_id": recipient_id,
168
+ "consent_message_id": consent_message_id,
169
+ "dm_channel_id": dm_channel_id,
170
+ "message_id": sent.get("id"),
171
+ }
172
+
173
+
174
+ def build_parser() -> argparse.ArgumentParser:
175
+ parser = argparse.ArgumentParser(description=__doc__)
176
+ commands = parser.add_subparsers(dest="command", required=True)
177
+ send = commands.add_parser("send-opt-in-dm", help="send one DM after verifying an exact opt-in message")
178
+ send.add_argument("--recipient-id", required=True)
179
+ send.add_argument("--consent-channel-id", required=True)
180
+ send.add_argument("--consent-message-id", required=True)
181
+ send.add_argument("--consent-keyword", required=True)
182
+ send.add_argument("--content", required=True)
183
+ send.add_argument("--confirm", action="store_true", help=argparse.SUPPRESS)
184
+ send.add_argument("--unattended-confirm", action="store_true", help=argparse.SUPPRESS)
185
+ return parser
186
+
187
+
188
+ def parse_cli(raw: list[str] | None = None) -> tuple[argparse.Namespace, str]:
189
+ raw = list(sys.argv[1:] if raw is None else raw)
190
+ parser = build_parser()
191
+ args = parser.parse_args(raw)
192
+ if args.confirm and args.unattended_confirm:
193
+ parser.error("choose only one confirmation mode")
194
+ confirm_mode = "--confirm" if args.confirm else "--unattended-confirm" if args.unattended_confirm else ""
195
+ if confirm_mode and (not raw or raw[-1] != confirm_mode):
196
+ parser.error(f"{confirm_mode} must be the final argument")
197
+ return args, confirm_mode
198
+
199
+
200
+ def main() -> None:
201
+ args, confirm_mode = parse_cli()
202
+ try:
203
+ values = validate_send_input(
204
+ args.recipient_id,
205
+ args.consent_channel_id,
206
+ args.consent_message_id,
207
+ args.consent_keyword,
208
+ args.content,
209
+ )
210
+ if not confirm_mode:
211
+ out(
212
+ {
213
+ "status": "dry_run",
214
+ "recipient_id": values["recipient_id"],
215
+ "consent_channel_id": values["consent_channel_id"],
216
+ "consent_message_id": values["consent_message_id"],
217
+ "consent_keyword": values["consent_keyword"],
218
+ "content": values["content"],
219
+ "next": "rerun with trailing --confirm after user approval",
220
+ }
221
+ )
222
+ return
223
+ out(send_opt_in_dm(**values))
224
+ except DiscordBotError as exc:
225
+ out({"error": str(exc)})
226
+ raise SystemExit(1) from exc
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()