Push Alerts to WeCom (WeChat Work) via RPC
1. Overview
ThinkLink (TKL) RPC can call external HTTP endpoints (see RPC Model §1.3.2 Type 4) and forward device alerts, status changes, or custom events to a WeCom group bot in real time. When a device triggers an alert condition, on-call engineers can be notified immediately in their WeCom group.
This note shows how to push messages to WeCom from an RPC. Three integration paths are covered (in recommended order):
| Path | When to use | Auth (storage location) |
|---|---|---|
| Group bot (§2–§3) | Push to a single fixed WeCom group | Webhook URL (Org Param wecom_webhook_url) |
| Smart bot (§6) | One bot needs to serve multiple groups, or you need template-card / callback features | Webhook URL (Org Params) |
| Self-built app (§7) | Target specific users / departments / tags (no group required) | corpid + corpsecret + agentid (Org Params) |
2. Prerequisites
2.1 Create a WeCom Group Bot
- In a WeCom group, open Group Settings → Group Bots → Add Bot
- Set the bot name and avatar, then copy the generated Webhook URL
- The webhook URL looks like:The
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxkeyquery parameter is the only thing protecting this bot.
2.2 Protect the Webhook Key (Required Reading)
⚠️ Security notice: The
keyin the webhook URL is the bot's full send-permission credential. Anyone who has it can post to your group.
Follow these rules:
- Do not store the key in
server_attrs, asset attributes, trigger models, or any other field that callers can read — those fields travel with the device/asset and broaden the leak surface - Recommended: store the full webhook URL in System Management → Server Configuration → Org Params, and read it from the RPC as
org_params.wecom_webhook_url. Why:- The Org Params page is visible to org admins only — regular operators and RPC callers cannot see it
- Rotating the key is a single edit in Server Configuration; every trigger, scheduled task, and external caller that uses the RPC automatically picks up the new value with no script redeploy
- Multiple bots in the same org (prod/staging, alarm/daily-report) can live under different keys without interfering with each other
- See Server Configuration §1.6 Org Params
- Rotate the webhook key at least once a quarter by clicking "Reset webhook URL" in the bot settings, then update only the Org Param value
- Limit group membership; outsiders joining the group also gain access to the webhook
- If you suspect the key has leaked, reset it immediately in the bot settings, update the Org Param value, and review recent group messages for unexpected posts
3. Calling WeCom from an RPC
3.1 Design Principle
Treat the WeCom alert RPC as a "notification service":
- Secret stays inside: the webhook URL lives in Org Params (
org_params.wecom_webhook_url), visible to org admins only - Interface faces outward: callers pass message text, alert level, mention targets, etc. via
params - Multiple bots: register several keys in Org Params (e.g.
wecom_webhook_alarm,wecom_webhook_daily) and route in-script viaparams.channel; the keys never leave Server Configuration
3.2 Prerequisite: Store the Webhook in Org Params
Open System Management → Server Configuration → Org Params and add an entry:
| Key | Value | Remark |
|---|---|---|
wecom_webhook_url | https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-... | WeCom alarm-bot webhook, rotate quarterly |
The new value is effective immediately for all RPCs — no service restart required.
3.3 RPC Script: Plain Text Message
function rpc_script({ device, params, alarms, logger, org_params }) {
let webhook = org_params?.wecom_webhook_url;
if (!webhook) {
logger.error("wecom_webhook_url not configured in org_params");
return null;
}
let content = params.content || `Device ${device.name}(${device.eui}) alert`;
return [
{
sleepTimeMs: 0,
type: "axios",
dnMsg: {
method: "POST",
url: webhook,
headers: { "Content-Type": "application/json" },
data: {
msgtype: "text",
text: { content: content }
},
timeout: 5000
}
}
];
}3.4 RPC Script: Alarm Notification (Recommended, paired with ALARM RPC)
This script accepts the same input params as the built-in ALARM RPC, and is intended to be called by the same trigger model so that a single uplink produces both a platform alarm record and a WeCom group message.
Key behaviors:
- action = "new": pushes a "Device Alarm Raised" message
- action = "clear": first checks whether the alarm is currently active via
alarms[name]. Only an active alarm being cleared triggers a "Alarm Recovered" push — repeatedclearcalls while the condition stays normal will not spam the group - Params aligned with ALARM RPC:
name/action/title/level/descuse identical names and meanings, so the trigger can feed the sameparamsobject to both RPCs - Webhook sourced from org_params: nothing is hardcoded in the script
function rpc_script({ device, params, alarms, logger, org_params }) {
let webhook = org_params?.wecom_webhook_url;
if (!webhook) {
logger.error("wecom_webhook_url not configured in org_params");
return null;
}
let name = params.name;
let action = params.action;
let title = params.title || name || "";
let level = params.level || "mid";
let desc = params.desc || "";
if (action === "clear") {
// Only push a recovery message if this alarm_name is currently active.
// Prevents flooding the group with clear messages on every uplink while
// the condition has been normal for a long time.
if (!alarms || alarms[name] === undefined) {
return null;
}
} else if (action !== "new") {
// Unknown action (e.g. "no"): do not push
return null;
}
let levelColor = {
urgent: "warning",
high: "warning",
mid: "comment",
low: "info"
}[level] || "info";
let header = action === "new" ? `### Device Alarm` : `### Alarm Recovered`;
let content = [
header,
`> **Device**: ${device.name}`,
`> **EUI**: \`${device.eui}\``,
`> **Alarm Name**: \`${name}\``,
`> **Title**: ${title}`,
`> **Level**: <font color="${levelColor}">${level.toUpperCase()}</font>`,
`> **${action === "new" ? "Description" : "Recovery note"}**: ${desc || "n/a"}`,
`> **Time**: ${new Date().toISOString()}`
].join("\n");
return [
{
sleepTimeMs: 0,
type: "axios",
dnMsg: {
method: "POST",
url: webhook,
headers: { "Content-Type": "application/json" },
data: {
msgtype: "markdown",
markdown: { content: content }
},
timeout: 5000
}
}
];
}💡
alarmsis one of the RPC inputs — a map keyed byalarm_nameof currently active alarms. A key present means the alarm is active; absent (undefined) means it was never raised or has already been cleared. See Alarm Management §1.6.2.
4. RPC Parameter Configuration
Register the following parameter fields on this RPC (intentionally aligned with the ALARM RPC so the trigger script can share one params object):
| Attr Name | Type | Alias | Description |
|---|---|---|---|
name | string | Alarm event name | Unique key, must match the name passed to the ALARM RPC — used for alarms[name] lookup |
action | string | Action | new raises an alarm / clear clears it (only pushes if the alarm was active) |
title | string | Alarm title | Title shown in the pushed message |
level | string | Alert level | low / mid / high / urgent |
desc | string | Description | Alert detail for new; recovery note for clear |
The plain-text variant (§3.3) takes a single
contentparam and is not part of this alarm pipeline — keep it as a separate RPC for non-alarm notifications such as daily reports or reconciliation summaries.
5. Wiring into the Alert Flow
Recommended pattern: in the same trigger model, call both alarm and wecom_notify with one shared params object. alarm maintains the platform alarm record, while wecom_notify pushes the message to WeCom.
function trigger_script(device, thingModelId) {
const ACTION = { no: "no", new: "new", clear: "clear" };
const LEVEL = { low: "low", mid: "mid", high: "high", urgent: "urgent" };
let tdata = device?.telemetry_data?.[thingModelId];
if (tdata === undefined) { return null; }
let name = "overheat_alarm";
let title = "Overheat: [" + device.name + "]";
let level = LEVEL.high;
let desc = "";
let action = ACTION.clear; // default: clear (condition not met)
let group = device?.server_attrs?.group ?? {};
if (tdata.temperature !== undefined && tdata.temperature >= device.server_attrs?.alarm_temp) {
desc = "[" + device.name + "] temperature " + tdata.temperature + "°C exceeded threshold " + device.server_attrs.alarm_temp + "°C";
action = ACTION.new; // condition met: raise alarm
}
// One params object feeds both alarm and wecom_notify
let alarmParams = {
_eui: device.eui,
action: action,
name: name,
title: title,
level: level,
desc: desc,
group: group
};
return {
delayms: 0,
abort_previous_timer: true,
actions: [
{ method: "alarm", params: alarmParams },
{ method: "wecom_notify", params: alarmParams }
]
};
}⚠️ If you observe that
clearactions reachwecom_notifywithalarms[name]alreadyundefined(i.e. the platform ranalarmfirst, removed the record, andwecom_notifycannot see it anymore), swap the actions order sowecom_notifyruns first, or record the previous active state yourself viadevice.server_attrsand use that for the decision.
6. Push via Smart Bot
Smart bots also use a webhook URL, but management is completely different from group bots:
| Group Bot | Smart Bot | |
|---|---|---|
| Creation entry | In a group → Group Settings → Group Bots → Add | WeCom Admin Console → App Management → Smart Bot |
| Permission required | Any group member | Corp admin |
| Scope | A single group | Can be added to multiple groups, centrally managed |
| Message types | text / markdown / image / news / file / template_card | Same + interactive template-card callbacks |
| Receive user messages | ❌ | ✅ (configurable callback URL) |
6.1 Create a Smart Bot
- Open WeCom Admin Console → App Management → Smart Bot → Add Bot
- Configure name, avatar, and visibility scope
- Add the bot to every group that should receive alerts (one bot can join many groups)
- Copy the Webhook URL from the bot detail page — same format as a group bot:
https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=<SMART-BOT-KEY>
6.2 RPC Script
The send API is identical to a group bot — replace the wecom_webhook_url value in Org Params with the smart-bot webhook URL; the §3.4 script needs no code changes, and one RPC will now post to every group this bot has joined.
To keep both a group bot and a smart bot live at the same time, register two keys in Org Params (e.g. wecom_webhook_group and wecom_webhook_smart) and let the caller pick one via params.channel:
let webhook = params.channel === "smart"
? org_params?.wecom_webhook_smart
: org_params?.wecom_webhook_group;6.3 Security Notes
- The key still lives only in Org Params (§2.2), never inside script bodies
- A smart-bot key has a larger blast radius than a group-bot key — it may be present in many groups simultaneously, so a leak spams all of them. Have an admin rotate it from the console at least quarterly and update the Org Param value
7. Push via Self-Built App
When alerts need to reach specific users, departments, or tags (rather than a fixed group), the self-built app message API is the right choice.
7.1 Prerequisites
- WeCom Admin Console → App Management → Self-Built → Create App
- Record three values:
corpid— from the "My Company" pageagentid— "AgentId" field on the app detail pagecorpsecret— "Secret" field on the app detail page
- Set the app's visibility scope (only members in scope can receive messages)
⚠️ A leaked
corpsecretlets an attacker impersonate this app and send arbitrary messages to every member in scope. Store it in Org Params per §2.2 — never inserver_attrs, trigger models, or anywhere a caller can read.
Register the three values in System Management → Server Configuration → Org Params:
| Key | Example | Remark |
|---|---|---|
wecom_corp_id | ww1234567890abcdef | WeCom corpid |
wecom_corp_secret | xxxxxxxxxxxxxxxxxxxxxxxxxxxx | Self-built app secret; rotate quarterly |
wecom_agent_id | 1000002 | Self-built app AgentId |
7.2 Call Flow
Sending one message requires two HTTP calls:
- Get access_token:
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=...&corpsecret=... - Send message:
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=...
Because step 2 depends on step 1's response, the RPC must await axios() for the token inside the script body, then return the send action (see RPC Model §1.3.1 axios input).
7.3 RPC Script
async function rpc_script({ device, params, alarms, logger, org_params }) {
let corpId = org_params?.wecom_corp_id;
let corpSecret = org_params?.wecom_corp_secret;
let agentId = org_params?.wecom_agent_id;
if (!corpId || !corpSecret || !agentId) {
logger.error("wecom_corp_id / wecom_corp_secret / wecom_agent_id not configured in org_params");
return null;
}
let name = params.name;
let action = params.action;
let title = params.title || name || "";
let level = params.level || "mid";
let desc = params.desc || "";
if (action === "clear") {
if (!alarms || alarms[name] === undefined) return null;
} else if (action !== "new") {
return null;
}
// 1) Fetch access_token first
let tokenResp = await axios({
method: "GET",
url: "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
params: { corpid: corpId, corpsecret: corpSecret },
timeout: 5000
});
let token = tokenResp.data?.access_token;
if (!token) {
logger.error("get wecom access_token failed", { resp: tokenResp.data });
return null;
}
let header = action === "new" ? "## Device Alarm" : "## Alarm Recovered";
let content = [
header,
`**Device**: ${device.name} (\`${device.eui}\`)`,
`**Alarm**: ${title}`,
`**Level**: ${level.toUpperCase()}`,
`**${action === "new" ? "Description" : "Recovery"}**: ${desc || "n/a"}`,
`**Time**: ${new Date().toISOString()}`
].join("\n\n");
// 2) Return the send action (fire-and-forget via the axios action type)
return [{
sleepTimeMs: 0,
type: "axios",
dnMsg: {
method: "POST",
url: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${token}`,
headers: { "Content-Type": "application/json" },
data: {
touser: params.touser || "@all",
toparty: params.toparty,
totag: params.totag,
msgtype: "markdown",
agentid: Number(agentId),
markdown: { content: content }
},
timeout: 5000
}
}];
}7.4 Recipient Parameters
touser / toparty / totag are passed in by the caller via params. At least one must be set:
| Field | Type | Description |
|---|---|---|
touser | string | User-ID list, multiple separated by |. @all means everyone in the app's visibility scope |
toparty | string | Department-ID list, multiple separated by | |
totag | string | Tag-ID list, multiple separated by | |
7.5 access_token Caching Strategy
The access_token is valid for 7200 seconds, and WeCom enforces a rate limit on /gettoken. Two strategies:
- Fetch on every alarm (used by the script above): simplest. Fine for low-volume alarms (a few dozen per day)
- Cache and reuse: persist the token and expiry into the device's
server_attrsvia atype: "modifyAttrs"action; on the next call, readdevice.server_attrs.wecom_tokenfirst and only re-fetch if expired. Enable this when alarm volume is high to drastically cut/gettokencalls
8. Troubleshooting
| Symptom | Likely Cause |
|---|---|
| No messages reach the group | Wrong key; bot removed from group; the TKL server cannot reach qyapi.weixin.qq.com |
RPC log shows errcode != 0 | Message format violates WeCom spec; @user phone number format is invalid |
| Occasional missed messages | WeCom rate limit (max 20 messages per minute per webhook) |
Self-built app errcode = 40014 | access_token is expired or invalid — verify caching logic handles expiry |
Self-built app errcode = 60011 | A user/department/tag in touser / toparty / totag is outside the app's visibility scope |
Self-built app errcode = 45033 | Hit the /gettoken rate limit — switch to the §7.5 caching strategy |
9. Notes
- Keep each message body under ~2048 characters
- Never include customer PII, full identity numbers, tokens, or other sensitive business data in alert messages — only enough to triage the issue
- When one bot is shared across purposes, prefix messages with an origin tag (e.g.
[PROD],[STAGING]) - For critical alerts, configure multiple notification channels (e.g. WeCom + email) so a single-channel outage does not silence alerts