feat(agents): 支持个人微信发布与渠道会话
This commit is contained in:
@@ -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