W1-W3 实现企业微信个人应用消息通知

这个提交包含在:
2026-05-11 09:17:00 +08:00
父节点 805e050ba8
当前提交 b1ae41fa99
共修改 12 个文件,包含 869 行新增和 111 行删除
+237 -1
查看文件
@@ -1,6 +1,10 @@
const crypto = require('node:crypto');
const { resolveChannelSecret } = require('../../notifications/channelConfig');
const {
getWecomAppAccessToken,
sanitizeWecomResponse,
} = require('../wecomAccessToken');
function toSafeString(value, maxLength = 2000) {
if (value == null) {
@@ -33,6 +37,30 @@ function buildWecomMarkdownMessage(message) {
return lines.filter(Boolean).join('\n\n').trim();
}
function buildWecomTextPreview(message) {
const title = toSafeString(message?.title, 200);
const body = toSafeString(message?.body, 4000);
const detailUrl = toSafeString(message?.detailUrl, 2000);
const footer = toSafeString(message?.footer, 200);
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 50) || '查看详情';
const lines = [];
if (title) {
lines.push(title);
}
if (body) {
lines.push(body);
}
if (detailUrl) {
lines.push(`${deepLinkLabel}:${detailUrl}`);
}
if (footer) {
lines.push(footer);
}
return lines.filter(Boolean).join('\n\n').trim();
}
function buildSignedWebhookUrl(webhookUrl, secret) {
const rawUrl = String(webhookUrl ?? '').trim();
if (!rawUrl || !secret) {
@@ -73,7 +101,205 @@ function mapSecretError(result, previewMessage) {
};
}
async function sendWecomMessage({ config, channel, message }) {
function normalizeExternalUserIds(values) {
return Array.from(
new Set(
(values ?? [])
.map((value) => String(value ?? '').trim())
.filter(Boolean),
),
);
}
function normalizeAgentId(value) {
const raw = String(value ?? '').trim();
const numeric = Number(raw);
return raw && Number.isFinite(numeric) ? numeric : raw;
}
function hasInvalidRecipients(responseBody) {
return Boolean(String(responseBody?.invaliduser ?? '').trim());
}
function buildWecomAppPayload({ config, message, touser }) {
const agentid = normalizeAgentId(config?.wecomAgentId);
const title = toSafeString(message?.title || '系统通知', 200) || '系统通知';
const body = toSafeString(message?.body, 4000);
const footer = toSafeString(message?.footer, 200);
const detailUrl = toSafeString(message?.detailUrl, 2000);
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 10) || '查看详情';
if (detailUrl) {
return {
touser,
msgtype: 'textcard',
agentid,
textcard: {
title,
description: [body, footer].filter(Boolean).join('\n\n') || title,
url: detailUrl,
btntxt: deepLinkLabel,
},
enable_id_trans: 0,
enable_duplicate_check: 1,
duplicate_check_interval: 1800,
};
}
return {
touser,
msgtype: 'text',
agentid,
text: {
content: buildWecomTextPreview(message) || title,
},
enable_id_trans: 0,
enable_duplicate_check: 1,
duplicate_check_interval: 1800,
};
}
async function sendWecomAppMessage({
config,
configJson,
message,
recipientExternalUserIds,
}) {
const previewMessage = buildWecomTextPreview(message);
const externalUserIds = normalizeExternalUserIds(recipientExternalUserIds);
if (!externalUserIds.length) {
return {
ok: true,
skipped: false,
retryable: false,
code: 'wecom_app_no_bound_recipients',
message: 'No bound WeCom recipients',
response: {
channelType: 'wecom',
mode: 'wecom_app_message',
recipientCount: 0,
},
previewMessage,
};
}
const timeoutMs = Math.max(100, Number(configJson.timeoutMs ?? 5000));
const tokenResult = await getWecomAppAccessToken({ config, timeoutMs });
if (!tokenResult.ok) {
return {
ok: false,
skipped: false,
retryable: tokenResult.retryable,
code: tokenResult.code,
message: tokenResult.message,
response: tokenResult.response,
previewMessage,
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/message/send');
url.searchParams.set('access_token', tokenResult.accessToken);
const touser = externalUserIds.join('|');
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(buildWecomAppPayload({ config, message, touser })),
signal: controller.signal,
});
const responseBody = await response.json().catch(() => null);
const sanitized = sanitizeWecomResponse(responseBody);
if (!response.ok) {
return {
ok: false,
skipped: false,
retryable: response.status >= 500 || response.status === 429,
code:
response.status >= 500
? 'wecom_app_http_5xx'
: response.status === 429
? 'wecom_app_http_429'
: 'wecom_app_http_4xx',
message: `WeCom app message HTTP ${response.status}`,
response: {
status: response.status,
body: sanitized,
},
previewMessage,
};
}
if (responseBody && Number(responseBody.errcode ?? 0) !== 0) {
return {
ok: false,
skipped: false,
retryable: false,
code: 'wecom_app_api_error',
message: `WeCom app message errcode ${responseBody.errcode}`,
response: sanitized,
previewMessage,
};
}
if (hasInvalidRecipients(responseBody)) {
return {
ok: false,
skipped: false,
retryable: false,
code: 'wecom_app_invaliduser',
message: 'WeCom app message invaliduser',
response: sanitized,
previewMessage,
};
}
return {
ok: true,
skipped: false,
retryable: false,
code: 'wecom_app_sent',
message: null,
response: {
...(sanitized ?? { errcode: 0 }),
recipientCount: externalUserIds.length,
},
previewMessage,
};
} catch (error) {
if (error?.name === 'AbortError') {
return {
ok: false,
skipped: false,
retryable: true,
code: 'wecom_app_timeout',
message: `WeCom app message timeout (${timeoutMs}ms)`,
response: null,
previewMessage,
};
}
return {
ok: false,
skipped: false,
retryable: true,
code: 'wecom_app_network_error',
message: String(error?.message ?? error ?? 'WeCom app message network error'),
response: null,
previewMessage,
};
} finally {
clearTimeout(timer);
}
}
async function sendWecomMessage({ config, channel, message, recipientExternalUserIds }) {
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
const previewMessage = buildWecomMarkdownMessage(message);
@@ -93,6 +319,15 @@ async function sendWecomMessage({ config, channel, message }) {
};
}
if (mode === 'wecom_app_message') {
return sendWecomAppMessage({
config,
configJson,
message,
recipientExternalUserIds,
});
}
if (mode !== 'wecom_webhook') {
return {
ok: false,
@@ -218,5 +453,6 @@ async function sendWecomMessage({ config, channel, message }) {
module.exports = {
buildSignedWebhookUrl,
buildWecomMarkdownMessage,
buildWecomTextPreview,
sendWecomMessage,
};