概述
本方案实现了 Claude Code CLI 与钉钉群的双向实时交互:
-
Claude Code -> 钉钉:权限请求、任务完成通知、错误告警自动推送到钉钉群
-
钉钉 -> Claude Code :在钉钉群中 @机器人 回复
/allow、/deny等命令,远程控制 Claude Code 的权限授予 -
双通道并行:终端和钉钉同时可用,哪个先响应就用哪个的结果
-
多终端区分:每个权限请求带有编号和工作目录标识,支持同时操作多个终端
典型使用场景
-
你在开会或离开电脑时,Claude Code 需要权限执行某个命令
-
钉钉群收到通知:
[#1 | my-project] 🔐 权限请求 --- Bash: npm install -
你在手机钉钉上 @机器人 回复
/allow 1 -
Claude Code 自动获得授权继续执行
-
如果你不回复,120 秒后自动回退到终端提示
架构原理
go
┌──────────────────────────────────────────────────────────────────┐
│ Claude Code CLI (终端) │
│ │
│ Hooks 触发: │
│ SessionStart -> dingtalk-hook.cjs (启动 bridge) │
│ PermissionRequest -> dingtalk-hook.cjs (同步,阻塞等待响应) │
│ Notification -> dingtalk-hook.cjs (异步,即发即走) │
│ Stop -> dingtalk-hook.cjs (异步,即发即走) │
└──────────┬───────────────────────────────────────────────────────┘
│ 文件 IPC (JSON 文件读写)
│ ~/.claude/helpers/dingtalk/
│ requests/<uuid>.json (hook 写入, bridge 读取)
│ responses/<uuid>.json (bridge 写入, hook 读取)
▼
┌──────────────────────────────────────────────────────────────────┐
│ dingtalk-bridge.cjs (后台常驻进程) │
│ │
│ ┌──────────────────┐ ┌───────────────────────────────────┐ │
│ │ 发送通道 │ │ 接收通道 │ │
│ │ Webhook POST │ │ DingTalk Stream SDK (WebSocket) │ │
│ │ (群 Webhook 机器人)│ │ (企业机器人应用) │ │
│ └────────┬─────────┘ └──────────────┬────────────────────┘ │
│ │ │ │
│ 轮询 requests/ 目录 解析 @机器人 的回复命令 │
│ 分配编号 #1 #2 #3... 写入 responses/<uuid>.json │
│ 格式化为 Markdown/ActionCard │
│ 发送到钉钉群 │
└──────────┬──────────────────────────┬────────────────────────────┘
│ HTTPS POST │ WebSocket (Stream SDK)
▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ 钉钉群 │
│ │
│ 群 Webhook 机器人 <── 发送消息 (Markdown 通知 / ActionCard 卡片) │
│ 企业机器人应用 --> 接收 @机器人 消息 (Stream 模式, 无需公网IP) │
└──────────────────────────────────────────────────────────────────┘
为什么需要两个机器人?
| 机器人类型 | 作用 | 原因 |
|---|---|---|
| 群 Webhook 机器人 | 发送消息到群 | 简单可靠,只需 HTTPS POST,支持加签安全校验 |
| 企业机器人应用 | 接收群里 @机器人 的消息 | 使用 Stream SDK (WebSocket),无需公网 IP,无需配置回调地址 |
核心通信机制
-
Hook -> Bridge :通过
requests/<uuid>.json文件传递请求 -
Bridge -> Hook :通过
responses/<uuid>.json文件传递响应 -
原子写入 :先写
.tmp文件再rename,保证文件系统上的原子性 -
单例锁 :
bridge.lock+ PID 检查,确保只有一个 bridge 实例运行 -
请求编号 :每个权限请求自动分配递增编号
#1,#2,#3...,支持多终端精确操作
前置条件
在开始之前,请确认以下条件已满足:
| 条件 | 要求 |
|---|---|
| 操作系统 | Windows 10/11 或 macOS / Linux |
| Node.js | v18 或更高版本 |
| Claude Code | v2.1.80 或更高版本 |
| 钉钉 | 有企业组织,能创建群 Webhook 机器人和企业机器人应用 |
验证 Node.js 版本:
go
node--version
# 应该输出 v18.x.x 或更高
验证 Claude Code 版本:
go
claude --version
# 应该输出 2.1.80 或更高
第一步:创建钉钉群 Webhook 机器人(发送通道)
这个机器人负责从 Claude Code 向钉钉群发送消息。
操作步骤
-
打开你要使用的钉钉群
-
点击右上角群设置(齿轮图标)
-
找到并点击机器人
-
点击添加机器人
-
选择自定义(通过 Webhook 接入自定义服务)
-
填写机器人名称,例如:
Claude Code 通知 -
安全设置选择加签
-
点击完成
记录以下信息
创建完成后,页面会显示两个关键信息,请立即复制保存:
-
Webhook 地址 :形如
https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxx -
加签密钥 :形如
SECxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
这个页面关闭后无法再次查看密钥,请务必保存好。
第二步:创建钉钉企业机器人应用(接收通道)
这个机器人负责接收钉钉群里用户 @机器人 的消息。
操作步骤
-
打开浏览器,登录 钉钉开放平台
-
在首页点击创建应用
-
在应用类型列表中选择机器人
-
填写机器人基本信息:
-
名称 :例如
Claude Code -
描述 :例如
Claude Code 远程控制
-
点击创建,完成后进入应用管理页面
记录以下信息
在应用的凭证与基础信息页面,找到并复制:
-
Client ID (原 AppKey):形如
dingxxxxxxxxxx -
Client Secret(原 AppSecret):一串较长的字符串
关于消息接收模式
机器人类型的应用默认使用 Stream 模式(基于 WebSocket),无需配置公网回调地址。这正是我们需要的模式。
第三步:将两个机器人添加到同一个钉钉群
3.1 Webhook 机器人
第一步创建时已经直接在群里创建了,所以已经在群里。
3.2 企业机器人应用
需要手动添加到群里:
-
打开目标钉钉群
-
群设置 -> 机器人 -> 添加机器人
-
搜索你在第二步创建的机器人应用名称(如
Claude Code) -
选择并添加到群
验证
添加完成后,在群设置 -> 机器人列表中,应该能看到两个机器人:
-
Claude Code 通知(Webhook 机器人)-- 负责发送 -
Claude Code(企业机器人应用)-- 负责接收
第四步:安装 Node.js 依赖
Claude Code 的 helpers 目录位于 ~/.claude/helpers/。我们需要在这个目录下安装钉钉 Stream SDK。
4.1 创建 helpers 目录(如果不存在)
go
mkdir-p ~/.claude/helpers
4.2 安装 dingtalk-stream
go
cd ~/.claude/helpers
npm install dingtalk-stream
这会在 ~/.claude/helpers/node_modules/ 下安装 dingtalk-stream 及其依赖(ws、axios、events)。
4.3 验证安装
go
cd ~/.claude/helpers
node-e"require('./node_modules/dingtalk-stream/dist/index.cjs'); console.log('dingtalk-stream OK');"
如果输出 dingtalk-stream OK,说明安装成功。
注意 :
dingtalk-stream的package.json中main字段指向dist/index.js,但实际文件是dist/index.cjs。后续代码中已处理此问题,直接引用.cjs文件。
第五步:创建配置文件 dingtalk-config.json
在 ~/.claude/helpers/ 目录下创建文件 dingtalk-config.json:
go
# 用你喜欢的编辑器创建此文件
# Windows: notepad %USERPROFILE%\.claude\helpers\dingtalk-config.json
# macOS/Linux: nano ~/.claude/helpers/dingtalk-config.json
文件内容如下(请替换为你自己的凭据):
go
{
"webhook": {
"url": "https://oapi.dingtalk.com/robot/send?access_token=你的群Webhook机器人Token",
"secret": "SEC你的群Webhook机器人加签密钥"
},
"stream": {
"appKey": "你的企业机器人应用ClientID",
"appSecret": "你的企业机器人应用ClientSecret"
},
"timeouts": {
"permissionRequestMs": 120000,
"pollIntervalMs": 500,
"requestScanIntervalMs": 1000
},
"allowedSenderStaffIds": [],
"debug": false
}
字段说明
| 字段 | 来源 | 示例 |
|---|---|---|
webhook.url |
第一步创建的群 Webhook 机器人 | https://oapi.dingtalk.com/robot/send?access_token=995fcf... |
webhook.secret |
第一步创建时的加签密钥 | SEC8465049e67efbc... |
stream.appKey |
第二步创建的企业机器人 Client ID | ding4k5dhsmpwhpvrfzj |
stream.appSecret |
第二步创建的企业机器人 Client Secret | 46H71mugBUyo3Dq3_-zbu... |
timeouts.permissionRequestMs |
权限请求等待钉钉响应的超时(毫秒) | 120000(2分钟) |
timeouts.pollIntervalMs |
Hook 轮询响应文件的间隔(毫秒) | 500 |
timeouts.requestScanIntervalMs |
Bridge 扫描请求目录的间隔(毫秒) | 1000 |
allowedSenderStaffIds |
允许操作的钉钉用户 StaffId 白名单 | [] 表示允许所有人 |
debug |
是否开启详细调试日志 | false |
第六步:创建 Bridge 服务 dingtalk-bridge.cjs
在 ~/.claude/helpers/ 目录下创建文件 dingtalk-bridge.cjs。
这是后台常驻服务,负责:
-
轮询 requests 目录,将请求发送到钉钉
-
为每个权限请求分配编号(#1, #2, #3...)
-
通过 Stream SDK 接收钉钉消息
-
解析命令并写入响应文件
-
多请求歧义处理(多个待处理时提示选择)
完整源码
go
#!/usr/bin/env node
'use strict';
constfs=require('fs');
constpath=require('path');
consthttps=require('https');
constcrypto=require('crypto');
// === Paths ===
constHELPERS_DIR=path.resolve(__dirname);
constCONFIG_PATH=path.join(HELPERS_DIR, 'dingtalk-config.json');
constIPC_DIR=path.join(HELPERS_DIR, 'dingtalk');
constREQUESTS_DIR=path.join(IPC_DIR, 'requests');
constRESPONSES_DIR=path.join(IPC_DIR, 'responses');
constPID_FILE=path.join(IPC_DIR, 'bridge.pid');
constLOCK_FILE=path.join(IPC_DIR, 'bridge.lock');
constLOG_FILE=path.join(IPC_DIR, 'bridge.log');
constMAX_LOG_SIZE=1024*1024; // 1MB
constREQUEST_EXPIRE_MS=10*60*1000; // 10 min
constRESPONSE_EXPIRE_MS=5*60*1000; // 5 min
letconfig=null;
letstreamClient=null;
letscanInterval=null;
letrunning=true;
letrequestCounter=0;
// === Logging ===
functionlog(level, msg) {
constts=newDate().toISOString();
constline=`[${ts}] [${level}] ${msg}\n`;
try {
conststat=fs.statSync(LOG_FILE).size;
if (stat>MAX_LOG_SIZE) {
fs.writeFileSync(LOG_FILE, line);
return;
}
} catch {}
fs.appendFileSync(LOG_FILE, line);
}
functionlogInfo(msg) { log('INFO', msg); }
functionlogError(msg) { log('ERROR', msg); }
functionlogDebug(msg) { if (config&&config.debug) log('DEBUG', msg); }
// === Ensure directories ===
functionensureDirs() {
for (constdirof [IPC_DIR, REQUESTS_DIR, RESPONSES_DIR]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
}
// === Config ===
functionloadConfig() {
config=JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
returnconfig;
}
// === Singleton lock ===
functionacquireLock() {
ensureDirs();
// Check if another bridge is running
if (fs.existsSync(LOCK_FILE)) {
try {
constexistingPid=parseInt(fs.readFileSync(LOCK_FILE, 'utf8').trim(), 10);
if (existingPid&&isProcessAlive(existingPid)) {
console.error(`Bridge already running (PID ${existingPid}). Exiting.`);
process.exit(0);
}
// Stale lock, remove it
fs.unlinkSync(LOCK_FILE);
} catch {}
}
// Create lock
fs.writeFileSync(LOCK_FILE, String(process.pid));
fs.writeFileSync(PID_FILE, String(process.pid));
}
functionreleaseLock() {
try { fs.unlinkSync(LOCK_FILE); } catch {}
try { fs.unlinkSync(PID_FILE); } catch {}
}
functionisProcessAlive(pid) {
try {
process.kill(pid, 0);
returntrue;
} catch {
returnfalse;
}
}
// === Webhook: send messages to DingTalk ===
functioncomputeWebhookSign(secret, timestamp) {
conststringToSign=`${timestamp}\n${secret}`;
consthmac=crypto.createHmac('sha256', secret).update(stringToSign).digest('base64');
returnencodeURIComponent(hmac);
}
functionsendWebhook(body) {
returnnewPromise((resolve, reject) => {
if (!config.webhook||!config.webhook.url) {
reject(newError('Webhook URL not configured'));
return;
}
leturl=config.webhook.url;
if (config.webhook.secret) {
consttimestamp=Date.now();
constsign=computeWebhookSign(config.webhook.secret, timestamp);
url+=`×tamp=${timestamp}&sign=${sign}`;
}
constparsed=newURL(url);
constpayload=JSON.stringify(body);
constreq=https.request({
hostname: parsed.hostname,
port: parsed.port||443,
path: parsed.pathname+parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
}, (res) => {
letdata='';
res.on('data', (chunk) =>data+=chunk);
res.on('end', () => {
try {
constresult=JSON.parse(data);
if (result.errcode===0) {
resolve(result);
} else {
reject(newError(`DingTalk API error: ${result.errmsg}(code: ${result.errcode})`));
}
} catch (e) {
reject(newError(`Invalid response: ${data}`));
}
});
});
req.on('error', reject);
req.setTimeout(10000, () => { req.destroy(); reject(newError('Webhook timeout')); });
req.write(payload);
req.end();
});
}
// === Message formatting ===
functionformatPermissionRequest(req) {
consttoolName=req.tool&&req.tool.name||'Unknown';
consttoolInput=req.tool&&req.tool.input|| {};
constshortId=req.id.substring(0, 8);
constnum=req.shortNumber?`#${req.shortNumber}` : shortId;
constdirLabel=shortDirName(req.cwd);
letinputDisplay='';
if (toolName==='Bash'&&toolInput.command) {
inputDisplay=`**命令:**\n\`\`\`\n${truncate(toolInput.command, 500)}\n\`\`\``;
} elseif (toolName==='Edit'&&toolInput.file_path) {
inputDisplay=`**文件:** ${toolInput.file_path}`;
} elseif (toolName==='Write'&&toolInput.file_path) {
inputDisplay=`**文件:** ${toolInput.file_path}`;
} else {
inputDisplay=`**参数:**\n\`\`\`json\n${truncate(JSON.stringify(toolInput, null, 2), 500)}\n\`\`\``;
}
consttitleLine=dirLabel
?`### [${num}| ${dirLabel}] 🔐 权限请求`
: `### [${num}] 🔐 权限请求`;
return {
msgtype: 'actionCard',
actionCard: {
title: `[${num}] 🔐 Claude Code 权限请求`,
text: [
titleLine,
'',
`**工具:** ${toolName}`,
inputDisplay,
'',
`**编号:** \`${num}\` | **请求ID:** \`${shortId}\``,
'',
'---',
`回复 \`/allow ${req.shortNumber||''}\` 允许 | \`/deny ${req.shortNumber||''}\` 拒绝 | \`/ask ${req.shortNumber||''}\` 转到终端`,
].join('\n'),
btnOrientation: '1',
singleTitle: '在终端查看',
singleURL: 'dingtalk://dingtalkclient/page/link?pc_slide=false',
},
};
}
functionformatNotification(req) {
constmessage=req.message||'(无内容)';
consttitle=req.title||'📢 Claude Code 通知';
return {
msgtype: 'markdown',
markdown: {
title,
text: [
`### ${title}`,
'',
message,
'',
'---',
`*会话: ${(req.sessionId||'').substring(0, 8)}*`,
].join('\n'),
},
};
}
functionformatStopMessage(req) {
constsummary=req.message||'Claude Code 已完成当前回合。';
return {
msgtype: 'markdown',
markdown: {
title: '✅ Claude Code 回合完成',
text: [
'### ✅ 回合完成',
'',
summary,
'',
'---',
`*会话: ${(req.sessionId||'').substring(0, 8)}| 等待下一步指令*`,
].join('\n'),
},
};
}
functiontruncate(str, maxLen) {
if (!str) return'';
if (str.length<=maxLen) returnstr;
returnstr.substring(0, maxLen) +'\n... (已截断)';
}
functionshortDirName(cwd) {
if (!cwd) return'';
constparts=cwd.replace(/\\/g, '/').split('/').filter(Boolean);
returnparts.length>=2?parts.slice(-2).join('/') : parts[parts.length-1] ||'';
}
functiontimeAgo(isoStr) {
if (!isoStr) return'';
constdiffMs=Date.now() -newDate(isoStr).getTime();
constsecs=Math.floor(diffMs/1000);
if (secs<60) return`${secs}秒前`;
constmins=Math.floor(secs/60);
if (mins<60) return`${mins}分钟前`;
return`${Math.floor(mins/60)}小时前`;
}
// === Request scanning and sending ===
asyncfunctionscanAndSendRequests() {
letfiles;
try {
files=fs.readdirSync(REQUESTS_DIR).filter(f=>f.endsWith('.json'));
} catch {
return;
}
for (constfileoffiles) {
constfilePath=path.join(REQUESTS_DIR, file);
try {
constcontent=fs.readFileSync(filePath, 'utf8');
constreq=JSON.parse(content);
// Assign short number for new requests
if (!req.shortNumber&&req.needsResponse) {
requestCounter++;
req.shortNumber=requestCounter;
atomicWriteJson(filePath, req);
}
// Skip already sent or permanently failed
if (req.sentToDingtalk||req.sendRetries>=3) {
// Check expiry
if (req.expiresAt&&newDate(req.expiresAt) <newDate()) {
logDebug(`Cleaning expired request: ${req.id}`);
try { fs.unlinkSync(filePath); } catch {}
}
continue;
}
// Format message based on type
letmsg;
switch (req.type) {
case'permission_request':
msg=formatPermissionRequest(req);
break;
case'notification':
msg=formatNotification(req);
break;
case'stop':
msg=formatStopMessage(req);
break;
default:
msg=formatNotification(req);
}
// Send
awaitsendWebhook(msg);
logInfo(`Sent ${req.type}to DingTalk: ${req.id.substring(0, 8)}`);
// Mark as sent
req.sentToDingtalk=true;
req.sentAt=newDate().toISOString();
atomicWriteJson(filePath, req);
// For fire-and-forget types, clean up after sending
if (!req.needsResponse) {
setTimeout(() => {
try { fs.unlinkSync(filePath); } catch {}
}, 5000);
}
} catch (err) {
logError(`Error processing request ${file}: ${err.message}`);
// Track retries to avoid infinite loops
try {
constreq=JSON.parse(fs.readFileSync(filePath, 'utf8'));
req.sendRetries= (req.sendRetries||0) +1;
atomicWriteJson(filePath, req);
} catch {}
}
}
}
// === Cleanup expired files ===
functioncleanupExpired() {
// Clean requests
try {
constfiles=fs.readdirSync(REQUESTS_DIR).filter(f=>f.endsWith('.json'));
for (constfileoffiles) {
constfilePath=path.join(REQUESTS_DIR, file);
try {
conststat=fs.statSync(filePath);
if (Date.now() -stat.mtimeMs>REQUEST_EXPIRE_MS) {
fs.unlinkSync(filePath);
logDebug(`Cleaned expired request: ${file}`);
}
} catch {}
}
} catch {}
// Clean responses
try {
constfiles=fs.readdirSync(RESPONSES_DIR).filter(f=>f.endsWith('.json'));
for (constfileoffiles) {
constfilePath=path.join(RESPONSES_DIR, file);
try {
conststat=fs.statSync(filePath);
if (Date.now() -stat.mtimeMs>RESPONSE_EXPIRE_MS) {
fs.unlinkSync(filePath);
logDebug(`Cleaned expired response: ${file}`);
}
} catch {}
}
} catch {}
}
// === Incoming message handling (Stream SDK) ===
functionparseIncomingCommand(text) {
constcleaned= (text||'').replace(/@\S+/g, '').trim();
constmatch=cleaned.match(/^\/(allow|deny|ask|status)(?:\s+(.*))?$/i);
if (!match) return { command: 'unknown', text: cleaned };
return {
command: match[1].toLowerCase(),
args: (match[2] ||'').trim(),
};
}
functionfindPendingRequest(idPrefix) {
try {
constfiles=fs.readdirSync(REQUESTS_DIR)
.filter(f=>f.endsWith('.json'))
.sort()
.reverse(); // newest first
for (constfileoffiles) {
constfilePath=path.join(REQUESTS_DIR, file);
constreq=JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!req.needsResponse) continue;
// Check if expired
if (req.expiresAt&&newDate(req.expiresAt) <newDate()) continue;
// Check if already responded
constrespPath=path.join(RESPONSES_DIR, file);
if (fs.existsSync(respPath)) continue;
// Match by ID prefix or return most recent
if (!idPrefix||req.id.startsWith(idPrefix)) {
returnreq;
}
}
} catch {}
returnnull;
}
functionfindPendingRequestByNumber(number) {
try {
constfiles=fs.readdirSync(REQUESTS_DIR).filter(f=>f.endsWith('.json'));
for (constfileoffiles) {
constfilePath=path.join(REQUESTS_DIR, file);
constreq=JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!req.needsResponse) continue;
if (req.expiresAt&&newDate(req.expiresAt) <newDate()) continue;
constrespPath=path.join(RESPONSES_DIR, file);
if (fs.existsSync(respPath)) continue;
if (req.shortNumber===number) returnreq;
}
} catch {}
returnnull;
}
functionwriteResponse(id, decision, reason, senderInfo) {
constresp= {
id,
decision,
reason: reason||'',
answeredBy: 'dingtalk',
answeredAt: newDate().toISOString(),
senderInfo,
};
constfilePath=path.join(RESPONSES_DIR, `${id}.json`);
atomicWriteJson(filePath, resp);
logInfo(`Wrote response for ${id.substring(0, 8)}: ${decision}`);
}
functionhandleIncomingMessage(messageContent, senderInfo) {
const { command, args } =parseIncomingCommand(messageContent);
logInfo(`Received command: /${command}${args}from ${senderInfo.senderNick||'unknown'}`);
switch (command) {
case'allow':
case'deny':
case'ask': {
constparts=args.split(/\s+/);
constfirstArg=parts[0] ||'';
constreason=parts.slice(1).join(' ');
letreq=null;
if (firstArg) {
// Try numeric match first (short number), then UUID prefix
constnum=parseInt(firstArg, 10);
if (!isNaN(num) &&String(num) ===firstArg) {
req=findPendingRequestByNumber(num);
}
if (!req) {
req=findPendingRequest(firstArg);
}
} else {
// No argument: check how many pending requests
constpending=listPendingRequests();
if (pending.length===0) {
replyViaDingtalk(senderInfo, '当前没有待处理的权限请求。');
return;
}
if (pending.length===1) {
req=pending[0];
} else {
// Multiple pending: ask user to specify
constlines=pending.map(r=> {
constnum=r.shortNumber?`#${r.shortNumber}` : r.id.substring(0, 8);
constdir=shortDirName(r.cwd);
consttool=r.tool&&r.tool.name||'Unknown';
constago=timeAgo(r.createdAt);
return`- **${num}** [${dir}] ${tool}${ago?' ('+ago+')' : ''}`;
});
replyViaDingtalk(senderInfo, [
'当前有多个待处理请求,请指定编号:',
'',
...lines,
'',
`回复 \`/${command}<编号>\` 来操作指定请求`,
].join('\n'));
return;
}
}
if (!req) {
replyViaDingtalk(senderInfo, '未找到匹配的权限请求。');
return;
}
writeResponse(req.id, command, reason, senderInfo);
constlabel=req.shortNumber?`#${req.shortNumber}` : req.id.substring(0, 8);
constactionText=command==='allow'?'已允许 ✅' : command==='deny'?'已拒绝 ❌' : '已转到终端 ↩️';
replyViaDingtalk(senderInfo, `请求 ${label}${actionText}${reason?' --- '+reason : ''}`);
break;
}
case'status': {
constpending=listPendingRequests();
if (pending.length===0) {
replyViaDingtalk(senderInfo, '当前没有待处理的请求。');
} else {
constlines=pending.map(r=> {
constnum=r.shortNumber?`#${r.shortNumber}` : r.id.substring(0, 8);
constdir=shortDirName(r.cwd);
consttool=r.tool&&r.tool.name||'Unknown';
constago=timeAgo(r.createdAt);
return`- **${num}** [${dir}] ${tool}${ago?' ('+ago+')' : ''}`;
});
replyViaDingtalk(senderInfo, [
'### 待处理请求',
'',
...lines,
'',
'回复 `/allow <编号>` 或 `/deny <编号>`',
].join('\n'));
}
break;
}
default:
replyViaDingtalk(senderInfo, [
'可用命令:',
'- `/allow [编号]` --- 允许权限请求',
'- `/deny [编号] [原因]` --- 拒绝权限请求',
'- `/ask [编号]` --- 转到终端处理',
'- `/status` --- 查看待处理请求',
].join('\n'));
}
}
functionlistPendingRequests() {
constresults= [];
try {
constfiles=fs.readdirSync(REQUESTS_DIR).filter(f=>f.endsWith('.json'));
for (constfileoffiles) {
constfilePath=path.join(REQUESTS_DIR, file);
constreq=JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!req.needsResponse) continue;
if (req.expiresAt&&newDate(req.expiresAt) <newDate()) continue;
constrespPath=path.join(RESPONSES_DIR, file);
if (fs.existsSync(respPath)) continue;
results.push(req);
}
} catch {}
returnresults;
}
// Reply via DingTalk (use sessionWebhook if available, otherwise main webhook)
functionreplyViaDingtalk(senderInfo, text) {
constmsg= {
msgtype: 'markdown',
markdown: {
title: 'Claude Code',
text,
},
};
if (senderInfo&&senderInfo.sessionWebhook) {
// Use session webhook for direct reply in the same conversation
httpPost(senderInfo.sessionWebhook, msg).catch(err=> {
logError(`Session webhook reply failed: ${err.message}, falling back to main webhook`);
sendWebhook(msg).catch(e=>logError(`Main webhook reply also failed: ${e.message}`));
});
} else {
sendWebhook(msg).catch(e=>logError(`Webhook reply failed: ${e.message}`));
}
}
functionhttpPost(url, body) {
returnnewPromise((resolve, reject) => {
constparsed=newURL(url);
constpayload=JSON.stringify(body);
constreq=https.request({
hostname: parsed.hostname,
port: parsed.port||443,
path: parsed.pathname+parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
}, (res) => {
letdata='';
res.on('data', (chunk) =>data+=chunk);
res.on('end', () =>resolve(data));
});
req.on('error', reject);
req.setTimeout(10000, () => { req.destroy(); reject(newError('HTTP timeout')); });
req.write(payload);
req.end();
});
}
// === Atomic file write ===
functionatomicWriteJson(filePath, data) {
consttmpPath=filePath+'.tmp';
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
fs.renameSync(tmpPath, filePath);
}
// === Stream SDK initialization ===
asyncfunctioninitStreamClient() {
if (!config.stream||!config.stream.appKey||!config.stream.appSecret||
config.stream.appKey==='YOUR_APP_KEY') {
logInfo('Stream SDK not configured (appKey/appSecret missing). Running in webhook-only mode.');
return;
}
try {
const { DWClient, DWClientDownStream, TOPIC_ROBOT } =require(path.join(HELPERS_DIR, 'node_modules', 'dingtalk-stream', 'dist', 'index.cjs'));
constclient=newDWClient({
clientId: config.stream.appKey,
clientSecret: config.stream.appSecret,
});
// Enable debug logging for troubleshooting
client.debug=config.debug||false;
client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
try {
logInfo(`Stream callback received: ${JSON.stringify(res.headers|| {})}`);
logDebug(`Stream callback data: ${res.data}`);
constdata=JSON.parse(res.data);
consttext=data.text&&data.text.content||'';
constsenderInfo= {
senderId: data.senderId||'',
senderNick: data.senderNick||'',
senderStaffId: data.senderStaffId||'',
conversationId: data.conversationId||'',
sessionWebhook: data.sessionWebhook||'',
};
// Check allowed senders
if (config.allowedSenderStaffIds&&config.allowedSenderStaffIds.length>0) {
if (!config.allowedSenderStaffIds.includes(senderInfo.senderStaffId)) {
logInfo(`Ignored message from unauthorized sender: ${senderInfo.senderStaffId}`);
// Send ACK to DingTalk
client.socketCallBackResponse(res.headers.messageId, { status: 'OK' });
return;
}
}
handleIncomingMessage(text, senderInfo);
// Send ACK to DingTalk to prevent retries
client.socketCallBackResponse(res.headers.messageId, { status: 'OK' });
} catch (err) {
logError(`Error handling incoming message: ${err.message}\n${err.stack}`);
try { client.socketCallBackResponse(res.headers.messageId, { status: 'OK' }); } catch {}
}
});
// Also listen for all events to catch anything we might miss
client.registerAllEventListener((msg) => {
logInfo(`Stream event received: type=${msg.type}topic=${msg.headers&&msg.headers.topic}`);
logDebug(`Stream event data: ${msg.data}`);
return { status: 'SUCCESS' };
});
awaitclient.connect();
streamClient=client;
logInfo('DingTalk Stream SDK connected successfully.');
} catch (err) {
logError(`Failed to initialize Stream SDK: ${err.message}`);
logInfo('Continuing in webhook-only mode.');
}
}
// === Main ===
asyncfunctionmain() {
try {
loadConfig();
ensureDirs();
acquireLock();
logInfo(`Bridge started (PID: ${process.pid})`);
// Init Stream SDK for incoming messages
awaitinitStreamClient();
// Start polling for outgoing requests
scanInterval=setInterval(async () => {
if (!running) return;
try {
awaitscanAndSendRequests();
cleanupExpired();
} catch (err) {
logError(`Scan cycle error: ${err.message}`);
}
}, config.timeouts.requestScanIntervalMs||1000);
// Initial scan
awaitscanAndSendRequests();
logInfo('Bridge is running. Waiting for requests...');
} catch (err) {
logError(`Bridge startup failed: ${err.message}`);
releaseLock();
process.exit(1);
}
}
// === Graceful shutdown ===
functionshutdown(signal) {
if (!running) return;
running=false;
logInfo(`Received ${signal}, shutting down...`);
if (scanInterval) clearInterval(scanInterval);
if (streamClient) {
try { streamClient.disconnect(); } catch {}
}
releaseLock();
logInfo('Bridge stopped.');
process.exit(0);
}
process.on('SIGTERM', () =>shutdown('SIGTERM'));
process.on('SIGINT', () =>shutdown('SIGINT'));
process.on('SIGHUP', () =>shutdown('SIGHUP'));
process.on('exit', () => { releaseLock(); });
process.on('uncaughtException', (err) => {
logError(`Uncaught exception: ${err.message}\n${err.stack}`);
});
process.on('unhandledRejection', (err) => {
logError(`Unhandled rejection: ${err}`);
});
main();
第七步:创建 Hook 处理器 dingtalk-hook.cjs
在 ~/.claude/helpers/ 目录下创建文件 dingtalk-hook.cjs。
这是 Claude Code Hooks 的调用入口,负责:
-
会话启动时确保 Bridge 运行
-
权限请求时写入请求文件并轮询等待钉钉响应
-
通知和回合完成时写入请求文件(即发即走)
-
提取工作目录(cwd)信息用于多终端区分
完整源码
go
#!/usr/bin/env node
'use strict';
constfs=require('fs');
constpath=require('path');
constcrypto=require('crypto');
const { spawn } =require('child_process');
// === Paths ===
constHELPERS_DIR=path.resolve(__dirname);
constCONFIG_PATH=path.join(HELPERS_DIR, 'dingtalk-config.json');
constIPC_DIR=path.join(HELPERS_DIR, 'dingtalk');
constREQUESTS_DIR=path.join(IPC_DIR, 'requests');
constRESPONSES_DIR=path.join(IPC_DIR, 'responses');
constPID_FILE=path.join(IPC_DIR, 'bridge.pid');
constBRIDGE_SCRIPT=path.join(HELPERS_DIR, 'dingtalk-bridge.cjs');
// === Read config ===
functionloadConfig() {
try {
returnJSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
} catch {
return { timeouts: { permissionRequestMs: 120000, pollIntervalMs: 500 } };
}
}
// === Ensure IPC dirs ===
functionensureDirs() {
for (constdirof [IPC_DIR, REQUESTS_DIR, RESPONSES_DIR]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
}
// === Read stdin JSON ===
functionreadStdin() {
try {
constinput=fs.readFileSync(0, 'utf8').trim();
if (input) returnJSON.parse(input);
} catch {}
return {};
}
// === Atomic file write ===
functionatomicWriteJson(filePath, data) {
consttmpPath=filePath+'.tmp';
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
fs.renameSync(tmpPath, filePath);
}
// === Write a request ===
functionwriteRequest(req) {
constfilePath=path.join(REQUESTS_DIR, `${req.id}.json`);
atomicWriteJson(filePath, req);
}
// === Poll for response ===
functionpollResponse(uuid, timeoutMs, pollIntervalMs) {
returnnewPromise((resolve) => {
constrespPath=path.join(RESPONSES_DIR, `${uuid}.json`);
constdeadline=Date.now() +timeoutMs;
constcheck= () => {
if (Date.now() >=deadline) {
resolve(null); // Timeout
return;
}
try {
if (fs.existsSync(respPath)) {
constcontent=fs.readFileSync(respPath, 'utf8');
constresp=JSON.parse(content);
resolve(resp);
// Clean up
try { fs.unlinkSync(respPath); } catch {}
return;
}
} catch {}
setTimeout(check, pollIntervalMs);
};
check();
});
}
// === Check if bridge is running ===
functionisBridgeRunning() {
try {
constpid=parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
if (pid) {
process.kill(pid, 0);
returntrue;
}
} catch {}
returnfalse;
}
// === Start bridge if not running ===
functionensureBridge() {
if (isBridgeRunning()) return;
ensureDirs();
try {
constchild=spawn(process.execPath, [BRIDGE_SCRIPT], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
} catch {}
}
// === Extract tool info from hook input ===
functionextractToolInfo(input) {
// PermissionRequest hook input structure
consttoolName=input.tool_name||input.toolName||
(input.tool&&input.tool.name) ||'Unknown';
consttoolInput=input.tool_input||input.toolInput||
(input.tool&&input.tool.input) || {};
return { name: toolName, input: toolInput };
}
// === Build summary message from stop data ===
functionbuildStopSummary(input) {
constparts= [];
if (input.stop_reason) parts.push(`**原因:** ${input.stop_reason}`);
if (input.message) parts.push(input.message);
if (input.summary) parts.push(input.summary);
returnparts.join('\n') ||'Claude Code 已完成当前回合。';
}
// === Handlers ===
asyncfunctionhandleSessionStart(input) {
ensureBridge();
}
asyncfunctionhandlePermissionRequest(input, config) {
ensureBridge();
constid=crypto.randomUUID();
consttool=extractToolInfo(input);
consttimeoutMs=config.timeouts.permissionRequestMs||120000;
constpollIntervalMs=config.timeouts.pollIntervalMs||500;
constreq= {
id,
type: 'permission_request',
createdAt: newDate().toISOString(),
expiresAt: newDate(Date.now() +timeoutMs).toISOString(),
sessionId: input.session_id||input.sessionId||'',
cwd: input.cwd||input.project_dir||process.cwd(),
tool,
message: `Claude Code 请求使用 ${tool.name}`,
needsResponse: true,
sentToDingtalk: false,
};
writeRequest(req);
// Poll for DingTalk response
constresp=awaitpollResponse(id, timeoutMs, pollIntervalMs);
// Clean up request file
try { fs.unlinkSync(path.join(REQUESTS_DIR, `${id}.json`)); } catch {}
if (resp) {
constdecision=resp.decision||'ask';
constoutput= {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: { behavior: decision },
},
};
if (decision==='deny'&&resp.reason) {
output.hookSpecificOutput.decision.message=`钉钉拒绝: ${resp.reason}`;
}
process.stdout.write(JSON.stringify(output));
} else {
// Timeout: fall through to terminal prompt
constoutput= {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: { behavior: 'ask' },
},
};
process.stdout.write(JSON.stringify(output));
}
}
asyncfunctionhandleNotification(input) {
ensureBridge();
constid=crypto.randomUUID();
constreq= {
id,
type: 'notification',
createdAt: newDate().toISOString(),
expiresAt: newDate(Date.now() +60000).toISOString(),
sessionId: input.session_id||input.sessionId||'',
title: input.title||input.notification_type||'📢 通知',
message: input.message||input.body||JSON.stringify(input).substring(0, 500),
needsResponse: false,
sentToDingtalk: false,
};
writeRequest(req);
// Fire and forget
}
asyncfunctionhandleStop(input) {
// Check stop_hook_active to prevent loops
if (input.stop_hook_active) return;
ensureBridge();
constid=crypto.randomUUID();
constreq= {
id,
type: 'stop',
createdAt: newDate().toISOString(),
expiresAt: newDate(Date.now() +60000).toISOString(),
sessionId: input.session_id||input.sessionId||'',
message: buildStopSummary(input),
needsResponse: false,
sentToDingtalk: false,
};
writeRequest(req);
// Fire and forget
}
// === Main ===
asyncfunctionmain() {
consthookType=process.argv[2];
constconfig=loadConfig();
ensureDirs();
constinput=readStdin();
switch (hookType) {
case'SessionStart':
awaithandleSessionStart(input);
break;
case'PermissionRequest':
awaithandlePermissionRequest(input, config);
break;
case'Notification':
awaithandleNotification(input);
break;
case'Stop':
awaithandleStop(input);
break;
default:
// Unknown hook type, just ensure bridge is running
ensureBridge();
}
}
main().catch((err) => {
process.stderr.write(`dingtalk-hook error: ${err.message}\n`);
process.exit(1);
});
第八步:配置 Claude Code Hooks
编辑 Claude Code 的全局设置文件 ~/.claude/settings.json。
找到或打开设置文件
go
# Windows
notepad %USERPROFILE%\.claude\settings.json
# macOS / Linux
nano ~/.claude/settings.json
在 hooks 对象中添加以下配置
重要 :以下路径以 Windows 为例,如果你使用 macOS/Linux,请将路径中的
C:/Users/你的用户名/.claude/helpers/替换为~/.claude/helpers/的绝对路径(如/home/yourname/.claude/helpers/)。
在 settings.json 的 hooks 字段中,添加 4 个 hook 事件:
go
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"C:/Users/你的用户名/.claude/helpers/dingtalk-hook.cjs\" SessionStart"
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "node \"C:/Users/你的用户名/.claude/helpers/dingtalk-hook.cjs\" PermissionRequest",
"timeout": 130
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "node \"C:/Users/你的用户名/.claude/helpers/dingtalk-hook.cjs\" Notification"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"C:/Users/你的用户名/.claude/helpers/dingtalk-hook.cjs\" Stop"
}
]
}
]
}
}
各 Hook 说明
| Hook 事件 | 触发时机 | 行为 |
|---|---|---|
SessionStart |
Claude Code 启动新会话 | 确保 Bridge 后台进程在运行 |
PermissionRequest |
Claude Code 需要用户授权工具调用 | 发送权限请求到钉钉,轮询等待响应(最长 120 秒) |
Notification |
Claude Code 发出通知 | 转发通知到钉钉(即发即走) |
Stop |
Claude Code 完成一轮响应 | 发送回合完成通知到钉钉(即发即走) |
关于 timeout: 130
PermissionRequest 的 timeout: 130 秒略大于配置文件中的 permissionRequestMs: 120000(120 秒),这样可以防止 Claude Code 在 hook 轮询期间提前终止 hook 进程。
如果你已有其他 hooks
如果 settings.json 中已经有 SessionStart 等 hook,将钉钉的 hook 条目追加到对应数组中即可。例如:
go
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "已有的其他hook命令" },
{ "type": "command", "command": "node \"C:/Users/你的用户名/.claude/helpers/dingtalk-hook.cjs\" SessionStart" }
]
}
]
}
}
第九步:验证部署
按以下顺序逐步验证每个组件是否正常工作。
9.1 验证 Webhook 发送(群机器人 -> 钉钉群)
运行以下命令发送一条测试消息:
go
cd ~/.claude/helpers
node-e"
const https = require('https');
const crypto = require('crypto');
const config = require('./dingtalk-config.json');
const ts = Date.now();
const sign = encodeURIComponent(
crypto.createHmac('sha256', config.webhook.secret)
.update(ts +'\n'+ config.webhook.secret)
.digest('base64')
);
const url = config.webhook.url +'×tamp='+ ts +'&sign='+ sign;
const body = JSON.stringify({
msgtype: 'text',
text: { content: 'Claude Code 钉钉集成测试成功!' }
});
const p = new URL(url);
const req = https.request({
hostname: p.hostname,
path: p.pathname + p.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
}, r => {
let d ='';
r.on('data', c => d += c);
r.on('end', () => console.log(d));
});
req.write(body);
req.end();
"
预期结果:
-
终端输出
{"errcode":0,"errmsg":"ok"} -
钉钉群收到消息:"Claude Code 钉钉集成测试成功!"
如果失败 :检查 dingtalk-config.json 中的 webhook.url 和 webhook.secret 是否正确。
9.2 验证 Bridge 启动
go
cd ~/.claude/helpers
node dingtalk-bridge.cjs &
等待 3 秒后查看日志:
go
cat ~/.claude/helpers/dingtalk/bridge.log
预期日志:
go
[...] [INFO] Bridge started (PID: xxxxx)
[...] [INFO] DingTalk Stream SDK connected successfully.
[...] [INFO] Bridge is running. Waiting for requests...
如果 Stream SDK 连接失败 (显示 Request failed with status code 400):
-
检查
stream.appKey和stream.appSecret是否正确 -
确认在钉钉开放平台创建的是"机器人"类型应用
9.3 验证 Stream 接收(钉钉群 -> Bridge)
在钉钉群中 @企业机器人应用(不是 Webhook 机器人),发送:
go
/status
预期结果:机器人回复"当前没有待处理的请求。"
如果无回复:
-
确认 @的是企业机器人应用
-
确认该机器人已添加到群里
-
检查 bridge.log 是否有
Stream callback received记录
9.4 验证完整权限请求流程
go
echo'{"tool_name":"Bash","tool_input":{"command":"echo hello"},"session_id":"test-session","cwd":"/test/project"}' | \
node ~/.claude/helpers/dingtalk-hook.cjs PermissionRequest
预期结果:
-
钉钉群收到带编号和目录的权限请求 ActionCard
-
在钉钉群 @企业机器人 回复
/allow -
终端输出包含
"behavior":"allow"的 JSON
9.5 停止测试用的 Bridge
如果你在 9.2 手动启动了 Bridge,后续正式使用时它会被 SessionStart hook 自动管理。你可以选择不管它(它会在锁文件机制下自动保持单例),或者手动停止:
go
# 查看 PID
cat ~/.claude/helpers/dingtalk/bridge.pid
# 停止进程(Windows)
taskkill /PID <PID> /F
# 停止进程(macOS/Linux)
kill <PID>
# 清理锁文件
rm ~/.claude/helpers/dingtalk/bridge.lock
rm ~/.claude/helpers/dingtalk/bridge.pid
使用指南
日常使用
配置完成后,无需任何手动操作。启动 Claude Code 会话时,SessionStart hook 会自动启动 Bridge 后台进程。
钉钉群命令
在钉钉群中 @企业机器人应用 发送以下命令:
| 命令 | 说明 | 示例 |
|---|---|---|
/allow |
允许(仅一个待处理时直接允许,多个时提示选择) | /allow |
/allow <编号> |
允许指定编号的请求 | /allow 3 |
/deny |
拒绝权限请求 | /deny |
/deny <编号> <原因> |
拒绝并附带原因 | /deny 3 不安全的命令 |
/ask |
将请求转到终端处理 | /ask |
/ask <编号> |
将指定请求转到终端 | /ask 3 |
/status |
查看所有待处理请求(含编号、目录、时间) | /status |
注意:必须 @企业机器人应用(不是 Webhook 机器人),消息才会被接收。
权限请求交互流程
go
Claude Code 需要工具权限
│
├──→ 钉钉群收到 ActionCard 通知(含编号 #N 和工作目录)
│ │
│ 用户在钉钉回复 /allow N
│ │
│ ←────┘ Claude Code 自动放行
│
└──→ 120 秒无钉钉回复
│
终端显示权限提示(回退到正常的终端交互)
│
用户在终端操作
通知类型
| 通知类型 | 触发时机 | 钉钉消息格式 |
|---|---|---|
| 权限请求 | Claude Code 需要用户授权工具调用 | ActionCard 卡片(含编号、目录、工具名) |
| 回合完成 | Claude Code 完成一轮响应 | Markdown 消息 |
| 通知 | Claude Code 发出通知提醒 | Markdown 消息 |
多终端会话区分
当你同时在多个终端运行 Claude Code 时,钉钉集成会自动区分每个终端的请求。
工作原理
-
自动编号 :每个权限请求自动分配递增编号
#1,#2,#3... -
目录标识 :消息中显示工作目录的最后两层路径,如
[AI/my-project] -
精确操作:通过编号精确控制特定终端的请求
钉钉消息示例
权限请求通知:
go
[#3 | XX/XX] 🔐 权限请求
工具: Bash
命令:
npm install express
编号: #3 | 请求ID: 307c1897
---
回复 /allow 3 允许 | /deny 3 拒绝 | /ask 3 转到终端
/status 返回:
go
待处理请求
- #3 XX/XX] Bash (12秒前)
- #4 [ZZ/ZZ] Write (5秒前)
回复 /allow <编号> 或 /deny <编号>
多请求歧义处理
| 场景 | /allow 不带编号的行为 |
|---|---|
| 0 个待处理请求 | 回复"当前没有待处理的权限请求" |
| 1 个待处理请求 | 直接允许该请求 |
| 多个待处理请求 | 列出所有待处理请求,提示用户指定编号 |
文件结构说明
go
~/.claude/
├── settings.json # Claude Code 设置(包含 hooks 配置)
└── helpers/
├── dingtalk-config.json # 钉钉凭据和参数配置
├── dingtalk-bridge.cjs # 后台 Bridge 服务(常驻进程)
├── dingtalk-hook.cjs # Claude Code Hook 处理器
├── package.json # npm 包信息(安装依赖时自动生成)
├── node_modules/
│ ├── dingtalk-stream/ # 钉钉 Stream SDK
│ ├── ws/ # WebSocket 库(dingtalk-stream 依赖)
│ └── axios/ # HTTP 库(dingtalk-stream 依赖)
└── dingtalk/ # IPC 运行时目录(自动创建)
├── requests/ # Hook -> Bridge 的请求文件
│ └── <uuid>.json
├── responses/ # Bridge -> Hook 的响应文件
│ └── <uuid>.json
├── bridge.pid # Bridge 进程 PID
├── bridge.lock # Bridge 单例锁
└── bridge.log # Bridge 运行日志
配置参数说明
dingtalk-config.json 完整参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
webhook.url |
string | (必填) | 群 Webhook 机器人的完整 URL(含 access_token) |
webhook.secret |
string | (必填) | 群 Webhook 机器人的加签密钥(SEC 开头) |
stream.appKey |
string | (必填) | 企业机器人应用的 Client ID(原 AppKey) |
stream.appSecret |
string | (必填) | 企业机器人应用的 Client Secret(原 AppSecret) |
timeouts.permissionRequestMs |
number | 120000 |
权限请求等待钉钉响应的超时(毫秒),超时后回退到终端 |
timeouts.pollIntervalMs |
number | 500 |
Hook 检查响应文件的轮询间隔(毫秒) |
timeouts.requestScanIntervalMs |
number | 1000 |
Bridge 扫描请求目录的间隔(毫秒) |
allowedSenderStaffIds |
string[] | [] |
允许发送命令的钉钉用户 StaffId 白名单,空数组允许所有人 |
debug |
boolean | false |
是否开启详细调试日志(排查问题时开启) |
settings.json Hook 配置参数
| 参数 | 说明 |
|---|---|
type |
固定为 "command" |
command |
执行的 node 命令,指向 dingtalk-hook.cjs 并传入 hook 类型 |
timeout |
Hook 超时时间(秒),仅 PermissionRequest 需要,建议设为 130 |
故障排查
问题:Bridge 未启动
症状:钉钉收不到任何消息。
go
# 检查 PID 文件是否存在
cat ~/.claude/helpers/dingtalk/bridge.pid
# 检查进程是否存活(Windows)
tasklist /FI "PID eq <PID>"
# 检查进程是否存活(macOS/Linux)
ps-p <PID>
# 查看日志
cat ~/.claude/helpers/dingtalk/bridge.log
# 手动启动(调试用)
cd ~/.claude/helpers && node dingtalk-bridge.cjs
问题:Stream SDK 连接失败(400 错误)
症状 :日志显示 Connect failed: Request failed with status code 400
原因:Client ID / Client Secret 不正确,或应用类型不对。
解决:
-
确认
dingtalk-config.json中stream.appKey和stream.appSecret正确 -
确认在钉钉开放平台创建的是"机器人"类型(不是"企业内部应用"或其他)
-
在开放平台确认应用状态为"开发中"或"已发布"
问题:Webhook 发送失败(token is not exist)
症状 :日志显示 DingTalk API error: token is not exist (code: 300005)
原因:access_token 不正确或 Webhook 机器人已被移除。
解决:
-
在钉钉群设置 -> 机器人中检查 Webhook 机器人是否还在
-
如果被移除,重新创建并更新
webhook.url和webhook.secret
问题:@机器人无响应
症状:在群里 @机器人发消息,没有任何回复。
排查:
-
确认 @的是企业机器人应用(不是 Webhook 机器人)
-
确认该机器人已添加到群里
-
检查日志是否有
Stream callback received记录 -
开启 debug:
dingtalk-config.json中debug设为true,然后重启 bridge
问题:残留进程或锁文件冲突
症状 :Bridge 无法启动,日志显示 Bridge already running。
go
# 强制清理(Windows)
taskkill /PID $(cat ~/.claude/helpers/dingtalk/bridge.pid) /F
# 强制清理(macOS/Linux)
kill-9$(cat ~/.claude/helpers/dingtalk/bridge.pid)
# 删除锁文件
rm ~/.claude/helpers/dingtalk/bridge.lock
rm ~/.claude/helpers/dingtalk/bridge.pid
# 可选:清空请求/响应文件
rm ~/.claude/helpers/dingtalk/requests/*.json 2>/dev/null
rm ~/.claude/helpers/dingtalk/responses/*.json 2>/dev/null
问题:权限请求总是超时回退到终端
症状:钉钉收到消息也回复了,但终端没有收到响应。
排查:
-
检查 bridge.log 中是否有
Wrote response for记录 -
检查
~/.claude/helpers/dingtalk/responses/目录是否有响应文件生成 -
确认
dingtalk-config.json中permissionRequestMs足够大(建议 120000) -
确认
settings.json中 PermissionRequest hook 的timeout大于permissionRequestMs / 1000
查看完整日志
go
# 实时查看日志
tail -f ~/.claude/helpers/dingtalk/bridge.log
# 开启详细调试日志
# 编辑 dingtalk-config.json,将 debug 设为 true
# 重启 bridge 后查看日志中的 [DEBUG] 条目
申明\]:以上内容为CC根据本地实际配置生成