feat(channel): support weichat channel (#620)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { readFile, rm } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -158,3 +159,38 @@ describe('WeCom plugin configuration', () => {
|
||||
expect(plugins.entries['wecom'].enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeChat dangling plugin cleanup', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
await rm(testHome, { recursive: true, force: true });
|
||||
await rm(testUserData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes dangling openclaw-weixin plugin registration and state when no channel config exists', async () => {
|
||||
const { cleanupDanglingWeChatPluginState, writeOpenClawConfig } = await import('@electron/utils/channel-config');
|
||||
|
||||
await writeOpenClawConfig({
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['openclaw-weixin'],
|
||||
entries: {
|
||||
'openclaw-weixin': { enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const staleStateDir = join(testHome, '.openclaw', 'openclaw-weixin', 'accounts');
|
||||
await mkdir(staleStateDir, { recursive: true });
|
||||
await writeFile(join(staleStateDir, 'bot-im-bot.json'), JSON.stringify({ token: 'stale-token' }), 'utf8');
|
||||
await writeFile(join(testHome, '.openclaw', 'openclaw-weixin', 'accounts.json'), JSON.stringify(['bot-im-bot']), 'utf8');
|
||||
|
||||
const result = await cleanupDanglingWeChatPluginState();
|
||||
expect(result.cleanedDanglingState).toBe(true);
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
expect(config.plugins).toBeUndefined();
|
||||
expect(existsSync(join(testHome, '.openclaw', 'openclaw-weixin'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const listAgentsSnapshotMock = vi.fn();
|
||||
const sendJsonMock = vi.fn();
|
||||
|
||||
vi.mock('@electron/utils/channel-config', () => ({
|
||||
cleanupDanglingWeChatPluginState: vi.fn(),
|
||||
deleteChannelAccountConfig: vi.fn(),
|
||||
deleteChannelConfig: vi.fn(),
|
||||
getChannelFormValues: vi.fn(),
|
||||
@@ -32,9 +33,17 @@ vi.mock('@electron/utils/plugin-install', () => ({
|
||||
ensureDingTalkPluginInstalled: vi.fn(),
|
||||
ensureFeishuPluginInstalled: vi.fn(),
|
||||
ensureQQBotPluginInstalled: vi.fn(),
|
||||
ensureWeChatPluginInstalled: vi.fn(),
|
||||
ensureWeComPluginInstalled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/wechat-login', () => ({
|
||||
cancelWeChatLoginSession: vi.fn(),
|
||||
saveWeChatAccountState: vi.fn(),
|
||||
startWeChatLoginSession: vi.fn(),
|
||||
waitForWeChatLoginSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/whatsapp-login', () => ({
|
||||
whatsAppLoginManager: {
|
||||
start: vi.fn(),
|
||||
@@ -51,7 +60,7 @@ describe('handleChannelRoutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
entries: [],
|
||||
agents: [],
|
||||
channelAccountOwners: {},
|
||||
});
|
||||
readOpenClawConfigMock.mockResolvedValue({
|
||||
@@ -75,7 +84,7 @@ describe('handleChannelRoutes', () => {
|
||||
},
|
||||
});
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
entries: [],
|
||||
agents: [],
|
||||
channelAccountOwners: {
|
||||
'feishu:default': 'main',
|
||||
'feishu:feishu-2412524e': 'code',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Channels } from '@/pages/Channels/index';
|
||||
|
||||
const hostApiFetchMock = vi.fn();
|
||||
@@ -126,4 +126,58 @@ describe('Channels page status refresh', () => {
|
||||
expect(agentFetchCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('treats WeChat accounts as plugin-managed QR accounts', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/channels/accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'wechat',
|
||||
defaultAccountId: 'wx-bot-im-bot',
|
||||
status: 'connected',
|
||||
accounts: [
|
||||
{
|
||||
accountId: 'wx-bot-im-bot',
|
||||
name: 'WeChat ClawBot',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (path === '/api/agents') {
|
||||
return {
|
||||
success: true,
|
||||
agents: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (path === '/api/channels/wechat/cancel') {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WeChat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'account.add' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dialog.configureTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText('account.customIdLabel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
103
tests/unit/wechat-login.test.ts
Normal file
103
tests/unit/wechat-login.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { readFile, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { testHome } = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
return {
|
||||
testHome: `/tmp/clawx-wechat-login-${suffix}`,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:os')>('node:os');
|
||||
const mocked = {
|
||||
...actual,
|
||||
homedir: () => testHome,
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
default: mocked,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: () => '/tmp/clawx-test-user-data',
|
||||
getVersion: () => '0.0.0-test',
|
||||
getAppPath: () => '/tmp',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('wechat login utility', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
await rm(testHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('starts a QR session, waits for confirmation, and stores account state in the plugin path', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
qrcode: 'qr-token',
|
||||
qrcode_img_content: 'https://example.com/qr.png',
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ status: 'wait' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({
|
||||
status: 'confirmed',
|
||||
bot_token: 'secret-token',
|
||||
ilink_bot_id: 'bot@im.bot',
|
||||
baseurl: 'https://ilinkai.weixin.qq.com',
|
||||
ilink_user_id: 'user-123',
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const {
|
||||
saveWeChatAccountState,
|
||||
startWeChatLoginSession,
|
||||
waitForWeChatLoginSession,
|
||||
} = await import('@electron/utils/wechat-login');
|
||||
|
||||
const startResult = await startWeChatLoginSession({});
|
||||
expect(startResult.qrcodeUrl).toMatch(/^data:image\/png;base64,/);
|
||||
expect(startResult.sessionKey).toBeTruthy();
|
||||
|
||||
const waitResult = await waitForWeChatLoginSession({
|
||||
sessionKey: startResult.sessionKey,
|
||||
timeoutMs: 2_500,
|
||||
});
|
||||
expect(waitResult.connected).toBe(true);
|
||||
expect(waitResult.accountId).toBe('bot@im.bot');
|
||||
expect(waitResult.botToken).toBe('secret-token');
|
||||
|
||||
const normalizedAccountId = await saveWeChatAccountState(waitResult.accountId!, {
|
||||
token: waitResult.botToken!,
|
||||
baseUrl: waitResult.baseUrl,
|
||||
userId: waitResult.userId,
|
||||
});
|
||||
|
||||
expect(normalizedAccountId).toBe('bot-im-bot');
|
||||
|
||||
const accountFile = JSON.parse(
|
||||
await readFile(join(testHome, '.openclaw', 'openclaw-weixin', 'accounts', 'bot-im-bot.json'), 'utf-8'),
|
||||
) as { token?: string; baseUrl?: string; userId?: string };
|
||||
expect(accountFile.token).toBe('secret-token');
|
||||
expect(accountFile.baseUrl).toBe('https://ilinkai.weixin.qq.com');
|
||||
expect(accountFile.userId).toBe('user-123');
|
||||
|
||||
const accountIndex = JSON.parse(
|
||||
await readFile(join(testHome, '.openclaw', 'openclaw-weixin', 'accounts.json'), 'utf-8'),
|
||||
) as string[];
|
||||
expect(accountIndex).toEqual(['bot-im-bot']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user