feat: add user-level WeChat channel accounts

This commit is contained in:
2026-09-14 00:26:29 +08:00
parent 46e7f6f1cd
commit b7d8b1298f
15 changed files with 575 additions and 63 deletions

View File

@@ -16,7 +16,7 @@ const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled',
const child = ['name', 'purpose', 'system_prompt'];
const channelView = ['id', 'address', 'display_name', 'target_agent_address', 'route_revision', 'provider_generation', 'binding_id', 'enabled', 'status', 'worker_online',
'agent_slug', 'published_version', 'desired_state', 'sync_state', 'health', 'last_confirmed_at', 'revision', 'blockers', 'access_mode', 'policy_revision',
'adoption_state', 'managed_agent_slug'];
'adoption_state', 'managed_agent_slug', 'target_agent_slug', 'target_agent_name', 'target_agent_published_version'];
const channelSession = ['session_id', 'caller_id', 'agent_slug', 'agent_name', 'binding_id', 'provider_generation', 'channel_account_id', 'core_conversation_id', 'access_mode', 'grant_state', 'session_state', 'sequence', 'content_uid', 'thread_id', 'created_at', 'closed_at'];
const channelCaller = ['caller_id', 'binding_id', 'provider_generation', 'channel_account_id', 'agent_slug', 'core_conversation_id', 'access_mode', 'grant_state', 'paired_ws_account_id', 'session_id', 'content_uid', 'thread_id', 'session_state', 'created_at', 'revoked_at'];
const channelOperation = ['operation_id', 'status', 'steps', 'result', 'error_code'];
@@ -56,7 +56,7 @@ function projectMutation(value: unknown): unknown {
const source = object(value);
return {
...(source.channel === undefined ? {} : { channel: projectChannelView(source.channel) }),
...(source.target_agent === undefined ? {} : { target_agent: typeof source.target_agent === 'string' ? source.target_agent : projectChannelView(source.target_agent) }),
...(source.target_agent === undefined ? {} : { target_agent: source.target_agent === null ? null : typeof source.target_agent === 'string' ? source.target_agent : projectChannelView(source.target_agent) }),
...(source.worker && typeof source.worker === 'object' ? { worker: copy(source.worker, ['account_id', 'address']) } : {}),
...(source.wechat && typeof source.wechat === 'object' ? { wechat: copy(source.wechat, ['status', 'message']) } : {}),
...(source.disconnected === undefined ? {} : { disconnected: source.disconnected === true }),
@@ -180,6 +180,10 @@ function projectSchedule(value: unknown): unknown {
function projectSchedules(value: unknown): unknown { return { jobs: list(object(value).jobs, projectSchedule) }; }
function validateChannelInput(operation: string, args: ObjectRecord): void {
const channelOperations = new Set([
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount',
'disconnectChannelAccount', 'channelAccountWechatBindStart', 'channelAccountWechatBindStatus',
'channelAccountWechatBindVerification', 'channelAccountWechatBindAccount', 'channelAccountWechatUnbind',
'channelAccountOperation',
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding',
'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart',
'wechatBindStatus', 'wechatBindVerification', 'wechatBindAccount', 'wechatUnbind', 'channelSelfCallers',
@@ -197,11 +201,17 @@ function validateChannelInput(operation: string, args: ObjectRecord): void {
if (args.operation_id !== undefined && (typeof args.operation_id !== 'string' || args.operation_id.length > 128)) throw new Error('invalid_id');
}
if (operation === 'createChannelBinding' && (typeof args.display_name !== 'string' || !args.display_name.trim() || args.display_name.length > 100)) throw new Error('invalid_input');
if (operation === 'createChannelAccount' && (typeof args.display_name !== 'string' || !args.display_name.trim() || args.display_name.length > 100)) throw new Error('invalid_input');
if (operation === 'routeChannelAccount' && args.target_agent_slug !== null
&& (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
if (operation === 'routeChannelAccount' && args.target_agent_slug === null && args.enabled !== false) throw new Error('invalid_input');
if (operation === 'switchChannelBindingAgent' && (typeof args.target_agent_slug !== 'string' || !/^ml-[a-f0-9]{32}$/.test(args.target_agent_slug))) throw new Error('invalid_id');
if (operation === 'channelPolicy' && !['self_only', 'invited'].includes(String(args.access_mode))) throw new Error('invalid_input');
if (operation === 'createChannelPairing' && !['self', 'invite'].includes(String(args.kind))) throw new Error('invalid_input');
if (operation === 'wechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
if (operation === 'channelAccountWechatBindStart' && typeof args.force !== 'boolean') throw new Error('invalid_input');
if (operation === 'wechatBindVerification' && (typeof args.verify_code !== 'string' || !/^[0-9A-Za-z-]{1,128}$/.test(args.verify_code))) throw new Error('invalid_input');
if (operation === 'channelAccountWechatBindVerification' && (typeof args.verify_code !== 'string' || !/^[0-9A-Za-z-]{1,128}$/.test(args.verify_code))) throw new Error('invalid_input');
if (operation === 'channelConversationControl' && !['resume', 'stop', 'new-session'].includes(String(args.action))) throw new Error('invalid_input');
for (const key of ['expected_revision', 'expected_policy_revision']) {
if (args[key] !== undefined && (!Number.isSafeInteger(args[key]) || Number(args[key]) < 0)) throw new Error('invalid_input');
@@ -274,6 +284,18 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
deleteAttachment: { method: 'DELETE', path: '/threads/:thread_id/attachments/:file_id', output: ['message'] },
files: { method: 'GET', path: '/threads/:thread_id/files', query: { path: 'path' }, output: ['files'] },
channelConnections: { method: 'GET', path: '/channel-connections', output: ['items'], project: value => ({ items: list(object(value).items, projectChannelView) }) },
channelAccounts: { method: 'GET', path: '/channel-accounts', output: ['items'], project: value => ({ items: list(object(value).items, projectChannelView) }) },
createChannelAccount: { method: 'POST', path: '/channel-accounts', body: ['operation_id', 'display_name', 'account_key'], output: ['channel'], project: projectMutation },
routeChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/route', body: ['operation_id', 'target_agent_slug', 'expected_revision', 'enabled'], output: ['channel', 'target_agent'], project: projectMutation },
enableChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
pauseChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
disconnectChannelAccount: { method: 'POST', path: '/channel-accounts/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation },
channelAccountWechatBindStart: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
channelAccountWechatBindStatus: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
channelAccountWechatBindVerification: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
channelAccountWechatBindAccount: { method: 'GET', path: '/channel-accounts/:channel_account_id/wechat-bind/account', output: ['status', 'message'] },
channelAccountWechatUnbind: { method: 'POST', path: '/channel-accounts/:channel_account_id/wechat-bind/unbind', body: ['operation_id'], output: ['status', 'message'] },
channelAccountOperation: { method: 'GET', path: '/channel-operations/:operation_id', output: channelOperation, project: projectOperation },
channelBindings: { method: 'GET', path: '/agents/:slug/channel-bindings', output: ['items', 'available_accounts'], project: projectBindings },
createChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings', body: ['operation_id', 'display_name', 'channel_account_id'], output: ['channel', 'worker'], project: projectMutation },
switchChannelBindingAgent: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/route', body: ['operation_id', 'target_agent_slug', 'expected_revision', 'enabled'], output: ['channel', 'target_agent'], project: projectMutation },

View File

@@ -253,16 +253,18 @@ export class CloudAgentsModule {
await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
return { discarded: true };
}
if (pending.operation === 'wechatBindVerification') {
if (pending.operation === 'wechatBindVerification' || pending.operation === 'channelAccountWechatBindVerification') {
// The verification code is intentionally never persisted. First recover the
// durable provider receipt: the code may already have been consumed even if
// the response was lost. Only an absent receipt requires new input.
const pendingInput = record(pending.input);
const pendingSlug = typeof pendingInput.slug === 'string' ? pendingInput.slug : undefined;
const operationId = typeof pendingInput.operation_id === 'string' ? pendingInput.operation_id : undefined;
if (pendingSlug && operationId) {
if (operationId && (pending.operation === 'channelAccountWechatBindVerification' || pendingSlug)) {
try {
const receipt = await this.execute({ operation: 'channelOperation', input: { slug: pendingSlug, operation_id: operationId } });
const receipt = await this.execute(pending.operation === 'channelAccountWechatBindVerification'
? { operation: 'channelAccountOperation', input: { operation_id: operationId } }
: { operation: 'channelOperation', input: { slug: pendingSlug, operation_id: operationId } });
const status = typeof record(receipt).status === 'string' ? String(record(receipt).status).toLowerCase() : '';
const terminal = ['completed', 'complete', 'succeeded', 'success', 'failed', 'rejected', 'cancelled', 'canceled', 'expired', 'error'].includes(status);
if (terminal) await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
@@ -332,12 +334,14 @@ export class CloudAgentsModule {
catch { throw new CloudAgentsError(422, 'invalid_input'); }
const invoke = async () => plan.project(await this.request(plan.path, plan.method, plan.body, plan.cloud === 'ws'));
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild',
'createChannelAccount', 'routeChannelAccount', 'enableChannelAccount', 'pauseChannelAccount', 'disconnectChannelAccount',
'channelAccountWechatBindStart', 'channelAccountWechatBindVerification', 'channelAccountWechatUnbind',
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding', 'disconnectChannelBinding',
'channelPolicy', 'createChannelPairing', 'wechatBindStart', 'wechatBindVerification', 'wechatUnbind', 'revokeChannelCaller',
'channelConversationControl', 'retryChannelDeliveryPart'];
let result: unknown;
if (recoverable.includes(plan.operation)) {
const journalInput = plan.operation === 'wechatBindVerification'
const journalInput = plan.operation === 'wechatBindVerification' || plan.operation === 'channelAccountWechatBindVerification'
? Object.fromEntries(Object.entries(plan.input).filter(([key]) => key !== 'verify_code'))
: plan.input;
result = await this.withRecovery(plan.operation, journalInput, invoke);