[铸渊修复] dingtalk-bot: 重写 index-stream.js 修复 P0/P1/P2 问题
根因分析: - 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>
This commit is contained in:
parent
cef640d62d
commit
28b0e89cf7
|
|
@ -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
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
.env
|
.env
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -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 | 主服务,接收钉钉消息,协调各模块 |
|
| index-stream.js | Stream 模式主入口(接收消息 → AI回复 → Notion写入) |
|
||||||
| syslog-parser.js | 解析SYSLOG格式,提取关键字段 |
|
| package.json | 依赖声明 |
|
||||||
| broadcast-generator.js | 调用模型API生成新广播 |
|
| ecosystem.config.js | pm2 进程配置 |
|
||||||
| dingtalk-api.js | 发送钉钉消息 + 更新多维表格 |
|
| .env.example | 环境变量模板(不含真实值) |
|
||||||
| config.json | 配置文件(端口、API密钥、模板等) |
|
| .gitignore | Git 忽略规则 |
|
||||||
| README.md | 本说明文档 |
|
| README.md | 本说明文档 |
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
```bash
|
```bash
|
||||||
npm install
|
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`。
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps : [{
|
apps : [{
|
||||||
name: 'dingtalk-bot',
|
name: 'dingtalk-stream',
|
||||||
script: 'index.js',
|
script: 'index-stream.js',
|
||||||
cwd: '/opt/guanghulab-dingtalk/dingtalk-bot',
|
cwd: '/opt/guanghulab-dingtalk/dingtalk-bot',
|
||||||
node_args: '--preserve-symlinks',
|
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production'
|
||||||
PORT: 3005
|
|
||||||
},
|
},
|
||||||
env_production: {
|
env_production: {
|
||||||
NODE_ENV: 'production'
|
NODE_ENV: 'production'
|
||||||
|
|
|
||||||
|
|
@ -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();
|
require('dotenv').config();
|
||||||
const { DWClient, TOPIC_ROBOT, EventAck } = require('dingtalk-stream');
|
const { DWClient, TOPIC_ROBOT } = require('dingtalk-stream');
|
||||||
const { handleMessage } = require('./message-router');
|
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({
|
const client = new DWClient({
|
||||||
clientId: process.env.DINGTALK_APP_KEY,
|
clientId: DINGTALK_APP_KEY,
|
||||||
clientSecret: process.env.DINGTALK_APP_SECRET,
|
clientSecret: DINGTALK_APP_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 注册消息回调 =====
|
||||||
client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
|
client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
|
||||||
console.log("[DEBUG] raw:", JSON.stringify(res).substring(0,300));
|
console.log('[Stream] 收到消息,原始数据:', JSON.stringify(res).substring(0, 300));
|
||||||
const { messageType, text, senderNick, conversationId, sessionWebhook } = res.data;
|
|
||||||
console.log(`[Stream] 收到消息 from ${senderNick}: ${text?.content}`);
|
|
||||||
|
|
||||||
|
// 解析消息数据(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 {
|
try {
|
||||||
const replyText = await handleMessage({
|
replyText = await callLLM(content || '你好');
|
||||||
msgtype: messageType,
|
console.log('[LLM] 回复生成成功,长度: ' + replyText.length);
|
||||||
text: { content: text?.content || '' },
|
|
||||||
senderNick,
|
|
||||||
conversationId,
|
|
||||||
sessionWebhook,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (replyText && sessionWebhook) {
|
|
||||||
const axios = require('axios');
|
|
||||||
await axios.post(sessionWebhook, {
|
|
||||||
msgtype: 'text',
|
|
||||||
text: { content: replyText },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} 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(() => {
|
client.connect().then(() => {
|
||||||
console.log('🚀 M-DINGTALK Phase 8 Stream 模式已启动');
|
console.log('🚀 秋秋(钉钉AI人格体)Stream 模式已启动');
|
||||||
|
console.log('📡 等待消息中... 在钉钉群 @秋秋 发消息试试');
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('[Stream] 启动失败:', err);
|
console.error('[Stream] 启动失败:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "dingtalk-bot",
|
"name": "dingtalk-bot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dingtalk-bot",
|
"name": "dingtalk-bot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@notionhq/client": "^5.13.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"body-parser": "^2.2.2",
|
"body-parser": "^2.2.2",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
|
|
@ -18,6 +19,15 @@
|
||||||
"express": "^5.2.1"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "dingtalk-bot",
|
"name": "dingtalk-bot",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "- 负责人:之之 - 状态:环节0已完成 - 技术栈:Node.js + Express - 依赖模块:无",
|
"description": "钉钉AI人格体「秋秋」· Stream模式 · HoloLake",
|
||||||
"main": "broadcast-generator.js",
|
"main": "index-stream.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"start": "node index-stream.js",
|
||||||
"start": "node server.js"
|
"start:webhook": "node server.js",
|
||||||
|
"check": "node --check index-stream.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@notionhq/client": "^5.13.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"body-parser": "^2.2.2",
|
"body-parser": "^2.2.2",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue