397 lines
17 KiB
JavaScript
397 lines
17 KiB
JavaScript
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;
|
||
}
|
||
|
||
/** 统一 uid 为可比较的字符串(支持 number 或 { low, high }) */
|
||
_normalizeUid(uid) {
|
||
if (uid == null) return null;
|
||
if (typeof uid === 'number' || typeof uid === 'string') return String(uid);
|
||
if (typeof uid === 'object' && typeof uid.low === 'number') return String(uid.low);
|
||
return null;
|
||
}
|
||
|
||
/** 过滤出 HR 发的、非系统、可回复的消息列表(已排除自己发的) */
|
||
_filterHrReplyableMessages(messages, geek_uid) {
|
||
if (!geek_uid || !Array.isArray(messages)) return [];
|
||
const geekStr = this._normalizeUid(geek_uid);
|
||
if (!geekStr) return [];
|
||
const list = messages.filter(msg => {
|
||
if (!msg.from) return false;
|
||
const fromStr = this._normalizeUid(msg.from.uid);
|
||
if (fromStr === geekStr) return false; // 自己发的,排除
|
||
if (this._isSystemMessage(msg)) return false;
|
||
return true;
|
||
});
|
||
return list;
|
||
}
|
||
|
||
/** AI 回复后写入 chat_reply_intent_log,options 含 sn_code/platform/friendId/encryptFriendId,securityId 为 HR 消息唯一 id */
|
||
_saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, replied, reason, securityId) {
|
||
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 || '',
|
||
security_id: securityId || null,
|
||
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可回复消息(已过滤系统与己方)', null);
|
||
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', null);
|
||
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 security_id = last.securityId || last.security_id || '';
|
||
|
||
if (security_id && options) {
|
||
try {
|
||
const logModel = db.getModel('chat_reply_intent_log');
|
||
const existing = await logModel.findOne({ where: { security_id } });
|
||
if (existing) {
|
||
// 已回复过的 HR 消息:不再重复发,避免每次扫描都发一条
|
||
if (existing.replied) {
|
||
return { replied: false, reason: '该条HR消息已回复过,跳过' };
|
||
}
|
||
// 之前记录为不回复:直接沿用,不再调 AI
|
||
return {
|
||
replied: false,
|
||
reason: existing.reason || '已记录为不回复'
|
||
};
|
||
}
|
||
} catch (e) {
|
||
console.warn('[聊天管理] 查询 chat_reply_intent_log 失败:', e.message);
|
||
}
|
||
}
|
||
|
||
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表示暂不匹配/无需回复', security_id || null);
|
||
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 未生成有效回复文案', security_id || null);
|
||
return { replied: false, reason: 'AI 未生成有效回复文案' };
|
||
}
|
||
|
||
this._saveReplyIntentLog(options, hr_message_text, jobInfo, action, reply_content, true, null, security_id || 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();
|