@acedatacloud/skills 2026.627.0 → 2026.627.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/README.md
CHANGED
|
@@ -69,6 +69,7 @@ These skills drive third-party connectors users wire up at [auth.acedata.cloud/u
|
|
|
69
69
|
| [notion](skills/notion/) | Read and search Notion pages, databases, and blocks | `notion` |
|
|
70
70
|
| [slack](skills/slack/) | Read Slack channels, messages, and user info via Web API | `slack` |
|
|
71
71
|
| [wechat-official-account](skills/wechat-official-account/) | Manage WeChat MP — drafts, publishing, materials, user tags | `wechat` (BYOC) |
|
|
72
|
+
| [personal-wechat](skills/personal-wechat/) | Operate a personal WeChat account through a self-hosted Wisdom service | `personalwechat` (BYOC) |
|
|
72
73
|
|
|
73
74
|
## Prerequisites
|
|
74
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.627.
|
|
3
|
+
"version": "2026.627.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,14 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: google-drive
|
|
3
|
-
description: Read,
|
|
3
|
+
description: Read, export, upload, rename, move and delete Google Drive files explicitly selected or shared with the app via the Drive v3 REST API. Use when the user provides a Drive file ID/link or has selected files for the Google Drive connector.
|
|
4
4
|
when_to_use: |
|
|
5
|
-
Trigger when the user wants to
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
trashing files. The installed connector always grants `drive.readonly`;
|
|
10
|
-
the user opts in to the broader `drive` scope (full read + write)
|
|
11
|
-
at install time — confirm before performing destructive writes.
|
|
5
|
+
Trigger when the user wants to read, download, export or modify Google
|
|
6
|
+
Drive files they explicitly selected, created, opened, or shared with
|
|
7
|
+
this app. The installed connector currently grants only `drive.file`,
|
|
8
|
+
so do not browse, search, or summarize the user's entire Drive.
|
|
12
9
|
connections: [google/drive]
|
|
13
10
|
allowed_tools: [Bash]
|
|
14
11
|
license: Apache-2.0
|
|
@@ -19,17 +16,21 @@ metadata:
|
|
|
19
16
|
|
|
20
17
|
Drive Google Drive via `curl + jq`. The user's OAuth bearer token is
|
|
21
18
|
in `$GOOGLE_DRIVE_TOKEN`; every call needs it as
|
|
22
|
-
`Authorization: Bearer $GOOGLE_DRIVE_TOKEN`.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
time it also carries the broader `drive` scope (full read + write).
|
|
19
|
+
`Authorization: Bearer $GOOGLE_DRIVE_TOKEN`. The token carries
|
|
20
|
+
`drive.file` plus identity scopes (`openid email profile`) and can only
|
|
21
|
+
access files the user selected, opened, created, or shared with this app.
|
|
26
22
|
|
|
27
23
|
The Drive API returns standard JSON; failures surface as
|
|
28
24
|
`{"error": {"code": 401|403|..., "message": "..."}}` — show that
|
|
29
25
|
error verbatim to the user. `401` means the token expired and the
|
|
30
26
|
user must re-install the connector. `403 insufficientPermissions`
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
means the file was not shared with this app, or the action needs a
|
|
28
|
+
broader Drive scope that is temporarily disabled during Google review.
|
|
29
|
+
|
|
30
|
+
Do not use this skill for broad Drive discovery: no "list my recent
|
|
31
|
+
files", full-text search across Drive, shared-with-me scans, root-folder
|
|
32
|
+
cleanup, or bulk moves based on a Drive-wide query. Ask the user to pick
|
|
33
|
+
or paste the exact file/folder IDs first.
|
|
33
34
|
|
|
34
35
|
**Before any destructive write** (renaming, moving, trashing, or
|
|
35
36
|
bulk-mutating files) show the exact target list and ask the user to
|
|
@@ -52,8 +53,8 @@ upload protocol.
|
|
|
52
53
|
hand-formatted `multipart/related` body with a JSON metadata part and a
|
|
53
54
|
binary file part separated by a boundary string — easy to get wrong from
|
|
54
55
|
curl. `gws drive +upload` does it correctly. **For everything else**
|
|
55
|
-
(
|
|
56
|
-
|
|
56
|
+
(get, export, rename, move, trash, delete) the curl recipes below are
|
|
57
|
+
equivalent and shorter — stay on those.
|
|
57
58
|
|
|
58
59
|
### Install
|
|
59
60
|
|
|
@@ -90,9 +91,7 @@ gws drive files create \
|
|
|
90
91
|
```
|
|
91
92
|
|
|
92
93
|
Both exit non-zero with a structured JSON error on stderr if Google
|
|
93
|
-
rejects the request — surface that verbatim.
|
|
94
|
-
`drive` scope; on `403 insufficientPermissions` ask the user to
|
|
95
|
-
re-install the connector with read+write checked.
|
|
94
|
+
rejects the request — surface that verbatim.
|
|
96
95
|
|
|
97
96
|
## Recipes
|
|
98
97
|
|
|
@@ -104,56 +103,9 @@ curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
|
104
103
|
| jq '{user, quota: .storageQuota}'
|
|
105
104
|
```
|
|
106
105
|
|
|
107
|
-
### List
|
|
108
|
-
|
|
109
|
-
```sh
|
|
110
|
-
curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
111
|
-
"https://www.googleapis.com/drive/v3/files?orderBy=modifiedTime%20desc&pageSize=20&fields=files(id,name,mimeType,modifiedTime,owners(emailAddress),webViewLink,parents)" \
|
|
112
|
-
| jq '.files[] | {id, name, mimeType, modified: .modifiedTime, owner: .owners[0].emailAddress, webViewLink}'
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
`pageSize` max is 1000; default is 100. Use `pageToken` from the
|
|
116
|
-
response (`nextPageToken`) for follow-up pages.
|
|
117
|
-
|
|
118
|
-
### Search by name / fulltext
|
|
119
|
-
|
|
120
|
-
```sh
|
|
121
|
-
# Exact-name fragments — note "name contains" supports tokens, not regex
|
|
122
|
-
Q='name contains "季度复盘" and trashed = false'
|
|
123
|
-
curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
124
|
-
--get "https://www.googleapis.com/drive/v3/files" \
|
|
125
|
-
--data-urlencode "q=$Q" \
|
|
126
|
-
--data-urlencode 'fields=files(id,name,mimeType,modifiedTime,webViewLink,owners(emailAddress))' \
|
|
127
|
-
--data-urlencode 'pageSize=20' \
|
|
128
|
-
| jq '.files[] | {id, name, modified: .modifiedTime, owner: .owners[0].emailAddress}'
|
|
129
|
-
|
|
130
|
-
# Full-text search (body + title)
|
|
131
|
-
Q='fullText contains "OKR 2026Q2" and trashed = false'
|
|
132
|
-
curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
133
|
-
--get "https://www.googleapis.com/drive/v3/files" \
|
|
134
|
-
--data-urlencode "q=$Q" \
|
|
135
|
-
--data-urlencode 'fields=files(id,name,modifiedTime,webViewLink)' \
|
|
136
|
-
| jq '.files[]'
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
The `q` param uses [Drive's mini query language](https://developers.google.com/drive/api/guides/search-files):
|
|
140
|
-
`name`, `fullText`, `mimeType`, `parents`, `'<email>' in owners`,
|
|
141
|
-
`'<email>' in writers`, `modifiedTime > '2026-01-01T00:00:00'`,
|
|
142
|
-
`sharedWithMe`, `trashed`, joined by `and` / `or` / `not`.
|
|
143
|
-
|
|
144
|
-
### List files shared with me
|
|
106
|
+
### List children of a selected folder
|
|
145
107
|
|
|
146
|
-
|
|
147
|
-
curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
148
|
-
--get "https://www.googleapis.com/drive/v3/files" \
|
|
149
|
-
--data-urlencode 'q=sharedWithMe and trashed = false' \
|
|
150
|
-
--data-urlencode 'orderBy=sharedWithMeTime desc' \
|
|
151
|
-
--data-urlencode 'fields=files(id,name,mimeType,sharedWithMeTime,owners(displayName,emailAddress))' \
|
|
152
|
-
--data-urlencode 'pageSize=30' \
|
|
153
|
-
| jq '.files[] | {name, sharedAt: .sharedWithMeTime, sharedBy: .owners[0]}'
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
### List children of a folder
|
|
108
|
+
Only use this after the user explicitly selected or provided the folder ID.
|
|
157
109
|
|
|
158
110
|
```sh
|
|
159
111
|
FOLDER_ID='1A2B3CdEfGhIjKlMn'
|
|
@@ -234,28 +186,11 @@ curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
|
234
186
|
| jq '.permissions[] | {who: (.emailAddress // .domain // .type), role}'
|
|
235
187
|
```
|
|
236
188
|
|
|
237
|
-
### Pagination boilerplate
|
|
238
|
-
|
|
239
|
-
```sh
|
|
240
|
-
PAGE_TOKEN=''
|
|
241
|
-
while : ; do
|
|
242
|
-
RESP=$(curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
243
|
-
--get "https://www.googleapis.com/drive/v3/files" \
|
|
244
|
-
--data-urlencode 'q=trashed = false' \
|
|
245
|
-
--data-urlencode 'fields=files(id,name),nextPageToken' \
|
|
246
|
-
--data-urlencode 'pageSize=200' \
|
|
247
|
-
${PAGE_TOKEN:+--data-urlencode "pageToken=$PAGE_TOKEN"})
|
|
248
|
-
echo "$RESP" | jq -c '.files[]'
|
|
249
|
-
PAGE_TOKEN=$(echo "$RESP" | jq -r '.nextPageToken // empty')
|
|
250
|
-
[ -z "$PAGE_TOKEN" ] && break
|
|
251
|
-
done
|
|
252
|
-
```
|
|
253
|
-
|
|
254
189
|
## Write recipes
|
|
255
190
|
|
|
256
|
-
These
|
|
257
|
-
|
|
258
|
-
|
|
191
|
+
These only work for files and folders available through `drive.file`.
|
|
192
|
+
If Google returns `403 insufficientPermissions`, surface the error and
|
|
193
|
+
ask the user to select/share the target file with the app.
|
|
259
194
|
**Always echo the target name + path back to the user before
|
|
260
195
|
trashing or bulk-moving anything.**
|
|
261
196
|
|
|
@@ -365,39 +300,14 @@ Prefer `trashed:true` over `DELETE` — `DELETE` is permanent and the
|
|
|
365
300
|
user can't undo it. Only use `DELETE` when they explicitly say
|
|
366
301
|
"permanently delete".
|
|
367
302
|
|
|
368
|
-
### Bulk "move every PDF in the root to /Documents/PDF" (confirmation pattern)
|
|
369
|
-
|
|
370
|
-
```sh
|
|
371
|
-
# 1. List candidates and show the user before doing anything
|
|
372
|
-
DST_FOLDER_ID='1XYZdocsPdfFolder'
|
|
373
|
-
ROOT_ID='root'
|
|
374
|
-
|
|
375
|
-
CANDS=$(curl -sS -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
376
|
-
--get "https://www.googleapis.com/drive/v3/files" \
|
|
377
|
-
--data-urlencode "q='$ROOT_ID' in parents and mimeType='application/pdf' and trashed=false" \
|
|
378
|
-
--data-urlencode 'fields=files(id,name,webViewLink)' \
|
|
379
|
-
| jq '.files')
|
|
380
|
-
echo "$CANDS" | jq -r '.[] | "- \(.name)"'
|
|
381
|
-
|
|
382
|
-
# 2. (after user confirms) actually move
|
|
383
|
-
echo "$CANDS" | jq -r '.[] | .id' | while read FID; do
|
|
384
|
-
curl -sS -X PATCH -H "Authorization: Bearer $GOOGLE_DRIVE_TOKEN" \
|
|
385
|
-
--data '' \
|
|
386
|
-
"https://www.googleapis.com/drive/v3/files/$FID?addParents=$DST_FOLDER_ID&removeParents=$ROOT_ID&fields=id,name,parents" \
|
|
387
|
-
| jq -c '{id, name, parents}'
|
|
388
|
-
done
|
|
389
|
-
```
|
|
390
|
-
|
|
391
303
|
## Common error codes
|
|
392
304
|
|
|
393
305
|
| HTTP | meaning | what to tell the user |
|
|
394
306
|
|---|---|---|
|
|
395
307
|
| `401 UNAUTHENTICATED` | token expired / revoked | "Reconnect the Google Drive connector on the Connections page." |
|
|
396
|
-
| `403 insufficientPermissions` |
|
|
308
|
+
| `403 insufficientPermissions` | target not shared with app / broader Drive scope disabled | "This file isn't available to the app under the current `drive.file` permission. Select or share the exact file with the Google Drive connector, then try again." |
|
|
397
309
|
| `403 userRateLimitExceeded` | quota | retry once after 5–10s; if it persists, tell the user. |
|
|
398
|
-
| `404 notFound` | wrong file id OR file isn't visible to this
|
|
310
|
+
| `404 notFound` | wrong file id OR file isn't visible to this app | double-check the id; ask the user to select or share the file with the connector. |
|
|
399
311
|
| `400 invalidQuery` | malformed `q` | print the `q` you sent + the error message back to the user. |
|
|
400
312
|
|
|
401
313
|
Never log or echo `$GOOGLE_DRIVE_TOKEN` — treat it as a secret.
|
|
402
|
-
|
|
403
|
-
Never log or echo `$GOOGLE_DRIVE_TOKEN` — treat it as a secret.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
when_to_use: |
|
|
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.
|
|
10
|
+
connections: [personalwechat]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Personal WeChat via Wisdom
|
|
19
|
+
|
|
20
|
+
Use the user's self-hosted **Wisdom** service to operate their personal WeChat
|
|
21
|
+
account. Wisdom runs on a Windows host with WeChat Desktop logged in and exposes
|
|
22
|
+
an HTTP API.
|
|
23
|
+
|
|
24
|
+
Credentials are injected by the `personalwechat` BYOC connector:
|
|
25
|
+
|
|
26
|
+
- `PERSONALWECHAT_BASE_URL` — Wisdom server base URL, e.g. `http://82.156.126.14:8000`.
|
|
27
|
+
- `PERSONALWECHAT_API_TOKEN` — Wisdom `API_TOKEN`. Secret — never echo, print, or log it.
|
|
28
|
+
|
|
29
|
+
The helper sends the token as `Authorization: Bearer ...`; it never puts the
|
|
30
|
+
token in the URL.
|
|
31
|
+
|
|
32
|
+
This is the user's **real personal WeChat account**. Read operations can run
|
|
33
|
+
directly. Sending messages or files must be dry-run first, then performed only
|
|
34
|
+
after the user explicitly approves the exact target and content.
|
|
35
|
+
|
|
36
|
+
## CLI
|
|
37
|
+
|
|
38
|
+
The skill ships a stdlib-only helper:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
WX=$SKILL_DIR/scripts/personal_wechat.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Verify Connection First
|
|
45
|
+
|
|
46
|
+
Always start with status when the user asks to use WeChat:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python3 $WX status
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Expected healthy shape:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"auth":{"logged_in":true,"wechat_running":true,"page":"logged_in"}}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If `logged_in=false`, tell the user to open the Wisdom web UI / RDP and scan the
|
|
59
|
+
WeChat QR code. If the API returns 401, ask the user to reconnect the Personal
|
|
60
|
+
WeChat connector with the current Wisdom API token.
|
|
61
|
+
|
|
62
|
+
## Read Workflows
|
|
63
|
+
|
|
64
|
+
### Current Account
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
python3 $WX account
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Contacts
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python3 $WX contacts --limit 50
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Recent Conversations
|
|
77
|
+
|
|
78
|
+
Use the normal conversations endpoint first; it prefers WeChat DB when ready and
|
|
79
|
+
falls back to Wisdom's app DB.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
python3 $WX conversations --limit 20
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
For decrypted local WeChat history sessions:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python3 $WX conversations --history --limit 20
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Messages in a Conversation
|
|
92
|
+
|
|
93
|
+
First list conversations, then use the `id` as `conversation_id`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python3 $WX messages "34642176898@chatroom" --limit 50 --order asc
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Historical Messages
|
|
100
|
+
|
|
101
|
+
Read from Wisdom's decrypted WeChat local databases:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 $WX history --limit 50
|
|
105
|
+
python3 $WX history --talker "34642176898@chatroom" --limit 50
|
|
106
|
+
python3 $WX history --limit 20 --offset 20
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Raw SQL, Read-Only Only
|
|
110
|
+
|
|
111
|
+
Wisdom permits only `SELECT` and `PRAGMA`:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
python3 $WX sql MicroMsg.db 'SELECT count(*) AS cnt FROM Session'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Use raw SQL only for diagnostics or targeted metadata queries. Do not dump large
|
|
118
|
+
message tables unless the user explicitly asks.
|
|
119
|
+
|
|
120
|
+
### Refresh History DB
|
|
121
|
+
|
|
122
|
+
If history looks stale, refresh the decrypted DB snapshot:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3 $WX refresh-history
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Search
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python3 $WX search "Alice"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Search drives the WeChat UI, so it may be slower than local DB history reads.
|
|
135
|
+
|
|
136
|
+
## Sending Messages — GATED
|
|
137
|
+
|
|
138
|
+
`send` dry-runs by default. It never sends unless `--confirm` is present.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python3 $WX send "Alice" "今晚 8 点开会吗?"
|
|
142
|
+
# -> {"dry_run": true, ...}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Show the dry-run output to the user and ask for explicit approval of the exact
|
|
146
|
+
recipient and text. Only then run:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
python3 $WX send "Alice" "今晚 8 点开会吗?" --confirm
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Never add `--confirm` in the first attempt. Never infer consent from vague text.
|
|
153
|
+
The user must clearly approve sending this exact message.
|
|
154
|
+
|
|
155
|
+
## Safety Rules
|
|
156
|
+
|
|
157
|
+
- Never print `PERSONALWECHAT_API_TOKEN`.
|
|
158
|
+
- Treat `PERSONALWECHAT_BASE_URL + API_TOKEN` as full remote control of the user's WeChat.
|
|
159
|
+
- For any write/send operation: dry-run first, ask for explicit approval, then re-run with `--confirm`.
|
|
160
|
+
- Do not call logout/restart endpoints from the skill unless the user explicitly asks to repair the Wisdom service.
|
|
161
|
+
- If Wisdom returns 503 for history, run `python3 $WX refresh-history` once, then retry the read.
|
|
162
|
+
- If the server is unreachable, ask the user to check the Windows host / security group / port 8000.
|
|
163
|
+
|
|
164
|
+
## Endpoint Mapping
|
|
165
|
+
|
|
166
|
+
The helper wraps these Wisdom endpoints:
|
|
167
|
+
|
|
168
|
+
- `GET /api/status`
|
|
169
|
+
- `GET /api/auth/status`
|
|
170
|
+
- `GET /api/account`
|
|
171
|
+
- `GET /api/contacts?version=2.0`
|
|
172
|
+
- `GET /api/conversations`
|
|
173
|
+
- `GET /api/conversations/history`
|
|
174
|
+
- `GET /api/messages`
|
|
175
|
+
- `GET /api/messages/history`
|
|
176
|
+
- `POST /api/messages/history/query`
|
|
177
|
+
- `POST /api/messages/history/refresh`
|
|
178
|
+
- `POST /api/search`
|
|
179
|
+
- `POST /api/messages/send` (only after `--confirm`)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small CLI for the Personal WeChat / Wisdom BYOC skill."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BASE_URL = os.environ.get("PERSONALWECHAT_BASE_URL", "").rstrip("/")
|
|
16
|
+
API_TOKEN = os.environ.get("PERSONALWECHAT_API_TOKEN", "")
|
|
17
|
+
MAX_TEXT_CHARS = 800
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _die(message: str, code: int = 1) -> None:
|
|
21
|
+
print(json.dumps({"error": message}, ensure_ascii=False), file=sys.stderr)
|
|
22
|
+
raise SystemExit(code)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json(data) -> None:
|
|
26
|
+
print(json.dumps(data, ensure_ascii=False, default=str))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def request(method: str, path: str, *, params: dict | None = None, body: dict | None = None):
|
|
30
|
+
if not BASE_URL:
|
|
31
|
+
_die("PERSONALWECHAT_BASE_URL is not set. Reconnect the Personal WeChat connector.")
|
|
32
|
+
if not API_TOKEN:
|
|
33
|
+
_die("PERSONALWECHAT_API_TOKEN is not set. Reconnect the Personal WeChat connector.")
|
|
34
|
+
|
|
35
|
+
query = dict(params or {})
|
|
36
|
+
suffix = f"?{urllib.parse.urlencode(query)}" if query else ""
|
|
37
|
+
url = f"{BASE_URL}{path}{suffix}"
|
|
38
|
+
payload = None
|
|
39
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {API_TOKEN}"}
|
|
40
|
+
if body is not None:
|
|
41
|
+
payload = json.dumps(body).encode("utf-8")
|
|
42
|
+
headers["Content-Type"] = "application/json"
|
|
43
|
+
|
|
44
|
+
req = urllib.request.Request(url, data=payload, headers=headers, method=method)
|
|
45
|
+
try:
|
|
46
|
+
with urllib.request.urlopen(req, timeout=45) as response:
|
|
47
|
+
raw = response.read().decode("utf-8", "replace")
|
|
48
|
+
if not raw:
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(raw)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
return {"raw": raw}
|
|
54
|
+
except urllib.error.HTTPError as exc:
|
|
55
|
+
raw = exc.read().decode("utf-8", "replace")
|
|
56
|
+
try:
|
|
57
|
+
body = json.loads(raw) if raw else {}
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
body = {"raw": raw}
|
|
60
|
+
_die(f"HTTP {exc.code}: {body}", code=2)
|
|
61
|
+
except urllib.error.URLError as exc:
|
|
62
|
+
reason = getattr(exc, "reason", None) or "connection failed"
|
|
63
|
+
_die(f"Cannot reach Wisdom at {BASE_URL}: {reason}", code=3)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compact_conversation(item: dict) -> dict:
|
|
67
|
+
return {
|
|
68
|
+
"id": item.get("id") or item.get("strUsrName"),
|
|
69
|
+
"name": item.get("name") or item.get("display_name") or item.get("strNickName"),
|
|
70
|
+
"type": item.get("type"),
|
|
71
|
+
"unread_count": item.get("unread_count") if "unread_count" in item else item.get("nUnReadCount"),
|
|
72
|
+
"last_active_at": item.get("last_active_at") or item.get("nTime"),
|
|
73
|
+
"last_message_count": len(item.get("messages") or []),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compact_message(item: dict) -> dict:
|
|
78
|
+
text = item.get("text") or item.get("StrContent") or item.get("DisplayContent")
|
|
79
|
+
if isinstance(text, str) and len(text) > MAX_TEXT_CHARS:
|
|
80
|
+
text = text[:MAX_TEXT_CHARS] + f"... [truncated {len(text) - MAX_TEXT_CHARS} chars]"
|
|
81
|
+
return {
|
|
82
|
+
"id": item.get("id") or item.get("MsgSvrID") or item.get("localId"),
|
|
83
|
+
"conversation_id": item.get("conversation_id") or item.get("StrTalker"),
|
|
84
|
+
"conversation_name": item.get("conversation_name"),
|
|
85
|
+
"sender_id": item.get("sender_id"),
|
|
86
|
+
"sender_name": item.get("sender_name"),
|
|
87
|
+
"direction": item.get("direction"),
|
|
88
|
+
"type": item.get("type") or item.get("Type"),
|
|
89
|
+
"text": text,
|
|
90
|
+
"sent_at": item.get("sent_at") or item.get("CreateTime"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> None:
|
|
95
|
+
parser = argparse.ArgumentParser(description="Personal WeChat (Wisdom) CLI")
|
|
96
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
97
|
+
|
|
98
|
+
sub.add_parser("status")
|
|
99
|
+
sub.add_parser("account")
|
|
100
|
+
|
|
101
|
+
contacts = sub.add_parser("contacts")
|
|
102
|
+
contacts.add_argument("--limit", type=int, default=20)
|
|
103
|
+
|
|
104
|
+
convs = sub.add_parser("conversations")
|
|
105
|
+
convs.add_argument("--limit", type=int, default=20)
|
|
106
|
+
convs.add_argument("--history", action="store_true")
|
|
107
|
+
|
|
108
|
+
msgs = sub.add_parser("messages")
|
|
109
|
+
msgs.add_argument("conversation_id")
|
|
110
|
+
msgs.add_argument("--limit", type=int, default=50)
|
|
111
|
+
msgs.add_argument("--offset", type=int, default=0)
|
|
112
|
+
msgs.add_argument("--order", choices=["asc", "desc"], default="asc")
|
|
113
|
+
|
|
114
|
+
hist = sub.add_parser("history")
|
|
115
|
+
hist.add_argument("--talker", default="")
|
|
116
|
+
hist.add_argument("--limit", type=int, default=50)
|
|
117
|
+
hist.add_argument("--offset", type=int, default=0)
|
|
118
|
+
|
|
119
|
+
sql = sub.add_parser("sql")
|
|
120
|
+
sql.add_argument("db")
|
|
121
|
+
sql.add_argument("sql")
|
|
122
|
+
|
|
123
|
+
search = sub.add_parser("search")
|
|
124
|
+
search.add_argument("query")
|
|
125
|
+
|
|
126
|
+
send = sub.add_parser("send")
|
|
127
|
+
send.add_argument("target")
|
|
128
|
+
send.add_argument("text")
|
|
129
|
+
send.add_argument("--confirm", action="store_true")
|
|
130
|
+
|
|
131
|
+
refresh = sub.add_parser("refresh-history")
|
|
132
|
+
refresh.set_defaults(cmd="refresh-history")
|
|
133
|
+
|
|
134
|
+
args = parser.parse_args()
|
|
135
|
+
|
|
136
|
+
if args.cmd == "status":
|
|
137
|
+
_json({"status": request("GET", "/api/status"), "auth": request("GET", "/api/auth/status")})
|
|
138
|
+
elif args.cmd == "account":
|
|
139
|
+
_json(request("GET", "/api/account"))
|
|
140
|
+
elif args.cmd == "contacts":
|
|
141
|
+
data = request("GET", "/api/contacts", params={"limit": args.limit, "version": "2.0"})
|
|
142
|
+
_json({"total": data.get("total"), "contacts": data.get("contacts", [])})
|
|
143
|
+
elif args.cmd == "conversations":
|
|
144
|
+
if args.history:
|
|
145
|
+
data = request("GET", "/api/conversations/history", params={"limit": args.limit})
|
|
146
|
+
_json({"count": data.get("count"), "conversations": [compact_conversation(i) for i in data.get("conversations", [])]})
|
|
147
|
+
else:
|
|
148
|
+
data = request("GET", "/api/conversations", params={"limit": args.limit})
|
|
149
|
+
_json({"total": data.get("total"), "conversations": [compact_conversation(i) for i in data.get("conversations", [])]})
|
|
150
|
+
elif args.cmd == "messages":
|
|
151
|
+
data = request(
|
|
152
|
+
"GET",
|
|
153
|
+
"/api/messages",
|
|
154
|
+
params={"conversation_id": args.conversation_id, "limit": args.limit, "offset": args.offset, "order": args.order},
|
|
155
|
+
)
|
|
156
|
+
_json([compact_message(i) for i in data])
|
|
157
|
+
elif args.cmd == "history":
|
|
158
|
+
params = {"limit": args.limit, "offset": args.offset}
|
|
159
|
+
if args.talker:
|
|
160
|
+
params["talker"] = args.talker
|
|
161
|
+
data = request("GET", "/api/messages/history", params=params)
|
|
162
|
+
_json({"count": data.get("count"), "db_ready": data.get("db_ready"), "messages": [compact_message(i) for i in data.get("messages", [])]})
|
|
163
|
+
elif args.cmd == "sql":
|
|
164
|
+
data = request("POST", "/api/messages/history/query", body={"db": args.db, "sql": args.sql})
|
|
165
|
+
_json(data)
|
|
166
|
+
elif args.cmd == "search":
|
|
167
|
+
_json(request("POST", "/api/search", body={"query": args.query}))
|
|
168
|
+
elif args.cmd == "send":
|
|
169
|
+
if not args.confirm:
|
|
170
|
+
_json({"dry_run": True, "target": args.target, "text": args.text, "note": "Re-run with --confirm only after the user explicitly approves sending this exact message."})
|
|
171
|
+
return
|
|
172
|
+
_json(request("POST", "/api/messages/send", body={"target": args.target, "type": "text", "text": args.text}))
|
|
173
|
+
elif args.cmd == "refresh-history":
|
|
174
|
+
_json(request("POST", "/api/messages/history/refresh"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
main()
|