'use strict'; /** * 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增) * ──────────────────────────────────────────────────────────────────────────── * 为什么不用企业内部应用发文件? * 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard, * 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。 * 业务最终选择「群里直接看文本」,于是回到最简单的一条路: * 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。 * * 请求形状(钉钉官方「自定义机器人接入」): * POST https://oapi.dingtalk.com/robot/send?access_token=xxx×tamp=xxx&sign=xxx * { "msgtype": "text", "text": { "content": "..." }, "at": { "isAtAll": false } } * → { "errcode": 0, "errmsg": "ok" } * * 三个必须知道的限制(踩过就知道疼): * ① text.content **上限 20000 字节**(中文 UTF-8 是 3 字节/字)→ 超了钉钉直接 400, * 所以这里按**字节**截断(不是按字符),并返回是否被截断。 * ② 加了「自定义关键词」安全设置时,content 里必须含关键词(常用「异常」「提醒」), * 否则 errcode 310000。这个错要让业务自己看得懂,所以原样透出 errmsg。 * ③ 加签密钥(SEC 开头)算出来的 sign 必须 **urlEncode**,且 timestamp 是**毫秒**。 * 顺序错了就是 310000 sign not match,很难看出是编码问题。 * * 失败一律抛错并带上钉钉原始 errcode/errmsg —— 业务排错全靠这个码。 */ const crypto = require('crypto'); const OAPI_BASE = process.env.DINGTALK_OAPI_BASE || 'https://oapi.dingtalk.com'; const REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_WEBHOOK_TIMEOUT_MS) || 20000, 3000); // 钉钉 text 消息 content 的硬上限(字节)。留 200 字节余量给自己追加的尾巴 const MAX_CONTENT_BYTES = 20000; const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 200; function normalizeText(value, maxLength = 512) { return String(value ?? '').trim().slice(0, maxLength); } /** 按 UTF-8 **字节**截断,且不把多字节字符切一半 */ function truncateBytes(text, maxBytes = SAFE_CONTENT_BYTES) { const source = String(text ?? ''); if (Buffer.byteLength(source, 'utf8') <= maxBytes) return { text: source, truncated: false }; const chars = Array.from(source); let bytes = 0; let out = ''; for (const char of chars) { const size = Buffer.byteLength(char, 'utf8'); if (bytes + size > maxBytes) break; bytes += size; out += char; } return { text: out, truncated: true }; } function buildDingTalkError(label, payload, httpStatus) { const code = payload?.errcode ?? payload?.code ?? httpStatus ?? null; const message = payload?.errmsg || payload?.message || `${label}失败`; const error = new Error(`${label}失败(${code}):${message}`); error.code = code; error.httpStatus = httpStatus || null; error.dingtalk = payload || null; return error; } /** * 校验 webhook 地址。放宽 host(支持自建代理 / 测试 stub),但必须 https 且是 robot/send。 * 返回去掉首尾空白后的地址;不合法抛 400(页面上直接提示,不让它跑到定时任务里才炸)。 */ function normalizeWebhookUrl(value) { const text = normalizeText(value, 1024); if (!text) { throw Object.assign(new Error('请填写钉钉群机器人的 Webhook 地址'), { status: 400, code: 'WEBHOOK_REQUIRED' }); } let url; try { url = new URL(text); } catch (_error) { throw Object.assign(new Error('Webhook 地址不是合法 URL'), { status: 400, code: 'WEBHOOK_INVALID' }); } if (!/^https?:$/.test(url.protocol)) { throw Object.assign(new Error('Webhook 地址必须以 http(s):// 开头'), { status: 400, code: 'WEBHOOK_INVALID' }); } // 严格匹配 `/robot/send`(不要用「以 /robot/send 结尾」—— 那样 // `topapi/robot/send` 这种企业内部应用地址也会被放过去,最后报一个难懂的 404) if (!/^\/robot\/send\/?$/.test(url.pathname)) { throw Object.assign( new Error('这不像群机器人 Webhook(正常形如 https://oapi.dingtalk.com/robot/send?access_token=xxx)'), { status: 400, code: 'WEBHOOK_INVALID' } ); } if (!url.searchParams.get('access_token')) { throw Object.assign( new Error('Webhook 地址缺少 access_token 参数,请从钉钉群机器人设置里完整复制'), { status: 400, code: 'WEBHOOK_INVALID' } ); } return url.toString(); } /** * 加签:sign = urlEncode(base64(HmacSHA256(secret, `${timestamp}\n${secret}`))) * ⚠️ HMAC 的 key 是 secret、消息是 `timestamp\nsecret`(顺序反过来就是 310000) */ function buildSignedUrl(webhookUrl, secret, timestampMs = Date.now()) { const timestamp = String(Number(timestampMs) || Date.now()); const timestamped = new URL(webhookUrl); timestamped.searchParams.set('timestamp', timestamp); const text = normalizeText(secret, 256); if (!text) return timestamped.toString(); const sign = crypto .createHmac('sha256', text) .update(`${timestamp}\n${text}`, 'utf8') .digest('base64'); timestamped.searchParams.set('sign', sign); return timestamped.toString(); } async function requestJson(label, url, options = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { ...options, signal: controller.signal }); const raw = await response.text(); let payload = null; try { payload = raw ? JSON.parse(raw) : {}; } catch (_error) { payload = { errmsg: raw.slice(0, 500) }; } if (!response.ok) throw buildDingTalkError(label, payload, response.status); if (payload && payload.errcode !== undefined && Number(payload.errcode) !== 0) { throw buildDingTalkError(label, payload, response.status); } return payload || {}; } catch (error) { if (error.name === 'AbortError') { throw Object.assign(new Error(`${label}超时(${REQUEST_TIMEOUT_MS}ms)`), { code: 'TIMEOUT' }); } throw error; } finally { clearTimeout(timer); } } /** * 发一条文本消息到群。 * @param {object} params * @param {string} params.webhookUrl 群机器人 Webhook(含 access_token) * @param {string} [params.secret] 加签密钥(安全设置选「加签」时填;选关键词/IP 时留空) * @param {string} params.content 纯文本(支持 \n 换行) * @param {boolean} [params.atAll] 是否 @所有人 * @param {string[]} [params.atMobiles] * @returns {Promise<{delivered:boolean,truncated:boolean,bytes:number,content:string}>} */ async function sendTextToWebhook({ webhookUrl, secret, content, atAll = false, atMobiles = [], }) { const base = normalizeWebhookUrl(webhookUrl); const raw = String(content ?? ''); if (!raw.trim()) { throw Object.assign(new Error('待发送的消息内容为空'), { code: 'EMPTY_CONTENT' }); } const limited = truncateBytes(raw); const url = buildSignedUrl(base, secret); const mobiles = (Array.isArray(atMobiles) ? atMobiles : []) .map((item) => normalizeText(item, 32)) .filter((item) => /^\d{6,20}$/.test(item)); const payload = await requestJson('发送钉钉群消息', url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=utf-8' }, body: JSON.stringify({ msgtype: 'text', text: { content: limited.text }, at: { isAtAll: atAll === true, atMobiles: mobiles }, }), }); return { delivered: true, truncated: limited.truncated, bytes: Buffer.byteLength(limited.text, 'utf8'), content: limited.text, errmsg: normalizeText(payload.errmsg, 200) || 'ok', }; } /** * 「测试推送」用一句短文本探活:只验 webhook(+加签)能不能通。 * 不读取业务数据 —— 测试时不该因为数据慢而误判成通道坏。 */ async function sendProbeText({ webhookUrl, secret, text }) { const content = normalizeText(text, 500) || '【仓储中心物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。'; return sendTextToWebhook({ webhookUrl, secret, content }); } module.exports = { MAX_CONTENT_BYTES, OAPI_BASE, buildSignedUrl, normalizeWebhookUrl, sendProbeText, sendTextToWebhook, truncateBytes, };