#!/usr/bin/env node 'use strict'; const assert = require('assert'); const fs = require('fs'); const path = require('path'); const { normalizeMessage, detectMessageType } = require('../mcp/src/core/message-normalizer'); const { sanitizePayload } = require('../mcp/src/providers/fmode-wecom-gateway'); const samples = [ ['text', 2, { content: 'hello' }, 'hello'], ['image', 101, { filename: 'sample.png' }, '[图片] sample.png'], ['voice', 16, { duration: 3 }, '[语音] 3 秒'], ['video', 103, { filename: 'sample.mp4' }, '[视频] sample.mp4'], ['file', 20, { filename: 'document.pdf' }, '[文件] document.pdf'], ['weapp', 78, { title: '服务小程序' }, '[小程序] 服务小程序'], ['meeting', 0, { title: '项目例会' }, '[会议邀请] 项目例会'], ['location', 6, { title: '会议室', latitude: 1, longitude: 2 }, '[位置] 会议室'], ['link', 13, { title: '帮助中心', url: 'https://example.invalid/help' }, '[链接] 帮助中心 · https://example.invalid/help'], ['card', 41, { displayName: '测试联系人' }, '[名片] 测试联系人'], ['emotion', 104, { title: '微笑' }, '[表情] 微笑'], ['revoke', 2063, { title: '消息已撤回' }, '[撤回消息] 消息已撤回'], ['agree', 0, { title: '同意存档' }, '[同意会话存档] 同意存档'], ['disagree', 0, { title: '不同意存档' }, '[不同意会话存档] 不同意存档'], ['chatrecord', 4, { title: '会话记录', itemCount: 2 }, '[会话记录] 会话记录 · 2 条'], ['todo', 0, { title: '跟进事项' }, '[待办] 跟进事项'], ['vote', 0, { title: '方案投票' }, '[投票] 方案投票'], ['collect', 0, { title: '信息登记' }, '[填表] 信息登记'], ['redpacket', 26, { title: '红包' }, '[红包] 红包'], ['external_redpacket', 0, { title: '互通红包' }, '[互通红包] 互通红包'], ['transfer', 588, { title: '转账' }, '[转账] 转账'], ['meeting_notification', 0, { title: '会议开始' }, '[会议控制] 会议开始'], ['docmsg', 0, { title: '在线文档' }, '[在线文档] 在线文档'], ['markdown', 2, { content: '**Markdown**' }, '**Markdown**'], ['news', 0, { title: '图文消息' }, '[图文消息] 图文消息'], ['calendar', 0, { title: '日程' }, '[日程] 日程'], ['mixed', 0, { title: '混合内容' }, '[混合消息] 混合内容'], ['meeting_voice_call', 0, { title: '会议音频' }, '[音频存档] 会议音频'], ['voip_doc_share', 0, { title: '共享文档' }, '[音频共享文档] 共享文档'], ['sphfeed', 141, { title: '视频号内容' }, '[视频号] 视频号内容'], ['voiptext', 0, { callDuration: 12 }, '[音视频通话] 12 秒'], ['qydiskfile', 15, { filename: '微盘文件.pdf' }, '[微盘文件] 微盘文件.pdf'], ['solitaire', 0, { title: '接龙' }, '[接龙] 接龙'], ['note', 0, { title: '笔记' }, '[笔记] 笔记'], ]; for (const [type, msgType, msgData, expected] of samples) { const normalized = normalizeMessage({ msgType, msgData: { type, ...msgData } }); assert.strictEqual(normalized.type, type, `${type} should retain its declared type`); assert.strictEqual(normalized.content, expected, `${type} should produce stable display content`); } assert.strictEqual(detectMessageType({ msgType: 16, msgData: { fileId: 'voice-file' } }), 'voice'); assert.strictEqual(detectMessageType({ msgType: 103, msgData: { fileId: 'video-file' } }), 'video'); assert.strictEqual(detectMessageType({ msgType: 104, msgData: { fileId: 'emotion-file' } }), 'emotion'); const redacted = sanitizePayload('SERVICE_PASSWORD=example-secret\n"masterKey": "example-key"\nsk-example1234567890'); assert.ok(!redacted.includes('example-secret') && !redacted.includes('example-key') && !redacted.includes('sk-example'), 'public message text should redact credential assignments'); const dashboardRoot = path.join(__dirname, '..', 'mcp', 'src', 'dashboard'); const app = fs.readFileSync(path.join(dashboardRoot, 'app.js'), 'utf8'); const styles = fs.readFileSync(path.join(dashboardRoot, 'styles.css'), 'utf8'); const server = fs.readFileSync(path.join(dashboardRoot, 'server.js'), 'utf8'); for (const contract of [ 'agentCardMessageHtml', 'agentReplyQuoteHtml', 'agentFinancialMessageHtml', 'agentChatrecordItemText', 'safeAgentMediaUrl', 'agentMessageData(message).reply', 'data.cards', 'subMsgTimestamp', 'data-agent-chatrecord-open', 'agent-video-play', 'openSessionConversation', 'view-session', ]) { assert.ok(app.includes(contract), `dashboard app should include ${contract}`); } const richRenderer = app.slice(app.indexOf('function agentCardMessageHtml'), app.indexOf('function renderAgentMessage')); const messageRenderer = app.slice(app.indexOf('function renderAgentMessage'), app.indexOf('function agentModeLabel')); for (const type of [ 'image', 'video', 'file', 'weapp', 'meeting', 'location', 'link', 'card', 'emotion', 'chatrecord', 'todo', 'vote', 'collect', 'redpacket', 'external_redpacket', 'transfer', 'docmsg', 'news', 'calendar', 'mixed', 'meeting_voice_call', 'voip_doc_share', 'sphfeed', 'voiptext', 'qydiskfile', 'solitaire', 'note', ]) { assert.ok(richRenderer.includes(`'${type}'`), `${type} should have a rich-message render path`); } for (const type of ['revoke', 'meeting_notification', 'agree', 'disagree']) { assert.ok(messageRenderer.includes(type), `${type} should have a system-message render path`); } assert.ok(messageRenderer.includes("type === 'voice'"), 'voice should have an audio render path'); assert.ok(messageRenderer.includes('agentTextMessageHtml(message)'), 'text and markdown should retain quoted-message rendering'); for (const contract of [ '.agent-message-quote', '.agent-financial-foot', '.agent-file-icon', '.agent-chatrecord-avatar', '.agent-feed-placeholder', '.agent-video-play', ]) { assert.ok(styles.includes(contract), `dashboard styles should include ${contract}`); } for (const route of ['session-transcript', '/media', '/open', 'wechat-emojis']) { assert.ok(server.includes(route), `dashboard server should include ${route}`); } console.log(`message types smoke passed (${samples.length} declared types + 3 numeric precedence checks + public redaction)`);