W1-W3 实现企业微信个人应用消息通知
这个提交包含在:
@@ -272,6 +272,7 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
|
||||
config,
|
||||
channel,
|
||||
payload,
|
||||
prisma,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
const tokenCache = new Map();
|
||||
|
||||
function toSafeString(value, maxLength = 2000) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function sanitizeWecomResponse(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const key of ['errcode', 'errmsg', 'invaliduser', 'invalidparty', 'invalidtag']) {
|
||||
if (payload[key] != null) {
|
||||
result[key] = payload[key];
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function getCredentialKey(config) {
|
||||
return [
|
||||
toSafeString(config?.wecomCorpId, 128),
|
||||
toSafeString(config?.wecomAgentId, 64),
|
||||
toSafeString(config?.wecomAgentSecret, 16),
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function resolveTimeoutMs(timeoutMs) {
|
||||
return Math.max(100, Number(timeoutMs ?? 5000));
|
||||
}
|
||||
|
||||
function buildMissingConfigResult(missing) {
|
||||
return {
|
||||
ok: false,
|
||||
retryable: false,
|
||||
code: 'wecom_app_missing_config',
|
||||
message: `Missing WeCom app config: ${missing.join(',')}`,
|
||||
response: {
|
||||
missing,
|
||||
},
|
||||
accessToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getWecomAppAccessToken({ config, timeoutMs = 5000, fetchImpl = fetch }) {
|
||||
const corpId = toSafeString(config?.wecomCorpId, 128);
|
||||
const agentId = toSafeString(config?.wecomAgentId, 64);
|
||||
const agentSecret = toSafeString(config?.wecomAgentSecret, 500);
|
||||
const missing = [];
|
||||
if (!corpId) missing.push('WECOM_CORP_ID');
|
||||
if (!agentId) missing.push('WECOM_AGENT_ID');
|
||||
if (!agentSecret) missing.push('WECOM_AGENT_SECRET');
|
||||
if (missing.length) {
|
||||
return buildMissingConfigResult(missing);
|
||||
}
|
||||
|
||||
const cacheKey = getCredentialKey(config);
|
||||
const cached = tokenCache.get(cacheKey);
|
||||
if (cached?.accessToken && cached.expiresAt > Date.now() + 60 * 1000) {
|
||||
return {
|
||||
ok: true,
|
||||
retryable: false,
|
||||
code: 'wecom_app_token_cached',
|
||||
message: null,
|
||||
response: { cached: true },
|
||||
accessToken: cached.accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
const timeout = resolveTimeoutMs(timeoutMs);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/gettoken');
|
||||
url.searchParams.set('corpid', corpId);
|
||||
url.searchParams.set('corpsecret', agentSecret);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const sanitized = sanitizeWecomResponse(payload);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
retryable: response.status >= 500 || response.status === 429,
|
||||
code:
|
||||
response.status >= 500
|
||||
? 'wecom_app_token_http_5xx'
|
||||
: response.status === 429
|
||||
? 'wecom_app_token_http_429'
|
||||
: 'wecom_app_token_http_4xx',
|
||||
message: `WeCom app token HTTP ${response.status}`,
|
||||
response: {
|
||||
status: response.status,
|
||||
body: sanitized,
|
||||
},
|
||||
accessToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
|
||||
return {
|
||||
ok: false,
|
||||
retryable: false,
|
||||
code: 'wecom_app_token_api_error',
|
||||
message: `WeCom app token errcode ${payload?.errcode ?? 'missing_payload'}`,
|
||||
response: sanitized,
|
||||
accessToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
const expiresInSeconds = Math.max(300, Number(payload.expires_in ?? 7200));
|
||||
tokenCache.set(cacheKey, {
|
||||
accessToken: String(payload.access_token),
|
||||
expiresAt: Date.now() + Math.max(60, expiresInSeconds - 120) * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
retryable: false,
|
||||
code: 'wecom_app_token_fetched',
|
||||
message: null,
|
||||
response: { cached: false, expiresIn: expiresInSeconds },
|
||||
accessToken: String(payload.access_token),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
return {
|
||||
ok: false,
|
||||
retryable: true,
|
||||
code: 'wecom_app_token_timeout',
|
||||
message: `WeCom app token timeout (${timeout}ms)`,
|
||||
response: null,
|
||||
accessToken: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
retryable: true,
|
||||
code: 'wecom_app_token_network_error',
|
||||
message: String(error?.message ?? error ?? 'WeCom app token network error'),
|
||||
response: null,
|
||||
accessToken: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function clearWecomAppAccessTokenCache() {
|
||||
tokenCache.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearWecomAppAccessTokenCache,
|
||||
getWecomAppAccessToken,
|
||||
sanitizeWecomResponse,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户