fix(cloud-agents): 移除微信本人配对入口

This commit is contained in:
2026-09-13 23:24:08 +08:00
parent 4ca1f89a7e
commit 8c1b161d0e
8 changed files with 100 additions and 38 deletions

View File

@@ -0,0 +1,42 @@
# Task: 移除微信本人配对界面并展示绑定身份状态
## Identity
- Task ID: 20260913-wechat-auto-self-ml-3679bce2
- Mode: Feature
- Branch: codex/20260913-wechat-auto-self-ml-3679bce2-wechat-auto-self-ml
- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260913-wechat-auto-self-ml-3679bce2
- Base commit: 4ca1f89a7e2d1a2c7e90af3b4aeb3541f3f0cd4f
- Owner: codex
- Status: Ready for Integration
## Scope
- Remove creator self pairing from personal WeChat publication. Explain automatic recognition of the QR login owner, retain explicit invitations for other contacts, and update conversation/schedule guidance, focused regressions and README.
## Intent And Constraints
- Resumed for the user-authorized main-branch merge. User explicitly confirmed one fresh read-only Reviewer and three primary registration handoffs. Source Concurrent/Planning Gates Passed on unchanged bases; only this feature task may record its remediation and commit evidence. Independent review is in progress.
- Concurrent and Planning Gates Passed using official start/status and loaded entry, task, current-state, personal cloud ADR, architecture and peer records. Historical placeholder peer context remains unknown/read-only without a concrete semantic dependency. Related channel peers are completed integration or read-only diagnosis.
- User explicitly replaces creator self-code behavior. Main remains the credential/network boundary; remote services determine owner identity. Preserve caller isolation, paid execution authorization, existing access revocation and invited contacts. No production actions, pushes, primary checkout edits or canonical memory writes.
- Plan: remove self-code actions and stale recovery presentation; update only meaningful guidance/error projections; verify invitations still work, first-use UI has no pairing action, and Electron publication/conversation flows remain usable.
## Outcome
- Removed the self-code action/card and old-self-code recovery display. Bound scanning WeChat can directly send messages; only invited contacts have an invite-code action. Conversation/history and scheduled target empty states explain sending the first message rather than pairing. Updated Main safe error guidance and README; existing privacy/revocation/approval and invitation transport remain.
- Source implementation is complete and ready for authorized local integration. The user-authorized fresh independent Reviewer returned final PASS. No packaging, deployment or paid requests were performed.
## Verification
- Final independent review: PASS; review task `20260913-wechat-self-review-ml-a85f20` is complete in its own retained read-only worktree. No remaining findings. Product validation evidence below remains applicable; no live WeChat or production acceptance is claimed.
- Pinned pnpm 10.33.4 install --frozen-lockfile passed. Focused panel/conversation/enable/workflow/Main suites: 64 passed, including absence of self pairing calls, explicit invitation command copy, binding switch and existing delivery behavior.
- pnpm exec tsc --noEmit -p tsconfig.json, pnpm run build:vite and scoped ESLint passed. git diff --check passed.
- Electron cloud-agent-channels.spec.ts: 1 passed, covering no self pairing action, QR/verification, owner approval/history/file delivery and notification target. Inspected wechat-access-without-pairing.png from the actual Electron test. Host API is fixture-backed; no live WeChat/model acceptance or new installer was performed.
## Follow-ups
- Integrate with matching AgentBus and Yuxi sources. Deployment and client packaging remain separate. A historical WeChat account without stored scanning identity needs one reconnect.
## Promotion Candidates
- Target: accepted personal cloud Agent ADR, current-state and channel architecture. Proposal: replace creator self pairing steps with automatic recognition of the scanning WeChat; first incoming owner message establishes history/notification session. Keep separate contact invitations and privacy. Evidence: focused unit, typecheck/build/lint and inspected Electron flow. Conflict: user explicitly removes previous self-code UX; canonical update belongs to integration, no further product confirmation.

View File

