|
|
@@ -1,11 +1,14 @@
|
|
|
const fs = require('fs');
|
|
|
const path = require('path');
|
|
|
+const os = require('os');
|
|
|
const crypto = require('crypto');
|
|
|
+const { spawn } = require('child_process');
|
|
|
+const WxVoice = require('@binsee/wx-voice');
|
|
|
const { PACKAGE_ROOT, WORKSPACE_ROOT, latestPath, categoryDir, outputsRoot } = require('../core/output-paths');
|
|
|
const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
|
|
|
const { AgentKnowledgeStore } = require('../core/agent-knowledge');
|
|
|
const { QiweiAgentRuntime, extractExplicitCustomerIntelligence } = require('../core/agent-runtime');
|
|
|
-const { getCustomerSessionGuide } = require('../core/agent-session-guide');
|
|
|
+const { getCustomerSessionGuide, safeCustomerArgument, sessionCommandPrefix } = require('../core/agent-session-guide');
|
|
|
const { AgentWorkbenchService } = require('../core/agent-workbench-service');
|
|
|
const { friendlyAgentError } = require('../core/agent-error-message');
|
|
|
const { VoiceCloneService } = require('../core/voice-clone-service');
|
|
|
@@ -17,12 +20,22 @@ const {
|
|
|
const { createCustomerTaskOfficialSync } = require('../core/customer-task-official-sync');
|
|
|
const { messageTimestamp, roomIdOf, isGroupMessage, messageContent, evaluatePolledMessage, normalizePersonalIntakeMode } = require('../core/agent-poller-policy');
|
|
|
const { sortConversationsByRecency, maxConversationTimestamp } = require('../core/conversation-order');
|
|
|
-const { saveQiweiClientConfig, setActiveQiweiContext, readFmodeVoiceToken } = require('../core/credentials');
|
|
|
+const {
|
|
|
+ saveQiweiClientConfig,
|
|
|
+ setActiveQiweiContext,
|
|
|
+ readFmodeVoiceToken,
|
|
|
+ readQiweiAccountMetadata,
|
|
|
+ qiweiAccountKey,
|
|
|
+} = require('../core/credentials');
|
|
|
const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
|
|
|
const { responseMonitor } = require('./response-monitor-service');
|
|
|
+const { GroupAgentService } = require('./group-agent-service');
|
|
|
const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../core/allowlist-config');
|
|
|
const { getProductMode } = require('../core/product-mode');
|
|
|
const { getRelayDaemonStatus } = require('../core/relay-daemon');
|
|
|
+const { normalizeMessage } = require('../core/message-normalizer');
|
|
|
+const { sanitizePayload } = require('../providers/fmode-wecom-gateway');
|
|
|
+const { appendChatRecord, ensureMemory } = require('../core/message-archive');
|
|
|
|
|
|
const PROJECT_ROOT = WORKSPACE_ROOT;
|
|
|
const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
|
|
|
@@ -276,8 +289,7 @@ function loadAgentConfig(overrides = {}) {
|
|
|
}
|
|
|
|
|
|
function accountRuntimeKey(input = {}) {
|
|
|
- const source = String(input.uid || input.guid || input.userId || 'default').trim();
|
|
|
- return crypto.createHash('sha256').update(source || 'default').digest('hex').slice(0, 16);
|
|
|
+ return qiweiAccountKey(input);
|
|
|
}
|
|
|
|
|
|
function accountWorkbenchOverrides(input = {}) {
|
|
|
@@ -351,6 +363,31 @@ function backfillCustomerMemory(service) {
|
|
|
return { version, conversations, captured };
|
|
|
}
|
|
|
|
|
|
+function backfillMessageNormalization(db) {
|
|
|
+ const version = '2';
|
|
|
+ if (db.getSetting('message_normalization_backfill_version', '') === version) return { skipped: true, updated: 0 };
|
|
|
+ const rows = db.db.prepare(`SELECT id, content, content_type, raw_json
|
|
|
+ FROM messages WHERE direction='inbound' AND raw_json IS NOT NULL AND raw_json<>''`).all();
|
|
|
+ const update = db.db.prepare('UPDATE messages SET content=?, content_type=?, raw_json=? WHERE id=?');
|
|
|
+ let updated = 0;
|
|
|
+ for (const row of rows) {
|
|
|
+ let raw;
|
|
|
+ try { raw = JSON.parse(row.raw_json || '{}'); } catch { continue; }
|
|
|
+ if (!raw.msgData || typeof raw.msgData !== 'object') continue;
|
|
|
+ const normalized = normalizeMessage(raw);
|
|
|
+ if (!normalized.type || normalized.type === 'unknown') continue;
|
|
|
+ const content = normalized.isText ? row.content : normalized.content;
|
|
|
+ const nextRaw = { ...raw, normalizedContentType: normalized.type, normalizedContentLabel: normalized.label };
|
|
|
+ if (row.content === content && row.content_type === normalized.type
|
|
|
+ && raw.normalizedContentType === normalized.type && raw.normalizedContentLabel === normalized.label) continue;
|
|
|
+ update.run(content, normalized.type, JSON.stringify(nextRaw), row.id);
|
|
|
+ updated += 1;
|
|
|
+ }
|
|
|
+ db.setSetting('message_normalization_backfill_version', version);
|
|
|
+ if (updated) db.audit({ actor: 'migration', action: 'message_normalization_backfilled', detail: { version, updated } });
|
|
|
+ return { version, updated };
|
|
|
+}
|
|
|
+
|
|
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
|
|
class QiweiAgentPoller {
|
|
|
@@ -539,6 +576,8 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
|
|
|
if (contactId && OUTBOUND_CONTENT_MSG_TYPES.has(Number(message.msgType)) && messageContent(message)) {
|
|
|
return recordOutboundMessage(target, message, source, contactId);
|
|
|
}
|
|
|
+ const normalized = normalizeMessage(message);
|
|
|
+ if (!normalized.isText) return archiveInboundMessage(target, message, source, normalized);
|
|
|
const intakePolicy = target.db.intakePolicy();
|
|
|
const candidate = evaluatePolledMessage(message, { ...target.config, intakeMode: intakePolicy.mode });
|
|
|
if (!candidate.eligible) return { status: `ignored_${candidate.reason || 'ineligible'}` };
|
|
|
@@ -592,6 +631,37 @@ async function ingestMessageForWorkbench(target, message = {}, source = 'callbac
|
|
|
}, { onboarding, allowAutoSend });
|
|
|
}
|
|
|
|
|
|
+function archiveInboundMessage(target, message, source, normalized = normalizeMessage(message)) {
|
|
|
+ const senderId = String(message.senderId || '');
|
|
|
+ if (!senderId) return { status: 'ignored_empty_sender' };
|
|
|
+ if (!(target.config.allowedSenders || []).map(String).includes(senderId)) return { status: 'ignored_not_allowlisted' };
|
|
|
+ const timestamp = messageTimestamp(message.timestamp);
|
|
|
+ if (!timestamp) return { status: 'ignored_invalid_timestamp' };
|
|
|
+ const conversation = target.db.ensureConversation(senderId, message.senderName || '企微客户');
|
|
|
+ ensureMemory(senderId, message.senderName || '企微客户');
|
|
|
+ target.service?.preCreateSession?.(senderId, message.senderName || '企微客户');
|
|
|
+ const externalId = String(message.msgServerId || message.msgUniqueIdentifier || `${senderId}:${message.seq}`);
|
|
|
+ const createdAt = new Date(timestamp * 1000).toISOString();
|
|
|
+ const inserted = target.db.insertMessage({
|
|
|
+ conversationId: conversation.id,
|
|
|
+ externalId,
|
|
|
+ direction: 'inbound',
|
|
|
+ senderType: 'customer',
|
|
|
+ content: normalized.content,
|
|
|
+ contentType: normalized.type,
|
|
|
+ status: 'received',
|
|
|
+ createdAt,
|
|
|
+ raw: { ...message, source, fromRoomId: message.fromRoomId || null, normalizedContentType: normalized.type, normalizedContentLabel: normalized.label },
|
|
|
+ });
|
|
|
+ if (inserted.created) {
|
|
|
+ appendChatRecord({ wxid: senderId, messageId: inserted.message.id, externalId, dir: 'in', senderType: 'customer', content: normalized.content, createdAt, source });
|
|
|
+ target.db.audit({ actor: 'runtime', action: 'inbound_non_text_archived', conversationId: conversation.id, entityId: inserted.message.id, detail: { source, contentType: normalized.type, seq: Number(message.seq) || 0 } });
|
|
|
+ target.service?.emit?.('change', { type: 'message', conversationId: conversation.id });
|
|
|
+ if (target.db === workbench.db) primeMessageMediaCache(inserted.message.id, normalized.type);
|
|
|
+ }
|
|
|
+ return { status: inserted.created ? 'inbound_archived' : 'inbound_duplicate', message: inserted.message };
|
|
|
+}
|
|
|
+
|
|
|
function createWorkbench(overrides = {}) {
|
|
|
const config = loadAgentConfig(overrides.config || {});
|
|
|
const db = overrides.db || new AgentWorkbenchDb(config.dbPath, {
|
|
|
@@ -619,6 +689,7 @@ function createWorkbench(overrides = {}) {
|
|
|
if (removed) db.audit({ actor: 'migration', action: 'duplicate_messages_cleaned', detail: { removed } });
|
|
|
backfillCustomerIntelligence(db);
|
|
|
}
|
|
|
+ if (!overrides.db) backfillMessageNormalization(db);
|
|
|
const knowledge = overrides.knowledge || new AgentKnowledgeStore({
|
|
|
knowledgeDir: config.knowledgeDir,
|
|
|
contextFiles: config.contextFiles,
|
|
|
@@ -689,13 +760,14 @@ const workbenches = new Map();
|
|
|
|
|
|
function activeAccountMetadata() {
|
|
|
const context = workbench.qiwei.context();
|
|
|
+ const stored = readQiweiAccountMetadata();
|
|
|
return {
|
|
|
uid: String(context.uid || workbench.config.qiwei.uid || '').trim(),
|
|
|
guid: String(context.guid || workbench.config.qiwei.guid || '').trim(),
|
|
|
apiBase: String(context.apiBase || workbench.config.qiwei.apiBase || '').trim(),
|
|
|
- userId: String(workbench.config.qiwei.userId || '').trim(),
|
|
|
- nickname: String(workbench.config.qiwei.nickname || '').trim(),
|
|
|
- corpName: String(workbench.config.qiwei.corpName || '').trim(),
|
|
|
+ userId: String(workbench.config.qiwei.userId || stored.userId || '').trim(),
|
|
|
+ nickname: String(workbench.config.qiwei.nickname || stored.nickname || '').trim(),
|
|
|
+ corpName: String(workbench.config.qiwei.corpName || stored.corpName || '').trim(),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
@@ -719,6 +791,12 @@ function provisionalAccountStatus(selected = activeAccountMetadata(), statusText
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+const groupAgentService = new GroupAgentService({
|
|
|
+ projectRoot: PROJECT_ROOT,
|
|
|
+ getRuntime: () => workbench,
|
|
|
+ getAccount: () => activeAccountMetadata(),
|
|
|
+});
|
|
|
+
|
|
|
const initialAccount = activeAccountMetadata();
|
|
|
workbenches.set(accountRuntimeKey(initialAccount), workbench);
|
|
|
applyActiveAccountContext(initialAccount);
|
|
|
@@ -761,6 +839,9 @@ async function switchActiveAccount(input = {}) {
|
|
|
uid: accountPatch.uid,
|
|
|
guid: accountPatch.guid,
|
|
|
apiBase: accountPatch.apiBase,
|
|
|
+ userId: accountPatch.userId,
|
|
|
+ nickname: accountPatch.nickname,
|
|
|
+ corpName: accountPatch.corpName,
|
|
|
envRoot: PROJECT_ROOT,
|
|
|
});
|
|
|
const status = provisionalAccountStatus();
|
|
|
@@ -827,11 +908,12 @@ async function refreshAccountStatus() {
|
|
|
}
|
|
|
}
|
|
|
if (accountRuntimeKey(activeAccountMetadata()) === refreshKey) {
|
|
|
- accountStatusCache = { checkedAt: Date.now(), value: next };
|
|
|
+ accountStatusCache = { checkedAt: Date.now(), value: { ...next, runtimeKey: refreshKey } };
|
|
|
}
|
|
|
if (next.userId) {
|
|
|
- targetWorkbench.config.qiwei.userId = String(next.userId);
|
|
|
targetWorkbench.config.qiwei.selfUserId = String(next.userId);
|
|
|
+ // Do not change storage scope while the current workbench is open.
|
|
|
+ if (selected.userId) targetWorkbench.config.qiwei.userId = String(next.userId);
|
|
|
}
|
|
|
return next;
|
|
|
})().finally(() => {
|
|
|
@@ -846,7 +928,8 @@ async function refreshAccountStatus() {
|
|
|
async function detectAccountStatus(force = false) {
|
|
|
const selected = activeAccountMetadata();
|
|
|
const selectedKey = accountRuntimeKey(selected);
|
|
|
- const cacheMatches = accountStatusCache.value && accountRuntimeKey(accountStatusCache.value) === selectedKey;
|
|
|
+ const cacheMatches = accountStatusCache.value
|
|
|
+ && String(accountStatusCache.value.runtimeKey || accountRuntimeKey(accountStatusCache.value)) === selectedKey;
|
|
|
if (force) return refreshAccountStatus();
|
|
|
if (cacheMatches) {
|
|
|
if (Date.now() - accountStatusCache.checkedAt >= 8000) void refreshAccountStatus();
|
|
|
@@ -951,11 +1034,14 @@ function publicConversation(row) {
|
|
|
return {
|
|
|
id: message.id,
|
|
|
role: message.direction === 'inbound' ? 'customer' : message.sender_type,
|
|
|
- content: message.content,
|
|
|
+ content: sanitizePayload(message.content),
|
|
|
timestamp: message.created_at,
|
|
|
status: message.status,
|
|
|
source: message.direction === 'inbound' ? 'live' : message.sender_type,
|
|
|
- raw,
|
|
|
+ contentType: message.content_type,
|
|
|
+ contentTypeLabel: raw.normalizedContentLabel || '',
|
|
|
+ payload: parseJson(message.payload_json, {}),
|
|
|
+ raw: sanitizePayload(raw),
|
|
|
};
|
|
|
}),
|
|
|
analysis: {
|
|
|
@@ -1178,6 +1264,7 @@ function updateAllowlistForWorkbench(target, input = {}) {
|
|
|
const addedIds = ids.filter(id => !previousIds.has(id));
|
|
|
for (const contactId of addedIds) {
|
|
|
target.db.ensureConversation(contactId, String(contactNames[contactId] || ''));
|
|
|
+ target.service.preCreateSession(contactId, String(contactNames[contactId] || ''));
|
|
|
}
|
|
|
if (ids.length && input.autoStart !== false) {
|
|
|
target.db.setSetting('listener_enabled', 'true');
|
|
|
@@ -1350,6 +1437,57 @@ function getResponseMonitor() {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+function getGroupAgents() {
|
|
|
+ const groups = groupAgentService.list();
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: `??? ${groups.length} ???????????`,
|
|
|
+ summary: {
|
|
|
+ groupCount: groups.length,
|
|
|
+ pendingCount: groups.filter(item => item.pendingReply).length,
|
|
|
+ errorCount: groups.filter(item => item.agentError || item.sendError).length,
|
|
|
+ autoCount: groups.filter(item => item.mode === 'auto').length,
|
|
|
+ },
|
|
|
+ data: { groups },
|
|
|
+ warnings: [],
|
|
|
+ errors: [],
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+async function generateGroupReply(roomId, options = {}) {
|
|
|
+ try {
|
|
|
+ const result = await groupAgentService.generate(roomId, options);
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ assistantMessage: result.status === 'no_reply_needed'
|
|
|
+ ? 'Agent ???????????'
|
|
|
+ : result.status === 'auto_sent' ? '?? Agent ???????' : '?? Agent ????????',
|
|
|
+ data: result,
|
|
|
+ };
|
|
|
+ } catch {
|
|
|
+ throw new Error('?? Agent ??????????????????????');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function changeGroupMode(roomId, mode, confirmation) {
|
|
|
+ const group = groupAgentService.setMode(roomId, mode, confirmation);
|
|
|
+ return { status: 'ok', assistantMessage: group.mode === 'auto' ? '??????????' : '???????????', data: { group } };
|
|
|
+}
|
|
|
+
|
|
|
+async function approveGroupDraft(roomId, draftId, content) {
|
|
|
+ const result = await groupAgentService.approve(roomId, draftId, content);
|
|
|
+ return { status: 'ok', assistantMessage: '?????????', data: result };
|
|
|
+}
|
|
|
+
|
|
|
+function rejectGroupDraft(roomId, draftId, reason) {
|
|
|
+ return { status: 'ok', assistantMessage: '???????', data: groupAgentService.reject(roomId, draftId, reason) };
|
|
|
+}
|
|
|
+
|
|
|
+async function regenerateGroupDraft(roomId, draftId) {
|
|
|
+ const result = await groupAgentService.regenerate(roomId, draftId);
|
|
|
+ return { status: 'ok', assistantMessage: '?? Agent ??????????', data: result };
|
|
|
+}
|
|
|
+
|
|
|
function updateCustomerProfile(conversationId, input = {}) {
|
|
|
const conversation = workbench.db.getConversation(conversationId);
|
|
|
if (!conversation) throw new Error('客户会话不存在');
|
|
|
@@ -1439,8 +1577,9 @@ async function syncConversations(input = {}) {
|
|
|
const senderId = String(message.senderId || '');
|
|
|
const receiverId = String(message.receiverId || '');
|
|
|
const contactId = syncScope.contacts.has(senderId) ? senderId : syncScope.contacts.has(receiverId) ? receiverId : '';
|
|
|
- const content = String(message.msgData?.content || '').trim();
|
|
|
- if (!contactId || !content || ![0, 1, 2].includes(Number(message.msgType))) continue;
|
|
|
+ const normalized = normalizeMessage(message);
|
|
|
+ const content = String(normalized.content || '').trim();
|
|
|
+ if (!contactId || !content || normalized.isNotification) continue;
|
|
|
|
|
|
const inbound = senderId === contactId;
|
|
|
const timestampSeconds = messageTimestamp(message.timestamp);
|
|
|
@@ -1461,8 +1600,9 @@ async function syncConversations(input = {}) {
|
|
|
externalId: externalId || null,
|
|
|
inbound,
|
|
|
content,
|
|
|
+ contentType: normalized.type,
|
|
|
timestamp,
|
|
|
- raw: { seq: message.seq, msgType: message.msgType, timestamp: message.timestamp, source: 'manual_sync' },
|
|
|
+ raw: { ...message, source: 'manual_sync', normalizedContentType: normalized.type, normalizedContentLabel: normalized.label },
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -1487,6 +1627,7 @@ async function syncConversations(input = {}) {
|
|
|
direction: message.inbound ? 'inbound' : 'outbound',
|
|
|
senderType: message.inbound ? 'customer' : 'human',
|
|
|
content: message.content,
|
|
|
+ contentType: message.contentType,
|
|
|
status: message.inbound ? 'received' : 'sent',
|
|
|
createdAt: message.timestamp,
|
|
|
raw: message.raw,
|
|
|
@@ -1772,6 +1913,264 @@ function getSentVoiceAudio(messageId) {
|
|
|
return { filePath: raw.audioPath, duration: Number(raw.duration) || 0 };
|
|
|
}
|
|
|
|
|
|
+function messageMediaData(raw = {}) {
|
|
|
+ return raw.msgData && typeof raw.msgData === 'object' ? raw.msgData : raw;
|
|
|
+}
|
|
|
+
|
|
|
+function trustedHttpUrl(...values) {
|
|
|
+ for (const item of values) {
|
|
|
+ if (typeof item !== 'string' || !/^https?:\/\/[^\s]+$/i.test(item.trim())) continue;
|
|
|
+ try {
|
|
|
+ const url = new URL(item.trim());
|
|
|
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
|
+ const privateHost = host === 'localhost' || host === '::1' || host.endsWith('.local')
|
|
|
+ || /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host)
|
|
|
+ || /^172\.(?:1[6-9]|2\d|3[01])\./.test(host) || /^(?:fc|fd)[a-f0-9]{0,2}:/i.test(host);
|
|
|
+ if (!privateHost) return url.toString();
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+}
|
|
|
+
|
|
|
+function firstHttpUrlInText(...values) {
|
|
|
+ for (const value of values) {
|
|
|
+ if (typeof value !== 'string') continue;
|
|
|
+ const match = value.match(/https?:\/\/[^\s<>"']+/i);
|
|
|
+ if (!match) continue;
|
|
|
+ const candidate = match[0].replace(/[),.;!?\]},。;!?)】]+$/u, '');
|
|
|
+ if (trustedHttpUrl(candidate)) return candidate;
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+}
|
|
|
+
|
|
|
+async function refreshMessageMediaRaw(message) {
|
|
|
+ const current = parseJson(message?.raw_json, {});
|
|
|
+ const seq = Number(current.seq);
|
|
|
+ if (!message || !Number.isFinite(seq)) return current;
|
|
|
+ const result = await workbench.qiwei.syncMessages(Math.max(0, seq - 2), 20);
|
|
|
+ const list = Array.isArray(result.syncMsgList) ? result.syncMsgList : [];
|
|
|
+ const fresh = list.find(item => String(item.msgServerId || item.msgUniqueIdentifier || '') === String(message.external_id || '') || Number(item.seq) === seq);
|
|
|
+ if (!fresh) return current;
|
|
|
+ const merged = { ...current, ...fresh, source: current.source || 'media_refresh', normalizedContentType: current.normalizedContentType || message.content_type, normalizedContentLabel: current.normalizedContentLabel || '' };
|
|
|
+ workbench.db.updateMessageRaw(message.id, merged);
|
|
|
+ return merged;
|
|
|
+}
|
|
|
+
|
|
|
+function selectedMessageMedia(message, raw, variant = 'original') {
|
|
|
+ const data = messageMediaData(raw);
|
|
|
+ const type = String(message.content_type || raw.normalizedContentType || '').toLowerCase();
|
|
|
+ const preview = variant === 'preview' || variant === 'cover';
|
|
|
+ let url = '';
|
|
|
+ let fileType = 5;
|
|
|
+ let fileSize = Number(data.fileSize) || 0;
|
|
|
+ let fileId = '';
|
|
|
+ let fileAesKey = String(data.fileAesKey || '');
|
|
|
+ let filename = String(data.filename || data.fileName || '').trim();
|
|
|
+ if (type === 'image') {
|
|
|
+ url = preview ? trustedHttpUrl(data.fileMiddleHttpUrl, data.fileThumbHttpUrl, data.fileHttpUrl, data.fileBigHttpUrl) : trustedHttpUrl(data.fileBigHttpUrl, data.fileHttpUrl, data.fileMiddleHttpUrl, data.fileThumbHttpUrl);
|
|
|
+ fileType = preview ? 2 : (data.fileBigHttpUrl ? 1 : 2);
|
|
|
+ fileSize = Number(preview ? (data.fileMiddleSize || data.fileThumbSize) : (data.fileBigSize || data.fileSize)) || fileSize;
|
|
|
+ fileId = String(data.fileId || '');
|
|
|
+ filename ||= `image-${message.id}.jpg`;
|
|
|
+ } else if (type === 'emotion') {
|
|
|
+ url = trustedHttpUrl(data.fileHttpUrl);
|
|
|
+ filename ||= `emotion-${message.id}.gif`;
|
|
|
+ } else if (type === 'video') {
|
|
|
+ url = preview ? trustedHttpUrl(data.coverImageHttpUrl, data.fileThumbHttpUrl, data.thumbUrl) : trustedHttpUrl(data.fileHttpUrl, data.fileBigHttpUrl);
|
|
|
+ fileType = preview ? 3 : 4;
|
|
|
+ fileSize = Number(preview ? (data.coverImageSize || data.fileThumbSize) : data.fileSize) || fileSize;
|
|
|
+ fileAesKey = String((preview ? data.coverImageAesKey : data.fileAesKey) || fileAesKey);
|
|
|
+ fileId = String((preview ? (data.coverImageId || data.coverFileId) : data.fileId) || '');
|
|
|
+ filename ||= preview ? `video-cover-${message.id}.jpg` : `video-${message.id}.mp4`;
|
|
|
+ } else if (type === 'voice') {
|
|
|
+ fileId = String(data.fileId || '');
|
|
|
+ filename = `voice-${message.id}.silk`;
|
|
|
+ } else if (['file', 'qydiskfile'].includes(type)) {
|
|
|
+ url = trustedHttpUrl(data.fileHttpUrl, data.fileBigHttpUrl);
|
|
|
+ fileId = String(data.fileId || '');
|
|
|
+ filename ||= `file-${message.id}`;
|
|
|
+ } else if (type === 'weapp') {
|
|
|
+ url = trustedHttpUrl(data.appMediaUrl, data.coverUrl, data.iconUrl);
|
|
|
+ fileId = String(data.coverImageId || data.coverFileId || '');
|
|
|
+ fileAesKey = String(data.coverImageAesKey || data.coverFileAesKey || '');
|
|
|
+ fileSize = Number(data.coverImageSize || data.coverFileSize) || 0;
|
|
|
+ fileType = 3;
|
|
|
+ filename = `miniapp-cover-${message.id}.jpg`;
|
|
|
+ } else if (type === 'sphfeed') {
|
|
|
+ url = trustedHttpUrl(data.coverUrl, data.headImgUrl);
|
|
|
+ filename = `channel-cover-${message.id}.jpg`;
|
|
|
+ } else if (type === 'card') {
|
|
|
+ url = trustedHttpUrl(data.avatarUrl);
|
|
|
+ filename = `avatar-${message.id}.jpg`;
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ type, url, fileType, fileSize, fileId, fileAesKey,
|
|
|
+ fileAuthKey: String(data.fileAuthKey || ''),
|
|
|
+ fileMd5: String((preview ? (data.fileMiddleMd5 || data.fileThumbMd5 || data.fileMd5) : data.fileMd5) || data.coverImageMd5 || ''),
|
|
|
+ filename: path.basename(filename).replace(/[\x00-\x1f<>:"/\\|?*]/g, '_').slice(0, 180) || `media-${message.id}`,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function mediaCacheVariants(type) {
|
|
|
+ if (['image', 'video'].includes(type)) return ['preview', 'original'];
|
|
|
+ if (['weapp', 'sphfeed', 'card'].includes(type)) return ['cover'];
|
|
|
+ if (['emotion', 'file', 'qydiskfile', 'voice'].includes(type)) return ['original'];
|
|
|
+ return [];
|
|
|
+}
|
|
|
+
|
|
|
+function primeMessageMediaCache(messageId, type) {
|
|
|
+ for (const variant of mediaCacheVariants(String(type || '').toLowerCase())) {
|
|
|
+ setTimeout(() => resolveMessageMedia(messageId, variant).catch(error => {
|
|
|
+ workbench.db.audit({ actor: 'runtime', action: 'message_media_cache_failed', entityId: messageId, detail: { variant, message: error.message } });
|
|
|
+ }), 0);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function messageMediaAesKey(value) {
|
|
|
+ const text = String(value || '').trim();
|
|
|
+ if (/^[a-f0-9]{32}$/i.test(text)) return Buffer.from(text, 'hex');
|
|
|
+ try {
|
|
|
+ const decoded = Buffer.from(text, 'base64');
|
|
|
+ const decodedText = decoded.toString('ascii');
|
|
|
+ if (/^[a-f0-9]{32}$/i.test(decodedText)) return Buffer.from(decodedText, 'hex');
|
|
|
+ if (decoded.length === 16) return decoded;
|
|
|
+ } catch {}
|
|
|
+ const direct = Buffer.from(text, 'utf8');
|
|
|
+ return direct.length === 16 ? direct : null;
|
|
|
+}
|
|
|
+
|
|
|
+function decryptMessageMedia(buffer, media) {
|
|
|
+ const key = messageMediaAesKey(media.fileAesKey);
|
|
|
+ if (!key || !buffer.length) return buffer;
|
|
|
+ if (media.fileMd5 && crypto.createHash('md5').update(buffer).digest('hex').toLowerCase() === media.fileMd5.toLowerCase()) return buffer;
|
|
|
+ try {
|
|
|
+ const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
|
+ const decrypted = Buffer.concat([decipher.update(buffer), decipher.final()]);
|
|
|
+ if (!media.fileMd5 || crypto.createHash('md5').update(decrypted).digest('hex').toLowerCase() === media.fileMd5.toLowerCase()) return decrypted;
|
|
|
+ } catch {}
|
|
|
+ return buffer;
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchMessageMediaUrl(url, media, attempts = 1) {
|
|
|
+ let lastStatus = 0;
|
|
|
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
|
+ if (attempt > 1) await delay(350 * attempt);
|
|
|
+ const response = await fetch(url, { signal: AbortSignal.timeout(60000) });
|
|
|
+ lastStatus = response.status;
|
|
|
+ if (!response.ok) continue;
|
|
|
+ const declaredSize = Number(response.headers.get('content-length')) || 0;
|
|
|
+ if (declaredSize > 120 * 1024 * 1024) throw new Error('媒体文件超过 120MB,暂不支持在线打开');
|
|
|
+ const encrypted = Buffer.from(await response.arrayBuffer());
|
|
|
+ if (encrypted.length > 120 * 1024 * 1024) throw new Error('媒体文件超过 120MB,暂不支持在线打开');
|
|
|
+ return { buffer: decryptMessageMedia(encrypted, media), contentType: String(response.headers.get('content-type') || '').split(';')[0] || 'application/octet-stream' };
|
|
|
+ }
|
|
|
+ const error = new Error(`媒体文件获取失败(HTTP ${lastStatus || 502})`);
|
|
|
+ error.httpStatus = lastStatus;
|
|
|
+ throw error;
|
|
|
+}
|
|
|
+
|
|
|
+function messageMediaContentType(declared, filename, buffer) {
|
|
|
+ const type = String(declared || '').toLowerCase();
|
|
|
+ if (type && !['application/octet-stream', 'binary/octet-stream'].includes(type)) return type;
|
|
|
+ const head = buffer.subarray(0, 12);
|
|
|
+ if (head[0] === 0xff && head[1] === 0xd8) return 'image/jpeg';
|
|
|
+ if (head.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) return 'image/png';
|
|
|
+ if (head.subarray(0, 6).toString('ascii').startsWith('GIF8')) return 'image/gif';
|
|
|
+ if (head.subarray(0, 4).toString('ascii') === 'RIFF' && head.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
|
|
|
+ if (head.subarray(4, 8).toString('ascii') === 'ftyp') return 'video/mp4';
|
|
|
+ return ({ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.silk': 'audio/silk', '.sil': 'audio/silk', '.amr': 'audio/amr', '.mp3': 'audio/mpeg', '.wav': 'audio/wav' })[path.extname(filename).toLowerCase()] || 'application/octet-stream';
|
|
|
+}
|
|
|
+
|
|
|
+function mediaCachePaths(messageId, variant, filename) {
|
|
|
+ const root = path.join(categoryDir('dashboard'), qiweiAccountKey(workbench.config.qiwei), 'message-media');
|
|
|
+ fs.mkdirSync(root, { recursive: true });
|
|
|
+ const extension = path.extname(filename).slice(0, 12) || '.bin';
|
|
|
+ const key = crypto.createHash('sha256').update(`${messageId}:${variant}`).digest('hex').slice(0, 24);
|
|
|
+ return { filePath: path.join(root, `${key}${extension}`), metaPath: path.join(root, `${key}.json`) };
|
|
|
+}
|
|
|
+
|
|
|
+function cachedMessageMedia(messageId, variant, filename) {
|
|
|
+ const paths = mediaCachePaths(messageId, variant, filename);
|
|
|
+ if (!fs.existsSync(paths.filePath) || !fs.existsSync(paths.metaPath)) return null;
|
|
|
+ try { return { ...JSON.parse(fs.readFileSync(paths.metaPath, 'utf8')), filePath: paths.filePath }; } catch { return null; }
|
|
|
+}
|
|
|
+
|
|
|
+async function resolveMessageMedia(messageId, variant = 'original') {
|
|
|
+ const message = workbench.db.getMessage(String(messageId || ''));
|
|
|
+ if (!message || message.direction !== 'inbound') throw new Error('消息不存在或不支持媒体访问');
|
|
|
+ let raw = parseJson(message.raw_json, {});
|
|
|
+ let media = selectedMessageMedia(message, raw, variant);
|
|
|
+ const existing = cachedMessageMedia(message.id, variant, media.filename);
|
|
|
+ if (existing) return existing;
|
|
|
+ if (!media.url && !media.fileId) {
|
|
|
+ raw = await refreshMessageMediaRaw(message);
|
|
|
+ media = selectedMessageMedia(message, raw, variant);
|
|
|
+ }
|
|
|
+ let fetched = null;
|
|
|
+ const failures = [];
|
|
|
+ if (media.url) try { fetched = await fetchMessageMediaUrl(media.url, media); } catch (error) { failures.push(`direct:${error.message}`); }
|
|
|
+ if (!fetched && media.fileAesKey && media.fileAuthKey && media.url) {
|
|
|
+ try {
|
|
|
+ const result = await workbench.qiwei.downloadMessageMedia({ fileAeskey: media.fileAesKey, fileAuthkey: media.fileAuthKey, fileSize: media.fileSize, fileType: media.fileType, fileUrl: media.url });
|
|
|
+ const url = trustedHttpUrl(result.cloudUrl, result.fileUrl, result.url);
|
|
|
+ if (url) fetched = await fetchMessageMediaUrl(url, media);
|
|
|
+ } catch (error) { failures.push(`wx:${error.message}`); }
|
|
|
+ }
|
|
|
+ if (!fetched && media.fileAesKey && media.fileId && media.fileMd5) {
|
|
|
+ try {
|
|
|
+ const result = await workbench.qiwei.convertCdnMessageMedia({ fileAeskey: media.fileAesKey, fileId: media.fileId, fileMd5: media.fileMd5 });
|
|
|
+ const url = trustedHttpUrl(result.fileUrl, result.coverUrl, result.cloudUrl, result.url);
|
|
|
+ if (url) fetched = await fetchMessageMediaUrl(url, media);
|
|
|
+ } catch (error) { failures.push(`cdn:${error.message}`); }
|
|
|
+ }
|
|
|
+ if (!fetched && media.fileAesKey && media.fileId) {
|
|
|
+ try {
|
|
|
+ const result = await workbench.qiwei.downloadWorkMessageMedia({ fileAeskey: media.fileAesKey, fileId: media.fileId, fileSize: media.fileSize, fileType: media.fileType });
|
|
|
+ const url = trustedHttpUrl(result.cloudUrl, result.fileUrl, result.url);
|
|
|
+ if (url) fetched = await fetchMessageMediaUrl(url, media, 3);
|
|
|
+ } catch (error) { failures.push(`work:${error.message}`); }
|
|
|
+ }
|
|
|
+ if (!fetched) throw new Error(media.url || media.fileId ? `媒体下载服务未返回可用文件${failures.length ? `(${failures.join(';')})` : ''}` : '该消息未提供可用的媒体文件');
|
|
|
+ const paths = mediaCachePaths(message.id, variant, media.filename);
|
|
|
+ const metadata = { filename: media.filename, contentType: messageMediaContentType(fetched.contentType, media.filename, fetched.buffer), size: fetched.buffer.length };
|
|
|
+ fs.writeFileSync(paths.filePath, fetched.buffer);
|
|
|
+ fs.writeFileSync(paths.metaPath, JSON.stringify(metadata, null, 2), 'utf8');
|
|
|
+ return { ...metadata, filePath: paths.filePath };
|
|
|
+}
|
|
|
+
|
|
|
+async function resolveMessageVoiceAudio(messageId) {
|
|
|
+ const message = workbench.db.getMessage(String(messageId || ''));
|
|
|
+ if (!message) throw new Error('语音消息不存在');
|
|
|
+ if (message.direction === 'outbound') return { ...getSentVoiceAudio(messageId), source: 'sent' };
|
|
|
+ if (String(message.content_type || '').toLowerCase() !== 'voice') throw new Error('该消息不是语音消息');
|
|
|
+ const media = await resolveMessageMedia(messageId, 'original');
|
|
|
+ if (!/audio\/silk/i.test(media.contentType || '') && !/\.(?:silk|sil)$/i.test(media.filePath || '')) return { ...media, source: 'inbound' };
|
|
|
+ const mp3Path = String(media.filePath).replace(/\.(?:silk|sil)$/i, '') + '.mp3';
|
|
|
+ if (!fs.existsSync(mp3Path)) {
|
|
|
+ const voice = new WxVoice(path.dirname(media.filePath));
|
|
|
+ let decodeError = null;
|
|
|
+ voice.on('error', error => { decodeError = error; });
|
|
|
+ try { await voice.decode(media.filePath, mp3Path, { format: 'mp3' }); }
|
|
|
+ catch (error) { throw new Error(`语音转码失败:${decodeError?.message || error.message}`); }
|
|
|
+ }
|
|
|
+ return { filePath: mp3Path, filename: `voice-${messageId}.mp3`, contentType: 'audio/mpeg', size: fs.statSync(mp3Path).size, source: 'inbound' };
|
|
|
+}
|
|
|
+
|
|
|
+async function getMessageExternalLink(messageId) {
|
|
|
+ const message = workbench.db.getMessage(String(messageId || ''));
|
|
|
+ if (!message || message.direction !== 'inbound') throw new Error('消息不存在或不支持打开');
|
|
|
+ let raw = parseJson(message.raw_json, {});
|
|
|
+ let data = messageMediaData(raw);
|
|
|
+ let url = trustedHttpUrl(data.channelUrl, data.linkUrl, data.url) || firstHttpUrlInText(data.content, ...(Array.isArray(data.moreDetail) ? data.moreDetail.map(item => item?.text) : []));
|
|
|
+ if (!url) {
|
|
|
+ raw = await refreshMessageMediaRaw(message);
|
|
|
+ data = messageMediaData(raw);
|
|
|
+ url = trustedHttpUrl(data.channelUrl, data.linkUrl, data.url) || firstHttpUrlInText(data.content, ...(Array.isArray(data.moreDetail) ? data.moreDetail.map(item => item?.text) : []));
|
|
|
+ }
|
|
|
+ if (!url) throw new Error('该消息未提供可打开的链接');
|
|
|
+ return url;
|
|
|
+}
|
|
|
+
|
|
|
async function updateCustomerTask(taskId, input = {}) {
|
|
|
if (!['open', 'in_progress', 'done', 'dismissed'].includes(String(input.status || ''))) throw new Error('不支持的客户待办状态');
|
|
|
const existing = workbench.db.getCustomerTask(taskId);
|
|
|
@@ -1930,6 +2329,122 @@ function getAgentRuntimeConfig() {
|
|
|
return { ...workbench.config.agent };
|
|
|
}
|
|
|
|
|
|
+function openCustomerSession(conversationId) {
|
|
|
+ const conversation = workbench.db.getConversation(conversationId);
|
|
|
+ if (!conversation) throw new Error('会话不存在');
|
|
|
+ const guide = getCustomerSessionGuide(conversation, { sessionFile: workbench.config.agent.claudeSessionFile });
|
|
|
+ if (!guide.ready) return { status: 'not_ready', assistantMessage: '客户 Session 尚未初始化,请等待会话初始化完成', data: { guide } };
|
|
|
+ const state = JSON.parse(fs.readFileSync(workbench.config.agent.claudeSessionFile, 'utf8'));
|
|
|
+ const session = state.sessions?.[conversationId];
|
|
|
+ if (!session?.id) throw new Error('Session 数据不完整');
|
|
|
+ const command = `${sessionCommandPrefix()} agent:session -- --customer "${safeCustomerArgument(conversation.contact_name || guide.customerName)}"`;
|
|
|
+ if (process.platform === 'win32') {
|
|
|
+ spawn('cmd', ['/c', 'start', 'cmd', '/k', `cd /d "${PROJECT_ROOT}" && ${command}`], { cwd: PROJECT_ROOT, detached: true, stdio: 'ignore', windowsHide: false }).unref();
|
|
|
+ } else if (process.platform === 'darwin') {
|
|
|
+ const script = `tell application "Terminal" to do script "cd \\"${PROJECT_ROOT}\\" && ${command}"`;
|
|
|
+ spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
|
|
|
+ } else {
|
|
|
+ spawn('x-terminal-emulator', ['-e', `cd "${PROJECT_ROOT}" && ${command}`], { detached: true, stdio: 'ignore' }).unref();
|
|
|
+ }
|
|
|
+ workbench.db.audit({ actor: 'human', action: 'session_opened_in_terminal', detail: { conversationId } });
|
|
|
+ return { status: 'ok', assistantMessage: `正在新终端窗口中打开 ${conversation.contact_name || '客户'} 的 Claude Code 审阅会话`, data: { customerName: conversation.contact_name, sessionId: session.id, command } };
|
|
|
+}
|
|
|
+
|
|
|
+function findSessionTranscript(sessionId) {
|
|
|
+ const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
|
|
|
+ if (!fs.existsSync(projectsRoot)) return null;
|
|
|
+ const target = `${sessionId}.jsonl`;
|
|
|
+ const queue = [projectsRoot];
|
|
|
+ while (queue.length) {
|
|
|
+ const current = queue.shift();
|
|
|
+ let entries;
|
|
|
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
|
|
+ for (const entry of entries) {
|
|
|
+ const fullPath = path.join(current, entry.name);
|
|
|
+ if (entry.isDirectory()) queue.push(fullPath);
|
|
|
+ else if (entry.name === target) return fullPath;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+function parseSessionTranscript(transcriptPath, maxRounds = 30) {
|
|
|
+ const lines = fs.readFileSync(transcriptPath, 'utf8').trim().split('\n').filter(Boolean).slice(-maxRounds * 8);
|
|
|
+ const steps = [];
|
|
|
+ let current = null;
|
|
|
+ const flush = () => {
|
|
|
+ if (current && (current.thinking || current.text || current.tools.length)) steps.push(current);
|
|
|
+ current = null;
|
|
|
+ };
|
|
|
+ for (const line of lines) {
|
|
|
+ let event;
|
|
|
+ try { event = JSON.parse(line); } catch { continue; }
|
|
|
+ if (event.type === 'queue-operation' && event.operation === 'enqueue') { flush(); continue; }
|
|
|
+ const message = event.message;
|
|
|
+ if (!message || !Array.isArray(message.content)) continue;
|
|
|
+ if (message.role === 'assistant') {
|
|
|
+ if (!current) current = { thinking: '', text: '', tools: [], timestamp: event.timestamp || null };
|
|
|
+ for (const item of message.content) {
|
|
|
+ if (item.type === 'thinking' && item.thinking) current.thinking += `${current.thinking ? '\n\n' : ''}${String(item.thinking)}`;
|
|
|
+ else if (item.type === 'tool_use') current.tools.push({ id: item.id || '', name: item.name || 'tool', input: item.input || {}, result: '' });
|
|
|
+ else if (item.type === 'text' && item.text) current.text += `${current.text ? '\n' : ''}${String(item.text)}`;
|
|
|
+ }
|
|
|
+ } else if (message.role === 'user') {
|
|
|
+ let sawText = false;
|
|
|
+ for (const item of message.content) {
|
|
|
+ if (item.type === 'tool_result') {
|
|
|
+ if (!current) current = { thinking: '', text: '', tools: [], timestamp: event.timestamp || null };
|
|
|
+ const resultText = typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? '');
|
|
|
+ const tool = current.tools.find(entry => entry.id === item.tool_use_id);
|
|
|
+ if (tool) tool.result = resultText;
|
|
|
+ else current.tools.push({ id: item.tool_use_id || '', name: 'tool_result', input: {}, result: resultText });
|
|
|
+ } else if (item.type === 'text' && String(item.text || '').trim()) sawText = true;
|
|
|
+ }
|
|
|
+ if (sawText) flush();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ flush();
|
|
|
+ return steps;
|
|
|
+}
|
|
|
+
|
|
|
+function getSessionTranscript(conversationId) {
|
|
|
+ const conversation = workbench.db.getConversation(conversationId);
|
|
|
+ if (!conversation) throw new Error('会话不存在');
|
|
|
+ let state;
|
|
|
+ try { state = JSON.parse(fs.readFileSync(workbench.config.agent.claudeSessionFile, 'utf8')); } catch { state = null; }
|
|
|
+ const session = state?.sessions?.[conversationId];
|
|
|
+ const detail = workbench.service.conversationDetail(conversationId);
|
|
|
+ const messages = (detail?.messages || []).map(message => ({
|
|
|
+ kind: message.direction === 'inbound' ? 'customer' : 'broker',
|
|
|
+ sender: message.direction === 'outbound' ? message.sender_type : null,
|
|
|
+ text: sanitizePayload(String(message.content || '')), time: message.created_at, id: message.id,
|
|
|
+ }));
|
|
|
+ const aiRounds = [];
|
|
|
+ let transcriptPath = null;
|
|
|
+ if (session?.id) {
|
|
|
+ transcriptPath = findSessionTranscript(session.id);
|
|
|
+ if (transcriptPath) aiRounds.push(...parseSessionTranscript(transcriptPath).map(step => ({
|
|
|
+ kind: 'ai',
|
|
|
+ thinking: sanitizePayload(step.thinking),
|
|
|
+ tools: sanitizePayload(step.tools),
|
|
|
+ text: sanitizePayload(step.text),
|
|
|
+ time: step.timestamp,
|
|
|
+ })));
|
|
|
+ }
|
|
|
+ const timeline = [...messages, ...aiRounds].sort((a, b) => String(a.time || '').localeCompare(String(b.time || ''))).slice(-200);
|
|
|
+ return { status: 'ok', data: { found: timeline.length > 0, hasAiWork: aiRounds.length > 0, sessionId: session?.id || '', displayName: conversation.contact_name || '', timeline } };
|
|
|
+}
|
|
|
+
|
|
|
+function closeAgentWorkbenches() {
|
|
|
+ const instances = new Set([...workbenches.values(), workbench]);
|
|
|
+ for (const instance of instances) {
|
|
|
+ instance.poller?.stop?.();
|
|
|
+ instance.service?.stopBackgroundWorkers?.();
|
|
|
+ instance.db?.close?.();
|
|
|
+ }
|
|
|
+ workbenches.clear();
|
|
|
+}
|
|
|
+
|
|
|
module.exports = {
|
|
|
switchActiveAccount,
|
|
|
getAgentStatus,
|
|
|
@@ -1941,6 +2456,12 @@ module.exports = {
|
|
|
retryOnboardingWelcome,
|
|
|
getConversations,
|
|
|
getResponseMonitor,
|
|
|
+ getGroupAgents,
|
|
|
+ changeGroupMode,
|
|
|
+ generateGroupReply,
|
|
|
+ approveGroupDraft,
|
|
|
+ rejectGroupDraft,
|
|
|
+ regenerateGroupDraft,
|
|
|
updateCustomerProfile,
|
|
|
syncConversations,
|
|
|
changeGlobalMode,
|
|
|
@@ -1956,6 +2477,9 @@ module.exports = {
|
|
|
revokeVoiceProfile,
|
|
|
sendClonedVoice,
|
|
|
getSentVoiceAudio,
|
|
|
+ resolveMessageVoiceAudio,
|
|
|
+ resolveMessageMedia,
|
|
|
+ getMessageExternalLink,
|
|
|
updateCustomerTask,
|
|
|
syncCustomerTaskToOfficialTodo,
|
|
|
updateCustomerAlert,
|
|
|
@@ -1966,7 +2490,10 @@ module.exports = {
|
|
|
ingestWebhookMessage,
|
|
|
startListener,
|
|
|
stopListener,
|
|
|
+ openCustomerSession,
|
|
|
+ getSessionTranscript,
|
|
|
getAgentRuntimeConfig,
|
|
|
+ closeAgentWorkbenches,
|
|
|
createWorkbench,
|
|
|
- __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
|
|
|
+ __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, refreshAllowedSendersFromEnv, hydrateAccountAllowlist, updateAllowlistForWorkbench, resolveConversationSyncScope, applyManualTakeover, requireAutopilotConfirmation, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, outboundContactId, recordOutboundMessage, syncOutboundNotification, ingestMessageForWorkbench, archiveInboundMessage, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillCustomerMemory, backfillMessageNormalization, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, startListenerForWorkbench, intakePolicyPayload, updateIntakePolicyForWorkbench },
|
|
|
};
|