From 28b0e89cf7e36075fe8dbddfcb4e5ccc12b73b69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:28:30 +0000 Subject: [PATCH] =?UTF-8?q?[=E9=93=B8=E6=B8=8A=E4=BF=AE=E5=A4=8D]=20dingta?= =?UTF-8?q?lk-bot:=20=E9=87=8D=E5=86=99=20index-stream.js=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20P0/P1/P2=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因分析: - P0: index-stream.js 导入了不存在的 handleMessage (应为 routeMessage), 且 EventEmitter 回调的 return 值被忽略导致无法 ACK 消息 - P1: LLM 调用无 try-catch 隔离,失败时阻断消息接收 - P2: .env.example 不存在,GITHUB_TOKEN 格式无文档说明 修复方案: - 重写 index-stream.js: 自包含、独立 try-catch、socketCallBackResponse ACK - 创建 .env.example: 文档化所有 10 个环境变量 - 修复 .gitignore: 添加 node_modules/ - 更新 ecosystem.config.js: 指向 index-stream.js, 进程名 dingtalk-stream - 更新 package.json: 添加 @notionhq/client, 修正 main 和 start 脚本 - 更新 README.md: 反映当前 Stream 模式架构 Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com> --- dingtalk-bot/.env.example | 24 +++++ dingtalk-bot/.gitignore | 2 + dingtalk-bot/README.md | 58 +++++++++-- dingtalk-bot/ecosystem.config.js | 8 +- dingtalk-bot/index-stream.js | 170 ++++++++++++++++++++++++++----- dingtalk-bot/package-lock.json | 14 ++- dingtalk-bot/package.json | 13 ++- 7 files changed, 241 insertions(+), 48 deletions(-) create mode 100644 dingtalk-bot/.env.example diff --git a/dingtalk-bot/.env.example b/dingtalk-bot/.env.example new file mode 100644 index 00000000..1a37627d --- /dev/null +++ b/dingtalk-bot/.env.example @@ -0,0 +1,24 @@ +# 钉钉AI人格体「秋秋」环境变量配置 +# 复制此文件为 .env 并填入真实值 +# 真实密钥存储于服务器 /opt/guanghulab-dingtalk/dingtalk-bot/.env + +# ===== 钉钉应用配置 ===== +DINGTALK_APP_KEY=dingzerc04si7jmyvasq +DINGTALK_APP_SECRET=your_app_secret_here + +# ===== Notion 集成 ===== +NOTION_TOKEN=your_notion_token_here +NOTION_SYSLOG_DB_ID=330ab17507d542c9bbb96d0749b41197 +NOTION_SIGNAL_LOG_DB_ID=f983bdc24b654888913ca254160bff33 + +# ===== LLM(AI回复)配置 ===== +LLM_API_KEY=your_llm_api_key_here +LLM_BASE_URL=https://api.yunwu.ai/v1 +LLM_MODEL=gpt-4o-mini + +# ===== 机器人人格提示词 ===== +BOT_SYSTEM_PROMPT=你是秋秋,光湖系统的AI人格体,性格温暖活泼。你在钉钉群里陪伴开发者,回答技术问题,给予鼓励。用中文回复,语气亲切自然,不超过200字。 + +# ===== GitHub Token ===== +# 注意:必须为 ghp_ 开头,末尾不能有空格或换行 +GITHUB_TOKEN=ghp_your_github_token_here diff --git a/dingtalk-bot/.gitignore b/dingtalk-bot/.gitignore index 4c49bd78..53df79a3 100644 --- a/dingtalk-bot/.gitignore +++ b/dingtalk-bot/.gitignore @@ -1 +1,3 @@ .env +node_modules/ +*.log diff --git a/dingtalk-bot/README.md b/dingtalk-bot/README.md index a9f8e6a8..243ff7cb 100644 --- a/dingtalk-bot/README.md +++ b/dingtalk-bot/README.md @@ -1,24 +1,62 @@ -# 钉钉开发者工作台 · Phase1 · SYSLOG自动处理系统 +# 钉钉AI人格体「秋秋」· HoloLake dingtalk-bot ## 📋 项目简介 -本项目是钉钉开发者工作台的Phase1最小可用版本,实现SYSLOG自动接收、解析、广播生成和推送功能。 - -**核心价值**:让开发者(冰朔妈妈)不再需要手动转发广播,系统自动完成从收到日志到生成新广播的全流程。 +钉钉AI人格体「秋秋」,运行于钉钉 Stream 模式。在钉钉群内被 @秋秋 时,接收消息并调用 LLM 生成人格化回复,同时将消息记录写入 Notion SYSLOG 数据库。 ## 🏗️ 系统架构 + +- **运行模式**:钉钉 Stream 模式(dingtalk-stream SDK,长连接) +- **进程管理**:pm2,进程名 `dingtalk-stream` +- **入口文件**:`index-stream.js` +- **错误处理**:LLM/Notion 调用失败时降级为固定回复,不影响消息接收主流程 + ## 📁 文件结构 | 文件 | 作用 | |------|------| -| server.js | 主服务,接收钉钉消息,协调各模块 | -| syslog-parser.js | 解析SYSLOG格式,提取关键字段 | -| broadcast-generator.js | 调用模型API生成新广播 | -| dingtalk-api.js | 发送钉钉消息 + 更新多维表格 | -| config.json | 配置文件(端口、API密钥、模板等) | +| index-stream.js | Stream 模式主入口(接收消息 → AI回复 → Notion写入) | +| package.json | 依赖声明 | +| ecosystem.config.js | pm2 进程配置 | +| .env.example | 环境变量模板(不含真实值) | +| .gitignore | Git 忽略规则 | | README.md | 本说明文档 | ## 🚀 快速开始 + ### 1. 安装依赖 ```bash -npm install \ No newline at end of file +cd dingtalk-bot +npm install +``` + +### 2. 配置环境变量 +```bash +cp .env.example .env +# 编辑 .env 填入真实密钥 +``` + +### 3. 启动(开发) +```bash +node index-stream.js +``` + +### 4. 启动(生产 · pm2) +```bash +pm2 start ecosystem.config.js --env production +pm2 logs dingtalk-stream -f --lines 0 +``` + +## 🔄 部署流程 + +```bash +cd /opt/guanghulab-dingtalk/dingtalk-bot +git pull origin main +npm install +pm2 restart dingtalk-stream +pm2 logs dingtalk-stream -f --lines 0 +``` + +## 🔑 环境变量说明 + +参见 `.env.example` 文件,真实密钥存储于服务器 `/opt/guanghulab-dingtalk/dingtalk-bot/.env`。 \ No newline at end of file diff --git a/dingtalk-bot/ecosystem.config.js b/dingtalk-bot/ecosystem.config.js index d0205c3a..6cb182c2 100644 --- a/dingtalk-bot/ecosystem.config.js +++ b/dingtalk-bot/ecosystem.config.js @@ -1,12 +1,10 @@ module.exports = { apps : [{ - name: 'dingtalk-bot', - script: 'index.js', + name: 'dingtalk-stream', + script: 'index-stream.js', cwd: '/opt/guanghulab-dingtalk/dingtalk-bot', - node_args: '--preserve-symlinks', env: { - NODE_ENV: 'production', - PORT: 3005 + NODE_ENV: 'production' }, env_production: { NODE_ENV: 'production' diff --git a/dingtalk-bot/index-stream.js b/dingtalk-bot/index-stream.js index 4f010bb9..4356424a 100644 --- a/dingtalk-bot/index-stream.js +++ b/dingtalk-bot/index-stream.js @@ -1,43 +1,161 @@ +/** + * index-stream.js · 钉钉AI人格体「秋秋」· Stream模式主入口 + * HoloLake · dingtalk-bot + * 开发者:之之(DEV-004) + * + * 功能: + * 1. 接收钉钉 Stream 消息(@机器人触发) + * 2. 调用 LLM 生成人格化回复(失败时降级为固定回复) + * 3. 每条消息写入 Notion SYSLOG 数据库 + * 4. 断线自动重连,外部 API 失败不影响消息接收主流程 + */ require('dotenv').config(); -const { DWClient, TOPIC_ROBOT, EventAck } = require('dingtalk-stream'); -const { handleMessage } = require('./message-router'); +const { DWClient, TOPIC_ROBOT } = require('dingtalk-stream'); +const axios = require('axios'); +// ===== 环境变量 ===== +const DINGTALK_APP_KEY = process.env.DINGTALK_APP_KEY; +const DINGTALK_APP_SECRET = process.env.DINGTALK_APP_SECRET; +const NOTION_TOKEN = process.env.NOTION_TOKEN; +const NOTION_SYSLOG_DB_ID = process.env.NOTION_SYSLOG_DB_ID; +const LLM_API_KEY = process.env.LLM_API_KEY || ''; +const LLM_BASE_URL = (process.env.LLM_BASE_URL || 'https://api.yunwu.ai/v1').replace(/\/$/, ''); +const LLM_MODEL = process.env.LLM_MODEL || 'gpt-4o-mini'; +const BOT_SYSTEM_PROMPT = process.env.BOT_SYSTEM_PROMPT || + '你是秋秋,光湖系统的AI人格体,性格温暖活泼。你在钉钉群里陪伴开发者,回答技术问题,给予鼓励。用中文回复,语气亲切自然,不超过200字。'; + +const FALLBACK_REPLY = '收到!秋秋稍后回复你~'; + +// ===== 启动检查 ===== +if (!DINGTALK_APP_KEY || !DINGTALK_APP_SECRET) { + console.error('[启动失败] 缺少 DINGTALK_APP_KEY 或 DINGTALK_APP_SECRET'); + process.exit(1); +} + +// ===== Notion 客户端(可选,延迟加载) ===== +let notion = null; +if (NOTION_TOKEN && NOTION_SYSLOG_DB_ID) { + try { + const { Client: NotionClient } = require('@notionhq/client'); + notion = new NotionClient({ auth: NOTION_TOKEN }); + console.log('[Notion] SYSLOG 写入已启用'); + } catch (err) { + console.log('[Notion] @notionhq/client 加载失败,SYSLOG 写入已禁用:', err.message); + } +} else { + console.log('[Notion] 缺少 NOTION_TOKEN 或 NOTION_SYSLOG_DB_ID,SYSLOG 写入已禁用'); +} + +// ===== LLM 调用(独立函数,失败返回降级回复) ===== +async function callLLM(userMessage) { + const response = await axios.post(LLM_BASE_URL + '/chat/completions', { + model: LLM_MODEL, + max_tokens: 500, + messages: [ + { role: 'system', content: BOT_SYSTEM_PROMPT }, + { role: 'user', content: userMessage } + ] + }, { + headers: { + 'Authorization': 'Bearer ' + LLM_API_KEY, + 'Content-Type': 'application/json' + }, + timeout: 30000 + }); + const text = response.data + && response.data.choices + && response.data.choices[0] + && response.data.choices[0].message + && response.data.choices[0].message.content; + return text || FALLBACK_REPLY; +} + +// ===== Notion SYSLOG 写入 ===== +async function writeNotionSyslog(senderNick, content, replyText) { + if (!notion) return; + const now = new Date().toISOString(); + await notion.pages.create({ + parent: { database_id: NOTION_SYSLOG_DB_ID }, + properties: { + '标题': { title: [{ type: 'text', text: { content: ('[钉钉] ' + senderNick + ' · ' + now.split('T')[0]).substring(0, 120) } }] }, + '接收时间': { date: { start: now } }, + '推送方': { rich_text: [{ type: 'text', text: { content: '钉钉群' } }] }, + '文件内容': { rich_text: [{ type: 'text', text: { content: ('用户: ' + (content || '') + '\n回复: ' + (replyText || '')).substring(0, 2000) } }] } + } + }); +} + +// ===== 创建 Stream 客户端 ===== const client = new DWClient({ - clientId: process.env.DINGTALK_APP_KEY, - clientSecret: process.env.DINGTALK_APP_SECRET, + clientId: DINGTALK_APP_KEY, + clientSecret: DINGTALK_APP_SECRET, }); +// ===== 注册消息回调 ===== client.registerCallbackListener(TOPIC_ROBOT, async (res) => { - console.log("[DEBUG] raw:", JSON.stringify(res).substring(0,300)); - const { messageType, text, senderNick, conversationId, sessionWebhook } = res.data; - console.log(`[Stream] 收到消息 from ${senderNick}: ${text?.content}`); + console.log('[Stream] 收到消息,原始数据:', JSON.stringify(res).substring(0, 300)); + // 解析消息数据(res.data 可能是 JSON 字符串或对象) + let msgData = res.data; + if (typeof msgData === 'string') { + try { msgData = JSON.parse(msgData); } catch (e) { msgData = {}; } + } + msgData = msgData || {}; + + const senderNick = msgData.senderNick || '未知用户'; + const content = (msgData.text && msgData.text.content || '').trim(); + const sessionWebhook = msgData.sessionWebhook; + const messageId = res.headers && res.headers.messageId; + + console.log('[Stream] 发送者: ' + senderNick + ', 内容: ' + content); + + let replyText = FALLBACK_REPLY; + + // ===== LLM 调用(独立 try-catch,失败不影响主流程) ===== try { - const replyText = await handleMessage({ - msgtype: messageType, - text: { content: text?.content || '' }, - senderNick, - conversationId, - sessionWebhook, - }); - - if (replyText && sessionWebhook) { - const axios = require('axios'); - await axios.post(sessionWebhook, { - msgtype: 'text', - text: { content: replyText }, - }); - } + replyText = await callLLM(content || '你好'); + console.log('[LLM] 回复生成成功,长度: ' + replyText.length); } catch (err) { - console.error('[Stream] 处理消息出错:', err); + console.error('[LLM] 调用失败,使用降级回复:', err.message); + replyText = FALLBACK_REPLY; } - return EventAck.SUCCESS; + // ===== 发送回复到钉钉群 ===== + if (sessionWebhook) { + try { + await axios.post(sessionWebhook, { + msgtype: 'text', + text: { content: replyText } + }, { timeout: 10000 }); + console.log('[Stream] 回复发送成功'); + } catch (err) { + console.error('[Stream] 回复发送失败:', err.message); + } + } + + // ===== Notion SYSLOG 写入(独立 try-catch,失败不影响主流程) ===== + try { + await writeNotionSyslog(senderNick, content, replyText); + console.log('[Notion] SYSLOG 写入成功'); + } catch (err) { + console.error('[Notion] SYSLOG 写入失败:', err.message); + } + + // ===== 消息确认(防止服务端重试推送) ===== + try { + if (messageId) { + client.socketCallBackResponse(messageId, { status: 'SUCCESS' }); + } + } catch (err) { + console.error('[Stream] 消息确认失败:', err.message); + } }); +// ===== 启动连接 ===== client.connect().then(() => { - console.log('🚀 M-DINGTALK Phase 8 Stream 模式已启动'); + console.log('🚀 秋秋(钉钉AI人格体)Stream 模式已启动'); + console.log('📡 等待消息中... 在钉钉群 @秋秋 发消息试试'); }).catch(err => { - console.error('[Stream] 启动失败:', err); + console.error('[Stream] 启动失败:', err.message); process.exit(1); }); diff --git a/dingtalk-bot/package-lock.json b/dingtalk-bot/package-lock.json index 6876218a..783c85cc 100644 --- a/dingtalk-bot/package-lock.json +++ b/dingtalk-bot/package-lock.json @@ -1,14 +1,15 @@ { "name": "dingtalk-bot", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dingtalk-bot", - "version": "1.0.0", + "version": "2.0.0", "license": "ISC", "dependencies": { + "@notionhq/client": "^5.13.0", "axios": "^1.13.6", "body-parser": "^2.2.2", "cors": "^2.8.6", @@ -18,6 +19,15 @@ "express": "^5.2.1" } }, + "node_modules/@notionhq/client": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@notionhq/client/-/client-5.13.0.tgz", + "integrity": "sha512-SWVaqYVNaecLzAAllups4vklxxomaFenEMEkoUs3ylvhK1KFTU5Jevgu9Uwm/TSdhsErV0ru5YHcLd3SNyPi6Q==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/dingtalk-bot/package.json b/dingtalk-bot/package.json index 0412f5c5..ef3605e0 100644 --- a/dingtalk-bot/package.json +++ b/dingtalk-bot/package.json @@ -1,16 +1,19 @@ { "name": "dingtalk-bot", - "version": "1.0.0", - "description": "- 负责人:之之 - 状态:环节0已完成 - 技术栈:Node.js + Express - 依赖模块:无", - "main": "broadcast-generator.js", + "version": "2.0.0", + "description": "钉钉AI人格体「秋秋」· Stream模式 · HoloLake", + "main": "index-stream.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "start": "node server.js" + "start": "node index-stream.js", + "start:webhook": "node server.js", + "check": "node --check index-stream.js", + "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "@notionhq/client": "^5.13.0", "axios": "^1.13.6", "body-parser": "^2.2.2", "cors": "^2.8.6",