feat(agents): 支持个人微信发布与渠道会话
This commit is contained in:
@@ -2,11 +2,11 @@ import type { CloudAgentOperations } from '../../shared/cloud-agents';
|
||||
|
||||
type OperationSpec = {
|
||||
method: string; path: string; body?: readonly string[]; output: readonly string[];
|
||||
query?: Record<string, string>; cloud?: 'ws';
|
||||
query?: Record<string, string>; fixedQuery?: Record<string, string>; cloud?: 'ws'; project?: (value: unknown) => unknown;
|
||||
};
|
||||
const prompt = ['request_id', 'thread_id', 'query', 'expected_revision', 'attachment_file_ids'];
|
||||
const request = ['request_id', 'thread_id', 'run_id', 'status', 'version'];
|
||||
const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled'];
|
||||
const schedule = ['name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'result_notification'];
|
||||
const job = ['id', 'agent_slug', ...schedule, 'next_run_at', 'runs'];
|
||||
const entry = ['slug', 'name', 'purpose', 'published_version', 'is_creator', 'payer'];
|
||||
const budget = ['agent_slug', 'unit', 'timezone', 'request_limit_points', 'daily_limit_points', 'daily_committed_points', 'resets_at'];
|
||||
@@ -14,6 +14,204 @@ const draft = ['slug', 'name', 'purpose', 'system_prompt', 'draft_revision', 'up
|
||||
const mcpInput = ['name', 'description', 'transport', 'url', 'headers'];
|
||||
const mcpOutput = ['slug', 'name', 'description', 'transport', 'url', 'enabled', 'has_credentials'];
|
||||
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'];
|
||||
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'];
|
||||
|
||||
type ObjectRecord = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as ObjectRecord : {};
|
||||
}
|
||||
function copy(value: unknown, keys: readonly string[]): ObjectRecord {
|
||||
const source = object(value);
|
||||
return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]));
|
||||
}
|
||||
function list(value: unknown, project: (item: unknown) => unknown, max = 200): unknown[] {
|
||||
return Array.isArray(value) ? value.slice(0, max).map(project) : [];
|
||||
}
|
||||
function safeHttps(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || value.length > 2048) return undefined;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.hash) return undefined;
|
||||
return url.toString();
|
||||
} catch { return undefined; }
|
||||
}
|
||||
function projectChannelView(value: unknown): ObjectRecord {
|
||||
const result = copy(value, channelView);
|
||||
if (Array.isArray(result.blockers)) result.blockers = result.blockers.filter((item): item is string => typeof item === 'string').slice(0, 20);
|
||||
return result;
|
||||
}
|
||||
function projectBindings(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return {
|
||||
items: list(source.items, projectChannelView),
|
||||
...(Array.isArray(source.available_accounts) ? { available_accounts: list(source.available_accounts, projectChannelView) } : {}),
|
||||
};
|
||||
}
|
||||
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.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 }),
|
||||
};
|
||||
}
|
||||
function projectPolicy(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return {
|
||||
...copy(source, ['binding_id', 'channel_account_id', 'access_mode', 'policy_revision']),
|
||||
revoked_caller_ids: Array.isArray(source.revoked_caller_ids) ? source.revoked_caller_ids.filter((item): item is string => typeof item === 'string').slice(0, 200) : [],
|
||||
revoked_invitation_ids: Array.isArray(source.revoked_invitation_ids) ? source.revoked_invitation_ids.filter((item): item is string => typeof item === 'string').slice(0, 200) : [],
|
||||
};
|
||||
}
|
||||
function projectPairing(value: unknown): unknown { return copy(value, ['invitation_id', 'binding_id', 'provider_generation', 'agent_slug', 'kind', 'expires_at', 'consumed', 'code']); }
|
||||
function projectWechat(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
const qrcode_url = safeHttps(source.qrcode_url);
|
||||
return { ...copy(source, ['session_key', 'status', 'message']), ...(qrcode_url ? { qrcode_url } : {}) };
|
||||
}
|
||||
function projectSession(value: unknown): unknown { return copy(value, channelSession); }
|
||||
function projectRun(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return { ...copy(source, ['agent_run_id', 'request_id', 'thread_id', 'agent_slug', 'status', 'output', 'version']),
|
||||
...(source.error && typeof source.error === 'object' ? { error: copy(source.error, ['type', 'message']) } : {}),
|
||||
...(source.interrupt && typeof source.interrupt === 'object' ? { interrupt: copy(source.interrupt, ['status', 'message', 'run_id', 'questions', 'approval']) } : {}),
|
||||
};
|
||||
}
|
||||
function projectConversation(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return { ...projectSession(source), messages: list(source.messages, item => copy(item, ['id', 'role', 'content', 'request_id', 'run_id', 'created_at'])),
|
||||
run: source.run == null ? null : projectRun(source.run),
|
||||
queued_requests: list(source.queued_requests, item => copy(item, ['request_id', 'thread_id', 'run_id', 'status', 'version'])),
|
||||
next_offset: source.next_offset ?? null };
|
||||
}
|
||||
function projectFiles(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return { session_id: source.session_id, thread_id: source.thread_id ?? null,
|
||||
files: list(source.files, item => copy(item, ['name', 'path', 'directory_path', 'is_dir', 'size'])) };
|
||||
}
|
||||
function projectCallers(value: unknown): unknown { return { items: list(object(value).items, item => copy(item, channelCaller)) }; }
|
||||
function projectDeliverySlot(value: unknown): ObjectRecord {
|
||||
return copy(value, ['state', 'message_id', 'status', 'artifact_status', 'artifact_count', 'artifact_failure_count']);
|
||||
}
|
||||
function projectActivityDelivery(value: unknown): ObjectRecord {
|
||||
const source = object(value);
|
||||
const result = copy(source, ['state', 'delivery_id', 'logical_message_id', 'request_id', 'run_id', 'inbound_message_id']);
|
||||
for (const key of ['ack', 'progress', 'result'] as const) if (source[key] !== undefined) result[key] = projectDeliverySlot(source[key]);
|
||||
const core = object(source.core);
|
||||
if (Object.keys(core).length) {
|
||||
const projectedCore = copy(core, ['state', 'core_status', 'adapter_status', 'provider_status', 'delivery_status', 'part_count', 'sent_part_count', 'failed_part_count']);
|
||||
if (Array.isArray(core.parts)) {
|
||||
projectedCore.parts = list(core.parts, part => {
|
||||
const item = object(part);
|
||||
return copy(item, ['part_id', 'type', 'core_status', 'adapter_status', 'provider_status', 'error_category']);
|
||||
});
|
||||
}
|
||||
result.core = projectedCore;
|
||||
}
|
||||
const outputMessageId = object(source.result).message_id;
|
||||
if (typeof outputMessageId === 'string' && outputMessageId) result.logical_message_id = outputMessageId;
|
||||
if (Array.isArray(core.parts)) {
|
||||
result.parts = list(core.parts, part => {
|
||||
const item = object(part);
|
||||
return { part_id: item.part_id, kind: item.type, status: item.adapter_status ?? item.core_status ?? item.provider_status };
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function projectActivity(value: unknown): unknown {
|
||||
return {
|
||||
items: list(object(value).items, item => {
|
||||
const source = object(item);
|
||||
const result = copy(source, ['run_id', 'request_id', 'agent_slug', 'caller_id', 'access_mode', 'binding_id', 'provider_generation', 'channel_account_id', 'status', 'source', 'channel', 'error_type']);
|
||||
if (source.token_usage && typeof source.token_usage === 'object') result.token_usage = copy(source.token_usage, ['input_tokens', 'output_tokens', 'total_tokens', 'prompt_tokens', 'completion_tokens', 'cached_tokens']);
|
||||
if (source.timing && typeof source.timing === 'object') result.timing = copy(source.timing, ['created_at', 'started_at', 'prepared_at', 'first_output_at', 'finished_at', 'first_model_request_at']);
|
||||
if (source.delivery && typeof source.delivery === 'object') {
|
||||
const delivery = object(source.delivery);
|
||||
result.delivery = projectActivityDelivery(delivery);
|
||||
}
|
||||
if (source.input_preparation && typeof source.input_preparation === 'object') result.input_preparation = copy(source.input_preparation, ['intake_id', 'delivery_id', 'logical_message_id', 'state', 'prepared_file_count', 'native_request_id', 'native_run_id', 'error_type']);
|
||||
return result;
|
||||
}),
|
||||
next_offset: object(value).next_offset ?? null,
|
||||
};
|
||||
}
|
||||
function projectDelivery(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return { ...copy(source, ['logical_message_id', 'message_id', 'status']), parts: list(source.parts, item => copy(item, ['part_id', 'part_index', 'type', 'status', 'attempts', 'sent_at'])) };
|
||||
}
|
||||
function projectControl(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return { ...copy(source, ['operation_id', 'status', 'session_id', 'previous_session_id', 'run_id', 'request_id', 'intake_ids', 'action']),
|
||||
...(source.session && typeof source.session === 'object' ? { session: projectSession(source.session) } : {}) };
|
||||
}
|
||||
function projectOperation(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
const operationResult = source.result && typeof source.result === 'object'
|
||||
? (object(source.result).channel !== undefined ? projectMutation(source.result) : projectChannelView(source.result))
|
||||
: undefined;
|
||||
return { ...copy(source, ['operation_id', 'status', 'error_code']),
|
||||
...(Array.isArray(source.steps) ? { steps: list(source.steps, item => copy(item, ['name', 'step', 'operation_id', 'status', 'error_code'])) } : {}),
|
||||
...(operationResult !== undefined ? { result: operationResult } : {}) };
|
||||
}
|
||||
function projectScheduleNotification(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
return copy(source, ['enabled', 'state', 'error_message', 'channel_account_id', 'caller_id']);
|
||||
}
|
||||
function projectSchedule(value: unknown): unknown {
|
||||
const source = object(value);
|
||||
const result = copy(source, ['id', 'agent_slug', 'name', 'prompt', 'cron_expression', 'timezone', 'enabled', 'next_run_at']);
|
||||
if (source.result_notification !== undefined) result.result_notification = source.result_notification === null ? null : projectScheduleNotification(source.result_notification);
|
||||
if (Array.isArray(source.runs)) {
|
||||
result.runs = list(source.runs, item => {
|
||||
const run = copy(item, ['status', 'thread_id', 'error_message', 'conversation_available', 'completed_at', 'run_id']);
|
||||
if (object(item).result_notification !== undefined) run.result_notification = object(item).result_notification === null ? null : projectScheduleNotification(object(item).result_notification);
|
||||
return run;
|
||||
}, 20);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function projectSchedules(value: unknown): unknown { return { jobs: list(object(value).jobs, projectSchedule) }; }
|
||||
function validateChannelInput(operation: string, args: ObjectRecord): void {
|
||||
const channelOperations = new Set([
|
||||
'createChannelBinding', 'switchChannelBindingAgent', 'enableChannelBinding', 'pauseChannelBinding',
|
||||
'disconnectChannelBinding', 'channelPolicy', 'createChannelPairing', 'wechatBindStart',
|
||||
'wechatBindStatus', 'wechatBindVerification', 'wechatBindAccount', 'wechatUnbind', 'channelSelfCallers',
|
||||
'channelCallers', 'revokeChannelCaller', 'channelActivity', 'channelConversations', 'channelConversation',
|
||||
'channelFiles', 'channelConversationControl', 'channelDelivery', 'retryChannelDeliveryPart', 'channelOperation',
|
||||
]);
|
||||
if (channelOperations.has(operation)) {
|
||||
for (const key of ['operation_id', 'channel_account_id', 'caller_id', 'session_id', 'session_key', 'logical_message_id', 'part_id', 'run_id']) {
|
||||
const value = args[key];
|
||||
const valid = key === 'session_key'
|
||||
? typeof value === 'string' && /^[\x21-\x7e]{1,512}$/.test(value)
|
||||
: typeof value === 'string' && /^[a-zA-Z0-9._:#-]{1,256}$/.test(value);
|
||||
if (value !== undefined && !valid) throw new Error('invalid_id');
|
||||
}
|
||||
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 === '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 === 'wechatBindVerification' && (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');
|
||||
}
|
||||
if (args.result_notification !== undefined && args.result_notification !== null) {
|
||||
const target = object(args.result_notification);
|
||||
if (target.enabled !== true || typeof target.channel_account_id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(target.channel_account_id)
|
||||
|| typeof target.caller_id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(target.caller_id)) throw new Error('invalid_input');
|
||||
}
|
||||
}
|
||||
/** Only these product operations can cross the Main boundary; no arbitrary upstream URL or credentials. */
|
||||
const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
|
||||
applicationCalls: { method: 'GET', path: '/applications/:application_id/calls', query: { offset: 'offset' }, output: ['items', 'next_offset'] },
|
||||
@@ -65,9 +263,9 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
|
||||
run: { method: 'GET', path: '/runs/:run_id', output: ['agent_run_id', 'request_id', 'thread_id', 'agent_slug', 'status', 'output', 'version', 'error', 'interrupt'] },
|
||||
cancelRun: { method: 'POST', path: '/runs/:run_id/cancel', output: ['status'] },
|
||||
resume: { method: 'POST', path: '/runs/:run_id/resume', body: ['operation_id', 'decision'], output: ['run_id', 'status'] },
|
||||
schedules: { method: 'GET', path: '/agents/:slug/schedules', output: ['jobs'] },
|
||||
createSchedule: { method: 'POST', path: '/agents/:slug/schedules', body: ['operation_id', ...schedule], output: job },
|
||||
updateSchedule: { method: 'PUT', path: '/agents/:slug/schedules/:job_id', body: schedule, output: job },
|
||||
schedules: { method: 'GET', path: '/agents/:slug/schedules', output: ['jobs'], project: projectSchedules },
|
||||
createSchedule: { method: 'POST', path: '/agents/:slug/schedules', body: ['operation_id', ...schedule], output: job, project: projectSchedule },
|
||||
updateSchedule: { method: 'PUT', path: '/agents/:slug/schedules/:job_id', body: schedule, output: job, project: projectSchedule },
|
||||
deleteSchedule: { method: 'DELETE', path: '/agents/:slug/schedules/:job_id', output: ['deleted'] },
|
||||
runSchedule: { method: 'POST', path: '/agents/:slug/schedules/:job_id/run-now', body: ['operation_id'], output: ['thread_id', 'status'] },
|
||||
attachments: { method: 'GET', path: '/threads/:thread_id/attachments', output: ['attachments'] },
|
||||
@@ -75,6 +273,31 @@ const operations: Record<keyof CloudAgentOperations, OperationSpec> = {
|
||||
confirmAttachment: { method: 'POST', path: '/threads/:thread_id/attachments/confirm', body: ['attachments'], output: ['attachments'] },
|
||||
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) }) },
|
||||
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 },
|
||||
enableChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/enable', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
|
||||
pauseChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/pause', body: ['operation_id', 'expected_revision'], output: ['channel'], project: projectMutation },
|
||||
disconnectChannelBinding: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/disconnect', body: ['operation_id', 'expected_revision'], output: ['channel', 'wechat', 'disconnected'], project: projectMutation },
|
||||
channelPolicy: { method: 'PATCH', path: '/agents/:slug/channel-bindings/:channel_account_id', body: ['operation_id', 'access_mode', 'expected_policy_revision'], output: ['binding_id', 'channel_account_id', 'access_mode', 'policy_revision', 'revoked_caller_ids', 'revoked_invitation_ids'], project: projectPolicy },
|
||||
createChannelPairing: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/pairings', body: ['operation_id', 'kind'], output: ['invitation_id', 'binding_id', 'provider_generation', 'agent_slug', 'kind', 'expires_at', 'consumed', 'code'], project: projectPairing },
|
||||
wechatBindStart: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/start', body: ['operation_id', 'force'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
|
||||
wechatBindStatus: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/status', query: { session_key: 'session_key' }, output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
|
||||
wechatBindVerification: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/verification', body: ['operation_id', 'session_key', 'verify_code'], output: ['session_key', 'status', 'message', 'qrcode_url'], project: projectWechat },
|
||||
wechatBindAccount: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/account', output: ['status', 'message'] },
|
||||
wechatUnbind: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/wechat-bind/unbind', body: ['operation_id'], output: ['status', 'message'] },
|
||||
channelSelfCallers: { method: 'GET', path: '/agents/:slug/channel-callers', fixedQuery: { access_mode: 'self_only' }, output: ['items'], project: projectCallers },
|
||||
channelCallers: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/callers', output: ['items'], project: projectCallers },
|
||||
revokeChannelCaller: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/callers/:caller_id/revoke', body: ['operation_id'], output: ['status', 'caller_id'] },
|
||||
channelActivity: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/activity', query: { offset: 'offset', limit: 'limit' }, output: ['items', 'next_offset'], project: projectActivity },
|
||||
channelConversations: { method: 'GET', path: '/channel-conversations', query: { slug: 'agent_slug', offset: 'offset' }, output: ['items', 'next_offset'], project: value => ({ items: list(object(value).items, projectSession), next_offset: object(value).next_offset ?? null }) },
|
||||
channelConversation: { method: 'GET', path: '/channel-conversations/:session_id', query: { offset: 'offset' }, output: ['session_id', 'caller_id', 'agent_slug', 'messages', 'run', 'queued_requests', 'next_offset'], project: projectConversation },
|
||||
channelFiles: { method: 'GET', path: '/channel-conversations/:session_id/files', query: { path: 'path' }, output: ['session_id', 'thread_id', 'files'], project: projectFiles },
|
||||
channelConversationControl: { method: 'POST', path: '/channel-conversations/:session_id/controls', body: ['operation_id', 'action', 'run_id', 'decision'], output: ['operation_id', 'status', 'session_id', 'action'], project: projectControl },
|
||||
channelDelivery: { method: 'GET', path: '/agents/:slug/channel-bindings/:channel_account_id/deliveries/:logical_message_id', output: ['logical_message_id', 'message_id', 'status', 'parts'], project: projectDelivery },
|
||||
retryChannelDeliveryPart: { method: 'POST', path: '/agents/:slug/channel-bindings/:channel_account_id/deliveries/:logical_message_id/parts/:part_id/retry', output: ['logical_message_id', 'message_id', 'status', 'parts'], project: projectDelivery },
|
||||
channelOperation: { method: 'GET', path: '/channel-operations/:operation_id', query: { slug: 'agent_slug' }, output: channelOperation, project: projectOperation },
|
||||
};
|
||||
|
||||
export function operationPlan(value: unknown) {
|
||||
@@ -84,18 +307,21 @@ export function operationPlan(value: unknown) {
|
||||
|| !input || typeof input !== 'object' || Array.isArray(input)) throw new Error('invalid_operation');
|
||||
const spec = operations[operation as keyof CloudAgentOperations];
|
||||
const args = input as Record<string, unknown>;
|
||||
validateChannelInput(operation, args);
|
||||
const routeKeys = [...spec.path.matchAll(/:([a-z_]+)/g)].map(match => match[1]);
|
||||
const path = spec.path.replace(/:([a-z_]+)/g, (_, key: string) => {
|
||||
const id = args[key];
|
||||
if (typeof id !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(id)
|
||||
if (typeof id !== 'string' || !/^[a-zA-Z0-9._:#-]{1,256}$/.test(id)
|
||||
|| (key === 'slug' && !/^ml-[a-f0-9]{32}$/.test(id))) throw new Error('invalid_id');
|
||||
return encodeURIComponent(id);
|
||||
});
|
||||
const query = new URLSearchParams();
|
||||
for (const [name, value] of Object.entries(spec.fixedQuery ?? {})) query.set(name, value);
|
||||
for (const [key, name] of Object.entries(spec.query ?? {})) {
|
||||
const v = args[key];
|
||||
if (v === undefined) continue;
|
||||
if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' ? !Number.isSafeInteger(v) || Number(v) < 0 : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : 100)) {
|
||||
if (key === 'archived' ? typeof v !== 'boolean' : key === 'offset' || key === 'limit' ? !Number.isSafeInteger(v) || Number(v) < 0 || (key === 'limit' && Number(v) > 100) : typeof v !== 'string' || v.length > (key === 'path' ? 2048 : key === 'session_key' ? 512 : 200)
|
||||
|| (key === 'slug' || key === 'agent_slug') && !/^ml-[a-f0-9]{32}$/.test(String(v))) {
|
||||
throw new Error('invalid_query');
|
||||
}
|
||||
query.set(name, String(v));
|
||||
@@ -107,6 +333,7 @@ export function operationPlan(value: unknown) {
|
||||
...spec, path: (spec.cloud === 'ws' ? '/api/cloud-agents' : '/api/makelore') + path + (query.size ? '?' + query : ''),
|
||||
body: spec.body ? Object.fromEntries(spec.body.filter(key => args[key] !== undefined).map(key => [key, args[key]])) : undefined,
|
||||
project: (result: unknown) => {
|
||||
if (spec.project) return spec.project(result);
|
||||
if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('invalid_response');
|
||||
const record = result as Record<string, unknown>;
|
||||
return Object.fromEntries(spec.output.filter(key => record[key] !== undefined).map(key => [key, record[key]]));
|
||||
|
||||
@@ -37,6 +37,34 @@ const MESSAGES: Record<string, string> = {
|
||||
agent_archived: '请先从归档恢复智能体,再启用',
|
||||
attachment_too_large: '附件最大支持 5 MB',
|
||||
download_failed: '文件保存失败,请重试',
|
||||
channel_not_found: '微信渠道不存在或已解绑',
|
||||
channel_operation_conflict: '微信渠道正在处理其他操作,请稍后查看状态',
|
||||
channel_request_invalid: '微信渠道请求无效,请刷新后重试',
|
||||
channel_rate_limited: '微信渠道操作过于频繁,请稍后重试',
|
||||
channel_conversation_not_found: '微信会话不存在或已关闭',
|
||||
channel_session_control_failed: '微信会话操作失败,请刷新后重试',
|
||||
channel_delivery_not_found: '渠道交付记录不存在',
|
||||
channel_delivery_retry_failed: '文件补发失败,请刷新交付记录后重试',
|
||||
pairing_expired: '配对码已过期,请重新生成',
|
||||
verification_required: '请先完成微信验证',
|
||||
artifact_unavailable: '文件已不可用,请刷新文件列表',
|
||||
operation_unavailable: '操作状态暂时无法确认,请稍后查询',
|
||||
channel_scope_changed: '微信渠道版本已变化,请刷新后重试',
|
||||
binding_revoked: '微信渠道已暂停、切换或撤销',
|
||||
binding_paused: '微信渠道已暂停,请先启用后重试',
|
||||
caller_not_found: '渠道调用者不存在',
|
||||
delivery_not_found: '交付记录不存在',
|
||||
delivery_part_not_found: '交付文件分片不存在',
|
||||
artifact_snapshot_missing: '文件快照尚未准备好,请稍后重试',
|
||||
channel_control_failed: '微信会话控制失败,请刷新后重试',
|
||||
run_active: '当前会话仍在执行,请稍后重试',
|
||||
run_not_active: '当前会话没有可操作的运行',
|
||||
channel_session_unavailable: '新的微信会话暂时无法建立',
|
||||
pairing_invalid: '配对请求无效,请刷新后重试',
|
||||
invitation_invalid: '配对码无效或已撤销',
|
||||
invitation_consumed: '配对码已经使用',
|
||||
invites_disabled: '当前渠道仅允许创建者本人配对',
|
||||
agentbus_unavailable: '渠道服务暂时不可用,请稍后重试',
|
||||
};
|
||||
|
||||
export class CloudAgentsError extends Error {
|
||||
@@ -177,7 +205,11 @@ export class CloudAgentsModule {
|
||||
private async withRecovery<T>(operation: string, input: Record<string, unknown>, execute: () => Promise<T>): Promise<T> {
|
||||
const binding = getWorksSquareAccountBinding();
|
||||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||||
const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', input.request_id ?? input.operation_id].join(':');
|
||||
const operationKey = operation === 'retryChannelDeliveryPart'
|
||||
? [input.channel_account_id, input.logical_message_id, input.part_id]
|
||||
.map(value => encodeURIComponent(typeof value === 'string' ? value : '')).join(':')
|
||||
: input.request_id ?? input.operation_id;
|
||||
const id = [operation, input.slug ?? input.application_id ?? input.run_id ?? '', operationKey].join(':');
|
||||
await this.journal.put(binding.accountKey, { id, operation, input, created_at: new Date().toISOString() }, () => this.requireCurrent(binding));
|
||||
this.requireCurrent(binding);
|
||||
const result = await execute();
|
||||
@@ -220,6 +252,28 @@ export class CloudAgentsModule {
|
||||
await this.journal.remove(binding.accountKey, id, () => this.requireCurrent(binding));
|
||||
return { discarded: true };
|
||||
}
|
||||
if (pending.operation === 'wechatBindVerification') {
|
||||
// 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) {
|
||||
try {
|
||||
const receipt = await this.execute({ 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));
|
||||
return { operation: pending.operation, input: pending.input, result: receipt, ...(terminal ? {} : { pending: true }) };
|
||||
} catch (error) {
|
||||
if (!(error instanceof CloudAgentsError) || error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
// No receipt exists (or this is an old record without enough metadata), so
|
||||
// the Renderer must collect a fresh verification code.
|
||||
return { operation: pending.operation, input: pending.input, requires_input: true };
|
||||
}
|
||||
const result = pending.operation === 'createAgent' ? await this.create(pending.input)
|
||||
: await this.execute({ operation: pending.operation, input: pending.input });
|
||||
return { operation: pending.operation, input: pending.input, result };
|
||||
@@ -276,8 +330,17 @@ export class CloudAgentsModule {
|
||||
try { plan = operationPlan(value); }
|
||||
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'];
|
||||
const result = await (recoverable.includes(plan.operation) ? this.withRecovery(plan.operation, plan.input, invoke) : invoke());
|
||||
const recoverable = ['submit', 'preview', 'resume', 'publish', 'createApplication', 'createKey', 'createKnowledge', 'processKnowledge', 'createSchedule', 'runSchedule', 'importKnowledgeAttachment', 'createChild',
|
||||
'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'
|
||||
? Object.fromEntries(Object.entries(plan.input).filter(([key]) => key !== 'verify_code'))
|
||||
: plan.input;
|
||||
result = await this.withRecovery(plan.operation, journalInput, invoke);
|
||||
} else result = await invoke();
|
||||
try {
|
||||
const projected = plan.project(result);
|
||||
if (record(value).operation === 'access') {
|
||||
@@ -395,6 +458,59 @@ export class CloudAgentsModule {
|
||||
} finally { await fs.unlink(temporary).catch(() => undefined); }
|
||||
}
|
||||
|
||||
/** Save a personal-channel artifact through the session-scoped bridge. */
|
||||
async downloadChannelArtifact(value: unknown): Promise<{ saved: boolean }> {
|
||||
const input = record(value);
|
||||
const sessionId = textField(input.session_id, 256);
|
||||
const path = textField(input.path, 2048);
|
||||
if (!/^[a-zA-Z0-9._:#-]{1,256}$/.test(sessionId)
|
||||
|| path.includes('\\') || path.includes('\0') || /:\/\//.test(path)
|
||||
|| path.replace(/^\/+/, '').split('/').some(part => !part || part === '..' || part === '.')) {
|
||||
throw new CloudAgentsError(422, 'invalid_input');
|
||||
}
|
||||
const binding = getWorksSquareAccountBinding();
|
||||
if (!binding) throw new CloudAgentsError(401, 'session_expired');
|
||||
const { dialog } = await import('electron');
|
||||
const fs = await import('node:fs/promises');
|
||||
const { basename, dirname, join } = await import('node:path');
|
||||
const picked = await dialog.showSaveDialog({ title: '保存微信渠道文件', defaultPath: basename(path),
|
||||
properties: ['createDirectory', 'showOverwriteConfirmation'] });
|
||||
this.requireCurrent(binding);
|
||||
if (picked.canceled || !picked.filePath) return { saved: false };
|
||||
const temporary = join(dirname(picked.filePath), '.makelore-channel-download-' + crypto.randomUUID());
|
||||
try {
|
||||
await runWithDeadline(async signal => {
|
||||
const session = await this.session(binding, signal);
|
||||
this.requireCurrent(binding);
|
||||
const encodedPath = path.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||
const url = session.apiBaseUrl + '/api/makelore/channel-conversations/' + encodeURIComponent(sessionId)
|
||||
+ '/artifacts/' + encodedPath + '?download=true';
|
||||
const response = await this.fetchImpl(url, { redirect: 'error', signal, headers: { Authorization: 'Bearer ' + session.accessToken } });
|
||||
if (!response.ok || !response.body) throw new CloudAgentsError(response.status || 502, 'download_failed');
|
||||
const output = await fs.open(temporary, 'wx');
|
||||
const reader = response.body.getReader();
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { value: chunk, done } = await reader.read();
|
||||
this.requireCurrent(binding);
|
||||
if (done) break;
|
||||
size += chunk.byteLength;
|
||||
if (size > 1024 * 1024 * 1024) throw new CloudAgentsError(413, 'download_failed');
|
||||
let offset = 0;
|
||||
while (offset < chunk.byteLength) offset += (await output.write(chunk, offset, chunk.byteLength - offset)).bytesWritten;
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => undefined); await output.close(); }
|
||||
}, 300000);
|
||||
this.requireCurrent(binding);
|
||||
await fs.rename(temporary, picked.filePath);
|
||||
return { saved: true };
|
||||
} catch(error) {
|
||||
if (error instanceof CloudAgentsError) throw error;
|
||||
throw new CloudAgentsError(502, 'download_failed');
|
||||
} finally { await fs.unlink(temporary).catch(() => undefined); }
|
||||
}
|
||||
|
||||
async *events(runId: string, after: string, signal: AbortSignal): AsyncGenerator<Uint8Array> {
|
||||
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(runId) || !/^\d+-\d+$/.test(after)) {
|
||||
throw new CloudAgentsError(422, 'invalid_input');
|
||||
|
||||
Reference in New Issue
Block a user