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

@@ -108,6 +108,8 @@ ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同
### ⏰ Cronベースの自動化
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
定期タスク画面では外部配信を「送信アカウント」と「受信先ターゲット」の 2 段階セレクターで設定できるようになりました。対応チャネルでは、受信先候補をチャネルのディレクトリ機能や既知セッション履歴から自動検出するため、`jobs.json` を手で編集する必要はありません。
既知の制限: WeChat は現在、定期タスク配信の対応チャネルから意図的に除外しています。`openclaw-weixin` プラグインの送信処理が、リアルタイム会話で得られる `contextToken` を必要とするため、cron のような能動配信をプラグイン自体がサポートしていません。
### 🧩 拡張可能なスキルシステム
事前構築されたスキルでAIエージェントを拡張できます。統合スキルパネルからスキルの閲覧、インストール、管理が可能です。パッケージマネージャーは不要です。

View File

@@ -108,6 +108,8 @@ ClawX now also bundles Tencent's official personal WeChat channel plugin, so you
### ⏰ Cron-Based Automation
Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention.
The Cron page now lets you configure external delivery directly in the task form with separate sender-account and recipient-target selectors. For supported channels, recipient targets are discovered automatically from channel directories or known session history, so you no longer need to edit `jobs.json` by hand.
Known limitation: WeChat is intentionally excluded from supported cron delivery channels for now. The current `openclaw-weixin` plugin requires a live conversation `contextToken` for outbound sends, so cron-style proactive delivery is not supported by the plugin itself.
### 🧩 Extensible Skill System
Extend your AI agents with pre-built skills. Browse, install, and manage skills through the integrated skill panel—no package managers required.

View File

@@ -109,6 +109,8 @@ ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Chan
### ⏰ 定时任务自动化
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
现在定时任务页面已经可以直接配置外部投递,统一拆成“发送账号”和“接收目标”两个下拉选择。对于已支持的通道,接收目标会从通道目录能力或已知会话历史中自动发现,不需要再手动修改 `jobs.json`
已知限制:微信当前不在支持的定时任务投递通道列表内。原因是 `openclaw-weixin` 插件的出站发送依赖实时会话里的 `contextToken`,插件本身不支持 cron 这类主动推送场景。
### 🧩 可扩展技能系统
通过预构建的技能扩展 AI 智能体的能力。在集成的技能面板中浏览、安装和管理技能——无需包管理器。

View File

