@acedatacloud/skills 2026.804.0 → 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.
|
|
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
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
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://
|
|
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.
|
|
31
|
-
|
|
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
|
|
35
|
-
directly.
|
|
36
|
-
after the user explicitly approves
|
|
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
|
-
{"
|
|
61
|
+
{"status":"ready","since":"…","wechat_running":true,"logged_in":true,"error":null}
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
### New Messages Since a Timestamp
|
|
107
119
|
|
|
108
120
|
```bash
|
|
109
|
-
python3 $WX
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
### Moments (朋友圈)
|
|
117
128
|
|
|
118
129
|
```bash
|
|
119
|
-
python3 $WX
|
|
130
|
+
python3 $WX moments --limit 30
|
|
131
|
+
python3 $WX moments --limit 30 --self-only
|
|
120
132
|
```
|
|
121
133
|
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
This is a synced view cache, so a just-deleted post may linger until WeChat
|
|
135
|
+
re-syncs.
|
|
124
136
|
|
|
125
|
-
###
|
|
137
|
+
### Tasks
|
|
126
138
|
|
|
127
|
-
|
|
139
|
+
Inspect queued/finished UI tasks when a write seems stuck:
|
|
128
140
|
|
|
129
141
|
```bash
|
|
130
|
-
python3 $WX
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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 "
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
-
|
|
187
|
-
-
|
|
188
|
-
-
|
|
189
|
-
-
|
|
190
|
-
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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")
|
|
95
|
-
"name": item.get("name")
|
|
102
|
+
"id": item.get("id"),
|
|
103
|
+
"name": item.get("name"),
|
|
96
104
|
"type": item.get("type"),
|
|
97
|
-
"
|
|
98
|
-
"
|
|
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")
|
|
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")
|
|
109
|
-
"conversation_id": item.get("conversation_id")
|
|
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")
|
|
124
|
+
"type": item.get("type"),
|
|
115
125
|
"text": text,
|
|
116
|
-
"
|
|
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
|
|
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("--
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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("--
|
|
156
|
-
send.add_argument("--
|
|
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
|
-
|
|
285
|
+
def main() -> None:
|
|
286
|
+
args = build_parser().parse_args()
|
|
162
287
|
|
|
163
288
|
if args.cmd == "status":
|
|
164
|
-
_json(
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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={
|
|
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 == "
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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/
|
|
205
|
-
elif args.cmd == "
|
|
206
|
-
_json(request("
|
|
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"
|
|
@@ -153,6 +153,7 @@ For best results follow this multi-step workflow:
|
|
|
153
153
|
| `style_negative` | string | Style tags to avoid (e.g., `"heavy metal, distortion"`) |
|
|
154
154
|
| `style_influence` | number | Strength of style influence (advanced custom mode, v5+ only) |
|
|
155
155
|
| `audio_weight` | number | Weight for audio reference when covering (advanced, v5+ only) |
|
|
156
|
+
| `duration` | integer | Target track length in seconds (typically 10–360). Best supported on `generate` with `custom: true` on newer models such as `chirp-v5-5` |
|
|
156
157
|
|
|
157
158
|
## Lyrics Format
|
|
158
159
|
|
|
@@ -180,6 +181,7 @@ Ending lyrics
|
|
|
180
181
|
- Style tags are descriptive phrases, not enum values (e.g., "Synthwave, Electronic, Dreamy")
|
|
181
182
|
- `vocal_gender` ("f"/"m") is only supported on v4.5+ models
|
|
182
183
|
- `variation_category` ("high"/"normal"/"subtle") is only supported on v5+ models
|
|
184
|
+
- `duration` is forwarded as you send it — support varies by model and action, and an unsupported combination may ignore it or return an error, so verify with one request before batching. Note the request `duration` is a *target*; the `duration` in each returned clip is the *actual* length and will vary slightly
|
|
183
185
|
- The `concat` action merges extended song segments — requires audio_id of the extended track
|
|
184
186
|
- `persona` requires an existing audio_id to extract the vocal reference from
|
|
185
187
|
- Upload external audio via `/suno/upload` before using it with extend/cover
|