acedatacloud/skills

wechat-official-account

Generate articles, manage drafts, publish to «发表记录», send customer-service messages, manage menus and pull stats on a WeChat Official Account (微信?

First seen May 6, 2026

Installation

$ npx skills add acedatacloud/skills --skill wechat-official-account

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from acedatacloud/skills · top by installs.

npx skills add acedatacloud/skills

Browse all from acedatacloud/skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 17
License LICENSE
Default branch main
Open issues 1
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0
LicenseApache-2.0
Allowed toolsBash
More metadata
author
acedatacloud
version
1.0

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 14,948 B
  • docs SUMMARY.md 445 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 233 installs

SKILL.md

We drive the WeChat MP server-side API with curl + jq. Unlike OAuth-bearer connectors, WeChat MP uses a two-step flow:

  1. Exchange AppID + AppSecret for an access_token (TTL 7200s, global limit ≈ 2000 calls/day per app).
  2. Pass that access_token as a query string parameter on every other call.

The user's credentials are in $WECHATAPPID and $WECHATAPPSECRET. Never log or echo $WECHATAPPSECRET — treat it like a password.

The WeChat MP API returns standard JSON. Errors are returned with HTTP 200; the body looks like {"errcode": 40013, "errmsg": "invalid appid"}. errcode == 0 means success — show the original errmsg to the user verbatim on any non-zero code.

Important constraints — surface these to the user before they're surprised

  • IP whitelist: every API call's source IP must be in this app's IP whitelist (公众平台 → 设置与开发 → 基本配置 → IP 白名单). If you see errcode 40164 ("invalid ip"), the worker's egress IP isn't whitelisted; tell the user to add the IP shown in errmsg and retry.
  • Verified account required for publishing: as of 2025-07, only verified (已认证) corporate-subject accounts can call freepublish/ and mass/. Personal-subject accounts and unverified corporate accounts get a permission error. Drafts (draft/) and customer messages (message/custom/) usually work without verification.
  • Group-send quota is harsh: 服务号 = 4 sends/month, 订阅号 = 1 send/day. Treat freepublish/submit and mass/sendall like a destructive operationalways confirm with the user before calling them, even if instructions say "publish it". Default to creating a draft and pasting the draft URL.
  • Customer-service window is 48 hours: message/custom/send only works for a follower whose openid interacted with the account in the last 48 hours. Outside that window you get errcode 45015.

Recipes

Step 0 — get an access_token (do this first, cache the result)

# Cache to /tmp so subsequent calls in the same session reuse it.
TOKEN_CACHE="/tmp/wx-mp-token-${WECHAT_APP_ID}.json"

# Reuse cached token if it's still valid (we conservatively refresh
# 5 minutes early to avoid edge-of-window failures).
NOW=$(date +%s)
if [ -f "$TOKEN_CACHE" ] && [ "$(jq -r '.exp_at // 0' "$TOKEN_CACHE")" -gt "$((NOW + 300))" ]; then
  WECHAT_ACCESS_TOKEN=$(jq -r '.access_token' "$TOKEN_CACHE")
else
  RESP=$(curl -sS "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${WECHAT_APP_ID}&secret=${WECHAT_APP_SECRET}")
  WECHAT_ACCESS_TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
  if [ -z "$WECHAT_ACCESS_TOKEN" ]; then
    echo "Failed to fetch access_token: $RESP" >&2
    exit 1
  fi
  EXPIRES=$(echo "$RESP" | jq -r '.expires_in // 7200')
  jq -nc --arg t "$WECHAT_ACCESS_TOKEN" --argjson e "$((NOW + EXPIRES))" \
    '{access_token:$t, exp_at:$e}' > "$TOKEN_CACHE"
fi
echo "OK token=${WECHAT_ACCESS_TOKEN:0:8}…"

If the response is {"errcode": 40164, ...} (invalid IP) or {"errcode": 40013, ...} (invalid appid) — surface that error to the user; it means the connector setup itself is wrong, not the request.

Verify the connection (always run this once before complex operations)

# Pull basic public-account self-info; cheapest call that proves the token works.
curl -sS "https://api.weixin.qq.com/cgi-bin/account/getaccountbasicinfo?access_token=${WECHAT_ACCESS_TOKEN}" | jq

Upload an image to use inside an article body (returns a public URL)

This is for <img src="..."> tags inside the article HTML. The returned url is hosted by Tencent and works in published articles.

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${WECHAT_ACCESS_TOKEN}" \
  -F "media=@/path/to/your-image.jpg"
# → {"url": "http://mmbiz.qpic.cn/..."}

Limits: ≤ 1 MB, JPG/PNG only, no count limit on this endpoint.

Upload a thumbnail (needed as the article cover)

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${WECHAT_ACCESS_TOKEN}&type=thumb" \
  -F "media=@/path/to/cover.jpg" | jq '{media_id, url}'
# → {"media_id": "MEDIA_ID", "url": "http://mmbiz.qpic.cn/..."}

The mediaid you get back is what you pass as thumbmedia_id when creating a draft. Recommended cover dimensions: 900×500 px, JPG, < 64 KB ideally.

Create a draft (the safe default — do not directly publish)

TITLE="Q1 product update"
AUTHOR="Acme Inc."
THUMB_MEDIA_ID="MEDIA_ID_FROM_PREVIOUS_STEP"
CONTENT_HTML='<p>欢迎关注我们的最新动态。</p><p><img src="http://mmbiz.qpic.cn/..."></p><p>更多内容请见底部「阅读原文」。</p>'
DIGEST="Q1 has been a wild ride — here's what shipped."
SOURCE_URL="https://example.com/q1-recap"   # optional; populates 「阅读原文」, leave empty to omit

PAYLOAD=$(jq -nc \
  --arg title       "$TITLE" \
  --arg author      "$AUTHOR" \
  --arg thumb       "$THUMB_MEDIA_ID" \
  --arg content     "$CONTENT_HTML" \
  --arg digest      "$DIGEST" \
  --arg source_url  "$SOURCE_URL" \
  '{articles: [{
    article_type: "news",
    title: $title,
    author: $author,
    thumb_media_id: $thumb,
    content: $content,
    digest: $digest,
    content_source_url: $source_url,
    need_open_comment: 0,
    only_fans_can_comment: 0
  }]}')

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$PAYLOAD" | jq
# → {"media_id": "DRAFT_MEDIA_ID"}

