feat(channels): enhance channel configuration with account support and improve agent handling (#420)
This commit is contained in:
@@ -5,9 +5,10 @@ import {
|
|||||||
createAgent,
|
createAgent,
|
||||||
deleteAgentConfig,
|
deleteAgentConfig,
|
||||||
listAgentsSnapshot,
|
listAgentsSnapshot,
|
||||||
|
resolveAccountIdForAgent,
|
||||||
updateAgentName,
|
updateAgentName,
|
||||||
} from '../../utils/agent-config';
|
} from '../../utils/agent-config';
|
||||||
import { deleteChannelConfig } from '../../utils/channel-config';
|
import { deleteChannelAccountConfig } from '../../utils/channel-config';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody, sendJson } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
|
|
||||||
@@ -91,9 +92,11 @@ export async function handleAgentRoutes(
|
|||||||
|
|
||||||
if (parts.length === 3 && parts[1] === 'channels') {
|
if (parts.length === 3 && parts[1] === 'channels') {
|
||||||
try {
|
try {
|
||||||
|
const agentId = decodeURIComponent(parts[0]);
|
||||||
const channelType = decodeURIComponent(parts[2]);
|
const channelType = decodeURIComponent(parts[2]);
|
||||||
await deleteChannelConfig(channelType);
|
const accountId = resolveAccountIdForAgent(agentId);
|
||||||
const snapshot = await clearChannelBinding(channelType);
|
await deleteChannelAccountConfig(channelType, accountId);
|
||||||
|
const snapshot = await clearChannelBinding(channelType, accountId);
|
||||||
scheduleGatewayReload(ctx, 'remove-agent-channel');
|
scheduleGatewayReload(ctx, 'remove-agent-channel');
|
||||||
sendJson(res, 200, { success: true, ...snapshot });
|
sendJson(res, 200, { success: true, ...snapshot });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
validateChannelConfig,
|
validateChannelConfig,
|
||||||
validateChannelCredentials,
|
validateChannelCredentials,
|
||||||
} from '../../utils/channel-config';
|
} from '../../utils/channel-config';
|
||||||
|
import { clearAllBindingsForChannel } from '../../utils/agent-config';
|
||||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||||
import type { HostApiContext } from '../context';
|
import type { HostApiContext } from '../context';
|
||||||
import { parseJsonBody, sendJson } from '../route-utils';
|
import { parseJsonBody, sendJson } from '../route-utils';
|
||||||
@@ -242,7 +243,7 @@ export async function handleChannelRoutes(
|
|||||||
|
|
||||||
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown> }>(req);
|
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
|
||||||
if (body.channelType === 'dingtalk') {
|
if (body.channelType === 'dingtalk') {
|
||||||
const installResult = await ensureDingTalkPluginInstalled();
|
const installResult = await ensureDingTalkPluginInstalled();
|
||||||
if (!installResult.installed) {
|
if (!installResult.installed) {
|
||||||
@@ -271,7 +272,7 @@ export async function handleChannelRoutes(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await saveChannelConfig(body.channelType, body.config);
|
await saveChannelConfig(body.channelType, body.config, body.accountId);
|
||||||
scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`);
|
scheduleGatewayChannelRestart(ctx, `channel:saveConfig:${body.channelType}`);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -295,9 +296,10 @@ export async function handleChannelRoutes(
|
|||||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
|
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||||
|
const accountId = url.searchParams.get('accountId') || undefined;
|
||||||
sendJson(res, 200, {
|
sendJson(res, 200, {
|
||||||
success: true,
|
success: true,
|
||||||
values: await getChannelFormValues(channelType),
|
values: await getChannelFormValues(channelType, accountId),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 500, { success: false, error: String(error) });
|
sendJson(res, 500, { success: false, error: String(error) });
|
||||||
@@ -309,6 +311,7 @@ export async function handleChannelRoutes(
|
|||||||
try {
|
try {
|
||||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||||
await deleteChannelConfig(channelType);
|
await deleteChannelConfig(channelType);
|
||||||
|
await clearAllBindingsForChannel(channelType);
|
||||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
|
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
|
import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
|
||||||
import { constants } from 'fs';
|
import { constants } from 'fs';
|
||||||
import { join, normalize } from 'path';
|
import { join, normalize } from 'path';
|
||||||
import { listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||||
import { expandPath, getOpenClawConfigDir } from './paths';
|
import { expandPath, getOpenClawConfigDir } from './paths';
|
||||||
import * as logger from './logger';
|
import * as logger from './logger';
|
||||||
|
|
||||||
const MAIN_AGENT_ID = 'main';
|
const MAIN_AGENT_ID = 'main';
|
||||||
const MAIN_AGENT_NAME = 'Main';
|
const MAIN_AGENT_NAME = 'Main';
|
||||||
|
const DEFAULT_ACCOUNT_ID = 'default';
|
||||||
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
|
const DEFAULT_WORKSPACE_PATH = '~/.openclaw/workspace';
|
||||||
const AGENT_BOOTSTRAP_FILES = [
|
const AGENT_BOOTSTRAP_FILES = [
|
||||||
'AGENTS.md',
|
'AGENTS.md',
|
||||||
@@ -49,6 +50,7 @@ interface AgentsConfig extends Record<string, unknown> {
|
|||||||
|
|
||||||
interface BindingMatch extends Record<string, unknown> {
|
interface BindingMatch extends Record<string, unknown> {
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
accountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BindingConfig extends Record<string, unknown> {
|
interface BindingConfig extends Record<string, unknown> {
|
||||||
@@ -56,9 +58,16 @@ interface BindingConfig extends Record<string, unknown> {
|
|||||||
match?: BindingMatch;
|
match?: BindingMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ChannelSectionConfig extends Record<string, unknown> {
|
||||||
|
accounts?: Record<string, Record<string, unknown>>;
|
||||||
|
defaultAccount?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentConfigDocument extends Record<string, unknown> {
|
interface AgentConfigDocument extends Record<string, unknown> {
|
||||||
agents?: AgentsConfig;
|
agents?: AgentsConfig;
|
||||||
bindings?: BindingConfig[];
|
bindings?: BindingConfig[];
|
||||||
|
channels?: Record<string, ChannelSectionConfig>;
|
||||||
session?: {
|
session?: {
|
||||||
mainKey?: string;
|
mainKey?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -192,13 +201,17 @@ function normalizeAgentsConfig(config: AgentConfigDocument): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSimpleChannelBinding(binding: unknown): binding is BindingConfig {
|
function isChannelBinding(binding: unknown): binding is BindingConfig {
|
||||||
if (!binding || typeof binding !== 'object') return false;
|
if (!binding || typeof binding !== 'object') return false;
|
||||||
const candidate = binding as BindingConfig;
|
const candidate = binding as BindingConfig;
|
||||||
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
|
if (typeof candidate.agentId !== 'string' || !candidate.agentId) return false;
|
||||||
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false;
|
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) return false;
|
||||||
|
if (typeof candidate.match.channel !== 'string' || !candidate.match.channel) return false;
|
||||||
const keys = Object.keys(candidate.match);
|
const keys = Object.keys(candidate.match);
|
||||||
return keys.length === 1 && typeof candidate.match.channel === 'string' && Boolean(candidate.match.channel);
|
// Accept bindings with just {channel} or {channel, accountId}
|
||||||
|
if (keys.length === 1 && keys[0] === 'channel') return true;
|
||||||
|
if (keys.length === 2 && keys.includes('channel') && keys.includes('accountId')) return true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize agent ID for consistent comparison (bindings vs entries). */
|
/** Normalize agent ID for consistent comparison (bindings vs entries). */
|
||||||
@@ -216,36 +229,61 @@ function buildAgentMainSessionKey(config: AgentConfigDocument, agentId: string):
|
|||||||
return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`;
|
return `agent:${normalizeAgentIdForBinding(agentId) || MAIN_AGENT_ID}:${normalizeMainKey(config.session?.mainKey)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSimpleChannelBindingMap(bindings: unknown): Map<string, string> {
|
/**
|
||||||
const owners = new Map<string, string>();
|
* Returns a map of channelType -> agentId from bindings.
|
||||||
if (!Array.isArray(bindings)) return owners;
|
* Account-scoped bindings are preferred; channel-wide bindings serve as fallback.
|
||||||
|
* Multiple agents can own the same channel type (different accounts).
|
||||||
|
*/
|
||||||
|
function getChannelBindingMap(bindings: unknown): {
|
||||||
|
channelToAgent: Map<string, string>;
|
||||||
|
accountToAgent: Map<string, string>;
|
||||||
|
} {
|
||||||
|
const channelToAgent = new Map<string, string>();
|
||||||
|
const accountToAgent = new Map<string, string>();
|
||||||
|
if (!Array.isArray(bindings)) return { channelToAgent, accountToAgent };
|
||||||
|
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
if (!isSimpleChannelBinding(binding)) continue;
|
if (!isChannelBinding(binding)) continue;
|
||||||
const agentId = normalizeAgentIdForBinding(binding.agentId!);
|
const agentId = normalizeAgentIdForBinding(binding.agentId!);
|
||||||
const channel = binding.match?.channel;
|
const channel = binding.match?.channel;
|
||||||
if (agentId && channel) owners.set(channel, agentId);
|
if (!agentId || !channel) continue;
|
||||||
|
|
||||||
|
const accountId = binding.match?.accountId;
|
||||||
|
if (accountId) {
|
||||||
|
accountToAgent.set(`${channel}:${accountId}`, agentId);
|
||||||
|
} else {
|
||||||
|
channelToAgent.set(channel, agentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return owners;
|
return { channelToAgent, accountToAgent };
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertBindingsForChannel(
|
function upsertBindingsForChannel(
|
||||||
bindings: unknown,
|
bindings: unknown,
|
||||||
channelType: string,
|
channelType: string,
|
||||||
agentId: string | null,
|
agentId: string | null,
|
||||||
|
accountId?: string,
|
||||||
): BindingConfig[] | undefined {
|
): BindingConfig[] | undefined {
|
||||||
const nextBindings = Array.isArray(bindings)
|
const nextBindings = Array.isArray(bindings)
|
||||||
? [...bindings as BindingConfig[]].filter((binding) => !(
|
? [...bindings as BindingConfig[]].filter((binding) => {
|
||||||
isSimpleChannelBinding(binding) && binding.match?.channel === channelType
|
if (!isChannelBinding(binding)) return true;
|
||||||
))
|
if (binding.match?.channel !== channelType) return true;
|
||||||
|
// Only remove binding that matches the exact accountId scope
|
||||||
|
if (accountId) {
|
||||||
|
return binding.match?.accountId !== accountId;
|
||||||
|
}
|
||||||
|
// No accountId: remove channel-wide binding (legacy)
|
||||||
|
return Boolean(binding.match?.accountId);
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
nextBindings.push({
|
const match: BindingMatch = { channel: channelType };
|
||||||
agentId,
|
if (accountId) {
|
||||||
match: { channel: channelType },
|
match.accountId = accountId;
|
||||||
});
|
}
|
||||||
|
nextBindings.push({ agentId, match });
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextBindings.length > 0 ? nextBindings : undefined;
|
return nextBindings.length > 0 ? nextBindings : undefined;
|
||||||
@@ -360,15 +398,67 @@ async function provisionAgentFilesystem(config: AgentConfigDocument, agent: Agen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveAccountIdForAgent(agentId: string): string {
|
||||||
|
return agentId === MAIN_AGENT_ID ? DEFAULT_ACCOUNT_ID : agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listConfiguredAccountIdsForChannel(config: AgentConfigDocument, channelType: string): string[] {
|
||||||
|
const channelSection = config.channels?.[channelType];
|
||||||
|
if (!channelSection || channelSection.enabled === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = channelSection.accounts;
|
||||||
|
if (!accounts || typeof accounts !== 'object' || Object.keys(accounts).length === 0) {
|
||||||
|
return [DEFAULT_ACCOUNT_ID];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(accounts)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||||
|
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<AgentsSnapshot> {
|
async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<AgentsSnapshot> {
|
||||||
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
|
const { entries, defaultAgentId } = normalizeAgentsConfig(config);
|
||||||
const configuredChannels = await listConfiguredChannels();
|
const configuredChannels = await listConfiguredChannels();
|
||||||
const explicitOwners = getSimpleChannelBindingMap(config.bindings);
|
const { channelToAgent, accountToAgent } = getChannelBindingMap(config.bindings);
|
||||||
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
|
const defaultAgentIdNorm = normalizeAgentIdForBinding(defaultAgentId);
|
||||||
const channelOwners: Record<string, string> = {};
|
const channelOwners: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Build per-agent channel lists from account-scoped bindings
|
||||||
|
const agentChannelSets = new Map<string, Set<string>>();
|
||||||
|
|
||||||
for (const channelType of configuredChannels) {
|
for (const channelType of configuredChannels) {
|
||||||
channelOwners[channelType] = explicitOwners.get(channelType) || defaultAgentIdNorm;
|
const accountIds = listConfiguredAccountIdsForChannel(config, channelType);
|
||||||
|
let primaryOwner: string | undefined;
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const owner =
|
||||||
|
accountToAgent.get(`${channelType}:${accountId}`)
|
||||||
|
|| (accountId === DEFAULT_ACCOUNT_ID ? (channelToAgent.get(channelType) || defaultAgentIdNorm) : undefined);
|
||||||
|
|
||||||
|
if (!owner) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryOwner ??= owner;
|
||||||
|
const existing = agentChannelSets.get(owner) ?? new Set();
|
||||||
|
existing.add(channelType);
|
||||||
|
agentChannelSets.set(owner, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!primaryOwner) {
|
||||||
|
primaryOwner = channelToAgent.get(channelType) || defaultAgentIdNorm;
|
||||||
|
const existing = agentChannelSets.get(primaryOwner) ?? new Set();
|
||||||
|
existing.add(channelType);
|
||||||
|
agentChannelSets.set(primaryOwner, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
channelOwners[channelType] = primaryOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
|
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
|
||||||
@@ -376,6 +466,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
|||||||
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
||||||
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
|
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
|
||||||
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
||||||
|
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
||||||
@@ -385,7 +476,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
|||||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||||
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
||||||
channelTypes: configuredChannels.filter((channelType) => channelOwners[channelType] === entryIdNorm),
|
channelTypes: configuredChannels.filter((ct) => ownedChannels.has(ct)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -489,7 +580,7 @@ export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot
|
|||||||
list: nextEntries,
|
list: nextEntries,
|
||||||
};
|
};
|
||||||
config.bindings = Array.isArray(config.bindings)
|
config.bindings = Array.isArray(config.bindings)
|
||||||
? config.bindings.filter((binding) => !(isSimpleChannelBinding(binding) && binding.agentId === agentId))
|
? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (defaultAgentId === agentId && nextEntries.length > 0) {
|
if (defaultAgentId === agentId && nextEntries.length > 0) {
|
||||||
@@ -500,6 +591,7 @@ export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot
|
|||||||
}
|
}
|
||||||
|
|
||||||
await writeOpenClawConfig(config);
|
await writeOpenClawConfig(config);
|
||||||
|
await deleteAgentChannelAccounts(agentId);
|
||||||
await removeAgentRuntimeDirectory(agentId);
|
await removeAgentRuntimeDirectory(agentId);
|
||||||
await removeAgentWorkspaceDirectory(removedEntry);
|
await removeAgentWorkspaceDirectory(removedEntry);
|
||||||
logger.info('Deleted agent config entry', { agentId });
|
logger.info('Deleted agent config entry', { agentId });
|
||||||
@@ -513,16 +605,31 @@ export async function assignChannelToAgent(agentId: string, channelType: string)
|
|||||||
throw new Error(`Agent "${agentId}" not found`);
|
throw new Error(`Agent "${agentId}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId);
|
const accountId = resolveAccountIdForAgent(agentId);
|
||||||
|
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
|
||||||
await writeOpenClawConfig(config);
|
await writeOpenClawConfig(config);
|
||||||
logger.info('Assigned channel to agent', { agentId, channelType });
|
logger.info('Assigned channel to agent', { agentId, channelType, accountId });
|
||||||
return buildSnapshotFromConfig(config);
|
return buildSnapshotFromConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearChannelBinding(channelType: string): Promise<AgentsSnapshot> {
|
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
|
||||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null);
|
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
||||||
await writeOpenClawConfig(config);
|
await writeOpenClawConfig(config);
|
||||||
logger.info('Cleared simplified channel binding', { channelType });
|
logger.info('Cleared channel binding', { channelType, accountId });
|
||||||
return buildSnapshotFromConfig(config);
|
return buildSnapshotFromConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
||||||
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
|
if (!Array.isArray(config.bindings)) return;
|
||||||
|
|
||||||
|
const nextBindings = config.bindings.filter((binding) => {
|
||||||
|
if (!isChannelBinding(binding)) return true;
|
||||||
|
return binding.match?.channel !== channelType;
|
||||||
|
});
|
||||||
|
|
||||||
|
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
||||||
|
await writeOpenClawConfig(config);
|
||||||
|
logger.info('Cleared all bindings for channel', { channelType });
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { proxyAwareFetch } from './proxy-fetch';
|
|||||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||||
const WECOM_PLUGIN_ID = 'wecom-openclaw-plugin';
|
const WECOM_PLUGIN_ID = 'wecom-openclaw-plugin';
|
||||||
|
const FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin';
|
||||||
|
const DEFAULT_ACCOUNT_ID = 'default';
|
||||||
|
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
|
||||||
|
|
||||||
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||||
const PLUGIN_CHANNELS = ['whatsapp'];
|
const PLUGIN_CHANNELS = ['whatsapp'];
|
||||||
@@ -93,14 +96,8 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
|
|||||||
|
|
||||||
// ── Channel operations ───────────────────────────────────────────
|
// ── Channel operations ───────────────────────────────────────────
|
||||||
|
|
||||||
export async function saveChannelConfig(
|
function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): void {
|
||||||
channelType: string,
|
|
||||||
config: ChannelConfigData
|
|
||||||
): Promise<void> {
|
|
||||||
const currentConfig = await readOpenClawConfig();
|
|
||||||
|
|
||||||
if (channelType === 'feishu') {
|
if (channelType === 'feishu') {
|
||||||
const FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin';
|
|
||||||
if (!currentConfig.plugins) {
|
if (!currentConfig.plugins) {
|
||||||
currentConfig.plugins = {
|
currentConfig.plugins = {
|
||||||
allow: [FEISHU_PLUGIN_ID],
|
allow: [FEISHU_PLUGIN_ID],
|
||||||
@@ -115,17 +112,13 @@ export async function saveChannelConfig(
|
|||||||
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
|
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
|
||||||
? (currentConfig.plugins.allow as string[])
|
? (currentConfig.plugins.allow as string[])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Remove legacy 'feishu' plugin from allowlist
|
|
||||||
const normalizedAllow = allow.filter((pluginId) => pluginId !== 'feishu');
|
const normalizedAllow = allow.filter((pluginId) => pluginId !== 'feishu');
|
||||||
|
|
||||||
if (!normalizedAllow.includes(FEISHU_PLUGIN_ID)) {
|
if (!normalizedAllow.includes(FEISHU_PLUGIN_ID)) {
|
||||||
currentConfig.plugins.allow = [...normalizedAllow, FEISHU_PLUGIN_ID];
|
currentConfig.plugins.allow = [...normalizedAllow, FEISHU_PLUGIN_ID];
|
||||||
} else if (normalizedAllow.length !== allow.length) {
|
} else if (normalizedAllow.length !== allow.length) {
|
||||||
currentConfig.plugins.allow = normalizedAllow;
|
currentConfig.plugins.allow = normalizedAllow;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explicitly disable the legacy plugin and enable the official one
|
|
||||||
if (!currentConfig.plugins.entries) {
|
if (!currentConfig.plugins.entries) {
|
||||||
currentConfig.plugins.entries = {};
|
currentConfig.plugins.entries = {};
|
||||||
}
|
}
|
||||||
@@ -141,12 +134,9 @@ export async function saveChannelConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DingTalk is a channel plugin; make sure it's explicitly allowed.
|
|
||||||
// Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty.
|
|
||||||
if (channelType === 'dingtalk') {
|
if (channelType === 'dingtalk') {
|
||||||
const defaultDingtalkAllow = ['dingtalk'];
|
|
||||||
if (!currentConfig.plugins) {
|
if (!currentConfig.plugins) {
|
||||||
currentConfig.plugins = { allow: defaultDingtalkAllow, enabled: true };
|
currentConfig.plugins = { allow: ['dingtalk'], enabled: true };
|
||||||
} else {
|
} else {
|
||||||
currentConfig.plugins.enabled = true;
|
currentConfig.plugins.enabled = true;
|
||||||
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
|
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
|
||||||
@@ -159,9 +149,8 @@ export async function saveChannelConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (channelType === 'wecom') {
|
if (channelType === 'wecom') {
|
||||||
const defaultWecomAllow = [WECOM_PLUGIN_ID];
|
|
||||||
if (!currentConfig.plugins) {
|
if (!currentConfig.plugins) {
|
||||||
currentConfig.plugins = { allow: defaultWecomAllow, enabled: true };
|
currentConfig.plugins = { allow: [WECOM_PLUGIN_ID], enabled: true };
|
||||||
} else {
|
} else {
|
||||||
currentConfig.plugins.enabled = true;
|
currentConfig.plugins.enabled = true;
|
||||||
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
|
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
|
||||||
@@ -176,8 +165,6 @@ export async function saveChannelConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// QQ Bot is a channel plugin; make sure it's explicitly allowed.
|
|
||||||
// Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty.
|
|
||||||
if (channelType === 'qqbot') {
|
if (channelType === 'qqbot') {
|
||||||
if (!currentConfig.plugins) {
|
if (!currentConfig.plugins) {
|
||||||
currentConfig.plugins = {};
|
currentConfig.plugins = {};
|
||||||
@@ -190,37 +177,15 @@ export async function saveChannelConfig(
|
|||||||
currentConfig.plugins.allow = [...allow, 'qqbot'];
|
currentConfig.plugins.allow = [...allow, 'qqbot'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
|
||||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
|
||||||
if (!currentConfig.plugins) {
|
|
||||||
currentConfig.plugins = {};
|
|
||||||
}
|
|
||||||
if (!currentConfig.plugins.entries) {
|
|
||||||
currentConfig.plugins.entries = {};
|
|
||||||
}
|
|
||||||
currentConfig.plugins.entries[channelType] = {
|
|
||||||
...currentConfig.plugins.entries[channelType],
|
|
||||||
enabled: config.enabled ?? true,
|
|
||||||
};
|
|
||||||
await writeOpenClawConfig(currentConfig);
|
|
||||||
logger.info('Plugin channel config saved', {
|
|
||||||
channelType,
|
|
||||||
configFile: CONFIG_FILE,
|
|
||||||
path: `plugins.entries.${channelType}`,
|
|
||||||
});
|
|
||||||
console.log(`Saved plugin channel config for ${channelType}`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentConfig.channels) {
|
function transformChannelConfig(
|
||||||
currentConfig.channels = {};
|
channelType: string,
|
||||||
}
|
config: ChannelConfigData,
|
||||||
|
existingAccountConfig: ChannelConfigData,
|
||||||
// Transform config to match OpenClaw expected format
|
): ChannelConfigData {
|
||||||
let transformedConfig: ChannelConfigData = { ...config };
|
let transformedConfig: ChannelConfigData = { ...config };
|
||||||
|
|
||||||
// Special handling for Discord: convert guildId/channelId to complete structure
|
|
||||||
if (channelType === 'discord') {
|
if (channelType === 'discord') {
|
||||||
const { guildId, channelId, ...restConfig } = config;
|
const { guildId, channelId, ...restConfig } = config;
|
||||||
transformedConfig = { ...restConfig };
|
transformedConfig = { ...restConfig };
|
||||||
@@ -256,7 +221,6 @@ export async function saveChannelConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for Telegram: convert allowedUsers string to allowlist array
|
|
||||||
if (channelType === 'telegram') {
|
if (channelType === 'telegram') {
|
||||||
const { allowedUsers, ...restConfig } = config;
|
const { allowedUsers, ...restConfig } = config;
|
||||||
transformedConfig = { ...restConfig };
|
transformedConfig = { ...restConfig };
|
||||||
@@ -272,13 +236,11 @@ export async function saveChannelConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for Feishu / WeCom: default to open DM policy with wildcard allowlist
|
|
||||||
if (channelType === 'feishu' || channelType === 'wecom') {
|
if (channelType === 'feishu' || channelType === 'wecom') {
|
||||||
const existingConfig = currentConfig.channels[channelType] || {};
|
const existingDmPolicy = existingAccountConfig.dmPolicy === 'pairing' ? 'open' : existingAccountConfig.dmPolicy;
|
||||||
const existingDmPolicy = existingConfig.dmPolicy === 'pairing' ? 'open' : existingConfig.dmPolicy;
|
|
||||||
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingDmPolicy ?? 'open';
|
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingDmPolicy ?? 'open';
|
||||||
|
|
||||||
let allowFrom = (transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*']) as string[];
|
let allowFrom = (transformedConfig.allowFrom ?? existingAccountConfig.allowFrom ?? ['*']) as string[];
|
||||||
if (!Array.isArray(allowFrom)) {
|
if (!Array.isArray(allowFrom)) {
|
||||||
allowFrom = [allowFrom] as string[];
|
allowFrom = [allowFrom] as string[];
|
||||||
}
|
}
|
||||||
@@ -290,9 +252,122 @@ export async function saveChannelConfig(
|
|||||||
transformedConfig.allowFrom = allowFrom;
|
transformedConfig.allowFrom = allowFrom;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge with existing config
|
return transformedConfig;
|
||||||
currentConfig.channels[channelType] = {
|
}
|
||||||
...currentConfig.channels[channelType],
|
|
||||||
|
function resolveAccountConfig(
|
||||||
|
channelSection: ChannelConfigData | undefined,
|
||||||
|
accountId: string,
|
||||||
|
): ChannelConfigData {
|
||||||
|
if (!channelSection) return {};
|
||||||
|
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||||
|
return accounts?.[accountId] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLegacyChannelPayload(channelSection: ChannelConfigData): ChannelConfigData {
|
||||||
|
const payload: ChannelConfigData = {};
|
||||||
|
for (const [key, value] of Object.entries(channelSection)) {
|
||||||
|
if (CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key)) continue;
|
||||||
|
payload[key] = value;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyChannelConfigToAccounts(
|
||||||
|
channelSection: ChannelConfigData,
|
||||||
|
defaultAccountId: string = DEFAULT_ACCOUNT_ID,
|
||||||
|
): void {
|
||||||
|
const legacyPayload = getLegacyChannelPayload(channelSection);
|
||||||
|
const legacyKeys = Object.keys(legacyPayload);
|
||||||
|
const hasAccounts =
|
||||||
|
Boolean(channelSection.accounts) &&
|
||||||
|
typeof channelSection.accounts === 'object' &&
|
||||||
|
Object.keys(channelSection.accounts as Record<string, ChannelConfigData>).length > 0;
|
||||||
|
|
||||||
|
if (legacyKeys.length === 0) {
|
||||||
|
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
|
||||||
|
channelSection.defaultAccount = defaultAccountId;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
|
||||||
|
channelSection.accounts = {};
|
||||||
|
}
|
||||||
|
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
|
||||||
|
const existingDefaultAccount = accounts[defaultAccountId] ?? {};
|
||||||
|
|
||||||
|
accounts[defaultAccountId] = {
|
||||||
|
...(channelSection.enabled !== undefined ? { enabled: channelSection.enabled } : {}),
|
||||||
|
...legacyPayload,
|
||||||
|
...existingDefaultAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
channelSection.defaultAccount =
|
||||||
|
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||||
|
? channelSection.defaultAccount
|
||||||
|
: defaultAccountId;
|
||||||
|
|
||||||
|
for (const key of legacyKeys) {
|
||||||
|
delete channelSection[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChannelConfig(
|
||||||
|
channelType: string,
|
||||||
|
config: ChannelConfigData,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
|
ensurePluginAllowlist(currentConfig, channelType);
|
||||||
|
|
||||||
|
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
||||||
|
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
|
if (!currentConfig.plugins) {
|
||||||
|
currentConfig.plugins = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.plugins.entries) {
|
||||||
|
currentConfig.plugins.entries = {};
|
||||||
|
}
|
||||||
|
currentConfig.plugins.entries[channelType] = {
|
||||||
|
...currentConfig.plugins.entries[channelType],
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
};
|
||||||
|
await writeOpenClawConfig(currentConfig);
|
||||||
|
logger.info('Plugin channel config saved', {
|
||||||
|
channelType,
|
||||||
|
configFile: CONFIG_FILE,
|
||||||
|
path: `plugins.entries.${channelType}`,
|
||||||
|
});
|
||||||
|
console.log(`Saved plugin channel config for ${channelType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentConfig.channels) {
|
||||||
|
currentConfig.channels = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.channels[channelType]) {
|
||||||
|
currentConfig.channels[channelType] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelSection = currentConfig.channels[channelType];
|
||||||
|
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||||
|
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
|
||||||
|
const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig);
|
||||||
|
|
||||||
|
// Write credentials into accounts.<accountId>
|
||||||
|
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
|
||||||
|
channelSection.accounts = {};
|
||||||
|
}
|
||||||
|
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
|
||||||
|
channelSection.defaultAccount =
|
||||||
|
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||||
|
? channelSection.defaultAccount
|
||||||
|
: DEFAULT_ACCOUNT_ID;
|
||||||
|
accounts[resolvedAccountId] = {
|
||||||
|
...accounts[resolvedAccountId],
|
||||||
...transformedConfig,
|
...transformedConfig,
|
||||||
enabled: transformedConfig.enabled ?? true,
|
enabled: transformedConfig.enabled ?? true,
|
||||||
};
|
};
|
||||||
@@ -300,23 +375,34 @@ export async function saveChannelConfig(
|
|||||||
await writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
logger.info('Channel config saved', {
|
logger.info('Channel config saved', {
|
||||||
channelType,
|
channelType,
|
||||||
|
accountId: resolvedAccountId,
|
||||||
configFile: CONFIG_FILE,
|
configFile: CONFIG_FILE,
|
||||||
rawKeys: Object.keys(config),
|
rawKeys: Object.keys(config),
|
||||||
transformedKeys: Object.keys(transformedConfig),
|
transformedKeys: Object.keys(transformedConfig),
|
||||||
enabled: currentConfig.channels[channelType]?.enabled,
|
|
||||||
});
|
});
|
||||||
console.log(`Saved channel config for ${channelType}`);
|
console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChannelConfig(channelType: string): Promise<ChannelConfigData | undefined> {
|
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
|
||||||
const config = await readOpenClawConfig();
|
const config = await readOpenClawConfig();
|
||||||
return config.channels?.[channelType];
|
const channelSection = config.channels?.[channelType];
|
||||||
|
if (!channelSection) return undefined;
|
||||||
|
|
||||||
|
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||||
|
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||||
|
if (accounts?.[resolvedAccountId]) {
|
||||||
|
return accounts[resolvedAccountId];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChannelFormValues(channelType: string): Promise<Record<string, string> | undefined> {
|
// Backward compat: fall back to flat top-level config (legacy format without accounts)
|
||||||
const saved = await getChannelConfig(channelType);
|
if (!accounts || Object.keys(accounts).length === 0) {
|
||||||
if (!saved) return undefined;
|
return channelSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFormValues(channelType: string, saved: ChannelConfigData): Record<string, string> {
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
|
|
||||||
if (channelType === 'discord') {
|
if (channelType === 'discord') {
|
||||||
@@ -355,9 +441,37 @@ export async function getChannelFormValues(channelType: string): Promise<Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannelFormValues(channelType: string, accountId?: string): Promise<Record<string, string> | undefined> {
|
||||||
|
const saved = await getChannelConfig(channelType, accountId);
|
||||||
|
if (!saved) return undefined;
|
||||||
|
|
||||||
|
const values = extractFormValues(channelType, saved);
|
||||||
return Object.keys(values).length > 0 ? values : undefined;
|
return Object.keys(values).length > 0 ? values : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
|
||||||
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
const channelSection = currentConfig.channels?.[channelType];
|
||||||
|
if (!channelSection) return;
|
||||||
|
|
||||||
|
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||||
|
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||||
|
if (!accounts?.[accountId]) return;
|
||||||
|
|
||||||
|
delete accounts[accountId];
|
||||||
|
|
||||||
|
if (Object.keys(accounts).length === 0) {
|
||||||
|
delete currentConfig.channels![channelType];
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeOpenClawConfig(currentConfig);
|
||||||
|
logger.info('Deleted channel account config', { channelType, accountId });
|
||||||
|
console.log(`Deleted channel account config for ${channelType}/${accountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
|
||||||
@@ -379,7 +493,6 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for WhatsApp credentials
|
|
||||||
if (channelType === 'whatsapp') {
|
if (channelType === 'whatsapp') {
|
||||||
try {
|
try {
|
||||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||||
@@ -393,17 +506,28 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
||||||
|
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||||
|
if (accounts && typeof accounts === 'object') {
|
||||||
|
return Object.values(accounts).some((acc) => acc.enabled !== false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listConfiguredChannels(): Promise<string[]> {
|
export async function listConfiguredChannels(): Promise<string[]> {
|
||||||
const config = await readOpenClawConfig();
|
const config = await readOpenClawConfig();
|
||||||
const channels: string[] = [];
|
const channels: string[] = [];
|
||||||
|
|
||||||
if (config.channels) {
|
if (config.channels) {
|
||||||
channels.push(...Object.keys(config.channels).filter(
|
for (const channelType of Object.keys(config.channels)) {
|
||||||
(channelType) => config.channels![channelType]?.enabled !== false
|
const section = config.channels[channelType];
|
||||||
));
|
if (section.enabled === false) continue;
|
||||||
|
if (channelHasAnyAccount(section) || Object.keys(section).length > 0) {
|
||||||
|
channels.push(channelType);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for WhatsApp credentials directory
|
|
||||||
try {
|
try {
|
||||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||||
if (await fileExists(whatsappDir)) {
|
if (await fileExists(whatsappDir)) {
|
||||||
@@ -429,6 +553,32 @@ export async function listConfiguredChannels(): Promise<string[]> {
|
|||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAgentChannelAccounts(agentId: string): Promise<void> {
|
||||||
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
if (!currentConfig.channels) return;
|
||||||
|
|
||||||
|
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
for (const channelType of Object.keys(currentConfig.channels)) {
|
||||||
|
const section = currentConfig.channels[channelType];
|
||||||
|
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
|
||||||
|
const accounts = section.accounts as Record<string, ChannelConfigData> | undefined;
|
||||||
|
if (!accounts?.[accountId]) continue;
|
||||||
|
|
||||||
|
delete accounts[accountId];
|
||||||
|
if (Object.keys(accounts).length === 0) {
|
||||||
|
delete currentConfig.channels[channelType];
|
||||||
|
}
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
await writeOpenClawConfig(currentConfig);
|
||||||
|
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
|
||||||
@@ -625,21 +775,22 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await readOpenClawConfig();
|
const config = await readOpenClawConfig();
|
||||||
if (!config.channels?.[channelType]) {
|
const savedChannelConfig = await getChannelConfig(channelType, DEFAULT_ACCOUNT_ID);
|
||||||
|
if (!config.channels?.[channelType] || !savedChannelConfig) {
|
||||||
result.errors.push(`Channel ${channelType} is not configured`);
|
result.errors.push(`Channel ${channelType} is not configured`);
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
} else if (!config.channels[channelType].enabled) {
|
} else if (config.channels[channelType].enabled === false) {
|
||||||
result.warnings.push(`Channel ${channelType} is disabled`);
|
result.warnings.push(`Channel ${channelType} is disabled`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channelType === 'discord') {
|
if (channelType === 'discord') {
|
||||||
const discordConfig = config.channels?.discord;
|
const discordConfig = savedChannelConfig;
|
||||||
if (!discordConfig?.token) {
|
if (!discordConfig?.token) {
|
||||||
result.errors.push('Discord: Bot token is required');
|
result.errors.push('Discord: Bot token is required');
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
}
|
}
|
||||||
} else if (channelType === 'telegram') {
|
} else if (channelType === 'telegram') {
|
||||||
const telegramConfig = config.channels?.telegram;
|
const telegramConfig = savedChannelConfig;
|
||||||
if (!telegramConfig?.botToken) {
|
if (!telegramConfig?.botToken) {
|
||||||
result.errors.push('Telegram: Bot token is required');
|
result.errors.push('Telegram: Bot token is required');
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface ChannelConfigModalProps {
|
|||||||
configuredTypes?: string[];
|
configuredTypes?: string[];
|
||||||
showChannelName?: boolean;
|
showChannelName?: boolean;
|
||||||
allowExistingConfig?: boolean;
|
allowExistingConfig?: boolean;
|
||||||
|
agentId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChannelSaved?: (channelType: ChannelType) => void | Promise<void>;
|
onChannelSaved?: (channelType: ChannelType) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -61,6 +62,7 @@ export function ChannelConfigModal({
|
|||||||
configuredTypes = [],
|
configuredTypes = [],
|
||||||
showChannelName = true,
|
showChannelName = true,
|
||||||
allowExistingConfig = true,
|
allowExistingConfig = true,
|
||||||
|
agentId,
|
||||||
onClose,
|
onClose,
|
||||||
onChannelSaved,
|
onChannelSaved,
|
||||||
}: ChannelConfigModalProps) {
|
}: ChannelConfigModalProps) {
|
||||||
@@ -115,8 +117,9 @@ export function ChannelConfigModal({
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
const accountParam = agentId ? `?accountId=${encodeURIComponent(agentId === 'main' ? 'default' : agentId)}` : '';
|
||||||
const result = await hostApiFetch<{ success: boolean; values?: Record<string, string> }>(
|
const result = await hostApiFetch<{ success: boolean; values?: Record<string, string> }>(
|
||||||
`/api/channels/config/${encodeURIComponent(selectedType)}`
|
`/api/channels/config/${encodeURIComponent(selectedType)}${accountParam}`
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -140,7 +143,7 @@ export function ChannelConfigModal({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [allowExistingConfig, configuredTypes, selectedType, showChannelName]);
|
}, [agentId, allowExistingConfig, configuredTypes, selectedType, showChannelName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) {
|
if (selectedType && !loadingConfig && showChannelName && firstInputRef.current) {
|
||||||
@@ -312,13 +315,14 @@ export function ChannelConfigModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: Record<string, unknown> = { ...configValues };
|
const config: Record<string, unknown> = { ...configValues };
|
||||||
|
const resolvedAccountId = agentId ? (agentId === 'main' ? 'default' : agentId) : undefined;
|
||||||
const saveResult = await hostApiFetch<{
|
const saveResult = await hostApiFetch<{
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
warning?: string;
|
warning?: string;
|
||||||
}>('/api/channels/config', {
|
}>('/api/channels/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ channelType: selectedType, config }),
|
body: JSON.stringify({ channelType: selectedType, config, accountId: resolvedAccountId }),
|
||||||
});
|
});
|
||||||
if (!saveResult?.success) {
|
if (!saveResult?.success) {
|
||||||
throw new Error(saveResult?.error || 'Failed to save channel config');
|
throw new Error(saveResult?.error || 'Failed to save channel config');
|
||||||
|
|||||||
@@ -535,6 +535,7 @@ function AgentSettingsModal({
|
|||||||
configuredTypes={agent.channelTypes}
|
configuredTypes={agent.channelTypes}
|
||||||
showChannelName={false}
|
showChannelName={false}
|
||||||
allowExistingConfig
|
allowExistingConfig
|
||||||
|
agentId={agent.id}
|
||||||
onClose={() => setShowChannelModal(false)}
|
onClose={() => setShowChannelModal(false)}
|
||||||
onChannelSaved={async (channelType) => {
|
onChannelSaved={async (channelType) => {
|
||||||
await handleChannelSaved(channelType);
|
await handleChannelSaved(channelType);
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ export function Channels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const safeChannels = Array.isArray(channels) ? channels : [];
|
const safeChannels = Array.isArray(channels) ? channels : [];
|
||||||
|
const configuredPlaceholderChannels: Channel[] = displayedChannelTypes
|
||||||
|
.filter((type) => configuredTypes.includes(type) && !safeChannels.some((channel) => channel.type === type))
|
||||||
|
.map((type) => ({
|
||||||
|
id: `${type}-default`,
|
||||||
|
type,
|
||||||
|
name: CHANNEL_NAMES[type] || CHANNEL_META[type].name,
|
||||||
|
status: 'disconnected',
|
||||||
|
}));
|
||||||
|
const availableChannels = [...safeChannels, ...configuredPlaceholderChannels];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
|
||||||
@@ -140,13 +149,13 @@ export function Channels() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{safeChannels.length > 0 && (
|
{availableChannels.length > 0 && (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
|
<h2 className="text-3xl font-serif text-foreground mb-6 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
|
||||||
{t('availableChannels')}
|
{t('availableChannels')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
{safeChannels.map((channel) => (
|
{availableChannels.map((channel) => (
|
||||||
<ChannelCard
|
<ChannelCard
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
@@ -169,9 +178,8 @@ export function Channels() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
{displayedChannelTypes.map((type) => {
|
{displayedChannelTypes.map((type) => {
|
||||||
const meta = CHANNEL_META[type];
|
const meta = CHANNEL_META[type];
|
||||||
const isConfigured = safeChannels.some((channel) => channel.type === type)
|
const isAvailable = availableChannels.some((channel) => channel.type === type);
|
||||||
|| configuredTypes.includes(type);
|
if (isAvailable) return null;
|
||||||
if (isConfigured) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user