diff --git a/docs/IM_PLATFORM.zh.md b/docs/IM_PLATFORM.zh.md index 60cd473..e4f55f4 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 609e11f..9f8c992 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 + + + + + +