Claude Code钉钉双向交互集成

概述

本方案实现了 Claude Code CLI 与钉钉群的双向实时交互

  • Claude Code -> 钉钉:权限请求、任务完成通知、错误告警自动推送到钉钉群

  • 钉钉 -> Claude Code :在钉钉群中 @机器人 回复 /allow/deny 等命令,远程控制 Claude Code 的权限授予

  • 双通道并行:终端和钉钉同时可用,哪个先响应就用哪个的结果

  • 多终端区分:每个权限请求带有编号和工作目录标识,支持同时操作多个终端

典型使用场景

  1. 你在开会或离开电脑时,Claude Code 需要权限执行某个命令

  2. 钉钉群收到通知:[#1 | my-project] 🔐 权限请求 --- Bash: npm install

  3. 你在手机钉钉上 @机器人 回复 /allow 1

  4. Claude Code 自动获得授权继续执行

  5. 如果你不回复,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 向钉钉群发送消息

操作步骤

  1. 打开你要使用的钉钉群

  2. 点击右上角群设置(齿轮图标)

  3. 找到并点击机器人

  4. 点击添加机器人

  5. 选择自定义(通过 Webhook 接入自定义服务)

  6. 填写机器人名称,例如:Claude Code 通知

  7. 安全设置选择加签

  8. 点击完成

记录以下信息

创建完成后,页面会显示两个关键信息,请立即复制保存

  • Webhook 地址 :形如 https://oapi.dingtalk.com/robot/send?access_token=xxxxxxxx

  • 加签密钥 :形如 SECxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

这个页面关闭后无法再次查看密钥,请务必保存好。


第二步:创建钉钉企业机器人应用(接收通道)

这个机器人负责接收钉钉群里用户 @机器人 的消息

操作步骤

  1. 打开浏览器,登录 钉钉开放平台

  2. 在首页点击创建应用

  3. 在应用类型列表中选择机器人

  4. 填写机器人基本信息:

  • 名称 :例如 Claude Code

  • 描述 :例如 Claude Code 远程控制

  • 点击创建,完成后进入应用管理页面

记录以下信息

在应用的凭证与基础信息页面,找到并复制:

  • Client ID (原 AppKey):形如 dingxxxxxxxxxx

  • Client Secret(原 AppSecret):一串较长的字符串

关于消息接收模式

机器人类型的应用默认使用 Stream 模式(基于 WebSocket),无需配置公网回调地址。这正是我们需要的模式。


第三步:将两个机器人添加到同一个钉钉群

3.1 Webhook 机器人

第一步创建时已经直接在群里创建了,所以已经在群里

3.2 企业机器人应用

需要手动添加到群里:

  1. 打开目标钉钉群

  2. 群设置 -> 机器人 -> 添加机器人

  3. 搜索你在第二步创建的机器人应用名称(如 Claude Code

  4. 选择并添加到群

验证

添加完成后,在群设置 -> 机器人列表中,应该能看到两个机器人:

  1. Claude Code 通知(Webhook 机器人)-- 负责发送

  2. 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 及其依赖(wsaxiosevents)。

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-streampackage.jsonmain 字段指向 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+=`&timestamp=${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.jsonhooks 字段中,添加 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

PermissionRequesttimeout: 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 +'&timestamp='+ 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.urlwebhook.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.appKeystream.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

预期结果

  1. 钉钉群收到带编号和目录的权限请求 ActionCard

  2. 在钉钉群 @企业机器人 回复 /allow

  3. 终端输出包含 "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. 自动编号 :每个权限请求自动分配递增编号 #1, #2, #3...

  2. 目录标识 :消息中显示工作目录的最后两层路径,如 [AI/my-project]

  3. 精确操作:通过编号精确控制特定终端的请求

钉钉消息示例

权限请求通知:

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 不正确,或应用类型不对。

解决

  1. 确认 dingtalk-config.jsonstream.appKeystream.appSecret 正确

  2. 确认在钉钉开放平台创建的是"机器人"类型(不是"企业内部应用"或其他)

  3. 在开放平台确认应用状态为"开发中"或"已发布"

问题:Webhook 发送失败(token is not exist)

症状 :日志显示 DingTalk API error: token is not exist (code: 300005)

原因:access_token 不正确或 Webhook 机器人已被移除。

解决

  1. 在钉钉群设置 -> 机器人中检查 Webhook 机器人是否还在

  2. 如果被移除,重新创建并更新 webhook.urlwebhook.secret

问题:@机器人无响应

症状:在群里 @机器人发消息,没有任何回复。

排查

  1. 确认 @的是企业机器人应用(不是 Webhook 机器人)

  2. 确认该机器人已添加到群里

  3. 检查日志是否有 Stream callback received 记录

  4. 开启 debug:dingtalk-config.jsondebug 设为 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

问题:权限请求总是超时回退到终端

症状:钉钉收到消息也回复了,但终端没有收到响应。

排查

  1. 检查 bridge.log 中是否有 Wrote response for 记录

  2. 检查 ~/.claude/helpers/dingtalk/responses/ 目录是否有响应文件生成

  3. 确认 dingtalk-config.jsonpermissionRequestMs 足够大(建议 120000)

  4. 确认 settings.json 中 PermissionRequest hook 的 timeout 大于 permissionRequestMs / 1000

查看完整日志

go 复制代码
# 实时查看日志
tail -f ~/.claude/helpers/dingtalk/bridge.log

# 开启详细调试日志
# 编辑 dingtalk-config.json,将 debug 设为 true
# 重启 bridge 后查看日志中的 [DEBUG] 条目

申明\]:以上内容为CC根据本地实际配置生成

相关推荐
TG_yunshuguoji2 天前
阿里云代理商:用 AppFlow 给钉钉机器人配置定时任务 阿里云自动化办公效率翻倍
阿里云·机器人·钉钉
SAP小崔说事儿2 天前
SAP B1 &钉钉集成解决方案—采购申请单审批
钉钉·sap·hana·无锡sap·sap和钉钉集成·sap集成开发·erp集成开发
Embrace9242 天前
钉钉工作台内嵌应用=》调用钉钉对话框
前端·javascript·钉钉
深眸财经5 天前
钉钉重走“取经路”
钉钉
需要点灵感5 天前
# 从身份证读卡到钉钉同步:C# WinForms企业级应用开发实战
开发语言·c#·钉钉
是Winky啊5 天前
【OpenClaw】钉钉数据分析bot
钉钉·agent·openclaw
天草二十六_简村人5 天前
阿里云DMS工单审批对接钉钉应用的实践示例
运维·数据库·后端·阿里云·云原生·云计算·钉钉
java资料站6 天前
钉钉远程一键执行服务器启动脚本
运维·服务器·钉钉
翼龙云_cloud8 天前
阿里云代理商:轻量服务器部署 OpenClaw 集成钉钉实现自动化办公
服务器·人工智能·阿里云·钉钉·openclaw