Skip to content

Commit 28c0b68

Browse files
authored
feat(weixin): add support for session management commands (session.new, session.status, etc.) (#2078)
1 parent e4e03a1 commit 28c0b68

2 files changed

Lines changed: 222 additions & 0 deletions

File tree

src/process/channels/plugins/weixin/WeixinPlugin.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,21 @@ export class WeixinPlugin extends BasePlugin {
146146
});
147147

148148
const unified = toUnifiedIncomingMessage(request);
149+
150+
// Check for menu button commands (consistent with Lark)
151+
if (unified.content.type === 'text' && unified.content.text) {
152+
const buttonAction = this.getMenuButtonAction(unified.content.text);
153+
if (buttonAction) {
154+
// Transform into action message
155+
unified.content.type = 'action';
156+
unified.content.text = buttonAction.action;
157+
unified.action = {
158+
type: buttonAction.type as 'system' | 'platform' | 'chat',
159+
name: buttonAction.action,
160+
};
161+
}
162+
}
163+
149164
this.emitMessage(unified)
150165
.then(() => {
151166
const pending = this.pendingResponses.get(conversationId);
@@ -166,6 +181,21 @@ export class WeixinPlugin extends BasePlugin {
166181
});
167182
}
168183

184+
/**
185+
* Map menu action strings to action info
186+
* Consistent with Lark implementation
187+
*/
188+
private getMenuButtonAction(text: string): { type: string; action: string } | null {
189+
const menuActions: Record<string, { type: string; action: string }> = {
190+
'session.new': { type: 'system', action: 'session.new' },
191+
'session.status': { type: 'system', action: 'session.status' },
192+
'help.show': { type: 'system', action: 'help.show' },
193+
'agent.show': { type: 'system', action: 'agent.show' },
194+
'pairing.check': { type: 'platform', action: 'pairing.check' },
195+
};
196+
return menuActions[text] || null;
197+
}
198+
169199
// ==================== Static ====================
170200

