import axios from 'axios'; import { appConfig } from '@/config'; function randomId() { if (crypto && crypto.randomUUID) { return crypto.randomUUID().replaceAll('-', ''); } return `${Date.now()}${Math.random().toString(16).slice(2)}`; } function getAllowedDevice(mobile) { const allowedDevices = { '18301902119': '67072d37062c4f31b1165073552933ba', '18301902118': 'de89fb2f2a61480a8c381f6c16e28bd8', '18301902117': 'd117f480faa64145b6162107b1b49617' }; return allowedDevices[mobile] || ''; } function getUniqueEquipment(mobile, fallback) { return getAllowedDevice(mobile) || localStorage.getItem('uniqueEquipment') || fallback; } function normalizeStatus(message) { if (message.direction !== 0) { return 'sent'; } if (message.status === 0 || message.status === 'sending') { return 'sending'; } if (message.status === 2 || message.status === 'failed' || message.status === -1) { return 'failed'; } return 'sent'; } function sendErrorText(errorCode) { const messages = { '-1': '连接不可用或发送超时', 1: '密钥不匹配', 2: '无效数据', 4: '服务端错误', 6: 'token错误', 7: '账号被踢下线', 8: '用户被禁用', 9: '不在群组中', 10: '无效消息', 22: '服务未授权', 28: 'WebSocket未配置', 30: '服务器时间不同步', 245: '用户被拉黑', 246: '在黑名单中', 247: '禁止发送消息', 248: '无权限', 249: '服务端超时', 250: '发送过于频繁', 251: '参数无效', 253: '目标不存在', 254: '服务未实现' }; return messages[errorCode] || `错误码 ${errorCode}`; } function resolveConversationTarget(conversation) { if (!conversation) { return ''; } return conversation.target || conversation.raw?.target || String(conversation.id || '').replace(/^single-/, '').replace(/^0-/, '').replace(/-0$/, ''); } export class ImClient { constructor() { this.listeners = new Map(); this.connected = false; this.currentUser = null; this.sdkReady = false; this.sdk = null; this.sdkModules = null; this.sdkEventsBound = false; this.pendingOutgoingMessages = new Map(); this.loginClientId = ''; } getClientId() { if (this.sdkReady && this.sdk) { return this.sdk.getClientId(); } let clientId = localStorage.getItem(appConfig.clientIdStorageKey); if (!clientId) { clientId = randomId(); localStorage.setItem(appConfig.clientIdStorageKey, clientId); } return clientId; } getSdkVersion() { if (!this.sdkReady || !this.sdk || typeof this.sdk.getVersion !== 'function') { return ''; } return this.sdk.getVersion() || ''; } prepareClientIdForLogin(mobile) { const pinnedClientId = getAllowedDevice(mobile) || localStorage.getItem('uniqueEquipment') || ''; if (pinnedClientId) { sessionStorage.setItem('clientId', pinnedClientId); localStorage.setItem('clientId', pinnedClientId); localStorage.setItem(appConfig.clientIdStorageKey, pinnedClientId); this.loginClientId = pinnedClientId; return pinnedClientId; } this.loginClientId = this.getClientId(); return this.loginClientId; } restoreClientId(clientId) { if (!clientId) { return; } sessionStorage.setItem('clientId', clientId); localStorage.setItem('clientId', clientId); localStorage.setItem(appConfig.clientIdStorageKey, clientId); this.loginClientId = clientId; } async loginWithPassword(mobile, password) { const deviceId = this.prepareClientIdForLogin(mobile); await this.ensureSdk(); const url = `${appConfig.appServer}/login_pwd`; const sdkClientId = this.getClientId(); if (sdkClientId !== deviceId) { console.warn('[im] clientId mismatch before login', { deviceId, sdkClientId }); } const response = await axios.post(url, { mobile, password, platform: appConfig.platform, clientId: deviceId, uniqueEquipment: deviceId }, { withCredentials: false }); this.persistAppToken(url, response); if (response.data?.code !== 0) { throw new Error(response.data?.message || '登录失败'); } return response.data.result; } async requestAuthCode(mobile) { const response = await axios.post(`${appConfig.appServer}/send_code`, { mobile }, { withCredentials: false }); if (response.data?.code !== 0) { throw new Error(response.data?.message || '验证码发送失败'); } return response.data.result; } async loginWithCode(mobile, code) { const deviceId = this.prepareClientIdForLogin(mobile); await this.ensureSdk(); const url = `${appConfig.appServer}/login`; const sdkClientId = this.getClientId(); if (sdkClientId !== deviceId) { console.warn('[im] clientId mismatch before login', { deviceId, sdkClientId }); } const response = await axios.post(url, { mobile, code, platform: appConfig.platform, clientId: deviceId, uniqueEquipment: deviceId }, { withCredentials: false }); this.persistAppToken(url, response); if (response.data?.code !== 0) { throw new Error(response.data?.message || '登录失败'); } return response.data.result; } async connect(userId, token) { this.currentUser = { userId, token }; await this.ensureSdk(); console.info('[im] connect', { userId, clientId: this.getClientId(), configuredClientId: this.loginClientId || localStorage.getItem(appConfig.clientIdStorageKey) || '' }); this.sdk.connect(userId, token); this.emit('connectionStatusChanged', { connected: false, statusText: '连接中' }); } disconnect() { if (this.sdkReady && this.sdk) { this.sdk.disconnect(); } this.connected = false; this.currentUser = null; this.emit('connectionStatusChanged', { connected: false }); } sendTextMessage(conversation, text, initialMessage = null) { const target = resolveConversationTarget(conversation); const rawConversation = conversation.raw || this.createRawSingleConversation(target); if (this.sdkReady && this.sdk && rawConversation) { const { TextMessageContent } = this.sdkModules; const content = new TextMessageContent(text); console.info('[im] send text', { conversationId: conversation.id, target: rawConversation.target, type: rawConversation.type, line: rawConversation.line, connected: this.connected }); this.sdk.sendConversationMessage( rawConversation, content, [], (messageId, timestamp) => { if (initialMessage) { this.pendingOutgoingMessages.set(String(messageId), { ...initialMessage, sdkMessageId: String(messageId), sdkTimestamp: Number(timestamp || Date.now()) }); } console.info('[im] send prepared', { conversationId: conversation.id, target: rawConversation.target, messageId, timestamp }); if (!initialMessage) { this.emit('messageSent', this.mapOutgoingMessage(conversation.id, messageId, timestamp, text, 'sending')); } }, null, (messageUid, timestamp) => { console.info('[im] send success', { conversationId: conversation.id, target: rawConversation.target, messageUid, timestamp }); this.emit('messageStatusChanged', { ...(initialMessage || {}), conversationId: conversation.id, id: initialMessage?.id || String(messageUid || randomId()), messageUid, timestamp: initialMessage?.timestamp || Number(timestamp || Date.now()), displayTimestamp: initialMessage?.displayTimestamp || initialMessage?.timestamp || Number(timestamp || Date.now()), status: 'sent' }); }, errorCode => { const failedMessage = { ...(initialMessage || {}), conversationId: conversation.id, id: initialMessage?.id || randomId(), text, direction: 'out', type: 'text', status: 'failed', errorCode, error: sendErrorText(errorCode) }; this.updatePendingOutgoingMessage(failedMessage); console.error('[im] send failed', { conversationId: conversation.id, target: rawConversation.target, connected: this.connected, errorCode, error: sendErrorText(errorCode) }); this.emit('messageStatusChanged', failedMessage); } ); return null; } const message = { id: randomId(), conversationId: conversation.id, from: this.currentUser?.userId || 'me', direction: 'out', type: 'text', text, status: 'failed', timestamp: Date.now(), error: rawConversation ? 'SDK未就绪' : `会话目标无效:${target || '空'}` }; this.emit('messageSent', message); return message; } getConversationList() { if (!this.sdkReady || !this.sdk) { return []; } const { ConversationType } = this.sdkModules; return this.sdk.getConversationList( [ConversationType.Single, ConversationType.Group, ConversationType.Channel], [0] ).map(item => this.mapConversationInfo(item)); } refreshConversationList() { const conversations = this.getConversationList(); this.emit('conversationsChanged', conversations); return conversations; } getMessages(conversation, count = 30) { const rawConversation = conversation.raw || this.createRawSingleConversation(resolveConversationTarget(conversation)); if (!this.sdkReady || !this.sdk || !rawConversation) { return []; } return this.sdk.getMessages(rawConversation, 0, true, count).map(message => this.mapMessage(message)); } async fetchRemoteMessages(conversation, limit = 50) { if (!conversation?.id || !this.currentUser?.userId) { return this.getMessages(conversation, limit); } const response = await axios.post(appConfig.messagesApi, { userId: this.currentUser.userId, target: resolveConversationTarget(conversation), limit }, { withCredentials: false }); if (response.data?.code !== 0) { throw new Error(response.data?.message || '消息同步失败'); } return (response.data?.result?.messages || []).map(message => ({ ...message, displayTimestamp: message.displayTimestamp || message.timestamp, conversationId: conversation.id })); } on(eventName, handler) { if (!this.listeners.has(eventName)) { this.listeners.set(eventName, new Set()); } this.listeners.get(eventName).add(handler); return () => this.off(eventName, handler); } off(eventName, handler) { this.listeners.get(eventName)?.delete(handler); } emit(eventName, payload) { this.listeners.get(eventName)?.forEach(handler => handler(payload)); } updatePendingOutgoingMessage(message) { if (!message?.id) { return; } this.pendingOutgoingMessages.forEach((pendingMessage, sdkMessageId) => { if (pendingMessage.id === message.id) { this.pendingOutgoingMessages.set(sdkMessageId, { ...pendingMessage, ...message, sdkMessageId }); } }); } mapSdkStatusMessage(message) { const mappedMessage = this.mapMessage(message); if (message.direction !== 0 || !message.messageId) { return mappedMessage; } const sdkMessageId = String(message.messageId); const pendingMessage = this.pendingOutgoingMessages.get(sdkMessageId); if (!pendingMessage) { return mappedMessage; } const nextMessage = { ...pendingMessage, ...mappedMessage, id: pendingMessage.id, conversationId: pendingMessage.conversationId || mappedMessage.conversationId, text: pendingMessage.text || mappedMessage.text, timestamp: pendingMessage.timestamp || mappedMessage.timestamp, displayTimestamp: pendingMessage.displayTimestamp || mappedMessage.displayTimestamp, sdkMessageId, errorCode: mappedMessage.errorCode || pendingMessage.errorCode, error: mappedMessage.error || pendingMessage.error }; this.pendingOutgoingMessages.set(sdkMessageId, nextMessage); if (nextMessage.status === 'sent') { window.setTimeout(() => this.pendingOutgoingMessages.delete(sdkMessageId), 60000); } return nextMessage; } async ensureSdk() { if (this.sdkReady || appConfig.sdkMode !== 'wildfire') { return; } const wfcModule = await import('legacy-wfc/client/wfc'); const configModule = await import('legacy-config'); const eventModule = await import('legacy-wfc/client/wfcEvent'); const connectionStatusModule = await import('legacy-wfc/client/connectionStatus'); const conversationTypeModule = await import('legacy-wfc/model/conversationType'); const conversationModule = await import('legacy-wfc/model/conversation'); const textMessageModule = await import('legacy-wfc/messages/textMessageContent'); const Config = configModule.default; Config.APP_SERVER = appConfig.appServer; Config.USE_WSS = appConfig.useWss; Config.ROUTE_PORT = appConfig.imRoutePort; Config.WS_PATH = appConfig.wsPath; Config.HTTP_IM_PREFIX_PATH = ''; Config.HTTP_UPLOAD_PREFIX_PATH = ''; Config.CLIENT_ID_STRATEGY = 2; this.sdk = wfcModule.default; this.sdkModules = { EventType: eventModule.default, ConnectionStatus: connectionStatusModule.default, ConversationType: conversationTypeModule.default, Conversation: conversationModule.default, TextMessageContent: textMessageModule.default }; this.bindSdkEvents(); this.sdk.init(); this.sdkReady = true; } bindSdkEvents() { if (this.sdkEventsBound) { return; } this.sdkEventsBound = true; const { EventType, ConnectionStatus } = this.sdkModules; this.sdk.eventEmitter.on(EventType.ConnectionStatusChanged, status => { const connected = status === ConnectionStatus.ConnectionStatusConnected; this.connected = connected; this.emit('connectionStatusChanged', { connected, status, statusText: ConnectionStatus.desc(status) || '连接状态变化' }); if (connected || status === ConnectionStatus.ConnectionStatusReceiveing) { window.setTimeout(() => this.refreshConversationList(), 0); } }); this.sdk.eventEmitter.on(EventType.ConversationInfoUpdate, () => { this.refreshConversationList(); }); this.sdk.eventEmitter.on(EventType.FriendListUpdate, () => { this.emit('contactsChanged', this.getContactList(true)); }); this.sdk.eventEmitter.on(EventType.UserInfosUpdate, () => { this.emit('contactsChanged', this.getContactList(false)); }); this.sdk.eventEmitter.on(EventType.MessageStatusUpdate, message => { this.emit('messageStatusChanged', this.mapSdkStatusMessage(message)); }); this.sdk.eventEmitter.on(EventType.ReceiveMessage, message => { this.emit('messageReceived', this.mapMessage(message)); this.refreshConversationList(); }); } mapConversationInfo(info) { const conversation = info.conversation; const id = `${conversation.type}-${conversation.target}-${conversation.line}`; const title = this.getConversationTitle(info); return { id, type: conversation.type, title, subtitle: info.lastMessage ? this.messageDigest(info.lastMessage) : '', unread: info.unreadCount?.unread || info.unreadCount || 0, avatarText: title.slice(0, 1).toUpperCase(), updatedAt: Number(info.timestamp || info.lastMessage?.timestamp || Date.now()), raw: conversation, rawInfo: info }; } getContactList(refresh = false) { if (!this.sdkReady || !this.sdk) { return []; } const friendIds = this.sdk.getMyFriendList(refresh) || []; if (!friendIds.length) { return []; } const userInfos = this.sdk.getUserInfos(friendIds, '') || []; return userInfos.map(userInfo => { return this.mapContact(userInfo.uid, null, userInfo); }).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')); } async getServerContactList(userId) { if (!userId) { return []; } const response = await axios.post(appConfig.contactsApi, { userId }, { withCredentials: false }); if (response.data?.code !== 0) { throw new Error(response.data?.message || '联系人加载失败'); } return (response.data?.result?.contacts || []).map(item => { return this.mapContact(item.uid, { alias: item.alias }, { uid: item.uid, displayName: item.displayName, name: item.name, mobile: item.mobile, portrait: item.portrait, friendAlias: item.alias }); }).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')); } refreshContactList() { const contacts = this.getContactList(true); this.emit('contactsChanged', contacts); return contacts; } createRawSingleConversation(userId) { if (!userId || !this.sdkModules?.Conversation || !this.sdkModules?.ConversationType) { return null; } const { Conversation, ConversationType } = this.sdkModules; return new Conversation(ConversationType.Single, userId, 0); } createSingleConversation(userId) { if (!this.sdkReady || !this.sdk) { const title = userId; return { id: `single-${userId}`, type: 'single', target: userId, title, subtitle: '', unread: 0, avatarText: title.slice(0, 1).toUpperCase(), updatedAt: Date.now(), raw: null }; } const { ConversationType } = this.sdkModules; const raw = this.createRawSingleConversation(userId); const info = this.sdk.getConversationInfo(raw); return info ? this.mapConversationInfo(info) : { id: `${ConversationType.Single}-${userId}-0`, type: ConversationType.Single, target: userId, title: this.sdk.getUserDisplayName(userId), subtitle: '', unread: 0, avatarText: this.sdk.getUserDisplayName(userId).slice(0, 1).toUpperCase(), updatedAt: Date.now(), raw }; } mapContact(userId, friend, userInfo) { const title = userInfo?.friendAlias || friend?.alias || userInfo?.displayName || userInfo?.name || userId; return { id: userId, uid: userId, title, subtitle: userInfo?.mobile || userInfo?.name || userId, portrait: userInfo?.portrait || '', avatarText: title.slice(0, 1).toUpperCase(), alias: friend?.alias || userInfo?.friendAlias || '', rawFriend: friend, rawUser: userInfo }; } getConversationTitle(info) { const conversation = info.conversation; if (conversation._target?._displayName) { return conversation._target._displayName; } if (conversation.type === this.sdkModules.ConversationType.Single) { return this.sdk.getUserDisplayName(conversation.target); } if (conversation.type === this.sdkModules.ConversationType.Group) { return this.sdk.getGroupInfo(conversation.target)?.name || conversation.target; } return conversation.target; } mapMessage(message) { return { id: String(message.messageId || message.messageUid || randomId()), messageUid: message.messageUid, conversationId: message.conversation ? `${message.conversation.type}-${message.conversation.target}-${message.conversation.line}` : '', from: message.from, direction: message.direction === 0 ? 'out' : 'in', type: 'text', text: this.messageDigest(message), status: normalizeStatus(message), timestamp: Number(message.timestamp || Date.now()), displayTimestamp: Number(message.timestamp || Date.now()), raw: message }; } mapOutgoingMessage(conversationId, messageId, timestamp, text, status) { return { id: String(messageId || randomId()), conversationId, from: this.currentUser?.userId || 'me', direction: 'out', type: 'text', text, status, timestamp: Number(timestamp || Date.now()) }; } messageDigest(message) { if (!message?.messageContent) { return ''; } if (message.messageContent.content) { return message.messageContent.content; } if (typeof message.messageContent.digest === 'function') { return message.messageContent.digest(message); } return '[暂不支持的消息]'; } persistAppToken(url, response) { const appToken = response.headers?.authtoken || response.headers?.authToken; if (appToken) { localStorage.setItem(appConfig.authTokenStoragePrefix + new URL(url).host, appToken); } } } export const imClient = new ImClient();