@@ -11,7 +11,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
云知识库的向量模型由平台管理员配置Yuxi 后台「智能体管理 → 模型供应商」启用 embedding 模型并填写实际维度Works Square 运营后台「模型管理」从 one-api 刷新并激活相同模型 ID。one-api 必须支持该模型的向量接口。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
在「发布与访问」中可通过 AgentBus 连接个人微信:选用本人已有连接或扫码连接新微信,完成本人配对、设置联系人使用权限,再确认启用。渠道支持暂停、切换智能体、断开连接和查看运行/投递活动;失败的文件可单独补发。本人已配对的微信会话可在客户端查看、审批、停止、新开一段并保存实际产物;受邀联系人的内容不会因创建者付费而对创建者开放。自动任务可明确选择把结果发送到已配对的本人微信,暂停保留目标;费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录。
在「发布与访问」中可通过 AgentBus 连接个人微信:选用本人已有连接或扫码连接新微信,设置联系人使用权限确认启用,绑定时扫码的微信可直接发送消息,无需本人配对码。其他联系人仍需创建者邀请。渠道支持暂停、切换智能体、断开连接和查看运行/投递活动;失败的文件可单独补发。本人的微信会话可在客户端查看、审批、停止、新开一段并保存实际产物;受邀联系人的内容不会因创建者付费而对创建者开放。自动任务可明确选择把结果发送到已经开始对话的本人微信,暂停保留目标;费用统计包含微信渠道。渠道需要配套版本的 AgentBus Core/微信 Adapter、Yuxi 和 Works Square桌面用户无需填写服务凭据。Main 保管操作恢复记录,重开界面不会自动重发绑定或执行请求;验证码不写入恢复记录。
- `Makelore CanvasAI 绘画`每个设计项目Workspace维护一份从创建起就存在的 Living Form。左侧项目栏负责新建、切换和管理 Workspace并在桌面设计模式下以 256px 宽度常驻展开;中央沿用 AI 编程的安静对话画布、自然消息流和底部悬浮输入器AI 整理出的制作方案作为对话内的轻量可编辑稿持续更新;桌面端右侧同为 256px 的全高历史作品栏集中展示当前项目的制作记录与生成结果。紧凑窗口通过左侧抽屉访问项目列表,历史记录保留在时间线中。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
- `Makelore RobotAI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。

View File

@@ -45,7 +45,8 @@ const MESSAGES: Record<string, string> = {
channel_session_control_failed: '微信会话操作失败,请刷新后重试',
channel_delivery_not_found: '渠道交付记录不存在',
channel_delivery_retry_failed: '文件补发失败,请刷新交付记录后重试',
pairing_expired: '配对码已过期,请重新生成',
self_pairing_removed: '本人无需配对,请直接使用绑定时扫码的微信发送消息',
pairing_expired: '邀请码已过期,请重新生成',
verification_required: '请先完成微信验证',
artifact_unavailable: '文件已不可用,请刷新文件列表',
operation_unavailable: '操作状态暂时无法确认,请稍后查询',
@@ -61,9 +62,9 @@ const MESSAGES: Record<string, string> = {
run_not_active: '当前会话没有可操作的运行',
channel_session_unavailable: '新的微信会话暂时无法建立',
pairing_invalid: '配对请求无效,请刷新后重试',
invitation_invalid: '配对码无效或已撤销',
invitation_consumed: '配对码已经使用',
invites_disabled: '当前渠道仅允许创建者本人配对',
invitation_invalid: '邀请码无效或已撤销',
invitation_consumed: '邀请码已经使用',
invites_disabled: '当前渠道仅允许本人使用',
agentbus_unavailable: '渠道服务暂时不可用,请稍后重试',
};

View File

@@ -50,11 +50,11 @@ export function CloudChannelConversations({ slug, activeView = true }: { slug: s
};
return <section aria-label="本人微信会话" className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium"></h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p></div>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p></div>
<Button variant="ghost" size="sm" disabled={loading || busy} onClick={() => setRevision(value => value + 1)}><RefreshCw className="mr-2 h-4 w-4" /></Button></div>
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{loading && !sessions.length && <p role="status" className="text-sm text-muted-foreground"></p>}
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground">访</p>}
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground">访</p>}
{!!sessions.length && <label className="block space-y-2 text-sm"><span></span>
<select className="h-10 w-full rounded-md border border-input bg-background px-3" value={selected} disabled={busy} onChange={event => setSelected(event.target.value)}>
{!sessions.some(session => session.session_id === selected) && <option value={selected}></option>}

View File

@@ -328,7 +328,7 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
setQrBindingKey(currentBindingKeyRef.current);
setQr(payload);
setQrOpen(true);
} else if (isPairing(payload) && matchesCurrent) {
} else if (isPairing(payload) && payload.kind === 'invite' && matchesCurrent) {
setCreatedPairing(payload);
}
await refresh();
@@ -458,9 +458,10 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
await loadCallers();
};
const issuePairing = async (kind: 'self' | 'invite') => {
const issueInvite = async () => {
const kind = 'invite';
if (!binding) return;
if (kind === 'invite' && binding.access_mode !== 'invited') {
if (binding.access_mode !== 'invited') {
setNotice('请先保存“本人及受邀联系人”,再生成邀请码');
return;
}
@@ -471,7 +472,7 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
operation_id: operationId(),
};
const result = await runMutation(key, 'createChannelPairing', input,
kind === 'self' ? '本人配对码已生成,请在目标微信对话中发送' : '邀请码已生成,请手动发给受邀联系人');
'邀请码已生成,请手动发给受邀联系人');
if (requestedAccountId === currentIdRef.current && isPairing(result)) setCreatedPairing(result);
};
@@ -633,7 +634,7 @@ export function CloudChannelPanel({ slug, publishedVersion, activeView = true }:
onVerificationCode={setVerificationCode} onVerify={() => void verify()} onEnable={() => void stateAction(true)} onPause={() => void stateAction(false)}
onRouteTarget={setRouteTargetSlug} onOpenRoute={() => void openRoutePicker()} onSwitchRoute={() => void switchRoute()} onConfirmEnable={setConfirmEnable} onUnbind={() => void unbind()} onConfirmUnbind={setConfirmUnbind}
/>}
{tab === 'access' && <AccessStep binding={binding} accessMode={accessMode} callers={callers} pairing={activePairing} busy={busy} busyAction={busyAction} onMode={setAccessMode} onSave={() => void updateAccessMode()} onPair={kind => void issuePairing(kind)} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />}
{tab === 'access' && <AccessStep binding={binding} accessMode={accessMode} callers={callers} pairing={activePairing} busy={busy} busyAction={busyAction} onMode={setAccessMode} onSave={() => void updateAccessMode()} onInvite={() => void issueInvite()} onClearPairing={() => setCreatedPairing(null)} onRevoke={caller => void revoke(caller)} />}
{tab === 'activity' && <ActivityStep slug={slug} channelAccountId={currentId} refreshKey={activityRevision} activity={activity} nextOffset={activityOffset} busy={busy} onRefresh={() => void loadActivity()} onMore={() => void (activityOffset == null ? undefined : loadActivity(activityOffset))} onRetry={retryPart} />}
{tab === 'sessions' && <CloudChannelConversations slug={slug} activeView={activeView && tab === 'sessions'} />}
</section>;
@@ -685,20 +686,20 @@ function StepHeading({ number, title, description }: { number: string; title: st
return <div className="flex gap-3 border-t border-border/60 pt-5"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-sm font-medium text-brand">{number}</span><div><h3 className="font-medium">{title}</h3><p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p></div></div>;
}
function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, onMode, onSave, onPair, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onPair: (kind: 'self' | 'invite') => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) {
function AccessStep({ binding, accessMode, callers, pairing, busy, busyAction, onMode, onSave, onInvite, onClearPairing, onRevoke }: { binding: CloudChannelView | null; accessMode: 'self_only' | 'invited'; callers: CloudChannelCaller[]; pairing: CloudChannelPairing | null; busy: boolean; busyAction: string; onMode: (value: 'self_only' | 'invited') => void; onSave: () => void; onInvite: () => void; onClearPairing: () => void; onRevoke: (caller: CloudChannelCaller) => void }) {
const inviteEnabled = accessMode === 'invited' && binding?.access_mode === 'invited';
return <div className="space-y-5"><StepHeading number="2" title="谁能使用" description="默认只允许你本人。配对码和邀请都是短期、单次使用;创建者只能查看受邀者状态和费用,不读取其私聊内容。" />
return <div className="space-y-5"><StepHeading number="2" title="谁能使用" description="默认只允许绑定时扫码的微信使用。受邀联系人需要单独邀请;你只能查看受邀者状态和费用,不读取其私聊内容。" />
{!binding ? <p className="rounded-xl bg-muted/30 p-4 text-sm text-muted-foreground"></p> : <>
<div className="space-y-3 rounded-xl border border-border p-4"><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'self_only'} onChange={() => onMode('self_only')} /><span><strong></strong><span className="mt-1 block text-muted-foreground"></span></span></label><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'invited'} onChange={() => onMode('invited')} /><span><strong></strong><span className="mt-1 block text-muted-foreground"></span></span></label><Button variant="outline" disabled={busy || accessMode === (binding.access_mode === 'invited' ? 'invited' : 'self_only')} onClick={onSave}>{busyAction.startsWith('policy:') ? '保存中…' : '保存使用范围'}</Button></div>
<div className="grid gap-4 md:grid-cols-2"><div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium"></h3><p className="text-xs leading-5 text-muted-foreground"></p><Button variant="outline" disabled={busy} onClick={() => onPair('self')}>{busyAction.includes(':self') ? '生成中…' : '生成本人配对码'}</Button></div>{accessMode === 'invited' && <div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium"></h3><p className="text-xs leading-5 text-muted-foreground"></p><Button variant="outline" disabled={busy || !inviteEnabled} onClick={() => onPair('invite')}>{busyAction.includes(':invite') ? '生成中…' : '生成邀请码'}</Button>{!inviteEnabled && <p className="text-xs text-amber-800"></p>}</div>}</div>
{pairing && <PairingResult value={pairing} onClear={onClearPairing} />}
<div className="space-y-3 rounded-xl border border-border p-4"><div className="flex items-center justify-between gap-3"><h3 className="flex items-center gap-2 text-sm font-medium"><Users className="h-4 w-4" /></h3><span className="text-xs text-muted-foreground">{callers.length} </span></div>{!callers.length && <p className="text-sm text-muted-foreground"></p>}{callers.map(caller => <div key={caller.caller_id} className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-sm"><div><p>{caller.access_mode === 'self_only' ? '本人微信会话' : '受邀联系人'}</p><p className="mt-1 text-xs text-muted-foreground">{statusLabel(caller.grant_state)} · {shortDate(caller.created_at)}</p></div><Button variant="ghost" size="sm" className="text-destructive" disabled={busy || caller.grant_state === 'revoked'} onClick={() => onRevoke(caller)}></Button></div>)}</div>
<div className="space-y-3 rounded-xl border border-border p-4"><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'self_only'} onChange={() => onMode('self_only')} /><span><strong></strong><span className="mt-1 block text-muted-foreground">使</span></span></label><label className="flex cursor-pointer gap-3 text-sm"><input type="radio" name="channel-access-mode" checked={accessMode === 'invited'} onChange={() => onMode('invited')} /><span><strong></strong><span className="mt-1 block text-muted-foreground"></span></span></label><Button variant="outline" disabled={busy || accessMode === (binding.access_mode === 'invited' ? 'invited' : 'self_only')} onClick={onSave}>{busyAction.startsWith('policy:') ? '保存中…' : '保存使用范围'}</Button></div>
<div>{accessMode === 'invited' && <div className="space-y-3 rounded-xl bg-muted/30 p-4"><h3 className="text-sm font-medium"></h3><p className="text-xs leading-5 text-muted-foreground"></p><Button variant="outline" disabled={busy || !inviteEnabled} onClick={onInvite}>{busyAction.includes(':invite') ? '生成中…' : '生成邀请码'}</Button>{!inviteEnabled && <p className="text-xs text-amber-800"></p>}</div>}</div>
{pairing?.kind === 'invite' && <PairingResult value={pairing} onClear={onClearPairing} />}
<div className="space-y-3 rounded-xl border border-border p-4"><div className="flex items-center justify-between gap-3"><h3 className="flex items-center gap-2 text-sm font-medium"><Users className="h-4 w-4" /></h3><span className="text-xs text-muted-foreground">{callers.length} </span></div>{!callers.length && <p className="text-sm text-muted-foreground"></p>}{callers.map(caller => <div key={caller.caller_id} className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-sm"><div><p>{caller.access_mode === 'self_only' ? '本人微信会话' : '受邀联系人'}</p><p className="mt-1 text-xs text-muted-foreground">{statusLabel(caller.grant_state)} · {shortDate(caller.created_at)}</p></div><Button variant="ghost" size="sm" className="text-destructive" disabled={busy || caller.grant_state === 'revoked'} onClick={() => onRevoke(caller)}></Button></div>)}</div>
</>}</div>;
}
function PairingResult({ value, onClear }: { value: CloudChannelPairing; onClear: () => void }) {
const copy = () => { if (value.code) void navigator.clipboard.writeText(`${value.kind === 'self' ? '配对' : '邀请'} ${value.code}`); };
return <div role="status" className="space-y-3 rounded-xl bg-amber-50 p-4 text-sm"><div className="flex items-start justify-between gap-3"><div><p className="font-medium">{value.kind === 'self' ? '本人一次性配对码' : '一次性邀请码'}</p><p className="mt-1 text-xs text-amber-900/70">{value.expires_at ? shortDate(value.expires_at) : '短期有效'}</p></div><Button variant="ghost" size="sm" onClick={onClear}></Button></div>{value.code ? <div className="flex items-center gap-2"><code className="min-w-0 flex-1 rounded-lg bg-white px-3 py-2 font-mono text-base tracking-widest">{value.code}</code><Button aria-label="复制配对命令" variant="outline" size="sm" onClick={copy}><Copy className="h-4 w-4" /></Button></div> : <p className="text-xs text-amber-900/70"></p>}</div>;
const copy = () => { if (value.code) void navigator.clipboard.writeText(`邀请 ${value.code}`); };
return <div role="status" className="space-y-3 rounded-xl bg-amber-50 p-4 text-sm"><div className="flex items-start justify-between gap-3"><div><p className="font-medium"></p><p className="mt-1 text-xs text-amber-900/70">{value.expires_at ? shortDate(value.expires_at) : '短期有效'}</p></div><Button variant="ghost" size="sm" onClick={onClear}></Button></div>{value.code ? <div className="flex items-center gap-2"><code className="min-w-0 flex-1 rounded-lg bg-white px-3 py-2 font-mono text-base tracking-widest">{value.code}</code><Button aria-label="复制邀请命令" variant="outline" size="sm" onClick={copy}><Copy className="h-4 w-4" /></Button></div> : <p className="text-xs text-amber-900/70"></p>}</div>;
}
function ActivityStep({ slug, channelAccountId, refreshKey, activity, nextOffset, busy, onRefresh, onMore, onRetry }: {

View File

@@ -56,7 +56,7 @@ export function CloudSchedules({ slug, onOpen, proposal, onProposalClosed }: { s
{job.runs?.map((run, i) => <div key={i} className="flex items-center justify-between gap-3 border-t border-border/50 pt-3 text-sm">
<div><p>{cloudStatus(run.status)}{run.error_message && <span className="ml-2 text-muted-foreground">{run.error_message}</span>}</p>
{run.result_notification && <p className="mt-1 text-xs text-muted-foreground">{run.result_notification.state === 'waiting_context'
? '微信通知待恢复:请确认连接,并在已配对的微信对话中发送一条消息。任务结果已独立保存。'
? '微信通知待恢复:请确认连接,并在本人微信对话中发送一条消息。任务结果已独立保存。'
: '微信通知已配置,实际送达情况可在发布渠道的活动记录中查看。'}</p>}</div>
{run.conversation_available && <Button variant="ghost" onClick={() => onOpen({ thread_id: run.thread_id, client_thread_id: null, agent_slug: slug,
title: job.name, status: run.status, mode: 'published', unread: false, updated_at: '', run_id: null })}></Button>}
@@ -160,8 +160,8 @@ function ScheduleNotification({ slug, value, onChange }: {
{channels.find(channel => channel.id === caller.channel_account_id)?.display_name ?? '个人微信'} · {index + 1}
</option>)}
</select></label>
<p className="text-xs leading-6 text-muted-foreground"></p>
{!loading && !error && !options.length && <p className="text-xs leading-6 text-muted-foreground">访</p>}
<p className="text-xs leading-6 text-muted-foreground"></p>
{!loading && !error && !options.length && <p className="text-xs leading-6 text-muted-foreground">访</p>}
{selected && !available && !loading && <p className="text-xs leading-6 text-muted-foreground"></p>}
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
</section>;

View File

@@ -85,9 +85,10 @@ test('own WeChat conversation approval and generated PPT delivery in the desktop
await expect.poll(async () => app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string; input: unknown }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'wechatBindVerification'))).toBe(true);
if (await page.getByRole('button', { name: '关闭二维码', exact: true }).count()) await page.getByRole('button', { name: '关闭二维码', exact: true }).click();
await page.getByRole('button', { name: '使用权限', exact: true }).click();
await page.getByRole('button', { name: '生成本人配对码', exact: true }).click();
await expect(page.getByText('TEST1234', { exact: true })).toBeVisible();
await page.getByRole('button', { name: '隐藏', exact: true }).click();
await expect(page.getByText('使用绑定时扫码的微信,直接发送消息即可。', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '生成本人配对码', exact: true })).toHaveCount(0);
expect(await app.evaluate(() => (globalThis as typeof globalThis & { cloudChannelFixtureCalls: { operation: string }[] }).cloudChannelFixtureCalls.some(call => call.operation === 'createChannelPairing'))).toBe(false);
await page.screenshot({ path: testInfo.outputPath('wechat-access-without-pairing.png') });
await page.getByRole('button', { name: '连接微信', exact: true }).click();
await page.screenshot({ path: testInfo.outputPath('wechat-publication.png') });
await app.evaluate(({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0]; win.setMinimumSize(800, 600); win.setSize(980, 680); });

View File

@@ -67,14 +67,31 @@ it('requires an explicit enable confirmation and sends the typed binding operati
expect(typeof input.operation_id).toBe('string');
});
it('uses the pairing code command and does not mutate when the access tab is opened', async () => {
it('uses the bound WeChat identity without offering or requesting a self pairing code', async () => {
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
await screen.findByRole('button', { name: '使用权限' });
fireEvent.click(screen.getByRole('button', { name: '使用权限' }));
fireEvent.click(await screen.findByRole('button', { name: '生成本人配对码' }));
await screen.findByText('ABC123');
expect(api.call.mock.calls.some(call => call[0] === 'channelPolicy')).toBe(false);
expect(api.call.mock.calls.find(call => call[0] === 'createChannelPairing')?.[1]).toEqual(expect.objectContaining({ kind: 'self', channel_account_id: 'wechat-1' }));
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
await screen.findByText('使用绑定时扫码的微信,直接发送消息即可。');
expect(screen.queryByRole('button', { name: '生成本人配对码' })).toBeNull();
expect(screen.queryByRole('button', { name: '生成邀请码' })).toBeNull();
expect(api.call.mock.calls.some(call => ['createChannelPairing', 'channelPolicy'].includes(call[0]))).toBe(false);
});
it('still requires an explicit invitation for other contacts and copies the complete command', async () => {
const base = api.call.getMockImplementation()!;
api.call.mockImplementation(async (operation: string, input) => {
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }], available_accounts: [] };
if (operation === 'createChannelPairing') return { invitation_id: 'invite-a', binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'invite', consumed: false, code: 'INVITE123' };
return base(operation, input);
});
const copy = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: copy } });
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
fireEvent.click(await screen.findByRole('button', { name: '生成邀请码' }));
await screen.findByText('INVITE123');
fireEvent.click(screen.getByRole('button', { name: '复制邀请命令' }));
expect(copy).toHaveBeenCalledWith('邀请 INVITE123');
expect(api.call.mock.calls.find(call => call[0] === 'createChannelPairing')?.[1]).toEqual(expect.objectContaining({ kind: 'invite', channel_account_id: 'wechat-1' }));
});
it('looks up delivery by the result message ID before offering file retry', async () => {
@@ -151,7 +168,7 @@ it('drops an old QR session when switching bindings and ignores its late status'
const oldStatus = new Promise(resolve => { releaseOldStatus = resolve; });
let statusCalls = 0;
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
if (operation === 'channelBindings') return { items: [binding, bindingB], available_accounts: [] };
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }, bindingB], available_accounts: [] };
if (operation === 'channelCallers') return { items: [] };
if (operation === 'channelActivity') return { items: [], next_offset: null };
if (operation === 'wechatBindStart') return { session_key: 'session-a', status: 'waiting', qrcode_url: 'https://provider.example/qr-a' };
@@ -181,16 +198,16 @@ it('clears the previous binding pairing, callers, and activity when switching ac
delivery: { state: 'known' }, timing: { created_at: '2026-09-13T00:00:00Z' },
};
api.call.mockImplementation(async (operation: string, input: Record<string, unknown>) => {
if (operation === 'channelBindings') return { items: [binding, bindingB], available_accounts: [] };
if (operation === 'channelBindings') return { items: [{ ...binding, access_mode: 'invited' }, bindingB], available_accounts: [] };
if (operation === 'channelCallers') return { items: input.channel_account_id === 'wechat-1' ? [callerA] : [] };
if (operation === 'channelActivity') return { items: input.channel_account_id === 'wechat-1' ? [activityA] : [], next_offset: null };
if (operation === 'createChannelPairing') return { binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'self', consumed: false, code: 'PAIR-A' };
if (operation === 'createChannelPairing') return { binding_id: 'binding-1', provider_generation: 'provider-1', kind: 'invite', consumed: false, code: 'PAIR-A' };
throw new Error(`Unexpected operation ${operation}`);
});
render(<CloudChannelPanel slug={slug} publishedVersion={2} />);
fireEvent.click(await screen.findByRole('button', { name: '使用权限' }));
await screen.findByText('受邀联系人', { exact: true });
fireEvent.click(screen.getByRole('button', { name: '生成本人配对码' }));
fireEvent.click(screen.getByRole('button', { name: '生成邀请码' }));
await screen.findByText('PAIR-A');
fireEvent.click(screen.getByRole('button', { name: '活动记录' }));
await screen.findByText('词元 7');
@@ -198,7 +215,7 @@ it('clears the previous binding pairing, callers, and activity when switching ac
fireEvent.change(await screen.findByLabelText('当前渠道'), { target: { value: 'wechat-2' } });
await waitFor(() => expect(screen.queryByText('PAIR-A')).toBeNull());
fireEvent.click(screen.getByRole('button', { name: '使用权限' }));
await screen.findByText('还没有已配对的调用者。');
await screen.findByText('首次收到你的微信消息后,会在这里显示本人会话。');
expect(screen.queryByText('受邀联系人', { exact: true })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '活动记录' }));
await screen.findByText('还没有渠道活动。');