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

@@ -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();