171201
static async testConnection(accountId: string, _botToken?: string): Promise<{ success: boolean; error?: string }> {
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { IChannelPluginConfig } from '@process/channels/types';
3+
import type { MonitorOptions } from '@process/channels/plugins/weixin/WeixinMonitor';
4+
import os from 'os';
5+
import path from 'path';
6+
7+
let mockStartFn = vi.fn();
8+
const TEST_DATA_DIR = path.join(os.tmpdir(), 'aionui-test-weixin-actions');
9+
10+
async function loadPluginClass() {
11+
vi.resetModules();
12+
vi.doMock('@process/channels/plugins/weixin/WeixinMonitor', () => ({
13+
startMonitor: (...args: unknown[]) => mockStartFn(...args),
14+
}));
15+
vi.doMock('@/common/platform', () => ({
16+
getPlatformServices: () => ({
17+
paths: {
18+
getDataDir: () => TEST_DATA_DIR,
19+
},
20+
}),
21+
}));
22+
const mod = await import('@process/channels/plugins/weixin/WeixinPlugin');
23+
return mod.WeixinPlugin;
24+
}
25+
26+
function createConfig(): IChannelPluginConfig {
27+
const now = Date.now();
28+
return {
29+
id: 'weixin-test',
30+
type: 'weixin' as const,
31+
name: 'WeChat',
32+
enabled: true,
33+
credentials: {
34+
accountId: 'test_user',
35+
botToken: 'test_token',
36+
baseUrl: 'https://example.com',
37+
},
38+
status: 'created' as const,
39+
createdAt: now,
40+
updatedAt: now,
41+
};
42+
}
43+
44+
describe('WeixinPlugin — Action Mapping', () => {
45+
beforeEach(() => {
46+
vi.clearAllMocks();
47+
mockStartFn = vi.fn();
48+
});
49+
50+
it('maps "session.new" text to session.new action', async () => {
51+
const WeixinPlugin = await loadPluginClass();
52+
const plugin = new WeixinPlugin();
53+
await plugin.initialize(createConfig());
54+
55+
const received: any[] = [];
56+
plugin.onMessage(async (msg) => {
57+
received.push(msg);
58+
// Simulate successful action execution response
59+
const msgId = await plugin.sendMessage(msg.chatId, { type: 'text', text: 'New session started' });
60+
await plugin.editMessage(msg.chatId, msgId, { type: 'text', text: 'Done', replyMarkup: {} });
61+
});
62+
63+
await plugin.start();
64+
const { agent } = mockStartFn.mock.calls[0][0] as MonitorOptions;
65+
66+
// Trigger chat with session.new
67+
await agent.chat({ conversationId: 'user123', text: 'session.new' });
68+
69+
expect(received).toHaveLength(1);
70+
expect(received[0].content.type).toBe('action');
71+
expect(received[0].content.text).toBe('session.new');
72+
expect(received[0].action).toEqual({
73+
type: 'system',
74+
name: 'session.new',
75+
});
76+
});
77+
78+
it('maps "session.status" text to session.status action', async () => {
79+
const WeixinPlugin = await loadPluginClass();
80+
const plugin = new WeixinPlugin();
81+
await plugin.initialize(createConfig());
82+
83+
const received: any[] = [];
84+
plugin.onMessage(async (msg) => {
85+
received.push(msg);
86+
const msgId = await plugin.sendMessage(msg.chatId, { type: 'text', text: 'Status info' });
87+
await plugin.editMessage(msg.chatId, msgId, { type: 'text', text: 'Status: OK', replyMarkup: {} });
88+
});
89+
90+
await plugin.start();
91+
const { agent } = mockStartFn.mock.calls[0][0] as MonitorOptions;
92+
93+
await agent.chat({ conversationId: 'user123', text: 'session.status' });
94+
95+
expect(received).toHaveLength(1);
96+
expect(received[0].content.type).toBe('action');
97+
expect(received[0].action.name).toBe('session.status');
98+
});
99+
100+
it('treats normal text as plain text', async () => {
101+
const WeixinPlugin = await loadPluginClass();
102+
const plugin = new WeixinPlugin();
103+
await plugin.initialize(createConfig());
104+
105+
const received: any[] = [];
106+
plugin.onMessage(async (msg) => {
107+
received.push(msg);
108+
const msgId = await plugin.sendMessage(msg.chatId, { type: 'text', text: 'AI Response' });
109+
await plugin.editMessage(msg.chatId, msgId, { type: 'text', text: 'Hello human', replyMarkup: {} });
110+
});
111+
112+
await plugin.start();
113+
const { agent } = mockStartFn.mock.calls[0][0] as MonitorOptions;
114+
115+
await agent.chat({ conversationId: 'user123', text: 'Hello AI' });
116+
117+
expect(received).toHaveLength(1);
118+
expect(received[0].content.type).toBe('text');
119+
expect(received[0].content.text).toBe('Hello AI');
120+
expect(received[0].action).toBeUndefined();
121+
});
122+
123+
it('handles empty text message correctly', async () => {
124+
const WeixinPlugin = await loadPluginClass();
125+
const plugin = new WeixinPlugin();
126+
await plugin.initialize(createConfig());
127+
128+
const received: any[] = [];
129+
plugin.onMessage(async (msg) => {
130+
received.push(msg);
131+
await plugin.sendMessage(msg.chatId, { type: 'text', text: 'Empty response' });
132+
});
133+
134+
await plugin.start();
135+
const { agent } = mockStartFn.mock.calls[0][0] as MonitorOptions;
136+
137+
// Trigger chat with empty text
138+
await agent.chat({ conversationId: 'user123', text: '' });
139+
140+
expect(received).toHaveLength(1);
141+
expect(received[0].content.text).toBe('');
142+
expect(received[0].action).toBeUndefined();
143+
});
144+
145+
it('ignores non-text message content types', async () => {
146+
const WeixinPlugin = await loadPluginClass();
147+
const plugin = new WeixinPlugin();
148+
await plugin.initialize(createConfig());
149+
150+
const received: any[] = [];
151+
plugin.onMessage(async (msg) => {
152+
received.push(msg);
153+
// Resolve the pending response to avoid timeout
154+
const msgId = await plugin.sendMessage(msg.chatId, { type: 'text', text: 'ok' });
155+
await plugin.editMessage(msg.chatId, msgId, { type: 'text', text: 'done', replyMarkup: {} });
156+
});
157+
158+
await plugin.start();
159+
160+
// We want to test the branch where unified.content.type !== 'text'
161+
// To do this, we need to mock the toUnifiedIncomingMessage internal call or
162+
// simulate the behavior. Since it's imported, we can mock the module.
163+
164+
vi.doMock('@process/channels/plugins/weixin/WeixinAdapter', () => ({
165+
toUnifiedIncomingMessage: () => ({
166+
id: '123',
167+
platform: 'weixin',
168+
chatId: 'user123',
169+
user: { id: 'user123', displayName: 'User' },
170+
content: { type: 'image', text: 'session.new' }, // Non-text type
171+
timestamp: Date.now(),
172+
}),
173+
stripHtml: (s: string) => s,
174+
}));
175+
176+
// Reload plugin to pick up the new mock
177+
const WeixinPluginWithMock = await loadPluginClass();
178+
const pluginWithMock = new WeixinPluginWithMock();
179+
await pluginWithMock.initialize(createConfig());
180+
pluginWithMock.onMessage(async (msg) => {
181+
received.push(msg);
182+
});
183+
await pluginWithMock.start();
184+
185+
const { agent } = mockStartFn.mock.calls[1][0] as MonitorOptions;
186+
await agent.chat({ conversationId: 'user123', text: 'session.new' });
187+
188+
expect(received).toHaveLength(1);
189+
expect(received[0].content.type).toBe('image');
190+
expect(received[0].action).toBeUndefined(); // Should NOT be mapped
191+
});
192+
});

0 commit comments

Comments
 (0)