Tell the user: "Draft created. Open https://mp.weixin.qq.com → 草稿箱 to review and tap «发表» from there." — that's the safest path because the user controls the publish click in WeChat's own UI.

List drafts (newest first)

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/batchget?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw '{"offset": 0, "count": 20, "no_content": 1}' \
  | jq '.item[] | {media_id, update_time, title: .content.news_item[0].title}'

Get the full content of one draft

DRAFT_MEDIA_ID="..."
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/get?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg m "$DRAFT_MEDIA_ID" '{media_id: $m}')" | jq

Update an existing draft

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/draft/update?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg m "$DRAFT_MEDIA_ID" --arg t "Updated title" --arg c "<p>new body</p>" --arg th "$THUMB_MEDIA_ID" '
    {media_id: $m, index: 0, articles: {
      title: $t, content: $c, thumb_media_id: $th
    }}')" | jq

Publish a draft to «发表记录» — DESTRUCTIVE, confirm before calling

# Eats one of the monthly publish slots. Always echo back to the user
# what's about to go live and require an explicit "yes" confirmation
# in conversation BEFORE invoking this.
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg m "$DRAFT_MEDIA_ID" '{media_id: $m}')" | jq
# → {"errcode": 0, "msg_data_id": ..., "publish_id": "..."}

Then poll publish status (publishing is async, takes ~5-30 seconds):

PUBLISH_ID="..."
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/freepublish/get?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg p "$PUBLISH_ID" '{publish_id: $p}')" | jq '{publish_status, fail_idx, article_id, article_url: .article_detail.item[0].article_url}'
# publish_status: 0 = success, 1 = publishing, 2 = original-check-failed,
#                 3 = failed, 4 = published-but-removed, 5 = unverified-removed

When publishstatus == 0, return articledetail.item[0].article_url to the user — that's the canonical https://mp.weixin.qq.com/s/... URL.

List already-published articles

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/freepublish/batchget?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw '{"offset": 0, "count": 20, "no_content": 1}' \
  | jq '.item[] | {article_id, update_time, title: .content.news_item[0].title, url: .content.news_item[0].url}'

Send a customer-service message (one specific follower, 48h window)

OPENID="oXXXXXXXXXXXXXXXXX"   # the recipient follower's openid
TEXT="Hi! Your subscription has been renewed. Thanks for sticking with us."

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg u "$OPENID" --arg t "$TEXT" '
    {touser: $u, msgtype: "text", text: {content: $t}}')" | jq

