@acedatacloud/skills 2026.804.1 → 2026.804.2

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.804.1",
3
+ "version": "2026.804.2",
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,18 +1,19 @@
1
1
  ---
2
2
  name: personal-wechat
3
- description: Operate the user's personal WeChat account through their self-hosted Wisdom service (BYOC) — check login status, list contacts/conversations, read and summarize history, search contacts, refresh the local history DB, and send messages only after explicit confirmation. Use when the user mentions 个人微信, 我的微信, WeChat personal chat, 微信聊天记录, 微信联系人, reading/summarizing WeChat messages, or sending a WeChat message.
3
+ description: Operate the user's personal WeChat account through their self-hosted Wisdom service (BYOC) — check status, list contacts/conversations, read messages, poll for new ones, search contacts, browse Moments, and (after explicit confirmation) send messages with real @-mentions, post or delete Moments, and manage group chats. Use when the user mentions 个人微信, 我的微信, WeChat personal chat, 微信聊天记录, 微信联系人, 微信群, 朋友圈, reading/summarizing WeChat messages, or sending a WeChat message.
4
4
  when_to_use: |
5
5
  Trigger for the user's personal WeChat account via their own Wisdom server:
6
- check status/account, list contacts, list recent conversations, read or
7
- summarize a chat, query local history, search contacts, or send a message.
8
- This acts on the user's real desktop WeChat, so writes are gated behind
9
- explicit confirmation.
6
+ check status/account, list contacts, list conversations, read or summarize a
7
+ chat, poll for new messages, search contacts, browse Moments, send a message
8
+ (with real @-mentions, quote-replies, or media), publish/delete a Moment, or
9
+ create/invite/remove/rename a group. This acts on the user's real desktop
10
+ WeChat, so every write is gated behind explicit confirmation.
10
11
  connections: [personalwechat]
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
19
  # Personal WeChat via Wisdom
@@ -23,17 +24,17 @@ an HTTP API.
23
24
 
24
25
  Credentials are injected by the `personalwechat` BYOC connector:
25
26
 
26
- - `PERSONALWECHAT_BASE_URL` — Wisdom server base URL, e.g. `http://82.156.126.14:8000`.
27
+ - `PERSONALWECHAT_BASE_URL` — Wisdom server base URL, e.g. `http://203.0.113.10:8000`.
27
28
  - `PERSONALWECHAT_API_TOKEN` — Wisdom `API_TOKEN`. Secret — never echo, print, or log it.
28
29
 
29
30
  The helper sends the token as `Authorization: Bearer ...`; it never puts the
30
- token in the URL. For queued UI operations such as search and send, the helper
31
- submits the task and waits for `/api/tasks/{id}` before printing the final
32
- result.
31
+ token in the URL. Wisdom answers every UI-driven action (search, send, Moments,
32
+ groups) with `202` + a task ID; the helper polls `/api/tasks/{id}` for you and
33
+ prints only the final result.
33
34
 
34
- This is the user's **real personal WeChat account**. Read operations can run
35
- directly. Sending messages or files must be dry-run first, then performed only
36
- after the user explicitly approves the exact target and content.
35
+ This is the user's **real personal WeChat account**. Read operations run
36
+ directly. Every write — send, Moment, group change — dry-runs first and only
37
+ executes after the user explicitly approves that exact payload.
37
38
 
38
39
  ## CLI
39
40
 
@@ -57,12 +58,23 @@ python3 $WX status
57
58
  Expected healthy shape:
58
59
 
59
60
  ```json
60
- {"auth":{"logged_in":true,"wechat_running":true,"page":"logged_in"}}
61
+ {"status":"ready","since":"…","wechat_running":true,"logged_in":true,"error":null}
61
62
  ```
62
63
 
63
- If `logged_in=false`, tell the user to open the Wisdom web UI / RDP and scan the
64
- WeChat QR code. If the API returns 401, ask the user to reconnect the Personal
65
- WeChat connector with the current Wisdom API token.
64
+ `/api/status` is the **single** source of truth for connectivity. Judge it by
65
+ `status`, `wechat_running` and `logged_in` — nothing else.
66
+
67
+ | Symptom | Meaning |
68
+ |---|---|
69
+ | `status: "ready"`, `logged_in: true` | Healthy — proceed. |
70
+ | `status: "qr_scan"` / `logged_in: false` | Ask the user to open the Wisdom web UI / RDP and scan the WeChat QR code. |
71
+ | `status: "preparing"` | The decrypted DB snapshot is still being built. Wait and retry; reads may fail until it finishes. |
72
+ | `status: "error"` | Report `error.code` / `error.message` to the user. |
73
+ | HTTP 401 | Ask the user to reconnect the Personal WeChat connector with the current Wisdom API token. |
74
+ | Connection refused / timeout | Ask the user to check the Windows host, security group, and port 8000. |
75
+
76
+ Never infer "WeChat is offline" from a 404 on some other path — a 404 means that
77
+ route does not exist on this Wisdom version, not that the account is logged out.
66
78
 
67
79
  ## Read Workflows
68
80
 
@@ -76,58 +88,59 @@ python3 $WX account
76
88
 
77
89
  ```bash
78
90
  python3 $WX contacts --limit 50
91
+ python3 $WX contacts --type group --limit 100
92
+ python3 $WX contacts --limit 100 --offset 100
79
93
  ```
80
94
 