@@ -1,4 +1,6 @@
import { readFile, readdir } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'http';
import { join } from 'node:path';
import {
deleteChannelAccountConfig,
deleteChannelConfig,
@@ -38,6 +40,7 @@ import {
toOpenClawChannelType,
toUiChannelType,
} from '../../utils/channel-alias';
import { getOpenClawConfigDir } from '../../utils/paths';
import {
cancelWeChatLoginSession,
saveWeChatAccountState,
@@ -45,6 +48,27 @@ import {
waitForWeChatLoginSession,
} from '../../utils/wechat-login';
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
import { proxyAwareFetch } from '../../utils/proxy-fetch';
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
normalizeDiscordMessagingTarget,
} from 'openclaw/plugin-sdk/discord';
import {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
normalizeTelegramMessagingTarget,
} from 'openclaw/plugin-sdk/telegram';
import {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
normalizeSlackMessagingTarget,
} from 'openclaw/plugin-sdk/slack';
import {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
normalizeWhatsAppMessagingTarget,
} from 'openclaw/plugin-sdk/whatsapp';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -287,6 +311,34 @@ interface ChannelAccountsView {
accounts: ChannelAccountView[];
}
interface ChannelTargetOptionView {
value: string;
label: string;
kind: 'user' | 'group' | 'channel';
}
interface QQBotKnownUserRecord {
openid?: string;
type?: 'c2c' | 'group';
nickname?: string;
groupOpenid?: string;
accountId?: string;
lastSeenAt?: number;
interactionCount?: number;
}
type JsonRecord = Record<string, unknown>;
type DirectoryEntry = {
kind: 'user' | 'group' | 'channel';
id: string;
name?: string;
handle?: string;
};
const CHANNEL_TARGET_CACHE_TTL_MS = 60_000;
const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true';
const channelTargetCache = new Map<string, { expiresAt: number; targets: ChannelTargetOptionView[] }>();
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([
listConfiguredChannels(),
@@ -371,6 +423,593 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
return channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
}
function buildChannelTargetLabel(baseLabel: string, value: string): string {
const trimmed = baseLabel.trim();
return trimmed && trimmed !== value ? `${trimmed} (${value})` : value;
}
function buildDirectoryTargetOptions(
entries: DirectoryEntry[],
normalizeTarget: (target: string) => string | undefined,
): ChannelTargetOptionView[] {
const results: ChannelTargetOptionView[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const normalized = normalizeTarget(entry.id) ?? entry.id;
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
results.push({
value: normalized,
label: buildChannelTargetLabel(entry.name || entry.handle || entry.id, normalized),
kind: entry.kind,
});
}
return results;
}
function mergeChannelAccountConfig(
config: JsonRecord,
channelType: string,
accountId?: string,
): JsonRecord {
const channels = (config.channels && typeof config.channels === 'object')
? config.channels as Record<string, unknown>
: undefined;
const channelSection = channels?.[channelType];
if (!channelSection || typeof channelSection !== 'object') {
return {};
}
const section = channelSection as JsonRecord;
const resolvedAccountId = accountId?.trim()
|| (typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount.trim()
: 'default');
const accounts = section.accounts && typeof section.accounts === 'object'
? section.accounts as Record<string, unknown>
: undefined;
const accountOverride =
resolvedAccountId !== 'default' && accounts?.[resolvedAccountId] && typeof accounts[resolvedAccountId] === 'object'
? accounts[resolvedAccountId] as JsonRecord
: undefined;
const { accounts: _ignoredAccounts, ...baseConfig } = section;
return accountOverride ? { ...baseConfig, ...accountOverride } : baseConfig;
}
function resolveFeishuApiOrigin(domain: unknown): string {
if (typeof domain === 'string' && domain.trim().toLowerCase() === 'lark') {
return 'https://open.larksuite.com';
}
return 'https://open.feishu.cn';
}
function normalizeFeishuTargetValue(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed || trimmed === '*') return null;
if (trimmed.startsWith('chat:') || trimmed.startsWith('user:')) return trimmed;
if (trimmed.startsWith('open_id:')) return `user:${trimmed.slice('open_id:'.length)}`;
if (trimmed.startsWith('feishu:')) return normalizeFeishuTargetValue(trimmed.slice('feishu:'.length));
if (trimmed.startsWith('oc_')) return `chat:${trimmed}`;
if (trimmed.startsWith('ou_')) return `user:${trimmed}`;
if (/^[a-zA-Z0-9]+$/.test(trimmed)) return `user:${trimmed}`;
return null;
}
function inferFeishuTargetKind(target: string): ChannelTargetOptionView['kind'] {
return target.startsWith('chat:') ? 'group' : 'user';
}
function buildFeishuTargetOption(
value: string,
label?: string,
kind?: ChannelTargetOptionView['kind'],
): ChannelTargetOptionView {
const normalizedLabel = typeof label === 'string' && label.trim() ? label.trim() : value;
return {
value,
label: buildChannelTargetLabel(normalizedLabel, value),
kind: kind ?? inferFeishuTargetKind(value),
};
}
function mergeTargetOptions(...groups: ChannelTargetOptionView[][]): ChannelTargetOptionView[] {
const seen = new Set<string>();
const results: ChannelTargetOptionView[] = [];
for (const group of groups) {
for (const option of group) {
if (!option.value || seen.has(option.value)) continue;
seen.add(option.value);
results.push(option);
}
}
return results;
}
function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function inferTargetKindFromValue(
channelType: string,
target: string,
chatType?: string,
): ChannelTargetOptionView['kind'] {
const normalizedChatType = chatType?.trim().toLowerCase();
if (normalizedChatType === 'group') return 'group';
if (normalizedChatType === 'channel') return 'channel';
if (target.startsWith('chat:') || target.includes(':group:')) return 'group';
if (target.includes(':channel:')) return 'channel';
if (channelType === 'dingtalk' && target.startsWith('cid')) return 'group';
return 'user';
}
function extractSessionRecords(store: JsonRecord): JsonRecord[] {
const directEntries = Object.entries(store)
.filter(([key, value]) => key !== 'sessions' && value && typeof value === 'object')
.map(([, value]) => value as JsonRecord);
const arrayEntries = Array.isArray(store.sessions)
? store.sessions.filter((entry): entry is JsonRecord => Boolean(entry && typeof entry === 'object'))
: [];
return [...directEntries, ...arrayEntries];
}
function buildChannelTargetCacheKey(params: {
channelType: string;
accountId?: string;
query?: string;
}): string {
return [
resolveStoredChannelType(params.channelType),
params.accountId?.trim() || '',
params.query?.trim().toLowerCase() || '',
].join('::');
}
async function listSessionDerivedTargetOptions(params: {
channelType: string;
accountId?: string;
query?: string;
}): Promise<ChannelTargetOptionView[]> {
const storedChannelType = resolveStoredChannelType(params.channelType);
const agentsDir = join(getOpenClawConfigDir(), 'agents');
const agentDirs = await readdir(agentsDir, { withFileTypes: true }).catch(() => []);
const q = params.query?.trim().toLowerCase() || '';
const candidates: Array<ChannelTargetOptionView & { updatedAt: number }> = [];
const seen = new Set<string>();
for (const entry of agentDirs) {
if (!entry.isDirectory()) continue;
const sessionsPath = join(agentsDir, entry.name, 'sessions', 'sessions.json');
const raw = await readFile(sessionsPath, 'utf8').catch(() => '');
if (!raw.trim()) continue;
let parsed: JsonRecord;
try {
parsed = JSON.parse(raw) as JsonRecord;
} catch {
continue;
}
for (const session of extractSessionRecords(parsed)) {
const deliveryContext = session.deliveryContext && typeof session.deliveryContext === 'object'
? session.deliveryContext as JsonRecord
: undefined;
const origin = session.origin && typeof session.origin === 'object'
? session.origin as JsonRecord
: undefined;
const sessionChannelType = readNonEmptyString(deliveryContext?.channel)
|| readNonEmptyString(session.lastChannel)
|| readNonEmptyString(session.channel)
|| readNonEmptyString(origin?.provider)
|| readNonEmptyString(origin?.surface);
if (!sessionChannelType || resolveStoredChannelType(sessionChannelType) !== storedChannelType) {
continue;
}
const sessionAccountId = readNonEmptyString(deliveryContext?.accountId)
|| readNonEmptyString(session.lastAccountId)
|| readNonEmptyString(origin?.accountId);
if (params.accountId && sessionAccountId && sessionAccountId !== params.accountId) {
continue;
}
if (params.accountId && !sessionAccountId) {
continue;
}
const value = readNonEmptyString(deliveryContext?.to)
|| readNonEmptyString(session.lastTo)
|| readNonEmptyString(origin?.to);
if (!value || seen.has(value)) continue;
const labelBase = readNonEmptyString(session.displayName)
|| readNonEmptyString(session.subject)
|| readNonEmptyString(origin?.label)
|| value;
const label = buildChannelTargetLabel(labelBase, value);
if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) {
continue;
}
seen.add(value);
candidates.push({
value,
label,
kind: inferTargetKindFromValue(
storedChannelType,
value,
readNonEmptyString(session.chatType) || readNonEmptyString(origin?.chatType),
),
updatedAt: typeof session.updatedAt === 'number' ? session.updatedAt : 0,
});
}
}
return candidates
.sort((left, right) => right.updatedAt - left.updatedAt || left.label.localeCompare(right.label))
.map(({ updatedAt: _updatedAt, ...option }) => option);
}
async function listWeComReqIdTargetOptions(accountId?: string, query?: string): Promise<ChannelTargetOptionView[]> {
const wecomDir = join(getOpenClawConfigDir(), 'wecom');
const files = await readdir(wecomDir, { withFileTypes: true }).catch(() => []);
const q = query?.trim().toLowerCase() || '';
const options: ChannelTargetOptionView[] = [];
const seen = new Set<string>();
for (const file of files) {
if (!file.isFile() || !file.name.startsWith('reqid-map-') || !file.name.endsWith('.json')) {
continue;
}
const resolvedAccountId = file.name.slice('reqid-map-'.length, -'.json'.length);
if (accountId && resolvedAccountId !== accountId) {
continue;
}
const raw = await readFile(join(wecomDir, file.name), 'utf8').catch(() => '');
if (!raw.trim()) continue;
let records: Record<string, unknown>;
try {
records = JSON.parse(raw) as Record<string, unknown>;
} catch {
continue;
}
for (const chatId of Object.keys(records)) {
const trimmedChatId = chatId.trim();
if (!trimmedChatId) continue;
const value = `wecom:${trimmedChatId}`;
const label = buildChannelTargetLabel('WeCom chat', value);
if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) {
continue;
}
if (seen.has(value)) continue;
seen.add(value);
options.push({ value, label, kind: 'channel' });
}
}
return options;
}
async function fetchFeishuTargetOptions(accountId?: string, query?: string): Promise<ChannelTargetOptionView[]> {
const config = await readOpenClawConfig() as JsonRecord;
const accountConfig = mergeChannelAccountConfig(config, 'feishu', accountId);
const appId = typeof accountConfig.appId === 'string' ? accountConfig.appId.trim() : '';
const appSecret = typeof accountConfig.appSecret === 'string' ? accountConfig.appSecret.trim() : '';
if (!appId || !appSecret) {
return [];
}
const q = query?.trim().toLowerCase() || '';
const configuredTargets: ChannelTargetOptionView[] = [];
const pushIfMatches = (value: string | null, label?: string, kind?: ChannelTargetOptionView['kind']) => {
if (!value) return;
const option = buildFeishuTargetOption(value, label, kind);
if (q && !option.label.toLowerCase().includes(q) && !option.value.toLowerCase().includes(q)) return;
configuredTargets.push(option);
};
const allowFrom = Array.isArray(accountConfig.allowFrom) ? accountConfig.allowFrom : [];
for (const entry of allowFrom) {
pushIfMatches(normalizeFeishuTargetValue(entry));
}
const dms = accountConfig.dms && typeof accountConfig.dms === 'object'
? accountConfig.dms as Record<string, unknown>
: undefined;
if (dms) {
for (const userId of Object.keys(dms)) {
pushIfMatches(normalizeFeishuTargetValue(userId));
}
}
const groups = accountConfig.groups && typeof accountConfig.groups === 'object'
? accountConfig.groups as Record<string, unknown>
: undefined;
if (groups) {
for (const groupId of Object.keys(groups)) {
pushIfMatches(normalizeFeishuTargetValue(groupId));
}
}
const origin = resolveFeishuApiOrigin(accountConfig.domain);
const tokenResponse = await proxyAwareFetch(`${origin}/open-apis/auth/v3/tenant_access_token/internal`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
app_id: appId,
app_secret: appSecret,
}),
});
const tokenPayload = await tokenResponse.json() as {
code?: number;
msg?: string;
tenant_access_token?: string;
};
if (!tokenResponse.ok || tokenPayload.code !== 0 || !tokenPayload.tenant_access_token) {
return configuredTargets;
}
const headers = {
Authorization: `Bearer ${tokenPayload.tenant_access_token}`,
};
const liveTargets: ChannelTargetOptionView[] = [];
try {
const appResponse = await proxyAwareFetch(`${origin}/open-apis/application/v6/applications/${appId}?lang=zh_cn`, {
headers,
});
const appPayload = await appResponse.json() as {
code?: number;
data?: { app?: JsonRecord } & JsonRecord;
app?: JsonRecord;
};
if (appResponse.ok && appPayload.code === 0) {
const app = (appPayload.data?.app ?? appPayload.app ?? appPayload.data) as JsonRecord | undefined;
const owner = (app?.owner && typeof app.owner === 'object') ? app.owner as JsonRecord : undefined;
const ownerType = owner?.owner_type ?? owner?.type;
const ownerOpenId = typeof owner?.owner_id === 'string' ? owner.owner_id.trim() : '';
const creatorId = typeof app?.creator_id === 'string' ? app.creator_id.trim() : '';
const effectiveOwnerOpenId = ownerType === 2 && ownerOpenId ? ownerOpenId : (creatorId || ownerOpenId);
pushIfMatches(effectiveOwnerOpenId ? `user:${effectiveOwnerOpenId}` : null, 'App Owner', 'user');
}
} catch {
// ignore
}
try {
const userResponse = await proxyAwareFetch(`${origin}/open-apis/contact/v3/users?page_size=100`, { headers });
const userPayload = await userResponse.json() as {
code?: number;
data?: { items?: Array<{ open_id?: string; name?: string }> };
};
if (userResponse.ok && userPayload.code === 0) {
for (const item of userPayload.data?.items ?? []) {
const value = normalizeFeishuTargetValue(item.open_id);
if (!value) continue;
const option = buildFeishuTargetOption(value, item.name, 'user');
if (q && !option.label.toLowerCase().includes(q) && !option.value.toLowerCase().includes(q)) continue;
liveTargets.push(option);
}
}
} catch {
// ignore
}
try {
const chatResponse = await proxyAwareFetch(`${origin}/open-apis/im/v1/chats?page_size=100`, { headers });
const chatPayload = await chatResponse.json() as {
code?: number;
data?: { items?: Array<{ chat_id?: string; name?: string }> };
};
if (chatResponse.ok && chatPayload.code === 0) {
for (const item of chatPayload.data?.items ?? []) {
const value = normalizeFeishuTargetValue(item.chat_id);
if (!value) continue;
const option = buildFeishuTargetOption(value, item.name, 'group');
if (q && !option.label.toLowerCase().includes(q) && !option.value.toLowerCase().includes(q)) continue;
liveTargets.push(option);
}
}
} catch {
// ignore
}
return mergeTargetOptions(configuredTargets, liveTargets);
}
async function listQQBotKnownTargetOptions(accountId?: string, query?: string): Promise<ChannelTargetOptionView[]> {
const knownUsersPath = join(getOpenClawConfigDir(), 'qqbot', 'data', 'known-users.json');
const raw = await readFile(knownUsersPath, 'utf8').catch(() => '');
if (!raw.trim()) return [];
let records: QQBotKnownUserRecord[];
try {
records = JSON.parse(raw) as QQBotKnownUserRecord[];
} catch {
return [];
}
const q = query?.trim().toLowerCase() || '';
const options: ChannelTargetOptionView[] = [];
const seen = new Set<string>();
const filtered = records
.filter((record) => !accountId || record.accountId === accountId)
.sort((left, right) => (right.lastSeenAt ?? 0) - (left.lastSeenAt ?? 0));
for (const record of filtered) {
if (record.type === 'group') {
const groupId = (record.groupOpenid || record.openid || '').trim();
if (!groupId) continue;
const value = `qqbot:group:${groupId}`;
const label = buildChannelTargetLabel(record.nickname || groupId, value);
if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) continue;
if (seen.has(value)) continue;
seen.add(value);
options.push({ value, label, kind: 'group' });
continue;
}
const userId = (record.openid || '').trim();
if (!userId) continue;
const value = `qqbot:c2c:${userId}`;
const label = buildChannelTargetLabel(record.nickname || userId, value);
if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) continue;
if (seen.has(value)) continue;
seen.add(value);
options.push({ value, label, kind: 'user' });
}
return options;
}
async function listWeComTargetOptions(accountId?: string, query?: string): Promise<ChannelTargetOptionView[]> {
const [reqIdTargets, sessionTargets] = await Promise.all([
listWeComReqIdTargetOptions(accountId, query),
listSessionDerivedTargetOptions({ channelType: 'wecom', accountId, query }),
]);
return mergeTargetOptions(sessionTargets, reqIdTargets);
}
async function listDingTalkTargetOptions(accountId?: string, query?: string): Promise<ChannelTargetOptionView[]> {
return await listSessionDerivedTargetOptions({ channelType: 'dingtalk', accountId, query });
}
async function listWeChatTargetOptions(accountId?: string, query?: string): Promise<ChannelTargetOptionView[]> {
return await listSessionDerivedTargetOptions({ channelType: OPENCLAW_WECHAT_CHANNEL_TYPE, accountId, query });
}
async function listConfigDirectoryTargetOptions(params: {
channelType: 'discord' | 'telegram' | 'slack' | 'whatsapp';
accountId?: string;
query?: string;
}): Promise<ChannelTargetOptionView[]> {
const cfg = await readOpenClawConfig();
const commonParams = {
cfg,
accountId: params.accountId ?? null,
query: params.query ?? null,
limit: 100,
};
if (params.channelType === 'discord') {
const [users, groups] = await Promise.all([
listDiscordDirectoryPeersFromConfig(commonParams),
listDiscordDirectoryGroupsFromConfig(commonParams),
]);
return buildDirectoryTargetOptions(
[...users, ...groups] as DirectoryEntry[],
normalizeDiscordMessagingTarget,
);
}
if (params.channelType === 'telegram') {
const [users, groups] = await Promise.all([
listTelegramDirectoryPeersFromConfig(commonParams),
listTelegramDirectoryGroupsFromConfig(commonParams),
]);
return buildDirectoryTargetOptions(
[...users, ...groups] as DirectoryEntry[],
normalizeTelegramMessagingTarget,
);
}
if (params.channelType === 'slack') {
const [users, groups] = await Promise.all([
listSlackDirectoryPeersFromConfig(commonParams),
listSlackDirectoryGroupsFromConfig(commonParams),
]);
return buildDirectoryTargetOptions(
[...users, ...groups] as DirectoryEntry[],
normalizeSlackMessagingTarget,
);
}
const [users, groups] = await Promise.all([
listWhatsAppDirectoryPeersFromConfig(commonParams),
listWhatsAppDirectoryGroupsFromConfig(commonParams),
]);
return buildDirectoryTargetOptions(
[...users, ...groups] as DirectoryEntry[],
normalizeWhatsAppMessagingTarget,
);
}
async function listChannelTargetOptions(params: {
channelType: string;
accountId?: string;
query?: string;
}): Promise<ChannelTargetOptionView[]> {
const storedChannelType = resolveStoredChannelType(params.channelType);
const cacheKey = buildChannelTargetCacheKey(params);
if (CHANNEL_TARGET_CACHE_ENABLED) {
const cached = channelTargetCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.targets;
}
if (cached) {
channelTargetCache.delete(cacheKey);
}
}
const targets = await (async (): Promise<ChannelTargetOptionView[]> => {
if (storedChannelType === 'feishu') {
const [feishuTargets, sessionTargets] = await Promise.all([
fetchFeishuTargetOptions(params.accountId, params.query),
listSessionDerivedTargetOptions(params),
]);
return mergeTargetOptions(feishuTargets, sessionTargets);
}
if (storedChannelType === 'qqbot') {
const [knownTargets, sessionTargets] = await Promise.all([
listQQBotKnownTargetOptions(params.accountId, params.query),
listSessionDerivedTargetOptions(params),
]);
return mergeTargetOptions(knownTargets, sessionTargets);
}
if (storedChannelType === 'wecom') {
return await listWeComTargetOptions(params.accountId, params.query);
}
if (storedChannelType === 'dingtalk') {
return await listDingTalkTargetOptions(params.accountId, params.query);
}
if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) {
return await listWeChatTargetOptions(params.accountId, params.query);
}
if (
storedChannelType === 'discord'
|| storedChannelType === 'telegram'
|| storedChannelType === 'slack'
|| storedChannelType === 'whatsapp'
) {
const [directoryTargets, sessionTargets] = await Promise.all([
listConfigDirectoryTargetOptions({
channelType: storedChannelType,
accountId: params.accountId,
query: params.query,
}),
listSessionDerivedTargetOptions(params),
]);
return mergeTargetOptions(directoryTargets, sessionTargets);
}
return await listSessionDerivedTargetOptions(params);
})();
if (CHANNEL_TARGET_CACHE_ENABLED) {
channelTargetCache.set(cacheKey, {
expiresAt: Date.now() + CHANNEL_TARGET_CACHE_TTL_MS,
targets,
});
}
return targets;
}
export async function handleChannelRoutes(
req: IncomingMessage,
res: ServerResponse,
@@ -393,6 +1032,24 @@ export async function handleChannelRoutes(
return true;
}
if (url.pathname === '/api/channels/targets' && req.method === 'GET') {
try {
const channelType = url.searchParams.get('channelType')?.trim() || '';
const accountId = url.searchParams.get('accountId')?.trim() || undefined;
const query = url.searchParams.get('query')?.trim() || undefined;
if (!channelType) {
sendJson(res, 400, { success: false, error: 'channelType is required' });
return true;
}
const targets = await listChannelTargetOptions({ channelType, accountId, query });
sendJson(res, 200, { success: true, channelType, accountId, targets });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);

View File

@@ -4,6 +4,7 @@ import { join } from 'node:path';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { getOpenClawConfigDir } from '../../utils/paths';
import { toOpenClawChannelType, toUiChannelType } from '../../utils/channel-alias';
interface GatewayCronJob {
id: string;
@@ -14,7 +15,7 @@ interface GatewayCronJob {
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
@@ -261,11 +262,109 @@ export function buildCronSessionFallbackMessages(params: {
return messages.slice(-limit);
}
type JsonRecord = Record<string, unknown>;
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
if (!channel) return null;
return toUiChannelType(channel) === 'wechat'
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
: null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: GatewayCronDelivery['mode'] = 'none',
): GatewayCronDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return { mode: fallbackMode };
}
const delivery = rawDelivery as JsonRecord;
const mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim()
? delivery.to.trim()
: undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) {
return { mode: 'none' };
}
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return {};
}
const delivery = rawDelivery as JsonRecord;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) {
patch.to = typeof delivery.to === 'string' ? delivery.to : '';
}
if ('accountId' in delivery) {
patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
}
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) {
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
}
return patch;
}
function transformCronJob(job: GatewayCronJob) {
const message = job.payload?.message || job.payload?.text || '';
const channelType = job.delivery?.channel;
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType
? { ...gatewayDelivery, channel: channelType }
: gatewayDelivery;
const target = channelType
? { channelType, channelId: channelType, channelName: channelType }
? {
channelType,
channelId: delivery.accountId || gatewayDelivery.channel,
channelName: channelType,
recipient: delivery.to,
}
: undefined;
const lastRun = job.state?.lastRunAtMs
? {
@@ -284,6 +383,7 @@ function transformCronJob(job: GatewayCronJob) {
name: job.name,
message,
schedule: job.schedule,
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
@@ -378,7 +478,19 @@ export async function handleCronRoutes(
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
try {
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
const input = await parseJsonBody<{
name: string;
message: string;
schedule: string;
delivery?: GatewayCronDelivery;
enabled?: boolean;
}>(req);
const delivery = normalizeCronDelivery(input.delivery);
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(delivery.channel);
if (delivery.mode === 'announce' && unsupportedDeliveryError) {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
const result = await ctx.gatewayManager.rpc('cron.add', {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
@@ -386,7 +498,7 @@ export async function handleCronRoutes(
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
delivery,
});
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
@@ -399,15 +511,23 @@ export async function handleCronRoutes(
try {
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
const input = await parseJsonBody<Record<string, unknown>>(req);
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
const patch = buildCronUpdatePatch(input);
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
sendJson(res, 400, { success: false, error: unsupportedDeliveryError });
return true;
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
const result = await ctx.gatewayManager.rpc('cron.update', { id, patch });
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}

View File

@@ -32,6 +32,7 @@ import {
validateChannelConfig,
validateChannelCredentials,
} from '../utils/channel-config';
import { toOpenClawChannelType, toUiChannelType } from '../utils/channel-alias';
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
import {
ensureDingTalkPluginInstalled,
@@ -494,7 +495,13 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
break;
}
if (request.action === 'create') {
type CronCreateInput = { name: string; message: string; schedule: string; enabled?: boolean };
type CronCreateInput = {
name: string;
message: string;
schedule: string;
delivery?: { mode: string; channel?: string; to?: string };
enabled?: boolean;
};
const payload = request.payload as
| { input?: CronCreateInput }
| [CronCreateInput]
@@ -516,8 +523,12 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
delivery: normalizeCronDelivery(input.delivery),
};
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel);
if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const created = await gatewayManager.rpc('cron.add', gatewayInput);
data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created;
break;
@@ -530,11 +541,19 @@ function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const id = Array.isArray(payload) ? payload[0] : payload?.id;
const input = Array.isArray(payload) ? payload[1] : payload?.input;
if (!id || !input) throw new Error('Invalid cron.update payload');
const patch = { ...input };
if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule };
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
const patch = buildCronUpdatePatch(input);
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
}
data = await gatewayManager.rpc('cron.update', { id, patch });
break;
@@ -716,7 +735,7 @@ interface GatewayCronJob {
updatedAtMs: number;
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
payload: { kind: string; message?: string; text?: string };
delivery?: { mode: string; channel?: string; to?: string };
delivery?: { mode: string; channel?: string; to?: string; accountId?: string };
sessionTarget?: string;
state: {
nextRunAtMs?: number;
@@ -727,17 +746,109 @@ interface GatewayCronJob {
};
}
type GatewayCronDelivery = NonNullable<GatewayCronJob['delivery']>;
function getUnsupportedCronDeliveryError(channel: string | undefined): string | null {
if (!channel) return null;
return toUiChannelType(channel) === 'wechat'
? 'WeChat scheduled delivery is not supported because the plugin requires a live conversation context token.'
: null;
}
function normalizeCronDelivery(
rawDelivery: unknown,
fallbackMode: GatewayCronDelivery['mode'] = 'none',
): GatewayCronDelivery {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return { mode: fallbackMode };
}
const delivery = rawDelivery as Record<string, unknown>;
const mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: fallbackMode;
const channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: undefined;
const to = typeof delivery.to === 'string' && delivery.to.trim()
? delivery.to.trim()
: undefined;
const accountId = typeof delivery.accountId === 'string' && delivery.accountId.trim()
? delivery.accountId.trim()
: undefined;
if (mode === 'announce' && !channel) {
return { mode: 'none' };
}
return {
mode,
...(channel ? { channel } : {}),
...(to ? { to } : {}),
...(accountId ? { accountId } : {}),
};
}
function normalizeCronDeliveryPatch(rawDelivery: unknown): Record<string, unknown> {
if (!rawDelivery || typeof rawDelivery !== 'object') {
return {};
}
const delivery = rawDelivery as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if ('mode' in delivery) {
patch.mode = typeof delivery.mode === 'string' && delivery.mode.trim()
? delivery.mode.trim()
: 'none';
}
if ('channel' in delivery) {
patch.channel = typeof delivery.channel === 'string' && delivery.channel.trim()
? toOpenClawChannelType(delivery.channel.trim())
: '';
}
if ('to' in delivery) {
patch.to = typeof delivery.to === 'string' ? delivery.to : '';
}
if ('accountId' in delivery) {
patch.accountId = typeof delivery.accountId === 'string' ? delivery.accountId : '';
}
return patch;
}
function buildCronUpdatePatch(input: Record<string, unknown>): Record<string, unknown> {
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
if ('delivery' in patch) {
patch.delivery = normalizeCronDeliveryPatch(patch.delivery);
}
return patch;
}
/**
* Transform a Gateway CronJob to the frontend CronJob format
*/
function transformCronJob(job: GatewayCronJob) {
// Extract message from payload
const message = job.payload?.message || job.payload?.text || '';
const gatewayDelivery = normalizeCronDelivery(job.delivery);
const channelType = gatewayDelivery.channel ? toUiChannelType(gatewayDelivery.channel) : undefined;
const delivery = channelType
? { ...gatewayDelivery, channel: channelType }
: gatewayDelivery;
// Build target from delivery info — only if a delivery channel is specified
const channelType = job.delivery?.channel;
const target = channelType
? { channelType, channelId: channelType, channelName: channelType }
? { channelType, channelId: delivery.accountId || gatewayDelivery.channel, channelName: channelType, recipient: delivery.to }
: undefined;
// Build lastRun from state
@@ -760,6 +871,7 @@ function transformCronJob(job: GatewayCronJob) {
name: job.name,
message,
schedule: job.schedule, // Pass the object through; frontend parseCronSchedule handles it
delivery,
target,
enabled: job.enabled,
createdAt: new Date(job.createdAtMs).toISOString(),
@@ -831,6 +943,7 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
name: string;
message: string;
schedule: string;
delivery?: GatewayCronDelivery;
enabled?: boolean;
}) => {
try {
@@ -845,8 +958,12 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
// not external messaging channels. Setting mode='none' prevents
// the Gateway from attempting channel delivery (which would fail
// with "Channel is required" when no channels are configured).
delivery: { mode: 'none' },
delivery: normalizeCronDelivery(input.delivery),
};
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(gatewayInput.delivery.channel);
if (gatewayInput.delivery.mode === 'announce' && unsupportedDeliveryError) {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.add', gatewayInput);
// Transform the returned job to frontend format
if (result && typeof result === 'object') {
@@ -862,18 +979,22 @@ function registerCronHandlers(gatewayManager: GatewayManager): void {
// Update an existing cron job
ipcMain.handle('cron:update', async (_, id: string, input: Record<string, unknown>) => {
try {
// Transform schedule string to CronSchedule object if present
const patch = { ...input };
if (typeof patch.schedule === 'string') {
patch.schedule = { kind: 'cron', expr: patch.schedule };
}
// Transform message to payload format if present
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
const patch = buildCronUpdatePatch(input);
const deliveryPatch = patch.delivery && typeof patch.delivery === 'object'
? patch.delivery as Record<string, unknown>
: undefined;
const deliveryChannel = typeof deliveryPatch?.channel === 'string' && deliveryPatch.channel.trim()
? deliveryPatch.channel.trim()
: undefined;
const deliveryMode = typeof deliveryPatch?.mode === 'string' && deliveryPatch.mode.trim()
? deliveryPatch.mode.trim()
: undefined;
const unsupportedDeliveryError = getUnsupportedCronDeliveryError(deliveryChannel);
if (unsupportedDeliveryError && deliveryMode !== 'none') {
throw new Error(unsupportedDeliveryError);
}
const result = await gatewayManager.rpc('cron.update', { id, patch });
return result;
return result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result;
} catch (error) {
console.error('Failed to update cron job:', error);
throw error;

View File

@@ -2,11 +2,14 @@
* Path Utilities
* Cross-platform path resolution helpers
*/
import { app } from 'electron';
import { createRequire } from 'node:module';
import { join } from 'path';
import { homedir } from 'os';
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs';
import { logger } from './logger';
const require = createRequire(import.meta.url);
type ElectronAppLike = Pick<typeof import('electron').app, 'isPackaged' | 'getPath' | 'getAppPath'>;
export {
quoteForCmd,
@@ -16,6 +19,24 @@ export {
appendNodeRequireToNodeOptions,
} from './win-shell';
function getElectronApp() {
if (process.versions?.electron) {
return (require('electron') as typeof import('electron')).app;
}
const fallbackUserData = process.env.CLAWX_USER_DATA_DIR?.trim() || join(homedir(), '.clawx');
const fallbackAppPath = process.cwd();
const fallbackApp: ElectronAppLike = {
isPackaged: false,
getPath: (name) => {
if (name === 'userData') return fallbackUserData;
return fallbackUserData;
},
getAppPath: () => fallbackAppPath,
};
return fallbackApp;
}
/**
* Expand ~ to home directory
*/
@@ -51,14 +72,14 @@ export function getClawXConfigDir(): string {
* Get ClawX logs directory
*/
export function getLogsDir(): string {
return join(app.getPath('userData'), 'logs');
return join(getElectronApp().getPath('userData'), 'logs');
}
/**
* Get ClawX data directory
*/
export function getDataDir(): string {
return app.getPath('userData');
return getElectronApp().getPath('userData');
}
/**
@@ -74,7 +95,7 @@ export function ensureDir(dir: string): void {
* Get resources directory (for bundled assets)
*/
export function getResourcesDir(): string {
if (app.isPackaged) {
if (getElectronApp().isPackaged) {
return join(process.resourcesPath, 'resources');
}
return join(__dirname, '../../resources');
@@ -93,7 +114,7 @@ export function getPreloadPath(): string {
* - Development: from node_modules/openclaw
*/
export function getOpenClawDir(): string {
if (app.isPackaged) {
if (getElectronApp().isPackaged) {
return join(process.resourcesPath, 'openclaw');
}
// Development: use node_modules/openclaw
@@ -127,7 +148,7 @@ export function getOpenClawEntryPath(): string {
* Get ClawHub CLI entry script path (clawdhub.js)
*/
export function getClawHubCliEntryPath(): string {
return join(app.getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js');
return join(getElectronApp().getAppPath(), 'node_modules', 'clawhub', 'bin', 'clawdhub.js');
}
/**
@@ -135,7 +156,7 @@ export function getClawHubCliEntryPath(): string {
*/
export function getClawHubCliBinPath(): string {
const binName = process.platform === 'win32' ? 'clawhub.cmd' : 'clawhub';
return join(app.getAppPath(), 'node_modules', '.bin', binName);
return join(getElectronApp().getAppPath(), 'node_modules', '.bin', binName);
}
/**
@@ -192,6 +213,11 @@ export function getOpenClawStatus(): OpenClawStatus {
version,
};
logger.info('OpenClaw status:', status);
try {
const { logger } = require('./logger') as typeof import('./logger');
logger.info('OpenClaw status:', status);
} catch {
// Ignore logger bootstrap issues in non-Electron contexts such as unit tests.
}
return status;
}

View File

@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { normalizeOpenClawAccountId } from './channel-alias';
import { getOpenClawResolvedDir } from './paths';
@@ -18,9 +18,22 @@ const WECHAT_STATE_DIR = join(OPENCLAW_DIR, 'openclaw-weixin');
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
const require = createRequire(import.meta.url);
type QrCodeMatrix = {
addData(input: string): void;
make(): void;
getModuleCount(): number;
isDark(row: number, col: number): boolean;
};
type QrCodeConstructor = new (typeNumber: number, errorCorrectionLevel: unknown) => QrCodeMatrix;
type QrErrorCorrectLevelModule = {
L: unknown;
};
type QrRenderDeps = {
QRCode: typeof import('qrcode-terminal/vendor/QRCode/index.js');
QRErrorCorrectLevel: typeof import('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
QRCode: QrCodeConstructor;
QRErrorCorrectLevel: QrErrorCorrectLevelModule;
};
let qrRenderDeps: QrRenderDeps | null = null;
@@ -31,10 +44,11 @@ function getQrRenderDeps(): QrRenderDeps {
}
const openclawRequire = createRequire(join(getOpenClawResolvedDir(), 'package.json'));
const qrcodeTerminalPath = dirname(openclawRequire.resolve('qrcode-terminal/package.json'));
const qrCodeModulePath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/index.js');
const qrErrorCorrectLevelPath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
qrRenderDeps = {
QRCode: require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index.js')),
QRErrorCorrectLevel: require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js')),
QRCode: require(qrCodeModulePath),
QRErrorCorrectLevel: require(qrErrorCorrectLevelPath),
};
return qrRenderDeps;
}

View File

@@ -28,7 +28,8 @@ function resolveOpenClawPackageJson(packageName: string): string {
}
const baileysPath = dirname(resolveOpenClawPackageJson('@whiskeysockets/baileys'));
const qrcodeTerminalPath = dirname(resolveOpenClawPackageJson('qrcode-terminal'));
const qrCodeModulePath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/index.js');
const qrErrorCorrectLevelPath = openclawRequire.resolve('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
// Load Baileys dependencies dynamically
const {
@@ -39,8 +40,8 @@ const {
} = require(baileysPath);
// Load QRCode dependencies dynamically
const QRCodeModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index.js'));
const QRErrorCorrectLevelModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js'));
const QRCodeModule = require(qrCodeModulePath);
const QRErrorCorrectLevelModule = require(qrErrorCorrectLevelPath);
// Types from Baileys (approximate since we don't have types for dynamic require)
interface BaileysError extends Error {
@@ -438,4 +439,4 @@ export class WhatsAppLoginManager extends EventEmitter {
}
}
export const whatsAppLoginManager = new WhatsAppLoginManager();
export const whatsAppLoginManager = new WhatsAppLoginManager();

View File

@@ -33,6 +33,39 @@
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
"usePresets": "Use presets",
"useCustomCron": "Use custom cron",
"deliveryTitle": "Delivery",
"deliveryDescription": "Choose whether this task stays in ClawX or is pushed to an external channel.",
"deliveryModeNone": "In ClawX only",
"deliveryModeNoneDesc": "Run the task and keep the result in the app.",
"deliveryModeAnnounce": "External channel",
"deliveryModeAnnounceDesc": "Send the final result through a configured channel.",
"deliveryChannel": "Channel",
"channelUnsupportedTag": "Unsupported",
"deliveryAccount": "Sending Account",
"selectDeliveryAccount": "Select an account",
"deliveryAccountDesc": "Uses the same configured account list shown on the Channels page.",
"selectChannel": "Select a channel",
"deliveryChannelUnsupported": "WeChat does not currently support scheduled outbound delivery because the plugin requires a live conversation context token.",
"deliveryDefaultAccountHint": "Uses the channel's default account: {{account}}",
"deliveryTarget": "Recipient / Target",
"selectDeliveryTarget": "Select a delivery target",
"loadingTargets": "Loading targets...",
"currentTarget": "Current target",
"deliveryTargetGroupDefault": "Recommended",
"deliveryTargetGroupUsers": "Users",
"deliveryTargetGroupChats": "Chats",
"noDeliveryTargets": "No delivery targets are available for the selected Feishu account.",
"deliveryTargetPlaceholder": "Enter the delivery target for this channel",
"deliveryTargetPlaceholderFeishu": "e.g., user:ou_xxx or chat:oc_xxx",
"deliveryTargetDesc": "This maps to delivery.to in OpenClaw and is sent as-is.",
"deliveryTargetDescFeishu": "Use a Feishu user target like user:ou_xxx or a chat target like chat:oc_xxx.",
"deliveryTargetDescAuto": "Select from targets discovered for the chosen channel account.",
"deliveryTargetDescFeishuSelect": "Select a verified Feishu recipient from the available users or chats.",
"deliveryTargetDescFeishuAccount": "Choose from the configured Feishu channel accounts, using the same account list as the Channels page.",
"feishuMainTargetTitle": "Feishu main account",
"feishuMainTargetDesc": "Auto-fill the current default Feishu account owner as the delivery target.",
"useFeishuMainTarget": "Use main account",
"resolvingTarget": "Resolving...",
"targetChannel": "Target Channel",
"noChannels": "No channels available. Add a channel first.",
"discordChannelId": "Discord Channel ID",
@@ -65,6 +98,11 @@
"nameRequired": "Please enter a task name",
"messageRequired": "Please enter a message",
"channelRequired": "Please select a channel",
"deliveryChannelUnsupported": "{{channel}} does not support scheduled delivery yet",
"deliveryTargetRequired": "Please enter a delivery target",
"deliveryTargetResolved": "Delivery target resolved",
"failedLoadDeliveryTargets": "Failed to load delivery targets",
"failedResolveDeliveryTarget": "Failed to resolve the default delivery target",
"discordIdRequired": "Please enter a Discord Channel ID",
"scheduleRequired": "Please select or enter a schedule"
},

View File

@@ -33,6 +33,37 @@
"cronPlaceholder": "Cron式0 9 * * *",
"usePresets": "プリセットを使用",
"useCustomCron": "カスタムCronを使用",
"deliveryTitle": "配信設定",
"deliveryDescription": "結果を ClawX 内だけに残すか、外部チャンネルへ送信するかを選びます。",
"deliveryModeNone": "ClawX 内のみ",
"deliveryModeNoneDesc": "タスクを実行し、結果はアプリ内だけに残します。",
"deliveryModeAnnounce": "外部チャンネル",
"deliveryModeAnnounceDesc": "最終結果を設定済みチャンネルへ送信します。",
"deliveryChannel": "チャンネル",
"deliveryAccount": "送信アカウント",
"selectDeliveryAccount": "アカウントを選択",
"deliveryAccountDesc": "Channels ページと同じ設定済みアカウント一覧を使います。",
"selectChannel": "チャンネルを選択",
"deliveryDefaultAccountHint": "このチャンネルの既定アカウントを使います: {{account}}",
"deliveryTarget": "送信先",
"selectDeliveryTarget": "送信先を選択",
"loadingTargets": "送信先を読み込み中...",
"currentTarget": "現在の送信先",
"deliveryTargetGroupDefault": "おすすめ",
"deliveryTargetGroupUsers": "ユーザー",
"deliveryTargetGroupChats": "グループ",
"noDeliveryTargets": "この Feishu アカウントでは選択可能な送信先が見つかりませんでした。",
"deliveryTargetPlaceholder": "このチャンネルの送信先を入力",
"deliveryTargetPlaceholderFeishu": "例: user:ou_xxx または chat:oc_xxx",
"deliveryTargetDesc": "この値は OpenClaw の delivery.to にそのまま保存されます。",
"deliveryTargetDescFeishu": "Feishu では user:ou_xxx のユーザー宛て、または chat:oc_xxx のグループ宛てを指定できます。",
"deliveryTargetDescAuto": "選択したチャンネルアカウントで見つかった送信先から選べます。",
"deliveryTargetDescFeishuSelect": "利用可能な Feishu ユーザーまたはグループから検証済みの送信先を選択してください。",
"deliveryTargetDescFeishuAccount": "Channels ページと同じ Feishu アカウント一覧を使い、選択したアカウントに対応するメイン送信先へ配信します。",
"feishuMainTargetTitle": "Feishu メインアカウント",
"feishuMainTargetDesc": "現在の既定 Feishu アカウントの所有者を送信先として自動入力します。",
"useFeishuMainTarget": "メインアカウントを使う",
"resolvingTarget": "解決中...",
"targetChannel": "ターゲットチャンネル",
"noChannels": "利用可能なチャンネルがありません。先にチャンネルを追加してください。",
"discordChannelId": "DiscordチャンネルID",
@@ -65,6 +96,10 @@
"nameRequired": "タスク名を入力してください",
"messageRequired": "メッセージを入力してください",
"channelRequired": "チャンネルを選択してください",
"deliveryTargetRequired": "送信先を入力してください",
"deliveryTargetResolved": "送信先を解決しました",
"failedLoadDeliveryTargets": "送信先の読み込みに失敗しました",
"failedResolveDeliveryTarget": "既定の送信先を解決できませんでした",
"discordIdRequired": "DiscordチャンネルIDを入力してください",
"scheduleRequired": "スケジュールを選択または入力してください"
},

View File

@@ -33,6 +33,39 @@
"cronPlaceholder": "Cron 表达式 (例如0 9 * * *)",
"usePresets": "使用预设",
"useCustomCron": "使用自定义 Cron",
"deliveryTitle": "投递设置",
"deliveryDescription": "选择仅在 ClawX 内保留结果,或把最终结果推送到外部通道。",
"deliveryModeNone": "仅在 ClawX 内",
"deliveryModeNoneDesc": "任务照常运行,结果只保留在应用内。",
"deliveryModeAnnounce": "发送到外部通道",
"deliveryModeAnnounceDesc": "将最终结果投递到已配置的消息通道。",
"deliveryChannel": "通道",
"channelUnsupportedTag": "暂不支持",
"deliveryAccount": "发送账号",
"selectDeliveryAccount": "选择账号",
"deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。",
"selectChannel": "选择通道",
"deliveryChannelUnsupported": "微信通道当前不支持定时任务主动投递,因为插件要求实时会话里的 contextToken。",
"deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}",
"deliveryTarget": "接收目标",
"selectDeliveryTarget": "选择接收目标",
"loadingTargets": "正在加载目标...",
"currentTarget": "当前目标",
"deliveryTargetGroupDefault": "推荐",
"deliveryTargetGroupUsers": "用户",
"deliveryTargetGroupChats": "群聊",
"noDeliveryTargets": "当前飞书账号暂无可选投递目标。",
"deliveryTargetPlaceholder": "输入该通道的投递目标",
"deliveryTargetPlaceholderFeishu": "例如user:ou_xxx 或 chat:oc_xxx",
"deliveryTargetDesc": "这里会直接写入 OpenClaw 的 delivery.to。",
"deliveryTargetDescFeishu": "飞书可以填写用户目标 user:ou_xxx或群聊目标 chat:oc_xxx。",
"deliveryTargetDescAuto": "这里会展示该通道账号下已发现的可投递目标。",
"deliveryTargetDescFeishuSelect": "请从可用的飞书用户或群聊中直接选择已校验的目标。",
"deliveryTargetDescFeishuAccount": "这里直接复用频道页的飞书账号列表,按所选账号对应的主账号目标进行投递。",
"feishuMainTargetTitle": "飞书主账号",
"feishuMainTargetDesc": "自动把当前默认飞书账号的主账号填入为投递目标。",
"useFeishuMainTarget": "使用主账号",
"resolvingTarget": "解析中...",
"targetChannel": "目标频道",
"noChannels": "无可用频道。请先添加频道。",
"discordChannelId": "Discord 频道 ID",
@@ -64,7 +97,12 @@
"failedDelete": "删除任务失败",
"nameRequired": "请输入任务名称",
"messageRequired": "请输入消息",
"channelRequired": "请选择道",
"channelRequired": "请选择道",
"deliveryChannelUnsupported": "{{channel}} 暂不支持定时任务投递",
"deliveryTargetRequired": "请输入投递目标",
"deliveryTargetResolved": "已解析投递目标",
"failedLoadDeliveryTargets": "加载投递目标失败",
"failedResolveDeliveryTarget": "解析默认投递目标失败",
"discordIdRequired": "请输入 Discord 频道 ID",
"scheduleRequired": "请选择或输入调度计划"
},

View File

@@ -2,7 +2,7 @@
* Cron Page
* Manage scheduled tasks
*/
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, type ReactNode, type SelectHTMLAttributes } from 'react';
import {
Plus,
Clock,
@@ -19,21 +19,24 @@ import {
Timer,
History,
Pause,
ChevronDown,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { hostApiFetch } from '@/lib/host-api';
import { useCronStore } from '@/stores/cron';
import { useGatewayStore } from '@/stores/gateway';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { formatRelativeTime, cn } from '@/lib/utils';
import { toast } from 'sonner';
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
@@ -170,14 +173,74 @@ function estimateNextRun(scheduleExpr: string): string | null {
return null;
}
interface DeliveryChannelAccount {
accountId: string;
name: string;
isDefault: boolean;
}
interface DeliveryChannelGroup {
channelType: string;
defaultAccountId: string;
accounts: DeliveryChannelAccount[];
}
interface ChannelTargetOption {
value: string;
label: string;
kind: 'user' | 'group' | 'channel';
}
function isKnownChannelType(value: string): value is ChannelType {
return value in CHANNEL_NAMES;
}
function getChannelDisplayName(value: string): string {
return isKnownChannelType(value) ? CHANNEL_NAMES[value] : value;
}
function getDeliveryAccountDisplayName(account: DeliveryChannelAccount, t: TFunction): string {
return account.accountId === 'default' && account.name === account.accountId
? t('channels:account.mainAccount')
: account.name;
}
const TESTED_CRON_DELIVERY_CHANNELS = new Set<string>(['feishu', 'telegram', 'qqbot', 'wecom']);
function isSupportedCronDeliveryChannel(channelType: string): boolean {
return TESTED_CRON_DELIVERY_CHANNELS.has(channelType);
}
interface SelectFieldProps extends SelectHTMLAttributes<HTMLSelectElement> {
children: ReactNode;
}
function SelectField({ className, children, ...props }: SelectFieldProps) {
return (
<div className="relative">
<Select
className={cn(
'h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-background text-[13px] pr-10 [background-image:none] appearance-none',
className,
)}
{...props}
>
{children}
</Select>
<ChevronDown className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
// Create/Edit Task Dialog
interface TaskDialogProps {
job?: CronJob;
configuredChannels: DeliveryChannelGroup[];
onClose: () => void;
onSave: (input: CronJobCreateInput) => Promise<void>;
}
function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProps) {
const { t } = useTranslation('cron');
const [saving, setSaving] = useState(false);
@@ -197,7 +260,100 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
const [customSchedule, setCustomSchedule] = useState('');
const [useCustom, setUseCustom] = useState(false);
const [enabled, setEnabled] = useState(job?.enabled ?? true);
const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>(job?.delivery?.mode === 'announce' ? 'announce' : 'none');
const [deliveryChannel, setDeliveryChannel] = useState(job?.delivery?.channel || '');
const [deliveryTarget, setDeliveryTarget] = useState(job?.delivery?.to || '');
const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState(job?.delivery?.accountId || '');
const [channelTargetOptions, setChannelTargetOptions] = useState<ChannelTargetOption[]>([]);
const [loadingChannelTargets, setLoadingChannelTargets] = useState(false);
const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule);
const selectableChannels = configuredChannels.filter((group) => isSupportedCronDeliveryChannel(group.channelType));
const availableChannels = selectableChannels.some((group) => group.channelType === deliveryChannel)
? selectableChannels
: (
deliveryChannel && isSupportedCronDeliveryChannel(deliveryChannel)
? [...selectableChannels, configuredChannels.find((group) => group.channelType === deliveryChannel) || { channelType: deliveryChannel, defaultAccountId: 'default', accounts: [] }]
: selectableChannels
);
const effectiveDeliveryChannel = deliveryChannel
|| (deliveryMode === 'announce' ? (availableChannels[0]?.channelType || '') : '');
const unsupportedDeliveryChannel = !!effectiveDeliveryChannel && !isSupportedCronDeliveryChannel(effectiveDeliveryChannel);
const selectedChannel = availableChannels.find((group) => group.channelType === effectiveDeliveryChannel);
const deliveryAccountOptions = (selectedChannel?.accounts ?? []).map((account) => ({
accountId: account.accountId,
displayName: getDeliveryAccountDisplayName(account, t),
}));
const hasCurrentDeliveryTarget = !!deliveryTarget;
const currentDeliveryTargetOption = hasCurrentDeliveryTarget
? {
value: deliveryTarget,
label: `${t('dialog.currentTarget')} (${deliveryTarget})`,
kind: 'user' as const,
}
: null;
const effectiveDeliveryAccountId = selectedDeliveryAccountId
|| selectedChannel?.defaultAccountId
|| deliveryAccountOptions[0]?.accountId
|| '';
const showsAccountSelector = (selectedChannel?.accounts.length ?? 0) > 0;
const selectedResolvedAccountId = effectiveDeliveryAccountId || undefined;
const availableTargetOptions = currentDeliveryTargetOption
? [currentDeliveryTargetOption, ...channelTargetOptions.filter((option) => option.value !== deliveryTarget)]
: channelTargetOptions;
useEffect(() => {
if (deliveryMode !== 'announce') {
setSelectedDeliveryAccountId('');
return;
}
if (!selectedDeliveryAccountId && selectedChannel?.defaultAccountId) {
setSelectedDeliveryAccountId(selectedChannel.defaultAccountId);
}
}, [deliveryMode, selectedChannel?.defaultAccountId, selectedDeliveryAccountId]);
useEffect(() => {
if (deliveryMode !== 'announce' || !effectiveDeliveryChannel || unsupportedDeliveryChannel) {
setChannelTargetOptions([]);
setLoadingChannelTargets(false);
return;
}
if (showsAccountSelector && !selectedResolvedAccountId) {
setChannelTargetOptions([]);
setLoadingChannelTargets(false);
return;
}
let cancelled = false;
setLoadingChannelTargets(true);
const params = new URLSearchParams({ channelType: effectiveDeliveryChannel });
if (selectedResolvedAccountId) {
params.set('accountId', selectedResolvedAccountId);
}
void hostApiFetch<{ success: boolean; targets?: ChannelTargetOption[]; error?: string }>(
`/api/channels/targets?${params.toString()}`,
).then((result) => {
if (cancelled) return;
if (!result.success) {
throw new Error(result.error || 'Failed to load channel targets');
}
setChannelTargetOptions(result.targets || []);
}).catch((error) => {
if (!cancelled) {
console.warn('Failed to load channel targets:', error);
setChannelTargetOptions([]);
}
}).finally(() => {
if (!cancelled) {
setLoadingChannelTargets(false);
}
});
return () => {
cancelled = true;
};
}, [deliveryMode, effectiveDeliveryChannel, selectedResolvedAccountId, showsAccountSelector, unsupportedDeliveryChannel]);
const handleSubmit = async () => {
if (!name.trim()) {
@@ -217,10 +373,37 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
setSaving(true);
try {
const finalDelivery = deliveryMode === 'announce'
? {
mode: 'announce' as const,
channel: effectiveDeliveryChannel.trim(),
...(selectedResolvedAccountId
? { accountId: effectiveDeliveryAccountId }
: {}),
to: deliveryTarget.trim(),
}
: { mode: 'none' as const };
if (finalDelivery.mode === 'announce') {
if (!finalDelivery.channel) {
toast.error(t('toast.channelRequired'));
return;
}
if (!isSupportedCronDeliveryChannel(finalDelivery.channel)) {
toast.error(t('toast.deliveryChannelUnsupported', { channel: getChannelDisplayName(finalDelivery.channel) }));
return;
}
if (!finalDelivery.to) {
toast.error(t('toast.deliveryTargetRequired'));
return;
}
}
await onSave({
name: name.trim(),
message: message.trim(),
schedule: finalSchedule,
delivery: finalDelivery,
enabled,
});
onClose();
@@ -318,6 +501,141 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
</div>
</div>
{/* Delivery */}
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[14px] text-foreground/80 font-bold">{t('dialog.deliveryTitle')}</Label>
<p className="text-[12px] text-muted-foreground">{t('dialog.deliveryDescription')}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={deliveryMode === 'none' ? 'default' : 'outline'}
size="sm"
onClick={() => setDeliveryMode('none')}
className={cn(
'justify-start h-auto min-h-12 rounded-xl px-4 py-3 text-left',
deliveryMode === 'none'
? 'bg-primary hover:bg-primary/90 text-primary-foreground border-transparent'
: 'bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
)}
>
<div>
<div className="text-[13px] font-semibold">{t('dialog.deliveryModeNone')}</div>
<div className="text-[11px] opacity-80">{t('dialog.deliveryModeNoneDesc')}</div>
</div>
</Button>
<Button
type="button"
variant={deliveryMode === 'announce' ? 'default' : 'outline'}
size="sm"
onClick={() => setDeliveryMode('announce')}
className={cn(
'justify-start h-auto min-h-12 rounded-xl px-4 py-3 text-left',
deliveryMode === 'announce'
? 'bg-primary hover:bg-primary/90 text-primary-foreground border-transparent'
: 'bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
)}
>
<div>
<div className="text-[13px] font-semibold">{t('dialog.deliveryModeAnnounce')}</div>
<div className="text-[11px] opacity-80">{t('dialog.deliveryModeAnnounceDesc')}</div>
</div>
</Button>
</div>
{deliveryMode === 'announce' && (
<div className="space-y-3 rounded-2xl border border-black/5 dark:border-white/5 bg-[#eeece3] dark:bg-muted p-4 shadow-sm">
<div className="space-y-2">
<Label htmlFor="delivery-channel" className="text-[13px] text-foreground/80 font-bold">
{t('dialog.deliveryChannel')}
</Label>
<SelectField
id="delivery-channel"
value={effectiveDeliveryChannel}
onChange={(event) => {
setDeliveryChannel(event.target.value);
setSelectedDeliveryAccountId('');
setDeliveryTarget('');
}}
>
<option value="">{t('dialog.selectChannel')}</option>
{availableChannels.map((group) => (
<option key={group.channelType} value={group.channelType}>
{!isSupportedCronDeliveryChannel(group.channelType)
? `${getChannelDisplayName(group.channelType)} (${t('dialog.channelUnsupportedTag')})`
: getChannelDisplayName(group.channelType)}
</option>
))}
</SelectField>
{availableChannels.length === 0 && (
<p className="text-[12px] text-muted-foreground">{t('dialog.noChannels')}</p>
)}
{unsupportedDeliveryChannel && (
<p className="text-[12px] text-destructive">{t('dialog.deliveryChannelUnsupported')}</p>
)}
{selectedChannel && (
<p className="text-[12px] text-muted-foreground">
{t('dialog.deliveryDefaultAccountHint', { account: selectedChannel.defaultAccountId })}
</p>
)}
</div>
{showsAccountSelector && (
<div className="space-y-2">
<Label htmlFor="delivery-account" className="text-[13px] text-foreground/80 font-bold">
{t('dialog.deliveryAccount')}
</Label>
<SelectField
id="delivery-account"
value={effectiveDeliveryAccountId}
onChange={(event) => {
setSelectedDeliveryAccountId(event.target.value);
setDeliveryTarget('');
}}
disabled={deliveryAccountOptions.length === 0}
>
<option value="">
{t('dialog.selectDeliveryAccount')}
</option>
{deliveryAccountOptions.map((option) => (
<option key={option.accountId} value={option.accountId}>
{option.displayName}
</option>
))}
</SelectField>
<p className="text-[12px] text-muted-foreground">{t('dialog.deliveryAccountDesc')}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="delivery-target-select" className="text-[13px] text-foreground/80 font-bold">
{t('dialog.deliveryTarget')}
</Label>
<SelectField
id="delivery-target-select"
value={deliveryTarget}
onChange={(event) => setDeliveryTarget(event.target.value)}
disabled={loadingChannelTargets || availableTargetOptions.length === 0}
>
<option value="">{loadingChannelTargets ? t('dialog.loadingTargets') : t('dialog.selectDeliveryTarget')}</option>
{availableTargetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectField>
<p className="text-[12px] text-muted-foreground">
{availableTargetOptions.length > 0
? t('dialog.deliveryTargetDescAuto')
: t('dialog.noDeliveryTargets')}
</p>
</div>
</div>
)}
</div>
{/* Enabled */}
<div className="flex items-center justify-between bg-[#eeece3] dark:bg-muted p-4 rounded-2xl shadow-sm border border-black/5 dark:border-white/5">
<div>
@@ -357,13 +675,14 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
// Job Card Component
interface CronJobCardProps {
job: CronJob;
deliveryAccountName?: string;
onToggle: (enabled: boolean) => void;
onEdit: () => void;
onDelete: () => void;
onTrigger: () => Promise<void>;
}
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
const { t } = useTranslation('cron');
const [triggering, setTriggering] = useState(false);
@@ -386,6 +705,12 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
onDelete();
};
const deliveryChannel = typeof job.delivery?.channel === 'string' ? job.delivery.channel : '';
const deliveryLabel = deliveryChannel ? getChannelDisplayName(deliveryChannel) : '';
const deliveryIcon = deliveryChannel && isKnownChannelType(deliveryChannel)
? CHANNEL_ICONS[deliveryChannel]
: null;
return (
<div
className="group flex flex-col p-5 rounded-2xl bg-transparent border border-transparent hover:bg-black/5 dark:hover:bg-white/5 transition-all relative overflow-hidden cursor-pointer"
@@ -432,10 +757,15 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
{/* Metadata */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[12px] text-muted-foreground/80 font-medium mb-3">
{job.target && (
{job.delivery?.mode === 'announce' && deliveryChannel && (
<span className="flex items-center gap-1.5">
{CHANNEL_ICONS[job.target.channelType as ChannelType]}
{job.target.channelName}
{deliveryIcon}
<span>{deliveryLabel}</span>
{deliveryAccountName ? (
<span className="max-w-[220px] truncate">{deliveryAccountName}</span>
) : job.delivery.to && (
<span className="max-w-[220px] truncate">{job.delivery.to}</span>
)}
</span>
)}
@@ -505,9 +835,25 @@ export function Cron() {
const [showDialog, setShowDialog] = useState(false);
const [editingJob, setEditingJob] = useState<CronJob | undefined>();
const [jobToDelete, setJobToDelete] = useState<{ id: string } | null>(null);
const [configuredChannels, setConfiguredChannels] = useState<DeliveryChannelGroup[]>([]);
const isGatewayRunning = gatewayStatus.state === 'running';
const fetchConfiguredChannels = useCallback(async () => {
try {
const response = await hostApiFetch<{ success: boolean; channels?: DeliveryChannelGroup[]; error?: string }>(
'/api/channels/accounts',
);
if (!response.success) {
throw new Error(response.error || 'Failed to load delivery channels');
}
setConfiguredChannels(response.channels || []);
} catch (fetchError) {
console.warn('Failed to load delivery channels:', fetchError);
setConfiguredChannels([]);
}
}, []);
// Fetch jobs on mount
useEffect(() => {
if (isGatewayRunning) {
@@ -515,6 +861,10 @@ export function Cron() {
}
}, [fetchJobs, isGatewayRunning]);
useEffect(() => {
void fetchConfiguredChannels();
}, [fetchConfiguredChannels]);
// Statistics
const safeJobs = Array.isArray(jobs) ? jobs : [];
const activeJobs = safeJobs.filter((j) => j.enabled);
@@ -564,7 +914,10 @@ export function Cron() {
<div className="flex items-center gap-3 md:mt-2">
<Button
variant="outline"
onClick={fetchJobs}
onClick={() => {
void fetchJobs();
void fetchConfiguredChannels();
}}
disabled={!isGatewayRunning}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
>
@@ -680,10 +1033,15 @@ export function Cron() {
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{safeJobs.map((job) => (
{safeJobs.map((job) => {
const channelGroup = configuredChannels.find((group) => group.channelType === job.delivery?.channel);
const account = channelGroup?.accounts.find((item) => item.accountId === job.delivery?.accountId);
const deliveryAccountName = account ? getDeliveryAccountDisplayName(account, t) : undefined;
return (
<CronJobCard
key={job.id}
job={job}
deliveryAccountName={deliveryAccountName}
onToggle={(enabled) => handleToggle(job.id, enabled)}
onEdit={() => {
setEditingJob(job);
@@ -692,7 +1050,8 @@ export function Cron() {
onDelete={() => setJobToDelete({ id: job.id })}
onTrigger={() => triggerJob(job.id)}
/>
))}
);
})}
</div>
)}
@@ -703,6 +1062,7 @@ export function Cron() {
{showDialog && (
<TaskDialog
job={editingJob}
configuredChannels={configuredChannels}
onClose={() => {
setShowDialog(false);
setEditingJob(undefined);

View File

@@ -53,13 +53,13 @@ export const useCronStore = create<CronState>((set) => ({
updateJob: async (id, input) => {
try {
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
const updatedJob = await hostApiFetch<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(input),
});
set((state) => ({
jobs: state.jobs.map((job) =>
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
job.id === id ? updatedJob : job
),
}));
} catch (error) {

View File

@@ -5,13 +5,23 @@
import { ChannelType } from './channel';
export type CronJobDeliveryMode = 'none' | 'announce';
export interface CronJobDelivery {
mode: CronJobDeliveryMode;
channel?: ChannelType | string;
to?: string;
accountId?: string;
}
/**
* Cron job target (where to send the result)
*/
export interface CronJobTarget {
channelType: ChannelType;
channelType: ChannelType | string;
channelId: string;
channelName: string;
recipient?: string;
}
/**
@@ -41,6 +51,7 @@ export interface CronJob {
name: string;
message: string;
schedule: string | CronSchedule;
delivery?: CronJobDelivery;
target?: CronJobTarget;
enabled: boolean;
createdAt: string;
@@ -51,13 +62,12 @@ export interface CronJob {
/**
* Input for creating a cron job from the UI.
* No target/delivery — UI-created tasks push results to the ClawX chat page.
* Tasks created via external channels are handled directly by the Gateway.
*/
export interface CronJobCreateInput {
name: string;
message: string;
schedule: string;
delivery?: CronJobDelivery;
enabled?: boolean;
}
@@ -68,6 +78,7 @@ export interface CronJobUpdateInput {
name?: string;
message?: string;
schedule?: string;
delivery?: CronJobDelivery;
enabled?: boolean;
}

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';

View File

@@ -4,6 +4,13 @@ import electron from 'vite-plugin-electron';
import renderer from 'vite-plugin-electron-renderer';
import { resolve } from 'path';
function isMainProcessExternal(id: string): boolean {
if (!id || id.startsWith('\0')) return false;
if (id.startsWith('.') || id.startsWith('/') || /^[A-Za-z]:[\\/]/.test(id)) return false;
if (id.startsWith('@/') || id.startsWith('@electron/')) return false;
return true;
}
// https://vitejs.dev/config/
export default defineConfig({
// Required for Electron: all asset URLs must be relative because the renderer
@@ -24,7 +31,7 @@ export default defineConfig({
build: {
outDir: 'dist-electron/main',
rollupOptions: {
external: ['electron-store', 'electron-updater', 'ws'],
external: isMainProcessExternal,
},
},
},