|
|
@@ -1,5 +1,28 @@
|
|
|
import Parse from '../../../../shared/db/parse-client.js';
|
|
|
import { ensureGroupChatForRoom } from './groups.service.js';
|
|
|
+import { getContactDetailsBatch, batchGetRoomDetails, getQiWeConfig } from './qiwe-api.service.js';
|
|
|
+import { resolveOwnerName } from './organization.service.js';
|
|
|
+
|
|
|
+const MSG_TYPE_LABELS: Record<number, string> = {
|
|
|
+ 0: '文本消息',
|
|
|
+ 2: '文本消息',
|
|
|
+ 13: '链接消息',
|
|
|
+ 14: '图片消息',
|
|
|
+ 15: '文件消息',
|
|
|
+ 16: '语音消息',
|
|
|
+ 31: '应用消息',
|
|
|
+ 2001: '已读通知',
|
|
|
+ 2063: '撤回消息',
|
|
|
+};
|
|
|
+
|
|
|
+/** 用户可见的聊天类消息(不含撤回/已读/群事件等系统消息) */
|
|
|
+const CHAT_MSG_TYPES = new Set([
|
|
|
+ 0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213,
|
|
|
+]);
|
|
|
+
|
|
|
+export function isChatMessageType(msgType: number): boolean {
|
|
|
+ return CHAT_MSG_TYPES.has(msgType);
|
|
|
+}
|
|
|
|
|
|
export interface MessagePersistInput {
|
|
|
msgUniqueIdentifier: string;
|
|
|
@@ -29,11 +52,23 @@ export function isLikelyWecomGroupRoomId(
|
|
|
return room.length >= 14 && /^\d+$/.test(room);
|
|
|
}
|
|
|
|
|
|
+function msgTypeLabel(msgType: number): string {
|
|
|
+ return MSG_TYPE_LABELS[msgType] ?? `消息类型 ${msgType}`;
|
|
|
+}
|
|
|
+
|
|
|
function extractContent(msgType: number, msgData: Record<string, unknown> = {}): string {
|
|
|
- const text = typeof msgData.content === 'string' ? msgData.content : '';
|
|
|
+ if (msgType === 2063 || msgData.revokeMsgUniqueIdentifier) {
|
|
|
+ return '撤回了一条消息';
|
|
|
+ }
|
|
|
+ const text = typeof msgData.content === 'string' ? msgData.content.trim() : '';
|
|
|
if (text) return text;
|
|
|
- if (typeof msgData.linkUrl === 'string') return msgData.linkUrl;
|
|
|
- if (typeof msgData.title === 'string') return msgData.title;
|
|
|
+ if (typeof msgData.linkUrl === 'string' && msgData.linkUrl) return msgData.linkUrl;
|
|
|
+ if (typeof msgData.title === 'string' && msgData.title) return msgData.title;
|
|
|
+ if (typeof msgData.desc === 'string' && msgData.desc) return msgData.desc;
|
|
|
+ if (msgType === 14) return '[图片]';
|
|
|
+ if (msgType === 16) return '[语音]';
|
|
|
+ if (msgType === 15 || msgType === 20) return '[文件]';
|
|
|
+ if (msgType === 22 || msgType === 23) return '[视频]';
|
|
|
const moreDetail = msgData.moreDetail;
|
|
|
if (Array.isArray(moreDetail)) {
|
|
|
const parts = moreDetail
|
|
|
@@ -41,13 +76,103 @@ function extractContent(msgType: number, msgData: Record<string, unknown> = {}):
|
|
|
.filter(Boolean);
|
|
|
if (parts.length > 0) return parts.join(' ');
|
|
|
}
|
|
|
- const keys = Object.keys(msgData);
|
|
|
- if (keys.length === 0) return '';
|
|
|
+ return '';
|
|
|
+}
|
|
|
+
|
|
|
+/** 将已入库的内容转为可读展示文本(兼容历史 JSON 脏数据) */
|
|
|
+export function formatDisplayContent(content: string, msgType: number): string {
|
|
|
+ if (msgType === 2063) return '撤回了一条消息';
|
|
|
+ const trimmed = (content || '').trim();
|
|
|
+ if (!trimmed) return `(${msgTypeLabel(msgType)})`;
|
|
|
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return trimmed;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
|
+ if (parsed.revokeMsgUniqueIdentifier) return '撤回了一条消息';
|
|
|
+ const extracted = extractContent(msgType, parsed);
|
|
|
+ if (extracted) return extracted;
|
|
|
+ return `(${msgTypeLabel(msgType)})`;
|
|
|
+ } catch {
|
|
|
+ return trimmed.length > 200 ? `${trimmed.slice(0, 200)}…` : trimmed;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function formatSenderDisplay(senderId: string, senderName: string): string {
|
|
|
+ if (senderName && senderName !== senderId) return senderName;
|
|
|
+ if (!senderId) return '未知';
|
|
|
+ return senderId;
|
|
|
+}
|
|
|
+
|
|
|
+async function loadRoomMemberNameMap(roomId: string, guid: string): Promise<Map<string, string>> {
|
|
|
+ const map = new Map<string, string>();
|
|
|
+
|
|
|
+ const dbQuery = new Parse.Query('GroupMember');
|
|
|
+ dbQuery.equalTo('roomId', roomId);
|
|
|
+ dbQuery.limit(500);
|
|
|
+ const dbMembers = await dbQuery.find({ useMasterKey: true });
|
|
|
+ for (const m of dbMembers) {
|
|
|
+ const uid = m.get('userId');
|
|
|
+ const nick = m.get('nickname');
|
|
|
+ if (uid && nick) map.set(String(uid), String(nick));
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
- return JSON.stringify(msgData);
|
|
|
+ const rooms = await batchGetRoomDetails(guid, [roomId]);
|
|
|
+ for (const room of rooms) {
|
|
|
+ for (const mem of room.members) {
|
|
|
+ if (mem.nickname) map.set(mem.userId, mem.nickname);
|
|
|
+ }
|
|
|
+ }
|
|
|
} catch {
|
|
|
- return '';
|
|
|
+ /* 群详情不可用时跳过 */
|
|
|
}
|
|
|
+
|
|
|
+ return map;
|
|
|
+}
|
|
|
+
|
|
|
+async function enrichMessagesForDisplay(
|
|
|
+ messages: MessageDto[],
|
|
|
+ roomId: string,
|
|
|
+): Promise<MessageDto[]> {
|
|
|
+ const guid = getQiWeConfig().guid;
|
|
|
+ const memberMap = guid ? await loadRoomMemberNameMap(roomId, guid) : new Map<string, string>();
|
|
|
+
|
|
|
+ const needIds = [...new Set(
|
|
|
+ messages
|
|
|
+ .filter((m) => !memberMap.has(m.senderId) && (!m.senderName || m.senderName === m.senderId))
|
|
|
+ .map((m) => m.senderId)
|
|
|
+ .filter(Boolean),
|
|
|
+ )];
|
|
|
+
|
|
|
+ const contactMap = new Map<string, string>();
|
|
|
+ if (needIds.length > 0) {
|
|
|
+ try {
|
|
|
+ const contacts = await getContactDetailsBatch(needIds.slice(0, 50));
|
|
|
+ for (const c of contacts) {
|
|
|
+ if (c.name && c.name !== c.userId) contactMap.set(c.userId, c.name);
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ /* 联系人接口不可用时跳过 */
|
|
|
+ }
|
|
|
+ for (const id of needIds.slice(0, 20)) {
|
|
|
+ if (contactMap.has(id) || memberMap.has(id)) continue;
|
|
|
+ const orgName = await resolveOwnerName(id);
|
|
|
+ if (orgName && orgName !== id) contactMap.set(id, orgName);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return messages.map((m) => {
|
|
|
+ const resolvedName =
|
|
|
+ (m.senderName && m.senderName !== m.senderId ? m.senderName : '')
|
|
|
+ || memberMap.get(m.senderId)
|
|
|
+ || contactMap.get(m.senderId)
|
|
|
+ || '';
|
|
|
+ return {
|
|
|
+ ...m,
|
|
|
+ senderName: formatSenderDisplay(m.senderId, resolvedName),
|
|
|
+ content: formatDisplayContent(m.content, m.msgType),
|
|
|
+ };
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
export function buildSyncMsgUniqueId(
|
|
|
@@ -189,7 +314,8 @@ export async function listMessagesByRoom(
|
|
|
roomId: string,
|
|
|
limit = 50,
|
|
|
skip = 0,
|
|
|
-): Promise<{ messages: MessageDto[]; total: number }> {
|
|
|
+ chatOnly = true,
|
|
|
+): Promise<{ messages: MessageDto[]; total: number; chatTotal: number }> {
|
|
|
const query = new Parse.Query('Message');
|
|
|
query.equalTo('roomId', roomId);
|
|
|
query.descending('timestamp');
|
|
|
@@ -198,12 +324,21 @@ export async function listMessagesByRoom(
|
|
|
countQuery.equalTo('roomId', roomId);
|
|
|
|
|
|
const [rows, total] = await Promise.all([
|
|
|
- query.skip(skip).limit(limit).find({ useMasterKey: true }),
|
|
|
+ query.skip(skip).limit(Math.min(limit * 3, 300)).find({ useMasterKey: true }),
|
|
|
countQuery.count({ useMasterKey: true }),
|
|
|
]);
|
|
|
|
|
|
- return {
|
|
|
- messages: rows.map(toMessageDto),
|
|
|
- total,
|
|
|
- };
|
|
|
+ let dtos = rows.map(toMessageDto);
|
|
|
+ if (chatOnly) {
|
|
|
+ dtos = dtos.filter((m) => isChatMessageType(m.msgType));
|
|
|
+ }
|
|
|
+ dtos = dtos.slice(0, limit);
|
|
|
+
|
|
|
+ const chatTotalQuery = new Parse.Query('Message');
|
|
|
+ chatTotalQuery.equalTo('roomId', roomId);
|
|
|
+ chatTotalQuery.containedIn('msgType', [...CHAT_MSG_TYPES]);
|
|
|
+ const chatTotal = await chatTotalQuery.count({ useMasterKey: true });
|
|
|
+
|
|
|
+ const messages = await enrichMessagesForDisplay(dtos, roomId);
|
|
|
+ return { messages, total, chatTotal };
|
|
|
}
|