feat: unify cron delivery account and target selection (#642)

This commit is contained in:
cedric
2026-03-25 10:12:49 +08:00
committed by GitHub
Unverified
parent 9aea3c9441
commit 9d40e1fa05
20 changed files with 2073 additions and 88 deletions

View File

@@ -18,25 +18,29 @@ const mockElectron = {
isDev: true,
};
Object.defineProperty(window, 'electron', {
value: mockElectron,
writable: true,
});
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'electron', {
value: mockElectron,
writable: true,
});
}
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
// Reset mocks after each test
afterEach(() => {

View File

@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const listConfiguredChannelsMock = vi.fn();
const listConfiguredChannelAccountsMock = vi.fn();
const readOpenClawConfigMock = vi.fn();
const listAgentsSnapshotMock = vi.fn();
const sendJsonMock = vi.fn();
const proxyAwareFetchMock = vi.fn();
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw');
vi.mock('@electron/utils/channel-config', () => ({
cleanupDanglingWeChatPluginState: vi.fn(),
@@ -56,9 +61,19 @@ vi.mock('@electron/api/route-utils', () => ({
sendJson: (...args: unknown[]) => sendJsonMock(...args),
}));
vi.mock('@electron/utils/paths', () => ({
getOpenClawConfigDir: () => testOpenClawConfigDir,
}));
vi.mock('@electron/utils/proxy-fetch', () => ({
proxyAwareFetch: (...args: unknown[]) => proxyAwareFetchMock(...args),
}));
describe('handleChannelRoutes', () => {
beforeEach(() => {
vi.resetAllMocks();
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
proxyAwareFetchMock.mockReset();
listAgentsSnapshotMock.mockResolvedValue({
agents: [],
channelAccountOwners: {},
@@ -68,6 +83,10 @@ describe('handleChannelRoutes', () => {
});
});
afterAll(() => {
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
});
it('reports healthy running multi-account channels as connected', async () => {
listConfiguredChannelsMock.mockResolvedValue(['feishu']);
listConfiguredChannelAccountsMock.mockResolvedValue({
@@ -235,4 +254,333 @@ describe('handleChannelRoutes', () => {
}),
);
});
it('lists known QQ Bot targets for a configured account', async () => {
const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data');
mkdirSync(knownUsersPath, { recursive: true });
writeFileSync(join(knownUsersPath, 'known-users.json'), JSON.stringify([
{
openid: '207A5B8339D01F6582911C014668B77B',
type: 'c2c',
nickname: 'Alice',
accountId: 'default',
lastSeenAt: 200,
},
{
openid: 'member-openid',
type: 'group',
nickname: 'Weather Group',
groupOpenid: 'GROUP_OPENID_123',
accountId: 'default',
lastSeenAt: 100,
},
]), 'utf8');
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
const handled = await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/channels/targets?channelType=qqbot&accountId=default'),
{
gatewayManager: {
rpc: vi.fn(),
getStatus: () => ({ state: 'running' }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
channelType: 'qqbot',
accountId: 'default',
targets: [
expect.objectContaining({
value: 'qqbot:c2c:207A5B8339D01F6582911C014668B77B',
kind: 'user',
}),
expect.objectContaining({
value: 'qqbot:group:GROUP_OPENID_123',
kind: 'group',
}),
],
}),
);
});
it('lists Feishu targets for a configured account', async () => {
readOpenClawConfigMock.mockResolvedValue({
channels: {
feishu: {
appId: 'cli_app_id',
appSecret: 'cli_app_secret',
allowFrom: ['ou_config_user'],
groups: {
oc_config_group: {},
},
},
},
});
proxyAwareFetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
if (url.includes('/tenant_access_token/internal')) {
const body = JSON.parse(String(init?.body || '{}')) as { app_id?: string };
if (body.app_id === 'cli_app_id') {
return {
ok: true,
json: async () => ({
code: 0,
tenant_access_token: 'tenant-token',
}),
};
}
}
if (url.includes('/applications/cli_app_id')) {
return {
ok: true,
json: async () => ({
code: 0,
data: {
app: {
creator_id: 'ou_owner',
owner: {
owner_type: 2,
owner_id: 'ou_owner',
},
},
},
}),
};
}
if (url.includes('/contact/v3/users')) {
return {
ok: true,
json: async () => ({
code: 0,
data: {
items: [
{ open_id: 'ou_live_user', name: 'Alice Feishu' },
],
},
}),
};
}
if (url.includes('/im/v1/chats')) {
return {
ok: true,
json: async () => ({
code: 0,
data: {
items: [
{ chat_id: 'oc_live_chat', name: 'Project Chat' },
],
},
}),
};
}
throw new Error(`Unexpected fetch: ${url}`);
});
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
const handled = await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/channels/targets?channelType=feishu&accountId=default'),
{
gatewayManager: {
rpc: vi.fn(),
getStatus: () => ({ state: 'running' }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
channelType: 'feishu',
accountId: 'default',
targets: expect.arrayContaining([
expect.objectContaining({ value: 'user:ou_owner', kind: 'user' }),
expect.objectContaining({ value: 'user:ou_live_user', kind: 'user' }),
expect.objectContaining({ value: 'chat:oc_live_chat', kind: 'group' }),
]),
}),
);
});
it('lists WeCom targets from reqid cache and session history', async () => {
mkdirSync(join(testOpenClawConfigDir, 'wecom'), { recursive: true });
writeFileSync(
join(testOpenClawConfigDir, 'wecom', 'reqid-map-default.json'),
JSON.stringify({
'chat-alpha': { reqId: 'req-1', ts: 100 },
}),
'utf8',
);
mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true });
writeFileSync(
join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'),
JSON.stringify({
'agent:main:wecom:chat-bravo': {
updatedAt: 200,
chatType: 'group',
displayName: 'Ops Group',
deliveryContext: {
channel: 'wecom',
accountId: 'default',
to: 'wecom:chat-bravo',
},
},
}),
'utf8',
);
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
const handled = await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/channels/targets?channelType=wecom&accountId=default'),
{
gatewayManager: {
rpc: vi.fn(),
getStatus: () => ({ state: 'running' }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
channelType: 'wecom',
accountId: 'default',
targets: expect.arrayContaining([
expect.objectContaining({ value: 'wecom:chat-bravo', kind: 'group' }),
expect.objectContaining({ value: 'wecom:chat-alpha', kind: 'channel' }),
]),
}),
);
});
it('lists DingTalk targets from session history', async () => {
mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true });
writeFileSync(
join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'),
JSON.stringify({
'agent:main:dingtalk:cid-group': {
updatedAt: 300,
chatType: 'group',
displayName: 'DingTalk Dev Group',
deliveryContext: {
channel: 'dingtalk',
accountId: 'default',
to: 'cidDeVGroup=',
},
},
}),
'utf8',
);
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
const handled = await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/channels/targets?channelType=dingtalk&accountId=default'),
{
gatewayManager: {
rpc: vi.fn(),
getStatus: () => ({ state: 'running' }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
channelType: 'dingtalk',
accountId: 'default',
targets: [
expect.objectContaining({
value: 'cidDeVGroup=',
kind: 'group',
}),
],
}),
);
});
it('lists WeChat targets from session history via the UI alias', async () => {
mkdirSync(join(testOpenClawConfigDir, 'agents', 'main', 'sessions'), { recursive: true });
writeFileSync(
join(testOpenClawConfigDir, 'agents', 'main', 'sessions', 'sessions.json'),
JSON.stringify({
'agent:main:wechat:wxid_target': {
updatedAt: 400,
chatType: 'direct',
displayName: 'Alice WeChat',
deliveryContext: {
channel: 'openclaw-weixin',
accountId: 'wechat-bot',
to: 'wechat:wxid_target',
},
},
}),
'utf8',
);
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
const handled = await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/channels/targets?channelType=wechat&accountId=wechat-bot'),
{
gatewayManager: {
rpc: vi.fn(),
getStatus: () => ({ state: 'running' }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(handled).toBe(true);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
channelType: 'wechat',
accountId: 'wechat-bot',
targets: [
expect.objectContaining({
value: 'wechat:wxid_target',
kind: 'user',
}),
],
}),
);
});
});

View File

@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IncomingMessage, ServerResponse } from 'http';
const parseJsonBodyMock = vi.fn();
const sendJsonMock = vi.fn();
vi.mock('@electron/api/route-utils', () => ({
parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
sendJson: (...args: unknown[]) => sendJsonMock(...args),
}));
describe('handleCronRoutes', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('creates cron jobs with external delivery configuration', async () => {
parseJsonBodyMock.mockResolvedValue({
name: 'Weather delivery',
message: 'Summarize today',
schedule: '0 9 * * *',
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_weather',
},
enabled: true,
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-1',
name: 'Weather delivery',
enabled: true,
createdAtMs: 1,
updatedAtMs: 2,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Summarize today' },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/cron/jobs'),
{
gatewayManager: { rpc },
} as never,
);
expect(handled).toBe(true);
expect(rpc).toHaveBeenCalledWith('cron.add', expect.objectContaining({
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
}));
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
id: 'job-1',
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_weather' },
}),
);
});
it('updates cron jobs with transformed payload and delivery fields', async () => {
parseJsonBodyMock.mockResolvedValue({
message: 'Updated prompt',
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_next',
},
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-2',
name: 'Updated job',
enabled: true,
createdAtMs: 1,
updatedAtMs: 3,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Updated prompt' },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
await handleCronRoutes(
{ method: 'PUT' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/cron/jobs/job-2'),
{
gatewayManager: { rpc },
} as never,
);
expect(rpc).toHaveBeenCalledWith('cron.update', {
id: 'job-2',
patch: {
payload: { kind: 'agentTurn', message: 'Updated prompt' },
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
},
});
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
id: 'job-2',
message: 'Updated prompt',
delivery: { mode: 'announce', channel: 'feishu', to: 'user:ou_next' },
}),
);
});
it('passes through delivery.accountId for multi-account cron jobs', async () => {
parseJsonBodyMock.mockResolvedValue({
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_owner',
accountId: 'feishu-0d009958',
},
});
const rpc = vi.fn().mockResolvedValue({
id: 'job-account',
name: 'Account job',
enabled: true,
createdAtMs: 1,
updatedAtMs: 4,
schedule: { kind: 'cron', expr: '0 9 * * *' },
payload: { kind: 'agentTurn', message: 'Prompt' },
delivery: { mode: 'announce', channel: 'feishu', accountId: 'feishu-0d009958', to: 'user:ou_owner' },
state: {},
});
const { handleCronRoutes } = await import('@electron/api/routes/cron');
await handleCronRoutes(
{ method: 'PUT' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/cron/jobs/job-account'),
{
gatewayManager: { rpc },
} as never,
);
expect(rpc).toHaveBeenCalledWith('cron.update', {
id: 'job-account',
patch: {
delivery: {
mode: 'announce',
channel: 'feishu',
to: 'user:ou_owner',
accountId: 'feishu-0d009958',
},
},
});
});
it('rejects WeChat scheduled delivery because the plugin requires a live context token', async () => {
parseJsonBodyMock.mockResolvedValue({
name: 'WeChat delivery',
message: 'Send update',
schedule: '0 10 * * *',
delivery: {
mode: 'announce',
channel: 'wechat',
to: 'wechat:wxid_target',
accountId: 'wechat-bot',
},
enabled: true,
});
const rpc = vi.fn();
const { handleCronRoutes } = await import('@electron/api/routes/cron');
const handled = await handleCronRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:3210/api/cron/jobs'),
{
gatewayManager: { rpc },
} as never,
);
expect(handled).toBe(true);
expect(rpc).not.toHaveBeenCalled();
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
400,
expect.objectContaining({
success: false,
error: expect.stringContaining('WeChat scheduled delivery is not supported'),
}),
);
});
});

View File

@@ -1,3 +1,4 @@
// @vitest-environment node
import { readFile, rm } from 'fs/promises';
import { join } from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';