@acedatacloud/skills 2026.702.0 → 2026.703.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/skills/_shared/wecom.md +97 -0
- package/skills/medium/SKILL.md +14 -5
- package/skills/medium/scripts/medium.py +204 -9
- package/skills/wecom/SKILL.md +287 -0
package/README.md
CHANGED
|
@@ -70,6 +70,7 @@ These skills drive third-party connectors users wire up at [auth.acedata.cloud/u
|
|
|
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
72
|
| [personal-wechat](skills/personal-wechat/) | Operate a personal WeChat account through a self-hosted Wisdom service | `personalwechat` (BYOC) |
|
|
73
|
+
| [wecom](skills/wecom/) | WeCom (企业微信) self-built app — contacts, app messages, WeDoc, schedules, meetings | `wecom` (BYOC) |
|
|
73
74
|
|
|
74
75
|
## Prerequisites
|
|
75
76
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.703.1",
|
|
4
4
|
"description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-skills",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# 企业微信 (WeCom) Authentication
|
|
2
|
+
|
|
3
|
+
The `wecom` skill talks to the [WeCom server-side API](https://developer.work.weixin.qq.com/document/path/90664)
|
|
4
|
+
(`https://qyapi.weixin.qq.com`) as a **self-built app (企业内部开发 / 自建应用)**.
|
|
5
|
+
Unlike OAuth-bearer connectors, WeCom uses a two-step token flow that is
|
|
6
|
+
identical in shape to the WeChat Official Account API:
|
|
7
|
+
|
|
8
|
+
1. Exchange `CorpID + Secret` for an `access_token` (TTL 7200s).
|
|
9
|
+
2. Pass that `access_token` as a **query string parameter** on every other call.
|
|
10
|
+
|
|
11
|
+
## Create a self-built app & collect three values
|
|
12
|
+
|
|
13
|
+
1. Sign in to the [WeCom admin console](https://work.weixin.qq.com/wework_admin/) as an
|
|
14
|
+
administrator → **应用管理 (App Management) → 自建 (Self-built) → 创建应用 (Create App)**.
|
|
15
|
+
2. After the app is created, open it and read off:
|
|
16
|
+
- **CorpID** — 我的企业 (My Company) → 企业信息 (Company Info), at the bottom (`ww...` / `wc...`).
|
|
17
|
+
- **Secret** — the app's own Secret, shown on the app detail page (click "查看"/"获取"; it is
|
|
18
|
+
sent to the admin's WeCom, not displayed inline).
|
|
19
|
+
- **AgentId** — the app's AgentId, shown on the same app detail page.
|
|
20
|
+
|
|
21
|
+
## Grant the app the permissions the skill needs
|
|
22
|
+
|
|
23
|
+
An app only sees what you grant it. On the app detail page configure:
|
|
24
|
+
|
|
25
|
+
| Capability the skill uses | What to enable on the app |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Read members / departments (通讯录) | **通讯录 (Contacts)** → set the app's "可见范围" (visible scope) to the members/departments it may act on; to read real names/mobiles, grant **通讯录同步/读取** privilege |
|
|
28
|
+
| Send app messages (应用消息) | enabled by default for a self-built app (uses its AgentId) |
|
|
29
|
+
| Docs / smart sheets (文档/智能表格) | **文档 (WeDoc)** interface permission |
|
|
30
|
+
| Schedules (日程) | **日历/日程 (Calendar)** interface permission |
|
|
31
|
+
| Meetings (会议) | **会议 (Meeting)** interface permission |
|
|
32
|
+
|
|
33
|
+
> ⚠️ **Server IP allowlist.** WeCom rejects calls whose source IP is not in the app's
|
|
34
|
+
> **企业可信IP (trusted IP)** list. If you see `errcode 60020` ("not allow to access from your ip"),
|
|
35
|
+
> add the caller's egress IP shown in `errmsg` to the app's trusted-IP list and retry.
|
|
36
|
+
|
|
37
|
+
## Environment variables
|
|
38
|
+
|
|
39
|
+
The skill reads three variables. On AceDataCloud they are injected automatically for you
|
|
40
|
+
(see below); for local runs, export them yourself.
|
|
41
|
+
|
|
42
|
+
| Variable | Description | Example |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `WECOM_CORP_ID` | Enterprise CorpID | `ww1234567890abcdef` |
|
|
45
|
+
| `WECOM_CORP_SECRET` | The self-built app's Secret (sensitive) | `xxxxxxxxxxxxxxxxxxxx` |
|
|
46
|
+
| `WECOM_AGENT_ID` | The self-built app's AgentId | `1000002` |
|
|
47
|
+
|
|
48
|
+
**Local dev — `.env` file**:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
WECOM_CORP_ID=ww1234567890abcdef
|
|
52
|
+
WECOM_CORP_SECRET=your-app-secret
|
|
53
|
+
WECOM_AGENT_ID=1000002
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Load it before running the skill:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
set -a; source .env; set +a
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> ⚠️ **Never commit `.env`** — the Secret is equivalent to full app access. Add it to `.gitignore`.
|
|
63
|
+
|
|
64
|
+
**Agent usage** (Claude / Studio / etc.): install the
|
|
65
|
+
[企业微信 connector](https://auth.acedata.cloud/user/connections) on AceDataCloud once. Your
|
|
66
|
+
CorpID / Secret / AgentId are AES-256-GCM encrypted at rest and injected as the env vars above
|
|
67
|
+
only inside the sandbox while the skill runs — no manual `.env` setup needed. Revoke anytime from
|
|
68
|
+
the connections page.
|
|
69
|
+
|
|
70
|
+
## Response shape & error handling
|
|
71
|
+
|
|
72
|
+
Every WeCom response is JSON returned with **HTTP 200**; success is `errcode == 0`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{"errcode": 0, "errmsg": "ok", "...": "..."}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Any non-zero `errcode` is an error — surface `errmsg` to the user verbatim. Common codes:
|
|
79
|
+
|
|
80
|
+
| errcode | meaning | what to do |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `40014` / `42001` | invalid / expired `access_token` | refresh the token (the recipe caches it — delete the cache and retry) |
|
|
83
|
+
| `40056` | invalid AgentId | check `WECOM_AGENT_ID` matches the app whose Secret you used |
|
|
84
|
+
| `48002` | API forbidden — app lacks this interface permission | grant the matching capability (docs/calendar/meeting) to the app |
|
|
85
|
+
| `60011` | no privilege to access this user/department/resource | the target is outside the app's visible scope — widen 可见范围 |
|
|
86
|
+
| `60020` | caller IP not in trusted-IP list | add the egress IP from `errmsg` to 企业可信IP |
|
|
87
|
+
| `301002` | no privilege to read contacts detail | grant 通讯录读取 privilege to the app |
|
|
88
|
+
| `81013` | user not in app scope | add the user to the app's 可见范围 |
|
|
89
|
+
|
|
90
|
+
## What is intentionally out of scope
|
|
91
|
+
|
|
92
|
+
- **Reading inbound chat history (会话内容存档 / msgaudit).** That is a separate paid
|
|
93
|
+
enterprise capability requiring a dedicated Secret, an RSA private key, and the native
|
|
94
|
+
WeCom Finance SDK — it cannot run inside this stdlib sandbox. The skill can *send* app
|
|
95
|
+
messages but cannot *read* members' private conversations.
|
|
96
|
+
- **A general todo (待办) CRUD.** WeCom's open API exposes no self-built-app todo endpoints;
|
|
97
|
+
use **schedules (日程)** for time-bound reminders instead.
|
package/skills/medium/SKILL.md
CHANGED
|
@@ -25,7 +25,9 @@ The connector injects the cookie jar as an env var:
|
|
|
25
25
|
|
|
26
26
|
- `MEDIUM_COOKIES` — a JSON array of cookies (`sid`, `uid`, `xsrf`). **Secret —
|
|
27
27
|
never echo or print it.** The CLI echoes the `xsrf` cookie as the
|
|
28
|
-
`x-xsrf-token` header on writes for you.
|
|
28
|
+
`x-xsrf-token` header on writes for you. If the captured jar has no `xsrf`
|
|
29
|
+
cookie (common), the CLI mints one automatically before writing, so publishes
|
|
30
|
+
no longer fail with "Missing xsrf token".
|
|
29
31
|
|
|
30
32
|
## CLI
|
|
31
33
|
|
|
@@ -51,7 +53,11 @@ On an auth error the cookie is expired — have the user reconnect at
|
|
|
51
53
|
## Publishing — GATED (dry-run unless trailing `--confirm`)
|
|
52
54
|
|
|
53
55
|
`publish` writes to the user's real account. Content is **Markdown** (converted
|
|
54
|
-
to Medium paragraph blocks:
|
|
56
|
+
to Medium paragraph blocks: `#`/`##`→heading, `###`→sub-heading, `>`→quote,
|
|
57
|
+
```` ``` ````→code, `-`/`*`→bullet list, `1.`→numbered list, tables→aligned
|
|
58
|
+
code block). Inline **bold**, *italic*, `code`, and `[links](url)` render as
|
|
59
|
+
real Medium formatting (clickable links included). Bare `https://…` URLs in text
|
|
60
|
+
are auto-linked too (URLs inside code spans are left untouched).
|
|
55
61
|
Without a trailing `--confirm` it dry-runs. `--confirm` is honored **only as the
|
|
56
62
|
last argument**. Always show the dry-run, get an explicit "yes", then re-run.
|
|
57
63
|
|
|
@@ -76,9 +82,12 @@ to a link paragraph (never blocks the post).
|
|
|
76
82
|
## Gotchas
|
|
77
83
|
|
|
78
84
|
- **This is the user's real Medium account.** Confirm before any publish.
|
|
79
|
-
- Markdown→Medium conversion
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
- Markdown→Medium conversion covers headings, quotes, fenced code, bullet/
|
|
86
|
+
numbered lists, images, and inline markups (bold/italic/code/links). Medium's
|
|
87
|
+
editor has no table element: a narrow markdown table renders as an aligned
|
|
88
|
+
monospace code block, while a wide one (long cells / URLs, which would wrap into
|
|
89
|
+
an unreadable grid) is rendered as per-row records — the first column bolded as
|
|
90
|
+
a lead-in, the remaining columns as `header: value` bullets.
|
|
82
91
|
- Medium sits behind Cloudflare; an occasional 403/429 is transient — the CLI
|
|
83
92
|
auto-retries once after a short pause. A *persistent* 403 means the cookie is
|
|
84
93
|
genuinely expired (reconnect).
|
|
@@ -174,6 +174,32 @@ def api(method, url, jar, *, body=None, _retried=False):
|
|
|
174
174
|
return d
|
|
175
175
|
|
|
176
176
|
|
|
177
|
+
def prime_xsrf(jar) -> None:
|
|
178
|
+
"""Ensure the jar carries an ``xsrf`` cookie. Captured jars frequently lack
|
|
179
|
+
it, but Medium hands one out on any GET of the site; without it the
|
|
180
|
+
``x-xsrf-token`` header is absent and every write fails ('Missing xsrf token')."""
|
|
181
|
+
if cookie_value(jar, "xsrf"):
|
|
182
|
+
return
|
|
183
|
+
url = BASE + "/"
|
|
184
|
+
hdrs = {"User-Agent": UA, "Accept": "text/html",
|
|
185
|
+
"X-Requested-With": "XMLHttpRequest"}
|
|
186
|
+
req = urllib.request.Request(url, headers=hdrs, method="GET")
|
|
187
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
188
|
+
try:
|
|
189
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
190
|
+
set_cookies = resp.headers.get_all("Set-Cookie") or []
|
|
191
|
+
except urllib.error.HTTPError as e:
|
|
192
|
+
set_cookies = e.headers.get_all("Set-Cookie") or []
|
|
193
|
+
except urllib.error.URLError:
|
|
194
|
+
return
|
|
195
|
+
for sc in set_cookies:
|
|
196
|
+
m = re.match(r"\s*xsrf=([^;]+)", sc)
|
|
197
|
+
if m:
|
|
198
|
+
jar.append({"name": "xsrf", "value": m.group(1).strip('"'),
|
|
199
|
+
"domain": ".medium.com", "path": "/"})
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
|
|
177
203
|
# ── commands ────────────────────────────────────────────────────────
|
|
178
204
|
|
|
179
205
|
def md_me(jar):
|
|
@@ -336,15 +362,163 @@ def medium_upload_image(jar, post_id, img_bytes, content_type):
|
|
|
336
362
|
return val["fileId"], val.get("imgWidth"), val.get("imgHeight")
|
|
337
363
|
|
|
338
364
|
|
|
365
|
+
# Inline markdown → Medium markups. Groups: 1/2 link, 3 bold, 4 code, 5 italic,
|
|
366
|
+
# 6 bare URL (autolinked).
|
|
367
|
+
_INLINE = re.compile(
|
|
368
|
+
r"\[([^\]]+)\]\((https?://[^)\s]+)\)"
|
|
369
|
+
r"|\*\*([^*]+)\*\*"
|
|
370
|
+
r"|`([^`]+)`"
|
|
371
|
+
r"|(?<![\w*])\*([^*\n]+)\*(?![\w*])"
|
|
372
|
+
r"|(https?://[^\s<>()\[\]]+)"
|
|
373
|
+
)
|
|
374
|
+
_TABLE_SEP = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$")
|
|
375
|
+
_LIST_ITEM = re.compile(r"^\s*([-*+]|\d+\.)\s+(.*)$")
|
|
376
|
+
# Medium's code column wraps past ~66 monospace chars; wider tables are rendered
|
|
377
|
+
# as per-row records instead of an unreadable wrapped grid.
|
|
378
|
+
_TABLE_MAX_W = 66
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _u16(s: str) -> int:
|
|
382
|
+
"""Length in UTF-16 code units — Medium indexes markup offsets like JS."""
|
|
383
|
+
return len(s.encode("utf-16-le")) // 2
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _inline(text: str):
|
|
387
|
+
"""Parse inline markdown → (plain_text, markups). Emits Medium markups for
|
|
388
|
+
links (type 3), bold (1), italic (2) and inline code (10) so they render as
|
|
389
|
+
real formatting instead of literal ``**`` / ``[..](..)`` characters. Bold,
|
|
390
|
+
italic and link labels are parsed recursively so nested markup (e.g. a link
|
|
391
|
+
inside bold) is preserved as overlapping markups rather than dropped."""
|
|
392
|
+
plain: list[str] = []
|
|
393
|
+
markups: list[dict] = []
|
|
394
|
+
pos = 0
|
|
395
|
+
off = 0 # running UTF-16 offset into the assembled plain text
|
|
396
|
+
|
|
397
|
+
def _nest(inner: str, start: int):
|
|
398
|
+
sub_plain, sub_mk = _inline(inner)
|
|
399
|
+
plain.append(sub_plain)
|
|
400
|
+
for x in sub_mk:
|
|
401
|
+
markups.append({**x, "start": x["start"] + start, "end": x["end"] + start})
|
|
402
|
+
return _u16(sub_plain)
|
|
403
|
+
|
|
404
|
+
for m in _INLINE.finditer(text):
|
|
405
|
+
if m.start() > pos:
|
|
406
|
+
seg = text[pos:m.start()]
|
|
407
|
+
plain.append(seg)
|
|
408
|
+
off += _u16(seg)
|
|
409
|
+
start = off
|
|
410
|
+
if m.group(1) is not None: # link
|
|
411
|
+
off += _nest(m.group(1), start)
|
|
412
|
+
markups.append({"type": 3, "start": start, "end": off,
|
|
413
|
+
"href": m.group(2), "title": "", "rel": "", "anchorType": 0})
|
|
414
|
+
elif m.group(3) is not None: # bold
|
|
415
|
+
off += _nest(m.group(3), start)
|
|
416
|
+
markups.append({"type": 1, "start": start, "end": off})
|
|
417
|
+
elif m.group(4) is not None: # inline code — literal, no recursion
|
|
418
|
+
inner = m.group(4)
|
|
419
|
+
plain.append(inner); off += _u16(inner)
|
|
420
|
+
markups.append({"type": 10, "start": start, "end": off})
|
|
421
|
+
elif m.group(5) is not None: # italic
|
|
422
|
+
off += _nest(m.group(5), start)
|
|
423
|
+
markups.append({"type": 2, "start": start, "end": off})
|
|
424
|
+
elif m.group(6) is not None: # bare URL → autolink
|
|
425
|
+
url = m.group(6)
|
|
426
|
+
tail = ""
|
|
427
|
+
while url and url[-1] in ".,;:!?。,;:!?":
|
|
428
|
+
tail = url[-1] + tail
|
|
429
|
+
url = url[:-1]
|
|
430
|
+
plain.append(url); off += _u16(url)
|
|
431
|
+
markups.append({"type": 3, "start": start, "end": off,
|
|
432
|
+
"href": url, "title": "", "rel": "", "anchorType": 0})
|
|
433
|
+
if tail:
|
|
434
|
+
plain.append(tail); off += _u16(tail)
|
|
435
|
+
pos = m.end()
|
|
436
|
+
if pos < len(text):
|
|
437
|
+
plain.append(text[pos:])
|
|
438
|
+
return "".join(plain), markups
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _is_table(lines):
|
|
442
|
+
return (len(lines) >= 2 and "|" in lines[0]
|
|
443
|
+
and "-" in lines[1] and _TABLE_SEP.match(lines[1]) is not None)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _split_row(line):
|
|
447
|
+
line = line.strip()
|
|
448
|
+
if line.startswith("|"):
|
|
449
|
+
line = line[1:]
|
|
450
|
+
if line.endswith("|"):
|
|
451
|
+
line = line[:-1]
|
|
452
|
+
return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", line)]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _render_table(lines):
|
|
456
|
+
"""Markdown table → aligned monospace text (rendered as a PRE code block, the
|
|
457
|
+
closest faithful representation since Medium's editor has no table element)."""
|
|
458
|
+
rows = [_split_row(ln) for i, ln in enumerate(lines) if i != 1]
|
|
459
|
+
ncol = max(len(r) for r in rows)
|
|
460
|
+
rows = [r + [""] * (ncol - len(r)) for r in rows]
|
|
461
|
+
widths = [max(len(r[c]) for r in rows) for c in range(ncol)]
|
|
462
|
+
def fmt(r):
|
|
463
|
+
return " | ".join(r[c].ljust(widths[c]) for c in range(ncol)).rstrip()
|
|
464
|
+
sep = "-+-".join("-" * widths[c] for c in range(ncol))
|
|
465
|
+
body = [fmt(rows[0]), sep] + [fmt(r) for r in rows[1:]]
|
|
466
|
+
return "\n".join(body)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _table_paras(lines):
|
|
470
|
+
"""Markdown table → Medium paragraphs. A table that fits Medium's ~66-char
|
|
471
|
+
code column keeps the aligned monospace grid; a wider one (long notes / full
|
|
472
|
+
URLs would wrap into an unreadable mess) becomes a per-row record — the first
|
|
473
|
+
cell as a bold lead-in, the remaining columns as ``header: value`` bullets.
|
|
474
|
+
Both paths stay inside the stdlib-only, no-image contract."""
|
|
475
|
+
grid = _render_table(lines)
|
|
476
|
+
if max((len(ln) for ln in grid.split("\n")), default=0) <= _TABLE_MAX_W:
|
|
477
|
+
return [{"type": 8, "text": grid}]
|
|
478
|
+
rows = [_split_row(ln) for i, ln in enumerate(lines) if i != 1]
|
|
479
|
+
header = rows[0] if rows else []
|
|
480
|
+
out = []
|
|
481
|
+
for r in rows[1:]:
|
|
482
|
+
cells = [c.strip() for c in r]
|
|
483
|
+
if not any(cells):
|
|
484
|
+
continue
|
|
485
|
+
if cells[0]: # lead cell → whole-cell bold
|
|
486
|
+
txt, mk = _inline(cells[0])
|
|
487
|
+
mk.append({"type": 1, "start": 0, "end": _u16(txt)})
|
|
488
|
+
out.append({"type": 1, "text": txt, "markups": mk})
|
|
489
|
+
for i in range(1, len(cells)):
|
|
490
|
+
if not cells[i]:
|
|
491
|
+
continue
|
|
492
|
+
vtxt, vmk = _inline(cells[i])
|
|
493
|
+
label = header[i].strip() if i < len(header) else ""
|
|
494
|
+
if label: # "label: value", label bolded
|
|
495
|
+
prefix = f"{label}: "
|
|
496
|
+
shift = _u16(prefix)
|
|
497
|
+
markups = [{"type": 1, "start": 0, "end": _u16(f"{label}:")}]
|
|
498
|
+
markups += [{**x, "start": x["start"] + shift, "end": x["end"] + shift}
|
|
499
|
+
for x in vmk]
|
|
500
|
+
out.append({"type": 9, "text": prefix + vtxt, "markups": markups})
|
|
501
|
+
else:
|
|
502
|
+
out.append({"type": 9, "text": vtxt, "markups": vmk})
|
|
503
|
+
return out or [{"type": 8, "text": grid}]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _is_list(lines):
|
|
507
|
+
ls = [ln for ln in lines if ln.strip()]
|
|
508
|
+
return bool(ls) and all(_LIST_ITEM.match(ln) for ln in ls)
|
|
509
|
+
|
|
510
|
+
|
|
339
511
|
def _build_deltas(jar, post_id, title, content, rehost=True):
|
|
340
|
-
"""Markdown → Medium paragraph deltas
|
|
341
|
-
|
|
342
|
-
|
|
512
|
+
"""Markdown → Medium paragraph deltas. Handles standalone ````
|
|
513
|
+
images (uploaded → type-4 image paragraph), headings, quotes, fenced code,
|
|
514
|
+
tables (narrow → aligned code block; wide → per-row records), bullet/ordered
|
|
515
|
+
lists, and inline markups (links/bold/italic/code) on ordinary text."""
|
|
343
516
|
paras = [{"type": 3, "text": title}]
|
|
344
517
|
for block in re.split(r"\n\s*\n", content.strip()):
|
|
345
518
|
b = block.strip("\n")
|
|
346
519
|
if not b.strip():
|
|
347
520
|
continue
|
|
521
|
+
lines = b.split("\n")
|
|
348
522
|
first = b.lstrip()
|
|
349
523
|
m = _MD_IMG.fullmatch(first)
|
|
350
524
|
if m:
|
|
@@ -367,19 +541,34 @@ def _build_deltas(jar, post_id, title, content, rehost=True):
|
|
|
367
541
|
continue
|
|
368
542
|
if first.startswith("```"):
|
|
369
543
|
paras.append({"type": 8, "text": re.sub(r"^```[^\n]*\n?|\n?```$", "", b)})
|
|
544
|
+
elif _is_table(lines):
|
|
545
|
+
paras.extend(_table_paras(lines))
|
|
546
|
+
elif _is_list(lines):
|
|
547
|
+
for ln in lines:
|
|
548
|
+
lm = _LIST_ITEM.match(ln)
|
|
549
|
+
if not lm:
|
|
550
|
+
continue
|
|
551
|
+
ptype = 10 if lm.group(1).endswith(".") else 9
|
|
552
|
+
txt, mk = _inline(lm.group(2).strip())
|
|
553
|
+
paras.append({"type": ptype, "text": txt, "markups": mk})
|
|
370
554
|
elif first.startswith("# "):
|
|
371
|
-
|
|
555
|
+
txt, mk = _inline(first[2:].strip())
|
|
556
|
+
paras.append({"type": 2, "text": txt, "markups": mk})
|
|
372
557
|
elif first.startswith("## "):
|
|
373
|
-
|
|
558
|
+
txt, mk = _inline(first[3:].strip())
|
|
559
|
+
paras.append({"type": 2, "text": txt, "markups": mk})
|
|
374
560
|
elif first.startswith("### "):
|
|
375
|
-
|
|
561
|
+
txt, mk = _inline(first[4:].strip())
|
|
562
|
+
paras.append({"type": 3, "text": txt, "markups": mk})
|
|
376
563
|
elif first.startswith("> "):
|
|
377
|
-
|
|
564
|
+
txt, mk = _inline(first[2:].strip())
|
|
565
|
+
paras.append({"type": 6, "text": txt, "markups": mk})
|
|
378
566
|
else:
|
|
379
|
-
|
|
567
|
+
txt, mk = _inline(b.replace("\n", " ").strip())
|
|
568
|
+
paras.append({"type": 1, "text": txt, "markups": mk})
|
|
380
569
|
out_deltas = []
|
|
381
570
|
for i, p in enumerate(paras):
|
|
382
|
-
para = {"type": p["type"], "text": p["text"], "markups": []}
|
|
571
|
+
para = {"type": p["type"], "text": p["text"], "markups": p.get("markups") or []}
|
|
383
572
|
if "layout" in p:
|
|
384
573
|
para["layout"] = p["layout"]
|
|
385
574
|
if "metadata" in p:
|
|
@@ -413,6 +602,12 @@ def cmd_publish(jar, args):
|
|
|
413
602
|
})
|
|
414
603
|
return
|
|
415
604
|
|
|
605
|
+
# Captured jars often lack an xsrf cookie; mint one so writes don't 400.
|
|
606
|
+
prime_xsrf(jar)
|
|
607
|
+
if not cookie_value(jar, "xsrf"):
|
|
608
|
+
die("could not obtain an xsrf token from Medium (cookie may be expired) — "
|
|
609
|
+
"reconnect at https://auth.acedata.cloud/user/connections.")
|
|
610
|
+
|
|
416
611
|
# 1. create empty draft
|
|
417
612
|
d = api("POST", f"{BASE}/new-story", jar, body={})
|
|
418
613
|
payload = d.get("payload") or {}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wecom
|
|
3
|
+
description: Read your WeCom (企业微信 / WeCom / Work Weixin) contacts, send app & group messages, create and read WeDoc docs/smart sheets, and manage schedules (日程) and meetings (会议) via the WeCom server-side API as a self-built app. Use when the user mentions 企业微信, WeCom, 通讯录成员/部门, 应用消息, 群机器人/应用群聊, 企业微信文档/智能表格, 企业微信日程 or 会议, or a qyapi.weixin.qq.com call.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger when the user wants to operate their WeCom (企业微信) via a
|
|
6
|
+
self-built app: list departments / members, look up a member's userid,
|
|
7
|
+
send an app message or push to an app group chat, create or read a
|
|
8
|
+
WeDoc document / smart sheet, or create / list / cancel a schedule
|
|
9
|
+
(日程) or meeting (会议).
|
|
10
|
+
connections: [wecom]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
We drive the [WeCom server-side API](https://developer.work.weixin.qq.com/document/path/90664)
|
|
19
|
+
(`https://qyapi.weixin.qq.com`) with `curl + jq` as a **self-built app (自建应用)**.
|
|
20
|
+
|
|
21
|
+
> **Setup:** see [WeCom authentication](../_shared/wecom.md) for how to create the self-built app,
|
|
22
|
+
> collect **CorpID / Secret / AgentId**, and grant it the contacts / docs / calendar / meeting
|
|
23
|
+
> permissions. The skill reads `WECOM_CORP_ID`, `WECOM_CORP_SECRET` and `WECOM_AGENT_ID` from the
|
|
24
|
+
> environment; on AceDataCloud they are injected automatically by the 企业微信 connector.
|
|
25
|
+
|
|
26
|
+
WeCom uses a two-step token flow (identical in shape to the WeChat MP API):
|
|
27
|
+
|
|
28
|
+
1. Exchange `CorpID + Secret` for an `access_token` (TTL 7200s).
|
|
29
|
+
2. Pass that `access_token` as a **query string parameter** on every other call.
|
|
30
|
+
|
|
31
|
+
**Never log or echo `$WECOM_CORP_SECRET`** — treat it like a password.
|
|
32
|
+
|
|
33
|
+
Responses are JSON returned with **HTTP 200**; `errcode == 0` means success. On any non-zero
|
|
34
|
+
`errcode`, show the original `errmsg` to the user verbatim (see the error table in
|
|
35
|
+
[the setup doc](../_shared/wecom.md#response-shape--error-handling)).
|
|
36
|
+
|
|
37
|
+
## Recipes
|
|
38
|
+
|
|
39
|
+
### Step 0 — get an access_token (do this first, cache the result)
|
|
40
|
+
|
|
41
|
+
Every recipe below assumes `$AT` holds a valid token from this step.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
# Fail loudly if credentials are missing/blank. WECOM_AGENT_ID must be a plain
|
|
45
|
+
# integer because message/meeting recipes pass it to jq via --argjson (numeric JSON).
|
|
46
|
+
: "${WECOM_CORP_ID:?WECOM_CORP_ID not set}" "${WECOM_CORP_SECRET:?WECOM_CORP_SECRET not set}"
|
|
47
|
+
case "${WECOM_AGENT_ID:?WECOM_AGENT_ID not set}" in *[!0-9]*|"") echo "WECOM_AGENT_ID must be an integer" >&2; exit 1;; esac
|
|
48
|
+
|
|
49
|
+
# Cache to $TMPDIR so subsequent calls in the same session reuse it (WeCom
|
|
50
|
+
# rate-limits gettoken). Refresh 5 minutes early to avoid edge-of-window failures.
|
|
51
|
+
TOKEN_CACHE="${TMPDIR:-/tmp}/wecom-token-${WECOM_CORP_ID}-${WECOM_AGENT_ID}.json"
|
|
52
|
+
NOW=$(date +%s)
|
|
53
|
+
if [ -f "$TOKEN_CACHE" ] && [ "$(jq -r '.exp_at // 0' "$TOKEN_CACHE")" -gt "$((NOW + 300))" ]; then
|
|
54
|
+
AT=$(jq -r '.access_token' "$TOKEN_CACHE")
|
|
55
|
+
else
|
|
56
|
+
RESP=$(curl -sS "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${WECOM_CORP_ID}&corpsecret=${WECOM_CORP_SECRET}")
|
|
57
|
+
AT=$(echo "$RESP" | jq -r 'if .errcode == 0 then .access_token else empty end')
|
|
58
|
+
if [ -z "$AT" ]; then
|
|
59
|
+
echo "Failed to fetch access_token: $RESP" >&2
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
EXPIRES=$(echo "$RESP" | jq -r '.expires_in // 7200')
|
|
63
|
+
echo "$RESP" | jq --argjson now "$NOW" --argjson ttl "$EXPIRES" \
|
|
64
|
+
'{access_token, exp_at: ($now + $ttl)}' > "$TOKEN_CACHE"
|
|
65
|
+
chmod 600 "$TOKEN_CACHE"
|
|
66
|
+
fi
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A tiny helper keeps the recipes short — GET with the token appended, erroring on non-zero `errcode`:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
wc_get() { curl -sS "https://qyapi.weixin.qq.com/cgi-bin/$1&access_token=${AT}" \
|
|
73
|
+
| jq 'if .errcode == 0 then . else error("WeCom \(.errcode): \(.errmsg)") end'; }
|
|
74
|
+
wc_post() { curl -sS -X POST "https://qyapi.weixin.qq.com/cgi-bin/$1?access_token=${AT}" \
|
|
75
|
+
-H 'Content-Type: application/json' -d "$2" \
|
|
76
|
+
| jq 'if .errcode == 0 then . else error("WeCom \(.errcode): \(.errmsg)") end'; }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 通讯录 Contacts (read)
|
|
80
|
+
|
|
81
|
+
> Reading real names / mobiles requires the app to have **通讯录读取** privilege; otherwise names
|
|
82
|
+
> come back masked. Members outside the app's 可见范围 return `errcode 60011` / `81013`.
|
|
83
|
+
|
|
84
|
+
List the department tree (root department id is `1`):
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
wc_get "department/list?id=1" | jq '[.department[] | {id, name, parentid}]'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Preferred enumeration — `user/list_id` (cursor).** This is the robust way to list members:
|
|
91
|
+
it works for a self-built app with only its own Secret. `user/simplelist` / `user/list` still
|
|
92
|
+
work *within the app's 可见范围*, but WeCom has been tightening those (the 2022‑08‑15 change blocks
|
|
93
|
+
newly‑added 通讯录同步 IPs from calling them), so lead with `user/list_id` and fall back to
|
|
94
|
+
`simplelist` only if you need names in one shot.
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
wc_post "user/list_id" '{"cursor":"","limit":10000}' \
|
|
98
|
+
| jq '{next_cursor, userids: [.dept_user[] | .userid] | unique}'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Get one member's full profile — this is how you resolve a **name → userid** for messaging:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
wc_get "user/get?userid=USERID" \
|
|
105
|
+
| jq '{userid, name, department, position, mobile, email, status}'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
List members of a department in one call (convenience; needs 通讯录 view on that department —
|
|
109
|
+
if it returns `60011`, use `user/list_id` + `user/get` above instead):
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
wc_get "user/simplelist?department_id=1&fetch_child=1" \
|
|
113
|
+
| jq '[.userlist[] | {userid, name, department}]'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Search by name — robust path (enumerate ids, then read each profile and filter):
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
for uid in $(wc_post "user/list_id" '{"cursor":"","limit":10000}' | jq -r '.dept_user[].userid' | sort -u); do
|
|
120
|
+
wc_get "user/get?userid=${uid}" | jq -c '{userid, name}'
|
|
121
|
+
done | jq -s --arg q "张三" '[.[] | select(.name | contains($q))]'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> Shortcut when `simplelist` is available to the app: `wc_get "user/simplelist?department_id=1&fetch_child=1" | jq --arg q "张三" '[.userlist[] | select(.name | contains($q)) | {userid, name}]'`.
|
|
125
|
+
|
|
126
|
+
### 应用消息 App messages (send) — GATED
|
|
127
|
+
|
|
128
|
+
`message/send` pushes a notification from your app to members. `agentid` is required and comes from
|
|
129
|
+
`$WECOM_AGENT_ID`. `touser` is a `|`-joined list of **userid**s (use `@all` for everyone in scope);
|
|
130
|
+
`toparty` / `totag` target departments / tags.
|
|
131
|
+
|
|
132
|
+
> Sending fans out to real people. **Always show the exact recipients + content and get explicit
|
|
133
|
+
> user confirmation before running `message/send` / `appchat/send`**, even if the instruction says
|
|
134
|
+
> "just send it".
|
|
135
|
+
|
|
136
|
+
Send a text message to one or more members:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
wc_post "message/send" "$(jq -nc --arg to "USERID1|USERID2" --argjson agent "${WECOM_AGENT_ID}" \
|
|
140
|
+
--arg content "构建已通过,请查看。" \
|
|
141
|
+
'{touser:$to, msgtype:"text", agentid:$agent, text:{content:$content}, safe:0}')" \
|
|
142
|
+
| jq '{msgid, invaliduser}'
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Send a Markdown card (richer formatting, members only):
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
wc_post "message/send" "$(jq -nc --arg to "@all" --argjson agent "${WECOM_AGENT_ID}" \
|
|
149
|
+
--arg md "**发布通知**\n>版本:v1.2.0\n>状态:<font color=\"info\">成功</font>" \
|
|
150
|
+
'{touser:$to, msgtype:"markdown", agentid:$agent, markdown:{content:$md}}')" \
|
|
151
|
+
| jq '{msgid}'
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Send a clickable text-card:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
wc_post "message/send" "$(jq -nc --arg to "USERID1" --argjson agent "${WECOM_AGENT_ID}" \
|
|
158
|
+
'{touser:$to, msgtype:"textcard", agentid:$agent,
|
|
159
|
+
textcard:{title:"周报已生成", description:"点击查看本周汇总", url:"https://example.com/report", btntxt:"详情"}}')" \
|
|
160
|
+
| jq '{msgid}'
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 应用群聊 App group chat
|
|
164
|
+
|
|
165
|
+
Create a group the app owns, then push messages into it. `chatid` is a custom id you choose (reuse it later).
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
# Create (owner + members are userids). Omit chatid to let WeCom assign one.
|
|
169
|
+
wc_post "appchat/create" '{"name":"项目组","owner":"USERID1","userlist":["USERID1","USERID2","USERID3"],"chatid":"proj_alpha"}' \
|
|
170
|
+
| jq '{chatid}'
|
|
171
|
+
|
|
172
|
+
# Send into it (same GATED confirmation rule as app messages).
|
|
173
|
+
wc_post "appchat/send" '{"chatid":"proj_alpha","msgtype":"text","text":{"content":"今晚 8 点线上同步。"}}' \
|
|
174
|
+
| jq '{errcode, errmsg}'
|
|
175
|
+
|
|
176
|
+
# Inspect a group the app created.
|
|
177
|
+
wc_get "appchat/get?chatid=proj_alpha" | jq '.chat_info | {name, owner, userlist}'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 企业微信文档 WeDoc (docs & smart sheets)
|
|
181
|
+
|
|
182
|
+
Requires the app's **文档 (WeDoc)** permission. `doc_type`: `3` = 文档 (document), `4` = 表格 (spreadsheet),
|
|
183
|
+
`10` = 智能表格 (smart sheet).
|
|
184
|
+
|
|
185
|
+
Create a document and get its edit URL:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
wc_post "wedoc/create_doc" "$(jq -nc --arg name "项目周报 2026-07" \
|
|
189
|
+
'{doc_type:3, doc_name:$name}')" \
|
|
190
|
+
| jq '{docid, url}'
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Get a shareable link for an existing doc:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
wc_post "wedoc/doc_share" '{"docid":"DOCID"}' | jq '{share_url: .share_url}'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Read a document's structured content. WeCom returns a `document` object (keyed by `document_id`,
|
|
200
|
+
not `doc_id`) whose body is a tree of typed blocks, **not** Markdown — inspect the real shape before
|
|
201
|
+
relying on inner field names:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
wc_post "wedoc/document/get" '{"docid":"DOCID"}' \
|
|
205
|
+
| jq '{document_id: .document.document_id, top_level_keys: (.document | keys)}'
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
> Smart-sheet (智能表格) sub-tables, fields and records use the `wedoc/smartsheet/*` endpoints
|
|
209
|
+
> (`get_sheet`, `get_fields`, `get_records`, `add_records`, …) — same `wc_post` pattern, keyed by
|
|
210
|
+
> `docid` + `sheet_id`. Reach for them when the user asks to read/write rows of a 智能表格.
|
|
211
|
+
|
|
212
|
+
### 日程 Schedule (日程)
|
|
213
|
+
|
|
214
|
+
Requires the app's **日历/日程** permission. Times are **epoch seconds**; convert with GNU `date`:
|
|
215
|
+
|
|
216
|
+
```sh
|
|
217
|
+
START=$(date -d "2026-07-10 15:00" +%s); END=$(date -d "2026-07-10 16:00" +%s)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Create a schedule (organizer + attendees are userids):
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
wc_post "oa/schedule/add" "$(jq -nc --arg org "USERID1" --argjson s "$START" --argjson e "$END" \
|
|
224
|
+
'{schedule:{organizer:$org, start_time:$s, end_time:$e, summary:"项目评审",
|
|
225
|
+
description:"评审 v1.2 需求", location:"会议室 A",
|
|
226
|
+
attendees:[{userid:"USERID2"},{userid:"USERID3"}],
|
|
227
|
+
reminders:{is_remind:1, remind_before_event_secs:900}}}')" \
|
|
228
|
+
| jq '{schedule_id}'
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Read schedules by id, and cancel one:
|
|
232
|
+
|
|
233
|
+
```sh
|
|
234
|
+
wc_post "oa/schedule/get" '{"schedule_id_list":["SCHEDULE_ID"]}' \
|
|
235
|
+
| jq '[.schedule_list[] | {schedule_id, summary, start_time, end_time, organizer}]'
|
|
236
|
+
|
|
237
|
+
wc_post "oa/schedule/del" '{"schedule_id":"SCHEDULE_ID"}' | jq '{errcode, errmsg}'
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
> To *list* a member's upcoming schedules you need their calendar id (`cal_id`) and
|
|
241
|
+
> `oa/schedule/get_by_calendar` — create/query calendars via the `oa/calendar/*` endpoints first.
|
|
242
|
+
|
|
243
|
+
### 会议 Meeting (会议)
|
|
244
|
+
|
|
245
|
+
Requires the app's **会议** permission. `meeting_start` is epoch seconds, `meeting_duration` is seconds.
|
|
246
|
+
|
|
247
|
+
Create a scheduled meeting:
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
wc_post "meeting/create" "$(jq -nc --arg admin "USERID1" --argjson start "$(date -d '2026-07-11 10:00' +%s)" \
|
|
251
|
+
'{admin_userid:$admin, title:"周例会", meeting_start:$start, meeting_duration:3600,
|
|
252
|
+
description:"同步本周进展", invitees:{userid:["USERID2","USERID3"]}}')" \
|
|
253
|
+
| jq '{meetingid}'
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
List a member's meetings, read details, and cancel (`get_user_meetingid` is a **POST** with a
|
|
257
|
+
cursor body, mirroring WeCom's other `get_user_*_id` list endpoints):
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
wc_post "meeting/get_user_meetingid" '{"userid":"USERID1","cursor":"","limit":100}' \
|
|
261
|
+
| jq '{meetingid_list, next_cursor}'
|
|
262
|
+
|
|
263
|
+
wc_post "meeting/get_info" '{"meetingid":"MEETINGID"}' \
|
|
264
|
+
| jq '.meeting_info | {title, meeting_start, meeting_duration, state}'
|
|
265
|
+
|
|
266
|
+
wc_post "meeting/cancel" '{"meetingid":"MEETINGID"}' | jq '{errcode, errmsg}'
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Safety rules
|
|
270
|
+
|
|
271
|
+
- **Never print `$WECOM_CORP_SECRET`.** It is equivalent to full app access.
|
|
272
|
+
- **Confirm before anything outward-facing** — `message/send`, `appchat/send`, `oa/schedule/add`
|
|
273
|
+
and `meeting/create` all notify real colleagues. Show the exact recipients / attendees and the
|
|
274
|
+
content, get explicit approval, *then* run the call. Never infer consent from vague wording.
|
|
275
|
+
- Messaging / schedules / meetings act on **userids**, not display names — resolve a name to a
|
|
276
|
+
userid with the contact recipes first, and confirm the person if the name is ambiguous.
|
|
277
|
+
- On `errcode 48002` (API forbidden) or `60011` / `301002` (no privilege), the app is missing a
|
|
278
|
+
permission or the target is outside its 可见范围 — tell the user which capability to grant rather
|
|
279
|
+
than retrying blindly.
|
|
280
|
+
|
|
281
|
+
## Not supported here
|
|
282
|
+
|
|
283
|
+
- **Reading inbound chat history** (会话内容存档 / msgaudit) — a separate paid capability needing a
|
|
284
|
+
dedicated Secret, an RSA key and the native WeCom Finance SDK; it can't run in this sandbox. The
|
|
285
|
+
skill *sends* messages but can't *read* members' private conversations.
|
|
286
|
+
- **A generic todo (待办) CRUD** — WeCom's open API exposes none for self-built apps; use
|
|
287
|
+
**schedules (日程)** for time-bound reminders instead.
|