81
- ### Recent Conversations
95
+ `--limit` is clamped to 200 per page (Wisdom's ceiling). Page with `--offset`
96
+ rather than asking for a bigger limit.
82
97
 
83
- Use the normal conversations endpoint first; it prefers WeChat DB when ready and
84
- falls back to Wisdom's app DB.
98
+ ### Conversations
85
99
 
86
100
  ```bash
87
101
  python3 $WX conversations --limit 20
88
102
  ```
89
103
 
90
- For decrypted local WeChat history sessions:
91
-
92
- ```bash
93
- python3 $WX conversations --history --limit 20
94
- ```
104
+ Each entry's `id` is what `messages` takes as `conversation_id`. This reads the
105
+ decrypted WeChat database directly and already covers full history — there is no
106
+ separate "history" endpoint.
95
107
 
96
108
  ### Messages in a Conversation
97
109
 
98
- First list conversations, then use the `id` as `conversation_id`:
99
-
100
110
  ```bash
101
111
  python3 $WX messages "CONVERSATION_ID" --limit 50 --order asc
112
+ python3 $WX messages "CONVERSATION_ID" --limit 50 --offset 50
102
113
  ```
103
114
 
104
- ### Historical Messages
115
+ Output includes `mentions`, `quoted_text` and `conversation_type`, so you can see
116
+ who was @-mentioned and what a 引用 reply was replying to.
105
117
 
106
- Read from Wisdom's decrypted WeChat local databases:
118
+ ### New Messages Since a Timestamp
107
119
 
108
120
  ```bash
109
- python3 $WX history --limit 50
110
- python3 $WX history --talker "CONVERSATION_ID" --limit 50
111
- python3 $WX history --limit 20 --offset 20
121
+ python3 $WX poll --since 1785000000 --limit 100
112
122
  ```
113
123
 
114
- ### Raw SQL, Read-Only Only
124
+ `--since` is a Unix timestamp in seconds. Use this to catch up after a gap
125
+ instead of re-reading a whole conversation.
115
126
 
116
- Wisdom permits only `SELECT` and `PRAGMA`:
127
+ ### Moments (朋友圈)
117
128
 
118
129
  ```bash
119
- python3 $WX sql MicroMsg.db 'SELECT count(*) AS cnt FROM Session'
130
+ python3 $WX moments --limit 30
131
+ python3 $WX moments --limit 30 --self-only
120
132
  ```
121
133
 
122
- Use raw SQL only for diagnostics or targeted metadata queries. Do not dump large
123
- message tables unless the user explicitly asks.
134
+ This is a synced view cache, so a just-deleted post may linger until WeChat
135
+ re-syncs.
124
136
 
125
- ### Refresh History DB
137
+ ### Tasks
126
138
 
127
- If history looks stale, refresh the decrypted DB snapshot:
139
+ Inspect queued/finished UI tasks when a write seems stuck:
128
140
 
129
141
  ```bash
130
- python3 $WX refresh-history
142
+ python3 $WX tasks --limit 20
143
+ python3 $WX task TASK_ID
131
144
  ```
132
145
 
133
146
  ## Search
@@ -136,28 +149,94 @@ python3 $WX refresh-history
136
149
  python3 $WX search "Alice"
137
150
  ```
138
151
 
139
- Search drives the WeChat UI, so it may be slower than local DB history reads.
152
+ Search drives the WeChat UI, so it is slower than the local DB reads above. Use
153
+ it to confirm a target exists before sending.
154
+
155
+ ## Writes — ALL GATED
156
+
157
+ Every write command below dry-runs by default, printing the exact payload it
158
+ would submit. It executes only with `--confirm`, or with `--unattended-confirm`
159
+ when an AceDataCloud scheduled task pre-authorized this Skill.
140
160
 
141
- ## Sending Messages — GATED
161
+ Show the dry-run to the user, get explicit approval of the exact target and
162
+ content, then re-run with `--confirm`. Never put `--confirm` in the first
163
+ attempt. Never infer consent from vague text.
142
164
 
143
- `send` dry-runs by default. It never sends unless `--confirm` is present, or
144
- unless an AceDataCloud scheduled task pre-authorized this Skill and you use
145
- `--unattended-confirm`.
165
+ ### Send a Message
146
166
 
147
167
  ```bash
148
168
  python3 $WX send "Alice" "今晚 8 点开会吗?"
149
- # -> {"dry_run": true, ...}
169
+ # -> {"dry_run": true, "action": "send", ...}
170
+ python3 $WX send "Alice" "今晚 8 点开会吗?" --confirm
150
171
  ```
151
172
 
152
- Show the dry-run output to the user and ask for explicit approval of the exact
153
- recipient and text. Only then run:
173
+ **Real @-mentions in a group.** Pass `--mention` (repeatable) — Wisdom drives
174
+ WeChat's own mention popover, producing a genuine @-notification. Do NOT type
175
+ `@Name` into the message text and hope; that is inert text that notifies nobody.
154
176
 
155
177
  ```bash
156
- python3 $WX send "Alice" "今晚 8 点开会吗?" --confirm
178
+ python3 $WX send "项目群" "记得今天交周报" --mention "Doms Jay" --confirm
179
+ python3 $WX send "项目群" "记得今天交周报" --mention "Alice" --mention "Bob" --confirm
180
+ python3 $WX send "项目群" "全员通知" --mention-all --confirm # owner/admin only
157
181
  ```
158
182
 
159
- Never add `--confirm` in the first attempt. Never infer consent from vague text.
160
- The user must clearly approve sending this exact message.
183
+ The send result echoes `mentions`. If it comes back `null` after you passed
184
+ `--mention`, the mention did not register — tell the user instead of claiming
185
+ the @ succeeded.
186
+
187
+ **Quote-reply.** Replies to the newest message in that chat whose text matches:
188
+
189
+ ```bash
190
+ python3 $WX send "Alice" "这个我来跟" --quote-text "这个 bug 谁跟一下" --confirm
191
+ ```
192
+
193
+ `--quote-text` takes precedence over mentions, and falls back to a plain send if
194
+ no matching message is found.
195
+
196
+ **Media.** Wisdom downloads the URL on the Windows host and sends the file:
197
+
198
+ ```bash
199
+ python3 $WX send "Alice" --type image --image-url https://example.com/a.png --confirm
200
+ python3 $WX send "Alice" --type video --video-url https://example.com/a.mp4 --confirm
201
+ python3 $WX send "Alice" --type file --file-url https://example.com/a.pdf --confirm
202
+ ```
203
+
204
+ **Retries.** A send whose outcome you never saw may still have been delivered.
205
+ When retrying, pass the same `--idempotency-key` so Wisdom returns the original
206
+ task instead of sending twice:
207
+
208
+ ```bash
209
+ python3 $WX send "Alice" "hi" --idempotency-key daily-2026-08-04 --confirm
210
+ ```
211
+
212
+ ### Moments (朋友圈)
213
+
214
+ ```bash
215
+ python3 $WX moment-post "今天上线了新功能"
216
+ python3 $WX moment-post "看看这张图" --image-url https://example.com/a.png --visibility public --confirm
217
+ ```
218
+
219
+ `--visibility` is `public` / `private` / `partial` / `exclude`
220
+ (公开 / 私密 / 部分可见 / 不给谁看), defaulting to `public`.
221
+
222
+ Deleting is **irreversible**. `match` must be a distinctive substring of one of
223
+ the user's own Moments; Wisdom refuses ambiguous matches rather than guessing:
224
+
225
+ ```bash
226
+ python3 $WX moment-delete "Veo Videos Generation API"
227
+ python3 $WX moment-delete "Veo Videos Generation API" --confirm
228
+ ```
229
+
230
+ ### Group Chats
231
+
232
+ These are visible to other people — always confirm the exact member list first.
233
+
234
+ ```bash
235
+ python3 $WX group-create "Alice" "Bob" --name "项目群" --confirm # needs >= 2 members
236
+ python3 $WX group-invite "项目群" "Carol" --confirm
237
+ python3 $WX group-remove "项目群" "Carol" --confirm # kicks; visible to the group
238
+ python3 $WX group-rename "项目群" "项目群 2026" --confirm # renames for everyone
239
+ ```
161
240
 
162
241
  ### Scheduled-task unattended confirmation
163
242
 
@@ -169,7 +248,7 @@ specific Skills for unattended execution. If all of these are true:
169
248
  - `AICHAT_ACTIVE_SKILL` appears in `AICHAT_UNATTENDED_ALLOWED_SKILLS`
170
249
 
171
250
  then the user has pre-authorized this Skill for that scheduled task. In that
172
- case, use:
251
+ case, use `--unattended-confirm` in place of `--confirm`:
173
252
 
174
253
  ```bash
175
254
  python3 $WX send "Alice" "今晚 8 点开会吗?" --unattended-confirm
@@ -183,25 +262,32 @@ selected in its unattended authorization settings.
183
262
 
184
263
  - Never print `PERSONALWECHAT_API_TOKEN`.
185
264
  - Treat `PERSONALWECHAT_BASE_URL + API_TOKEN` as full remote control of the user's WeChat.
186
- - For normal chat write/send operations: dry-run first, ask for explicit approval, then re-run with `--confirm`.
187
- - For scheduled-task unattended writes: use `--unattended-confirm` only when the platform env says this Skill is pre-authorized.
188
- - Do not call logout/restart endpoints from the skill unless the user explicitly asks to repair the Wisdom service.
189
- - If Wisdom returns 503 for history, run `python3 $WX refresh-history` once, then retry the read.
190
- - If the server is unreachable, ask the user to check the Windows host / security group / port 8000.
265
+ - Dry-run every write first, ask for explicit approval, then re-run with `--confirm`.
266
+ - Use `--unattended-confirm` only when the platform env says this Skill is pre-authorized.
267
+ - Group and Moment writes are visible to other people; `moment-delete` is irreversible.
268
+ - Report what actually happened. If `mentions` came back `null`, the @ did not register.
269
+ - Do not call restart/logout endpoints unless the user explicitly asks to repair the service.
270
+ - Judge connectivity only by `python3 $WX status`. A 404 on another path means that route
271
+ does not exist, not that WeChat is offline.
191
272
 
192
273
  ## Endpoint Mapping
193
274
 
194
275
  The helper wraps these Wisdom endpoints:
195
276
 
196
- - `GET /api/status`
197
- - `GET /api/auth/status`
198
- - `GET /api/account`
199
- - `GET /api/contacts?version=2.0`
200
- - `GET /api/conversations`
201
- - `GET /api/conversations/history`
202
- - `GET /api/messages`
203
- - `GET /api/messages/history`
204
- - `POST /api/messages/history/query`
205
- - `POST /api/messages/history/refresh`
206
- - `POST /api/search`
207
- - `POST /api/messages/send` (only after `--confirm` or verified `--unattended-confirm`)
277
+ | Command | Endpoint |
278
+ |---|---|
279
+ | `status` | `GET /api/status` |
280
+ | `account` | `GET /api/account` |
281
+ | `contacts` | `GET /api/contacts` |
282
+ | `conversations` | `GET /api/conversations` |
283
+ | `messages` | `GET /api/messages` |
284
+ | `poll` | `GET /api/messages/poll` |
285
+ | `moments` | `GET /api/moments` |
286
+ | `tasks` / `task` | `GET /api/tasks`, `GET /api/tasks/{id}` |
287
+ | `search` | `POST /api/search` |
288
+ | `send` | `POST /api/messages/send` |
289
+ | `moment-post` / `moment-delete` | `POST /api/moments`, `DELETE /api/moments` |
290
+ | `group-create` / `-invite` / `-remove` / `-rename` | `POST /api/groups`, `/invite`, `/remove`, `/rename` |
291
+
292
+ Everything from `search` down is a queued UI task (`202` + task ID); the write
293
+ ones additionally need `--confirm` or a verified `--unattended-confirm`.
@@ -20,6 +20,10 @@ API_TOKEN = os.environ.get("PERSONALWECHAT_API_TOKEN", "")
20
20
  MAX_TEXT_CHARS = 800
21
21
  SKILL_SLUGS = {"personal-wechat", "acedatacloud/personal-wechat"}
22
22
 
23
+ # Wisdom rejects an over-ceiling limit with 422 instead of clamping, so clamp here: a
24
+ # generous --limit degrades to the largest page rather than failing the whole command.
25
+ MAX_LIMIT = {"contacts": 200, "conversations": 200, "messages": 200, "poll": 500, "moments": 200, "tasks": 200}
26
+
23
27
 
24
28
  def _die(message: str, code: int = 1) -> None:
25
29
  print(json.dumps({"error": message}, ensure_ascii=False), file=sys.stderr)
@@ -30,6 +34,10 @@ def _json(data) -> None:
30
34
  print(json.dumps(data, ensure_ascii=False, default=str))
31
35
 
32
36
 
37
+ def clamp(value: int, ceiling: int) -> int:
38
+ return max(1, min(value, ceiling))
39
+
40
+
33
41
  def request(method: str, path: str, *, params: dict | None = None, body: dict | None = None):
34
42
  if not BASE_URL:
35
43
  _die("PERSONALWECHAT_BASE_URL is not set. Reconnect the Personal WeChat connector.")
@@ -91,33 +99,80 @@ def request_task(method: str, path: str, *, params: dict | None = None, body: di
91
99
 
92
100
  def compact_conversation(item: dict) -> dict:
93
101
  return {
94
- "id": item.get("id") or item.get("strUsrName"),
95
- "name": item.get("name") or item.get("display_name") or item.get("strNickName"),
102
+ "id": item.get("id"),
103
+ "name": item.get("name"),
96
104
  "type": item.get("type"),
97
- "unread_count": item.get("unread_count") if "unread_count" in item else item.get("nUnReadCount"),
98
- "last_active_at": item.get("last_active_at") or item.get("nTime"),
105
+ "is_pinned": item.get("is_pinned"),
106
+ "unread_count": item.get("unread_count"),
107
+ "last_active_at": item.get("last_active_at"),
99
108
  "last_message_count": len(item.get("messages") or []),
100
109
  }
101
110
 
102
111
 
103
112
  def compact_message(item: dict) -> dict:
104
- text = item.get("text") or item.get("StrContent") or item.get("DisplayContent")
113
+ text = item.get("text")
105
114
  if isinstance(text, str) and len(text) > MAX_TEXT_CHARS:
106
115
  text = text[:MAX_TEXT_CHARS] + f"... [truncated {len(text) - MAX_TEXT_CHARS} chars]"
107
116
  return {
108
- "id": item.get("id") or item.get("MsgSvrID") or item.get("localId"),
109
- "conversation_id": item.get("conversation_id") or item.get("StrTalker"),
117
+ "id": item.get("id"),
118
+ "conversation_id": item.get("conversation_id"),
110
119
  "conversation_name": item.get("conversation_name"),
120
+ "conversation_type": item.get("conversation_type"),
111
121
  "sender_id": item.get("sender_id"),
112
122
  "sender_name": item.get("sender_name"),
113
123
  "direction": item.get("direction"),
114
- "type": item.get("type") or item.get("Type"),
124
+ "type": item.get("type"),
115
125
  "text": text,
116
- "sent_at": item.get("sent_at") or item.get("CreateTime"),
126
+ "mentions": item.get("mentions"),
127
+ "quoted_text": item.get("quoted_text"),
128
+ "sent_at": item.get("sent_at"),
117
129
  }
118
130
 
119
131
 
120
- def main() -> None:
132
+ def compact_moment(item: dict) -> dict:
133
+ return {
134
+ "feed_id": item.get("feed_id"),
135
+ "author_name": item.get("author_name"),
136
+ "is_self": item.get("is_self"),
137
+ "caption": item.get("caption"),
138
+ "article_title": item.get("article_title"),
139
+ "article_url": item.get("article_url"),
140
+ "media_count": item.get("media_count"),
141
+ "create_time": item.get("create_time"),
142
+ }
143
+
144
+
145
+ def gate(args, *, action: str, preview: dict) -> bool:
146
+ """Return True when the write may proceed, else print the dry-run and stop.
147
+
148
+ Writes touch the user's real WeChat account, so they need either explicit approval
149
+ of this exact payload (--confirm) or a platform pre-authorization (--unattended-confirm).
150
+ """
151
+ if getattr(args, "unattended_confirm", False):
152
+ allowed, reason = unattended_confirm_allowed(SKILL_SLUGS)
153
+ if allowed:
154
+ return True
155
+ _json({"dry_run": True, "action": action, **preview, "error": "unattended_confirmation_denied", "reason": reason})
156
+ return False
157
+ if getattr(args, "confirm", False):
158
+ return True
159
+ _json(
160
+ {
161
+ "dry_run": True,
162
+ "action": action,
163
+ **preview,
164
+ "note": "Re-run with --confirm after explicit user approval, or --unattended-confirm when this Skill is pre-authorized for an AceDataCloud scheduled task.",
165
+ }
166
+ )
167
+ return False
168
+
169
+
170
+ def add_write_flags(parser: argparse.ArgumentParser) -> None:
171
+ parser.add_argument("--confirm", action="store_true")
172
+ parser.add_argument("--unattended-confirm", action="store_true")
173
+
174
+
175
+ def build_parser() -> argparse.ArgumentParser:
121
176
  parser = argparse.ArgumentParser(description="Personal WeChat (Wisdom) CLI")
122
177
  sub = parser.add_subparsers(dest="cmd", required=True)
123
178
 
@@ -126,10 +181,12 @@ def main() -> None:
126
181
 
127
182
  contacts = sub.add_parser("contacts")
128
183
  contacts.add_argument("--limit", type=int, default=20)
184
+ contacts.add_argument("--offset", type=int, default=0)
185
+ contacts.add_argument("--type", choices=["friend", "group", "official"], default=None)
129
186
 
130
187
  convs = sub.add_parser("conversations")
131
188
  convs.add_argument("--limit", type=int, default=20)
132
- convs.add_argument("--history", action="store_true")
189
+ convs.add_argument("--offset", type=int, default=0)
133
190
 
134
191
  msgs = sub.add_parser("messages")
135
192
  msgs.add_argument("conversation_id")
@@ -137,73 +194,179 @@ def main() -> None:
137
194
  msgs.add_argument("--offset", type=int, default=0)
138
195
  msgs.add_argument("--order", choices=["asc", "desc"], default="asc")
139
196
 
140
- hist = sub.add_parser("history")
141
- hist.add_argument("--talker", default="")
142
- hist.add_argument("--limit", type=int, default=50)
143
- hist.add_argument("--offset", type=int, default=0)
144
-
145
- sql = sub.add_parser("sql")
146
- sql.add_argument("db")
147
- sql.add_argument("sql")
197
+ poll = sub.add_parser("poll")
198
+ poll.add_argument("--since", type=int, required=True, help="Unix timestamp in seconds")
199
+ poll.add_argument("--limit", type=int, default=100)
148
200
 
149
201
  search = sub.add_parser("search")
150
202
  search.add_argument("query")
151
203
 
152
204
  send = sub.add_parser("send")
153
205
  send.add_argument("target")
154
- send.add_argument("text")
155
- send.add_argument("--confirm", action="store_true")
156
- send.add_argument("--unattended-confirm", action="store_true")
206
+ send.add_argument("text", nargs="?", default=None)
207
+ send.add_argument("--type", choices=["text", "image", "video", "file"], default="text")
208
+ send.add_argument("--image-url", default=None)
209
+ send.add_argument("--video-url", default=None)
210
+ send.add_argument("--file-url", default=None)
211
+ send.add_argument("--mention", action="append", default=None, metavar="NAME", help="Real @-mention of a group member; repeatable")
212
+ send.add_argument("--mention-all", action="store_true", help="@-mention everyone (group owner/admin only)")
213
+ send.add_argument("--quote-text", default=None, help="Quote-reply to the newest message whose text matches this")
214
+ send.add_argument("--idempotency-key", default=None)
215
+ add_write_flags(send)
216
+
217
+ moments = sub.add_parser("moments")
218
+ moments.add_argument("--limit", type=int, default=30)
219
+ moments.add_argument("--self-only", action="store_true")
220
+
221
+ moment_post = sub.add_parser("moment-post")
222
+ moment_post.add_argument("text", nargs="?", default=None)
223
+ moment_post.add_argument("--image-url", action="append", default=None, metavar="URL")
224
+ moment_post.add_argument("--visibility", choices=["public", "private", "partial", "exclude"], default="public")
225
+ add_write_flags(moment_post)
226
+
227
+ moment_delete = sub.add_parser("moment-delete")
228
+ moment_delete.add_argument("match", help="Distinctive substring of one of your own Moments; must be unique")
229
+ add_write_flags(moment_delete)
230
+
231
+ group_create = sub.add_parser("group-create")
232
+ group_create.add_argument("members", nargs="+", help="Names of at least 2 contacts")
233
+ group_create.add_argument("--name", default=None)
234
+ add_write_flags(group_create)
235
+
236
+ group_invite = sub.add_parser("group-invite")
237
+ group_invite.add_argument("group")
238
+ group_invite.add_argument("members", nargs="+")
239
+ add_write_flags(group_invite)
240
+
241
+ group_remove = sub.add_parser("group-remove")
242
+ group_remove.add_argument("group")
243
+ group_remove.add_argument("members", nargs="+")
244
+ add_write_flags(group_remove)
245
+
246
+ group_rename = sub.add_parser("group-rename")
247
+ group_rename.add_argument("group")
248
+ group_rename.add_argument("new_name")
249
+ add_write_flags(group_rename)
250
+
251
+ tasks = sub.add_parser("tasks")
252
+ tasks.add_argument("--limit", type=int, default=20)
253
+
254
+ task = sub.add_parser("task")
255
+ task.add_argument("task_id")
256
+
257
+ return parser
258
+
259
+
260
+ def build_send_body(args) -> dict:
261
+ if args.type == "text" and not args.text:
262
+ _die("text is required for --type text")
263
+ media = {"image": args.image_url, "video": args.video_url, "file": args.file_url}.get(args.type)
264
+ if args.type != "text" and not media:
265
+ _die(f"--{args.type}-url is required for --type {args.type}")
266
+
267
+ body = {"target": args.target, "type": args.type, "text": args.text}
268
+ if args.image_url:
269
+ body["image_url"] = args.image_url
270
+ if args.video_url:
271
+ body["video_url"] = args.video_url
272
+ if args.file_url:
273
+ body["file_url"] = args.file_url
274
+ if args.mention:
275
+ body["mentions"] = args.mention
276
+ if args.mention_all:
277
+ body["mention_all"] = True
278
+ if args.quote_text:
279
+ body["quote_text"] = args.quote_text
280
+ if args.idempotency_key:
281
+ body["idempotency_key"] = args.idempotency_key
282
+ return body
157
283
 
158
- refresh = sub.add_parser("refresh-history")
159
- refresh.set_defaults(cmd="refresh-history")
160
284
 
161
- args = parser.parse_args()
285
+ def main() -> None:
286
+ args = build_parser().parse_args()
162
287
 
163
288
  if args.cmd == "status":
164
- _json({"status": request("GET", "/api/status"), "auth": request("GET", "/api/auth/status")})
289
+ _json(request("GET", "/api/status"))
165
290
  elif args.cmd == "account":
166
291
  _json(request("GET", "/api/account"))
167
292
  elif args.cmd == "contacts":
168
- data = request("GET", "/api/contacts", params={"limit": args.limit, "version": "2.0"})
293
+ params = {"limit": clamp(args.limit, MAX_LIMIT["contacts"]), "offset": args.offset}
294
+ if args.type:
295
+ params["type"] = args.type
296
+ data = request("GET", "/api/contacts", params=params)
169
297
  _json({"total": data.get("total"), "contacts": data.get("contacts", [])})
170
298
  elif args.cmd == "conversations":
171
- if args.history:
172
- data = request("GET", "/api/conversations/history", params={"limit": args.limit})
173
- _json({"count": data.get("count"), "conversations": [compact_conversation(i) for i in data.get("conversations", [])]})
174
- else:
175
- data = request("GET", "/api/conversations", params={"limit": args.limit})
176
- _json({"total": data.get("total"), "conversations": [compact_conversation(i) for i in data.get("conversations", [])]})
299
+ params = {"limit": clamp(args.limit, MAX_LIMIT["conversations"]), "offset": args.offset}
300
+ data = request("GET", "/api/conversations", params=params)
301
+ _json({"total": data.get("total"), "conversations": [compact_conversation(i) for i in data.get("conversations", [])]})
177
302
  elif args.cmd == "messages":
178
303
  data = request(
179
304
  "GET",
180
305
  "/api/messages",
181
- params={"conversation_id": args.conversation_id, "limit": args.limit, "offset": args.offset, "order": args.order},
306
+ params={
307
+ "conversation_id": args.conversation_id,
308
+ "limit": clamp(args.limit, MAX_LIMIT["messages"]),
309
+ "offset": args.offset,
310
+ "order": args.order,
311
+ },
182
312
  )
183
313
  _json([compact_message(i) for i in data])
184
- elif args.cmd == "history":
185
- params = {"limit": args.limit, "offset": args.offset}
186
- if args.talker:
187
- params["talker"] = args.talker
188
- data = request("GET", "/api/messages/history", params=params)
189
- _json({"count": data.get("count"), "db_ready": data.get("db_ready"), "messages": [compact_message(i) for i in data.get("messages", [])]})
190
- elif args.cmd == "sql":
191
- data = request("POST", "/api/messages/history/query", body={"db": args.db, "sql": args.sql})
192
- _json(data)
314
+ elif args.cmd == "poll":
315
+ data = request("GET", "/api/messages/poll", params={"since": args.since, "limit": clamp(args.limit, MAX_LIMIT["poll"])})
316
+ _json([compact_message(i) for i in data])
193
317
  elif args.cmd == "search":
194
318
  _json(request_task("POST", "/api/search", body={"query": args.query}, timeout=120))
195
319
  elif args.cmd == "send":
196
- if args.unattended_confirm:
197
- allowed, reason = unattended_confirm_allowed(SKILL_SLUGS)
198
- if not allowed:
199
- _json({"dry_run": True, "target": args.target, "text": args.text, "error": "unattended_confirmation_denied", "reason": reason})
200
- return
201
- elif not args.confirm:
202
- _json({"dry_run": True, "target": args.target, "text": args.text, "note": "Re-run with --confirm after explicit user approval, or --unattended-confirm when this Skill is pre-authorized for an AceDataCloud scheduled task."})
320
+ body = build_send_body(args)
321
+ if not gate(args, action="send", preview={"target": args.target, "payload": body}):
322
+ return
323
+ _json(request_task("POST", "/api/messages/send", body=body))
324
+ elif args.cmd == "moments":
325
+ params = {"limit": clamp(args.limit, MAX_LIMIT["moments"]), "self_only": "true" if args.self_only else "false"}
326
+ data = request("GET", "/api/moments", params=params)
327
+ _json({"total": data.get("total"), "moments": [compact_moment(i) for i in data.get("moments", [])]})
328
+ elif args.cmd == "moment-post":
329
+ if not args.text and not args.image_url:
330
+ _die("a Moment needs text, images, or both")
331
+ body = {"text": args.text, "visibility": args.visibility}
332
+ if args.image_url:
333
+ body["image_urls"] = args.image_url
334
+ if not gate(args, action="moment-post", preview={"payload": body}):
335
+ return
336
+ _json(request_task("POST", "/api/moments", body=body))
337
+ elif args.cmd == "moment-delete":
338
+ body = {"match": args.match}
339
+ if not gate(args, action="moment-delete", preview={"payload": body, "warning": "Deleting a Moment is irreversible."}):
340
+ return
341
+ _json(request_task("DELETE", "/api/moments", body=body))
342
+ elif args.cmd == "group-create":
343
+ if len(args.members) < 2:
344
+ _die("group-create needs at least 2 members")
345
+ body = {"members": args.members}
346
+ if args.name:
347
+ body["name"] = args.name
348
+ if not gate(args, action="group-create", preview={"payload": body}):
349
+ return
350
+ _json(request_task("POST", "/api/groups", body=body))
351
+ elif args.cmd == "group-invite":
352
+ body = {"group": args.group, "members": args.members}
353
+ if not gate(args, action="group-invite", preview={"payload": body}):
354
+ return
355
+ _json(request_task("POST", "/api/groups/invite", body=body))
356
+ elif args.cmd == "group-remove":
357
+ body = {"group": args.group, "members": args.members}
358
+ if not gate(args, action="group-remove", preview={"payload": body, "warning": "Removing members is visible to the whole group."}):
359
+ return
360
+ _json(request_task("POST", "/api/groups/remove", body=body))
361
+ elif args.cmd == "group-rename":
362
+ body = {"group": args.group, "new_name": args.new_name}
363
+ if not gate(args, action="group-rename", preview={"payload": body, "warning": "Renaming changes the group name for every member."}):
203
364
  return
204
- _json(request_task("POST", "/api/messages/send", body={"target": args.target, "type": "text", "text": args.text}))
205
- elif args.cmd == "refresh-history":
206
- _json(request("POST", "/api/messages/history/refresh"))
365
+ _json(request_task("POST", "/api/groups/rename", body=body))
366
+ elif args.cmd == "tasks":
367
+ _json(request("GET", "/api/tasks", params={"limit": clamp(args.limit, MAX_LIMIT["tasks"])}))
368
+ elif args.cmd == "task":
369
+ _json(request("GET", f"/api/tasks/{args.task_id}"))
207
370
 
208
371
 
209
372
  if __name__ == "__main__":
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib.util
4
+ import json
4
5
  import sys
5
6
  from pathlib import Path
6
7
  from unittest.mock import patch
7
8
 
9
+ import pytest
10
+
8
11
 
9
12
  SCRIPT = Path(__file__).parents[1] / "scripts" / "personal_wechat.py"
10
13
  SPEC = importlib.util.spec_from_file_location("personal_wechat_skill_script", SCRIPT)
@@ -14,6 +17,39 @@ personal_wechat = importlib.util.module_from_spec(SPEC)
14
17
  SPEC.loader.exec_module(personal_wechat)
15
18
 
16
19
 
20
+ # Routes Wisdom removed. The skill kept calling all of these until they started
21
+ # 404ing in production; keep them from creeping back in.
22
+ RETIRED_ROUTES = (
23
+ "/api/auth/status",
24
+ "/api/conversations/history",
25
+ "/api/messages/history",
26
+ "/api/messages/history/query",
27
+ "/api/messages/history/refresh",
28
+ )
29
+
30
+
31
+ def run(argv: list[str], *, responses=None, task_result=None):
32
+ """Invoke main() with argv, capturing every HTTP call the CLI would make."""
33
+ calls: list[dict] = []
34
+
35
+ def fake_request(method, path, *, params=None, body=None):
36
+ calls.append({"method": method, "path": path, "params": params, "body": body})
37
+ if responses and path in responses:
38
+ return responses[path]
39
+ if path.startswith("/api/tasks/"):
40
+ return {"status": "succeeded", "result": task_result}
41
+ return {} if method == "GET" else {"id": "task-1"}
42
+
43
+ printed: list[str] = []
44
+ with patch.object(personal_wechat, "request", side_effect=fake_request):
45
+ with patch("builtins.print", side_effect=lambda *a, **k: printed.append(a[0] if a else "")):
46
+ with patch.object(sys, "argv", ["personal_wechat.py", *argv]):
47
+ personal_wechat.main()
48
+
49
+ stdout = [json.loads(line) for line in printed if isinstance(line, str) and line.startswith(("{", "["))]
50
+ return calls, stdout
51
+
52
+
17
53
  def test_wait_task_returns_successful_result() -> None:
18
54
  with patch.object(
19
55
  personal_wechat,
@@ -23,3 +59,142 @@ def test_wait_task_returns_successful_result() -> None:
23
59
  result = personal_wechat.wait_task("task-1", timeout=0.1)
24
60
 
25
61
  assert result == {"sent": True}
62
+
63
+
64
+ def test_script_references_no_retired_routes() -> None:
65
+ source = SCRIPT.read_text(encoding="utf-8")
66
+ for route in RETIRED_ROUTES:
67
+ assert route not in source, f"{route} was removed from Wisdom"
68
+
69
+
70
+ def test_skill_doc_references_no_retired_routes() -> None:
71
+ doc = (SCRIPT.parents[1] / "SKILL.md").read_text(encoding="utf-8")
72
+ for route in RETIRED_ROUTES:
73
+ assert route not in doc, f"{route} was removed from Wisdom"
74
+
75
+
76
+ def test_status_only_reads_api_status() -> None:
77
+ """The 404 outage: status also probed /api/auth/status, failing the whole command."""
78
+ calls, _ = run(["status"], responses={"/api/status": {"status": "ready", "logged_in": True}})
79
+
80
+ assert [c["path"] for c in calls] == ["/api/status"]
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ "argv,ceiling,path,response",
85
+ [
86
+ (["contacts", "--limit", "500"], 200, "/api/contacts", {"total": 0, "contacts": []}),
87
+ (["conversations", "--limit", "500"], 200, "/api/conversations", {"total": 0, "conversations": []}),
88
+ (["messages", "CONV", "--limit", "500"], 200, "/api/messages", []),
89
+ (["poll", "--since", "1", "--limit", "5000"], 500, "/api/messages/poll", []),
90
+ ],
91
+ )
92
+ def test_limit_is_clamped_instead_of_422(argv, ceiling, path, response) -> None:
93
+ calls, _ = run(argv, responses={path: response})
94
+
95
+ assert calls[0]["params"]["limit"] == ceiling
96
+
97
+
98
+ def test_send_dry_runs_by_default() -> None:
99
+ calls, stdout = run(["send", "Alice", "hi"])
100
+
101
+ assert calls == []
102
+ assert stdout[0]["dry_run"] is True
103
+
104
+
105
+ def test_send_with_confirm_posts_the_payload() -> None:
106
+ calls, _ = run(["send", "Alice", "hi", "--confirm"], task_result={"sent": True})
107
+
108
+ assert calls[0]["path"] == "/api/messages/send"
109
+ assert calls[0]["body"] == {"target": "Alice", "type": "text", "text": "hi"}
110
+
111
+
112
+ def test_mentions_go_to_the_api_not_into_the_text() -> None:
113
+ """A pasted '@Name' notifies nobody; only the mentions field is a real @-mention."""
114
+ calls, _ = run(
115
+ ["send", "项目群", "周报", "--mention", "Doms Jay", "--mention", "Alice", "--confirm"],
116
+ task_result={"sent": True},
117
+ )
118
+
119
+ body = calls[0]["body"]
120
+ assert body["mentions"] == ["Doms Jay", "Alice"]
121
+ assert body["text"] == "周报"
122
+
123
+
124
+ def test_mention_all_is_forwarded() -> None:
125
+ calls, _ = run(["send", "项目群", "通知", "--mention-all", "--confirm"], task_result={"sent": True})
126
+
127
+ assert calls[0]["body"]["mention_all"] is True
128
+
129
+
130
+ def test_quote_and_idempotency_key_are_forwarded() -> None:
131
+ calls, _ = run(
132
+ ["send", "Alice", "我来跟", "--quote-text", "谁跟一下", "--idempotency-key", "k-1", "--confirm"],
133
+ task_result={"sent": True},
134
+ )
135
+
136
+ body = calls[0]["body"]
137
+ assert body["quote_text"] == "谁跟一下"
138
+ assert body["idempotency_key"] == "k-1"
139
+
140
+
141
+ def test_media_send_requires_its_url() -> None:
142
+ with pytest.raises(SystemExit):
143
+ run(["send", "Alice", "--type", "image", "--confirm"])
144
+
145
+
146
+ def test_media_send_forwards_the_url() -> None:
147
+ calls, _ = run(
148
+ ["send", "Alice", "--type", "image", "--image-url", "https://example.com/a.png", "--confirm"],
149
+ task_result={"sent": True},
150
+ )
151
+
152
+ assert calls[0]["body"]["image_url"] == "https://example.com/a.png"
153
+
154
+
155
+ @pytest.mark.parametrize(
156
+ "argv,path,method",
157
+ [
158
+ (["moment-post", "hello"], "/api/moments", "POST"),
159
+ (["moment-delete", "some distinctive text"], "/api/moments", "DELETE"),
160
+ (["group-create", "Alice", "Bob"], "/api/groups", "POST"),
161
+ (["group-invite", "项目群", "Carol"], "/api/groups/invite", "POST"),
162
+ (["group-remove", "项目群", "Carol"], "/api/groups/remove", "POST"),
163
+ (["group-rename", "项目群", "新名字"], "/api/groups/rename", "POST"),
164
+ ],
165
+ )
166
+ def test_every_write_dry_runs_then_executes(argv, path, method) -> None:
167
+ dry_calls, stdout = run(argv)
168
+ assert dry_calls == [], f"{argv[0]} must not touch the API without --confirm"
169
+ assert stdout[0]["dry_run"] is True
170
+
171
+ calls, _ = run([*argv, "--confirm"], task_result={"ok": True})
172
+ assert calls[0]["path"] == path
173
+ assert calls[0]["method"] == method
174
+
175
+
176
+ def test_group_create_needs_two_members() -> None:
177
+ with pytest.raises(SystemExit):
178
+ run(["group-create", "Alice", "--confirm"])
179
+
180
+
181
+ def test_moment_post_needs_text_or_images() -> None:
182
+ with pytest.raises(SystemExit):
183
+ run(["moment-post", "--confirm"])
184
+
185
+
186
+ def test_unattended_confirm_is_denied_outside_a_scheduled_task(monkeypatch) -> None:
187
+ monkeypatch.delenv("AICHAT_UNATTENDED_MODE", raising=False)
188
+ calls, stdout = run(["send", "Alice", "hi", "--unattended-confirm"])
189
+
190
+ assert calls == []
191
+ assert stdout[0]["error"] == "unattended_confirmation_denied"
192
+
193
+
194
+ def test_unattended_confirm_allows_a_preauthorized_skill(monkeypatch) -> None:
195
+ monkeypatch.setenv("AICHAT_UNATTENDED_MODE", "true")
196
+ monkeypatch.setenv("AICHAT_ACTIVE_SKILL", "acedatacloud/personal-wechat")
197
+ monkeypatch.setenv("AICHAT_UNATTENDED_ALLOWED_SKILLS", '["acedatacloud/personal-wechat"]')
198
+ calls, _ = run(["send", "Alice", "hi", "--unattended-confirm"], task_result={"sent": True})
199
+
200
+ assert calls[0]["path"] == "/api/messages/send"