errcode 45015 = recipient hasn't messaged the account in the last 48h — explain that to the user; there's nothing the API can do about it.

To send an article card via customer message, change to msgtype: "mpnews" with mpnews: {mediaid: "..."} (use material/addnews to first create a permanent news media_id).

Pull follower-growth + read stats

BEGIN="2026-04-25"
END="2026-05-01"   # max 7-day window for getusersummary

# New / unsubscribed / cumulative followers per day
curl -sS -X POST \
  "https://api.weixin.qq.com/datacube/getusersummary?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg b "$BEGIN" --arg e "$END" '{begin_date: $b, end_date: $e}')" \
  | jq '.list[] | {date: .ref_date, new: .new_user, lost: .cancel_user, source: .user_source}'

# Article reads per day (max 3-day window for getuserread)
curl -sS -X POST \
  "https://api.weixin.qq.com/datacube/getuserread?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$(jq -nc --arg b "$BEGIN" --arg e "$BEGIN" '{begin_date: $b, end_date: $e}')" | jq

datacube/* requires an authenticated (已认证) account with the data-cube permission.

Manage the bottom menu

# Read current menu
curl -sS "https://api.weixin.qq.com/cgi-bin/menu/get?access_token=${WECHAT_ACCESS_TOKEN}" | jq

# Replace the menu (max 3 top-level buttons; each top-level button can have ≤ 5 sub-buttons)
MENU=$(jq -nc '{
  button: [
    {type: "click", name: "今日推荐", key: "DAILY_REC"},
    {name: "更多",
     sub_button: [
       {type: "view", name: "官网",   url: "https://example.com"},
       {type: "view", name: "联系我们", url: "https://example.com/contact"}
     ]}
  ]
}')
curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw "$MENU" | jq
# → {"errcode": 0, "errmsg": "ok"}

Get the follower list (paginated by next_openid)

NEXT=""
curl -sS "https://api.weixin.qq.com/cgi-bin/user/get?access_token=${WECHAT_ACCESS_TOKEN}&next_openid=${NEXT}" \
  | jq '{total, count, openids: .data.openid, next: .next_openid}'
# Loop: pass the returned `next_openid` as NEXT until count < 10000.

For each openid, batch-fetch profile (≤ 100 per call):

curl -sS -X POST \
  "https://api.weixin.qq.com/cgi-bin/user/info/batchget?access_token=${WECHAT_ACCESS_TOKEN}" \
  -H "Content-Type: application/json; charset=utf-8" \
  --data-raw '{"user_list": [{"openid": "OPENID1", "lang": "zh_CN"}]}' \
  | jq '.user_info_list[] | {openid, nickname, subscribe_time, tagid_list}'

HTML rules for article content

The content field in draft/add accepts a subset of HTML, not full web HTML:

  • Allowed: <p>, <span>, <strong>, <em>, <a href>, <img src>, <br>, <h1><h3>, <ul>/<ol>/<li>, <blockquote>, <section>.
  • Inline styles only (<p style="...">) — no <style> blocks, no <link> to external CSS.
  • All <img> src URLs must be either previously-uploaded mmbiz.qpic.cn URLs (from media/uploadimg) or already-published mmbiz URLs. WeChat strips images hosted on third-party domains.
  • Total content size limit: ~ 20 K characters (HTML included).

Common errcode cheat-sheet

errcode meaning what to tell the user
0 success
40001 invalid access_token Token expired mid-call; flush $TOKEN_CACHE and retry
40013 invalid appid The AppID in the connector is wrong — re-add the connection
40164 source IP not in whitelist Add the IP shown in errmsg to 公众平台 → 设置与开发 → 基本配置 → IP白名单
41001 missing access_token Bug — you forgot to pass ?access_token=...
45009 API daily quota exceeded Try again tomorrow; the per-app daily quota was hit
45015 response message out of 48h Customer-service window has closed for this openid; can't recover via API
48001 api unauthorized Account doesn't have permission for this endpoint (e.g. publish without 认证); see the doc URL in errmsg
61450 system error Tencent-side flake; retry once after a 1-second backoff

Why we use bare curl + jq (not an SDK)

Skill bodies must be self-contained shell. Calling pip install wechatpy is not allowed in the sandbox, and the API surface here is small (15-ish endpoints) and stable since 2018. The python SDKs (wechatpy, werobot) are useful references for parameter shapes, but every recipe above is a direct call to the documented endpoint — the SDKs are just thin wrappers around the same JSON.