Files
autoAiWorkSys/api/middleware/job/managers/chatManager.js
张成 a40219c7e4 1
2026-02-28 17:38:45 +08:00

367 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const ai_service = require('../../../services/ai_service');
const db = require('../../dbProxy');
/**
* 聊天管理模块
* 负责沟通列表、沟通详情、发送消息等与设备端的 MQTT 指令对接
*/
class ChatManager {
/**
* 解析沟通列表返回值,统一为 { friendList, foldText, ... }
* 只支持新的结构:
* response.data = { success, apiData: [ { response: { code, zpData:{...} } } ] }
* @private
*/
_parse_chat_list_response(response) {
const outerData = response && response.data;
if (!outerData || !Array.isArray(outerData.apiData) || outerData.apiData.length === 0) {
return { friendList: [], foldText: '', filterEncryptIdList: [], filterBossIdList: [] };
}
const firstApi = outerData.apiData[0] || {};
const innerResp = firstApi.response || firstApi.data || null;
const raw = innerResp && (innerResp.zpData != null ? innerResp.zpData : innerResp.data);
if (!raw) {
return { friendList: [], foldText: '', filterEncryptIdList: [], filterBossIdList: [] };
}
return {
friendList: Array.isArray(raw.friendList) ? raw.friendList : [],
foldText: raw.foldText || '',
filterEncryptIdList: Array.isArray(raw.filterEncryptIdList) ? raw.filterEncryptIdList : [],
filterBossIdList: Array.isArray(raw.filterBossIdList) ? raw.filterBossIdList : []
};
}
/**
* 获取聊天列表
* 返回值结构: { friendList, foldText, filterEncryptIdList, filterBossIdList }
* friendList 每项: friendId, encryptFriendId, name, updateTime, brandName, jobName, jobCity, positionName, bossTitle 等
* @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端
* @param {object} params - 参数
* @returns {Promise<object>} { friendList, foldText, filterEncryptIdList, filterBossIdList }
*/
async get_chat_list(sn_code, mqttClient, params = {}) {
const { platform = 'boss', pageCount = 3 } = params;
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的聊天列表`);
const response = await mqttClient.publishAndWait(sn_code, {
platform,
action: 'get_chat_list',
data: { pageCount }
});
// 只认新结构data.success === true
const ok = !!response && response.data && response.data.success === true;
if (!ok) {
console.error(`[聊天管理] 获取聊天列表失败:`, response);
throw new Error(response?.message || '获取聊天列表失败');
}
const parsed = this._parse_chat_list_response(response);
// 存储数据库
console.log(`[聊天管理] 成功获取聊天列表,共 ${parsed.friendList.length} 个联系人`);
return parsed;
}
/**
* 解析 get_chat_detail 设备端返回格式
* 格式: { type, code, message, data: { success, apiData: { response: { zpData } }, getBossData: { response: { zpData } } } }
* apiData.response.zpData = 消息列表 hasMore/messages/type/minMsgId
* getBossData.response.zpData = 会话 data+job
* @private
*/
_parse_chat_detail_response(response) {
if (!response) return { variant: 'unknown', data: null, job: null, hasMore: false, messages: [] };
const d = response.data;
const api_data = d && d.apiData;
const get_boss_data = d && d.getBossData;
const msg_zp = api_data && api_data.response && api_data.response.zpData;
const boss_zp = get_boss_data && get_boss_data.response && get_boss_data.response.zpData;
if (msg_zp && Array.isArray(msg_zp.messages)) {
return {
variant: 'messages',
hasMore: !!msg_zp.hasMore,
messages: msg_zp.messages,
type: msg_zp.type,
minMsgId: msg_zp.minMsgId,
data: (boss_zp && boss_zp.data) || null,
job: (boss_zp && boss_zp.job) || null
};
}
if (boss_zp && (boss_zp.data != null || boss_zp.job != null)) {
return {
variant: 'session',
data: boss_zp.data || null,
job: boss_zp.job || null,
hasMore: false,
messages: [],
type: null,
minMsgId: null
};
}
return { variant: 'unknown', data: null, job: null, hasMore: false, messages: [] };
}
/**
* 解析详情,统一返回 { variant, hasMore, minMsgId, messages, data, job }
* 入参可为设备端完整返回data.apiData/data.getBossData或已解析对象直接返回
*/
parseDetailResponse(apiResponse) {
if (apiResponse && (apiResponse.variant === 'messages' || apiResponse.variant === 'session' || apiResponse.variant === 'unknown')) {
return apiResponse;
}
return this._parse_chat_detail_response(apiResponse);
}
/**
* 获取沟通详情(会话信息或聊天消息列表)
* 返回值: { variant: 'session'|'messages', ... }
* - session: data(boss/会话信息), job(职位信息)
* - messages: hasMore, messages[], type, minMsgId
* @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端
* @param {object} params - 参数,如 friendId/encryptBossId/encryptJobId 等,由设备端约定
* @returns {Promise<object>}
*/
async get_chat_detail(sn_code, mqttClient, params = {}) {
const { platform = 'boss', ...rest } = params;
console.log(`[聊天管理] 开始获取设备 ${sn_code} 的沟通详情`);
const response = await mqttClient.publishAndWait(sn_code, {
platform,
action: 'get_chat_detail',
data: rest
});
const ok = response && (response.code === 200 || response.code === 0);
if (!ok) {
console.error(`[聊天管理] 获取沟通详情失败:`, response);
throw new Error(response?.message || '获取沟通详情失败');
}
const parsed = this._parse_chat_detail_response(response);
const logExtra = parsed.variant === 'session'
? '会话'
: parsed.variant === 'messages'
? `消息 ${parsed.messages.length}`
: '未知';
console.log(`[聊天管理] 成功获取沟通详情 (${logExtra})`);
return parsed;
}
/**
* 发送聊天消息(支持多条 + 文本/发简历/换电话/换微信)
* @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端
* @param {object} params - friendId(必填), messages(数组), chatType, use_real_type, platform
* @param {string} params.friendId - 好友ID用于打开该好友的聊天面板
* @param {Array} params.messages - 每项为 string 或 { type: 'text'|'send_resume'|'exchange_phone'|'exchange_wechat', content?: string }
* @param {boolean} params.use_real_type - 是否模拟真实打字,默认 false
* @returns {Promise<object>} 发送结果
*/
async send_chat_message(sn_code, mqttClient, params) {
const { friendId, messages, chatType, use_real_type = false, platform = 'boss' } = params || {};
if (!friendId) throw new Error('friendId 不能为空');
if (!Array.isArray(messages) || messages.length === 0) throw new Error('messages 必须是非空数组');
const normalized_messages = messages.map((item) => {
if (typeof item === 'string') return { type: 'text', content: item };
return { type: item.type || 'text', content: item.content || '' };
});
console.log(`[聊天管理] 设备 ${sn_code} 发送聊天消息friendId=${friendId},条数=${normalized_messages.length}`);
const response = await mqttClient.publishAndWait(sn_code, {
platform,
action: 'send_chat_message',
data: { friendId, messages: normalized_messages, chatType, use_real_type: !!use_real_type }
});
if (!response || (response.code !== 0 && response.code !== 200)) {
console.error(`[聊天管理] 聊天消息发送失败:`, response);
throw new Error(response?.message || '聊天消息发送失败');
}
console.log(`[聊天管理] 聊天消息发送成功`);
return response;
}
/** 是否为系统/模板消息竞争者PK、拒绝模板、系统卡片等不参与回复判断 */
_isSystemMessage(msg) {
const body = msg.body || {};
if (msg.bizType === 317 || msg.bizType === 21050003) return true;
if (msg.type === 4) return true;
if (body.type === 16) return true;
return false;
}
/** 过滤出 HR 发的、非系统、可回复的消息列表(已排除自己发的) */
_filterHrReplyableMessages(messages, geek_uid) {
if (!geek_uid || !Array.isArray(messages)) return [];
let list = messages.filter(msg => {
if (!msg.from || msg.from.uid === geek_uid) return false;
if (this._isSystemMessage(msg)) return false;
return true;
});
return list
}
/** AI 回复后写入 chat_reply_intent_logoptions 含 sn_code/platform/friendId/encryptFriendId 时落库 */
_saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, replied, reason) {
if (!options || options.sn_code == null) return;
try {
const model = db.getModel('chat_reply_intent_log');
model.create({
sn_code: options.sn_code || '',
platform: options.platform || 'boss',
friendId: options.friendId ?? null,
encrypt_friend_id: options.encryptFriendId || '',
hr_message_text: hr_message_text || null,
action: action || '',
reply_content: reply_content || null,
replied: !!replied,
reason: reason || null,
job_name: (jobInfo && (jobInfo.jobName || jobInfo.title)) || null,
create_time: new Date()
}).catch(e => console.warn('[聊天管理] 写入 chat_reply_intent_log 失败:', e.message));
} catch (e) {
console.warn('[聊天管理] 写入 chat_reply_intent_log 失败:', e.message);
}
}
/**
* 根据沟通详情判断是否需回复,并用 AI 判断意图(文字回复 / 发简历)及生成内容
* @param {object} detail - 沟通详情,含 variant、messages、job 等
* @param {object} options - 可选,落库用 { sn_code, platform, friendId, encryptFriendId }
* @returns {Promise<object>} { replied, action?, reply_content?, hr_message_text?, reason? }
*/
async getReplyContentFromDetail(detail, options) {
if (!detail || detail.variant !== 'messages' || !Array.isArray(detail.messages) || detail.messages.length === 0) {
return { replied: false, reason: '无可用消息' };
}
const messages = detail.messages;
let hr_uid = null;
let geek_uid = null;
for (const msg of messages) {
const body = msg.body || {};
const jobDesc = body.jobDesc || body.job_desc || null;
if (jobDesc) {
if (jobDesc.boss && jobDesc.boss.uid && !hr_uid) hr_uid = jobDesc.boss.uid;
if (jobDesc.geek && jobDesc.geek.uid && !geek_uid) geek_uid = jobDesc.geek.uid;
}
if (hr_uid && geek_uid) break;
}
const lastRaw = messages[messages.length - 1];
if (lastRaw && lastRaw.from && lastRaw.to) {
hr_uid = hr_uid || lastRaw.from.uid;
geek_uid = geek_uid || lastRaw.to.uid;
}
const jobInfo = detail.job || {};
const hrList = this._filterHrReplyableMessages(messages, geek_uid);
if (hrList.length === 0) {
this._saveReplyIntentLog(options, '', jobInfo, '', '', false, '无HR可回复消息已过滤系统与己方');
return { replied: false, reason: '无HR可回复消息已过滤系统与己方' };
}
const last = hrList[hrList.length - 1];
if (!last.from || last.from.uid !== hr_uid) {
this._saveReplyIntentLog(options, '', jobInfo, '', '', false, '最后一条可回复消息不是HR');
return { replied: false, reason: '最后一条可回复消息不是HR' };
}
const body = last.body || {};
const hr_message_text =
(typeof body.text === 'string' && body.text) ||
(typeof last.pushText === 'string' && last.pushText) ||
'';
const { action, reply_content } = await ai_service.replyIntentAndContent({
jobInfo,
hrMessage: hr_message_text,
previousMessages: hrList.slice(-5).map(m => (m.body && m.body.text) || m.pushText || '')
});
if (action === 'no_reply') {
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, false, 'HR表示暂不匹配/无需回复');
return { replied: false, reason: 'HR表示暂不匹配/无需回复' };
}
const needContent = action === 'text';
if (needContent && (!reply_content || !reply_content.trim())) {
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, false, 'AI 未生成有效回复文案');
return { replied: false, reason: 'AI 未生成有效回复文案' };
}
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, true, null);
return {
replied: true,
action: action || 'text',
reply_content: reply_content || '',
hr_message_text
};
}
/**
* 使用 AI 自动决定是否回复,并发送回复(内部会先获取详情,再调用 getReplyContentFromDetail再发送
* 单条指令场景用;任务 auto_chat 已改为下发 get_chat_list / get_chat_detail / send_chat_message 多条指令。
*
* @param {string} sn_code - 设备SN码
* @param {object} mqttClient - MQTT客户端
* @param {object} params - 包含 friendId + 获取详情所需参数
* @returns {Promise<object>} { replied, reply_content?, hr_message_text?, reason? }
*/
async auto_reply_with_ai(sn_code, mqttClient, params = {}) {
const { friendId, platform = 'boss', ...detailParams } = params;
if (!friendId) throw new Error('friendId 不能为空');
const parsed = await this.get_chat_detail(sn_code, mqttClient, { platform, ...detailParams });
const decision = await this.getReplyContentFromDetail(parsed, {
sn_code,
platform,
friendId,
encryptFriendId: detailParams.encryptFriendId || ''
});
if (!decision.replied) return decision;
const action = decision.action || 'text';
const content = decision.reply_content || '';
const actionMessages = {
send_resume: [{ type: 'send_resume', content }],
exchange_wechat: [{ type: 'exchange_wechat', content }],
exchange_phone: [{ type: 'exchange_phone', content }]
};
const messages = actionMessages[action] || [{ type: 'text', content }];
await this.send_chat_message(sn_code, mqttClient, {
friendId,
messages,
chatType: 'reply',
platform
});
return {
replied: true,
reply_content: decision.reply_content,
hr_message_text: decision.hr_message_text
};
}
}
module.exports = new ChatManager();