From 9b708860af2abd7b1bcf03b4dd998080cc554f39 Mon Sep 17 00:00:00 2001 From: orange Date: Sat, 10 Oct 2026 00:51:27 +0800 Subject: [PATCH] =?UTF-8?q?bridge:=20=E8=B4=A6=E5=8F=B7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=A0=E9=99=A4/=E6=B8=85=E7=90=86?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=9D=A1=E7=9B=AE=EF=BC=8C=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=20server=20config=20=E5=8F=AF=E9=80=89=20host:port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账号列表管理: - 卡片新增复选框与 Delete account,多选后可 Delete selected。删除都经过确认弹窗: 列出具体条目、写明会有几个已登录的 Telegram 客户端被登出;条目不由配置文件管理时 直接禁用确认按钮 - findDuplicateAccounts() 只报能证明的重复:adapter 解析到同一个平台用户 (platformKind + userId),或该条目因虚拟手机号被另一条目占用而 provision 失败 (VirtualPhoneClaimedError)。保留客户端已登录的那条,平手按 platformId 稳定排序。 页面的 Select duplicates 只做选中,看到确认列表后才删 - deleteAccounts() 先校验全部目标,再停订阅、清理平台会话/虚拟号/TOTP secret/两步 验证密码/binding/客户端授权、吊销对应 auth key,最后通过所属的 loader 树删掉 config entry(app.yml 会写回,重启后不会再被 provision)。消息与会话历史保留 - 新增 account-removal.ts / account-duplicates.ts 及单元测试;另有直接跑真实 Loader + Include 的测试,覆盖嵌套树的 entry key 与删除写回 复制 server config: - dashboard 新增 serverEndpoints:主地址在前,altEndpoints 按配置顺序解析后跟在后面并去重 - Copy 按钮左侧新增 Endpoint 下拉(只有一个地址时不显示,默认主地址),只改写 host/port 与 dcs[].ip/port;页面展示与剪贴板仍是同一份文档,格式与 main 一致 --- docs/IM_PLATFORM.zh.md | 2 + packages/bridge/client/accounts.tsx | 298 +++++++++++++++++- packages/bridge/client/bridge-model.test.ts | 93 ++++++ packages/bridge/client/bridge-model.ts | 61 ++++ packages/bridge/client/style.css | 71 +++++ packages/bridge/src/account-dashboard.test.ts | 17 +- packages/bridge/src/account-dashboard.ts | 28 +- .../bridge/src/account-duplicates.test.ts | 69 ++++ packages/bridge/src/account-duplicates.ts | 82 +++++ .../bridge/src/account-removal-loader.test.ts | 70 ++++ packages/bridge/src/account-removal.test.ts | 155 +++++++++ packages/bridge/src/account-removal.ts | 139 ++++++++ packages/bridge/src/dashboard-types.ts | 48 +++ packages/bridge/src/index.ts | 98 +++++- packages/bridge/src/platform-account.ts | 17 +- .../pages/crossgram-components.test.tsx | 46 ++- .../src/bridge.browser.e2e.test.ts | 249 ++++++++++++++- 17 files changed, 1515 insertions(+), 28 deletions(-) create mode 100644 packages/bridge/src/account-duplicates.test.ts create mode 100644 packages/bridge/src/account-duplicates.ts create mode 100644 packages/bridge/src/account-removal-loader.test.ts create mode 100644 packages/bridge/src/account-removal.test.ts create mode 100644 packages/bridge/src/account-removal.ts diff --git a/docs/IM_PLATFORM.zh.md b/docs/IM_PLATFORM.zh.md index 60cd4736..e4f55f46 100644 --- a/docs/IM_PLATFORM.zh.md +++ b/docs/IM_PLATFORM.zh.md @@ -66,6 +66,8 @@ interface IMPlatform { 同一个 Cordis platform entry 对应一个账号和一个虚拟手机号。bridge 在 `/platform-accounts` 页面展示平台资料、手机号和每 30 秒轮换的六位 TOTP 登录码。`user.avatar` 继续使用 adapter 的 typed `IMMedia`,页面头像由 bridge 调用 `downloadMedia()` 读取;TOTP secret 和 credentials 不会下发到浏览器。 +页面上的「删除账号」删掉的是**这一条 platform entry 和它的登录面**:平台会话、虚拟手机号、TOTP secret、两步验证密码和客户端授权一起清理,绑定在这些凭据上的客户端会被登出,最后 entry 从配置文件里移除(所以重启后不会再被 provision)。消息、会话和 `mtproto_im_user` 身份行保留:它们按 platform session 隔离,条目删掉后不再可达,用同一个 entry id 重新配置时旧消息仍属于同一个平台用户。删除只对由配置文件(loader)管理的 entry 生效,adapter 直接 `ctx.plugin()` 注册的条目会被拒绝,需要自行停用。 + ## 2. ID 与幂等 - user、conversation、message、media 和 group ID 都是 opaque string。 diff --git a/packages/bridge/client/accounts.tsx b/packages/bridge/client/accounts.tsx index 609e11f9..9f8c9922 100644 --- a/packages/bridge/client/accounts.tsx +++ b/packages/bridge/client/accounts.tsx @@ -7,9 +7,13 @@ import { Index, onCleanup, Show, + untrack, } from 'solid-js' import type { PlatformAccountDashboardData, + PlatformAccountDuplicateGroup, + PlatformAccountRemovalPreview, + PlatformAccountServerEndpoint, PlatformAccountView, } from '../src/dashboard-types.js' import { useRpc, type PageProps } from 'cordis-webui-solidjs/client' @@ -24,18 +28,27 @@ import { import { sessionToken } from 'cordis-webui-solidjs/session' import { copyText, + describeDuplicateGroup, + duplicateOwners, + duplicatePlatformIds, + formatEndpoint, formatPhone, parseTelegramLoginUrl, remainingSeconds, safeImageURL, sameOriginPath, + withServerEndpoint, } from './bridge-model.js' export default function AccountsPage(props: PageProps) { const rpc = useRpc(props.entryId), action = useAction() const [now, setNow] = createSignal(Date.now()), [qr, setQr] = createSignal(false), - [copied, setCopied] = createSignal(false) + [copied, setCopied] = createSignal(false), + [endpointKey, setEndpointKey] = createSignal(''), + [selected, setSelected] = createSignal([]), + [deleting, setDeleting] = createSignal(null), + [duplicates, setDuplicates] = createSignal([]) const tick = () => { if (!document.hidden) setNow(Date.now()) }, @@ -55,8 +68,42 @@ export default function AccountsPage(props: PageProps) { ), ) const ids = createMemo(() => [...accounts().keys()]) + // Duplicate detection is a durable backend answer, so it is fetched once per + // connection instead of riding the per-second account snapshot. + const duplicateScan = useAction() + const scanDuplicates = async () => { + await duplicateScan.run(async () => { + setDuplicates((await rpc.data.findDuplicateAccounts()) ?? []) + }) + } + createEffect(() => { + if (!rpc.ready) return + untrack(() => void scanDuplicates()) + }) + const duplicateOf = createMemo(() => duplicateOwners(duplicates())) + // Copying for another endpoint must not silently change the default: nothing is + // selected until the reader picks an endpoint, and the primary entry wins then. + const endpoints = createMemo( + () => rpc.data.serverEndpoints ?? [], + ) + const endpoint = createMemo(() => { + const list = endpoints(), + key = endpointKey() + return ( + list.find((item) => formatEndpoint(item) === key) ?? + list.find((item) => item.primary) ?? + list[0] + ) + }) + // The page displays and copies the same document, for the selected endpoint. + const configuredEndpoint = createMemo(() => { + const config = rpc.data.serverConfig, + target = endpoint() + // Without an advertised endpoint list the document stays exactly as configured. + return config && target ? withServerEndpoint(config, target) : config + }) const configuration = createMemo(() => - JSON.stringify(rpc.data.serverConfig, null, 2), + JSON.stringify(configuredEndpoint(), null, 2), ) const [search, setSearch] = createSignal(''), [limit, setLimit] = createSignal(24) @@ -68,6 +115,20 @@ export default function AccountsPage(props: PageProps) { .includes(search().toLowerCase()) }), ) + const duplicateIds = createMemo(() => duplicatePlatformIds(duplicates())) + const toggle = (id: string, on: boolean) => + setSelected((current) => + on + ? current.includes(id) + ? current + : [...current, id] + : current.filter((value) => value !== id), + ) + const afterRemoval = async () => { + setSelected([]) + setDeleting(null) + await scanDuplicates() + } return ( <> void action.run(() => rpc.data.refresh())} + onClick={() => + void action.run(async () => { + await rpc.data.refresh() + await scanDuplicates() + }) + } > Refresh accounts @@ -102,18 +168,39 @@ export default function AccountsPage(props: PageProps) { Import this server configuration into a patched Crossgram client.

- +
+ 1}> + + + +
View server configuration
{configuration()}
@@ -123,6 +210,36 @@ export default function AccountsPage(props: PageProps) {

Your identities

{ids().length} accounts + + + + + +