From 0d0b7d8905960068bb81c4a57783fea95cb064e8 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Fri, 16 Oct 2026 06:45:47 +0900 Subject: [PATCH 1/5] feat: prepare checked Lark parent videos in Channel bubbles Issue: #1023 Agent-Task: codex/local/01a13902-0d72-7872-ac0f-440e152155d5 --- .../features/conversation-timeline.md | 2 + apps/mobile/src/host/conversation.ts | 38 +-- apps/mobile/src/host/wire.ts | 7 +- apps/mobile/test/conversation.test.ts | 30 ++ changes/1023-lark-channel-video.md | 11 + ...dge-media-uses-channel-source-authority.md | 6 + docs/architecture/botharness-architecture.md | 2 + docs/bot-cli.md | 3 + docs/bot-cli.zh.md | 3 + .../verification/lark-1023-channel-video.md | 40 +++ docs/lark-connection.md | 8 + docs/lark-connection.zh.md | 8 + .../src/client/bridge-video-resource.ts | 146 +++++++++ packages/client/src/client/bridge-video.tsx | 98 ++++++ packages/client/src/client/bridge.ts | 38 ++- .../src/client/channel-message-body.tsx | 16 +- packages/client/src/client/locale.ts | 17 ++ packages/client/test/bridge-video.test.ts | 286 ++++++++++++++++++ packages/core/src/attachments/http.ts | 2 +- packages/core/src/bots/bot-create-cli.ts | 2 +- packages/core/src/bots/cli-channels.ts | 8 + packages/core/src/channels/channel.ts | 8 +- packages/core/src/messaging/channel-media.ts | 8 +- packages/core/src/messaging/channel-target.ts | 26 +- packages/core/src/messaging/dsh-im.ts | 55 +++- packages/core/src/messaging/provider.ts | 2 + .../core/test/channel-bridge-media.test.ts | 176 ++++++++++- packages/core/test/cli-channels.test.ts | 38 +++ scripts/dev-im-provider.mjs | 6 +- 29 files changed, 1032 insertions(+), 58 deletions(-) create mode 100644 changes/1023-lark-channel-video.md create mode 100644 docs/dev/verification/lark-1023-channel-video.md create mode 100644 packages/client/src/client/bridge-video-resource.ts create mode 100644 packages/client/src/client/bridge-video.tsx create mode 100644 packages/client/test/bridge-video.test.ts diff --git a/.agents/skills/verify-deepseekbot/features/conversation-timeline.md b/.agents/skills/verify-deepseekbot/features/conversation-timeline.md index 8216674da6..60d571ed33 100644 --- a/.agents/skills/verify-deepseekbot/features/conversation-timeline.md +++ b/.agents/skills/verify-deepseekbot/features/conversation-timeline.md @@ -32,6 +32,8 @@ Preconditions: a model credential, a Bot with a few replies (one long list), thr ## Gotchas +- The #1023 Lark parent-video candidate needs a native `media` parent quoted by an actual Bot-mentioned Human request in a synced conversation. Open that Channel and confirm there is no video/cover request before **Prepare video**. Prepare, then manually play, pause and seek; verify advancing decoded frames rather than only a successful HTTP response. Original download is independent, and a missing/failed native cover must not block playback. Use a fresh native resource to verify stop-before-acquisition; reusing an acquired parent tests cached access instead. Native and installed-product qualification remain pending in `docs/dev/verification/lark-1023-channel-video.md`; do not fabricate a Source Event or rename an ordinary file to qualify video. + - Read positions only move forward. To see the divider again, post a new message so the Bot replies after the anchor. - Headless Chromium reports `hover: none`, which hides hover-only times and shows the hover actions permanently. Launch it with `--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4` to capture desktop hover states. - `deepseekbot pause` and `resume` have no online adapter; you cannot pause a Bot on a live Host to keep it from answering seeded messages. diff --git a/apps/mobile/src/host/conversation.ts b/apps/mobile/src/host/conversation.ts index 489af1f5af..3c9838c1bf 100644 --- a/apps/mobile/src/host/conversation.ts +++ b/apps/mobile/src/host/conversation.ts @@ -297,24 +297,26 @@ function attachmentsOf( }; }); const sourceEventId = message.bridgeOrigin?.sourceEventId; - const bridged = (message.bridgeMedia?.items ?? []).map((item): MessageAttachment => ({ - key: `${message.id}:bridge:${item.id}`, - kind: item.kind, - name: item.name, - ...(item.sizeBytes === undefined ? {} : { size: item.sizeBytes }), - ...(sourceEventId === undefined - ? {} - : { - source: resolve( - attachmentPath({ - channelId: entry.channelId, - sourceEventId, - attachmentId: item.id, - }), - ), - }), - local: false, - })); + const bridged = (message.bridgeMedia?.items ?? []) + .filter((item) => item.id !== message.bridgeMedia?.video?.posterAttachmentId) + .map((item): MessageAttachment => ({ + key: `${message.id}:bridge:${item.id}`, + kind: item.kind === 'video' ? 'file' : item.kind, + name: item.name, + ...(item.sizeBytes === undefined ? {} : { size: item.sizeBytes }), + ...(sourceEventId === undefined + ? {} + : { + source: resolve( + attachmentPath({ + channelId: entry.channelId, + sourceEventId, + attachmentId: item.id, + }), + ), + }), + local: false, + })); return [...stored, ...bridged]; } diff --git a/apps/mobile/src/host/wire.ts b/apps/mobile/src/host/wire.ts index 2fcef9f212..415d8762c4 100644 --- a/apps/mobile/src/host/wire.ts +++ b/apps/mobile/src/host/wire.ts @@ -42,7 +42,10 @@ export interface ChannelWireMessage extends WireMessage { author: WireAuthor; reactions?: WireReaction[]; attachments?: readonly AttachmentRef[]; - bridgeMedia?: { items: readonly BridgeMediaItem[] }; + bridgeMedia?: { + items: readonly BridgeMediaItem[]; + video?: { attachmentId: string; durationMs?: number; posterAttachmentId?: string }; + }; bridgeOrigin?: { sourceEventId: string }; toolApprovalRequest?: unknown; userQuestionRequest?: unknown; @@ -56,7 +59,7 @@ export interface ChannelWireMessage extends WireMessage { export interface BridgeMediaItem { id: string; - kind: 'image' | 'file' | 'audio'; + kind: 'image' | 'file' | 'audio' | 'video'; name: string; sizeBytes?: number; } diff --git a/apps/mobile/test/conversation.test.ts b/apps/mobile/test/conversation.test.ts index 926ced1f21..ab679c6d69 100644 --- a/apps/mobile/test/conversation.test.ts +++ b/apps/mobile/test/conversation.test.ts @@ -501,6 +501,36 @@ describe('DM conversation', () => { ]); }); + it('keeps bridged video as an explicit original-file action and does not expose its cover as an eager image', async () => { + const host = new FakeHost(); + const channel = host.channel(DM); + channel.append( + { + id: 'native-video', + author: { kind: 'bridged', source: 'lark' }, + body: '', + bridgeMedia: { + video: { attachmentId: 'clip', posterAttachmentId: 'cover' }, + items: [ + { id: 'clip', kind: 'video', name: 'clip.mp4' }, + { id: 'cover', kind: 'image', name: 'cover' }, + ], + }, + bridgeOrigin: { sourceEventId: 'video-event' } as never, + }, + false, + ); + const target = open(host); + const live = await until(target, (s) => s.messages.length === 1); + expect(live.messages[0]?.attachments).toHaveLength(1); + expect(live.messages[0]?.attachments[0]).toMatchObject({ + kind: 'file', + name: 'clip.mp4', + local: false, + source: { uri: expect.stringContaining('attachmentId=clip') }, + }); + }); + it('streams a Bot draft live and replaces it with the committed reply', async () => { const host = new FakeHost(); const channel = host.channel(DM); diff --git a/changes/1023-lark-channel-video.md b/changes/1023-lark-channel-video.md new file mode 100644 index 0000000000..07bb387865 --- /dev/null +++ b/changes/1023-lark-channel-video.md @@ -0,0 +1,11 @@ +--- +section: Added +--- + +## en + +- Prepare a checked Lark parent video in its Channel request bubble and play it manually, with platform duration and a native cover when supplied, separate original download and bounded source authorization; audio and direct media messages remain separate qualification scopes ([#1023](https://github.com/BotHarness/DeepSeekBot/issues/1023)). + +## zh + +- 在 Channel 请求气泡内明确准备并手动播放经核验的 Lark 引用视频,显示平台已知时长及原生封面,支持单独下载原件并沿用有界来源授权;音频和直接媒体消息仍需独立验收([#1023](https://github.com/BotHarness/DeepSeekBot/issues/1023))。 diff --git a/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md b/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md index 9b0b695ae0..2554d870d3 100644 --- a/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md +++ b/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md @@ -29,6 +29,12 @@ No schema migration, parallel media catalog, remote original editing, autoplay, ## Qualification +The #1023 first video tracer extends this application-defined contract to a checked native Lark `media` parent of an App-mentioned text request. Provider `sourceVideos` opt-in retains the exact video descriptor and optional platform duration in milliseconds; `sourceImages` additionally retains a cover only when the same native parent supplies its image key. Generic file names never establish video kind. Canonical Source Event evidence owns this association; Channel projection exposes opaque video/cover IDs and known duration without resource keys. Intake acquires no media bytes. + +Human preparation reuses the existing authenticated attachment route, source authority and bounded acquisition owner. `representation=playback` serves the unchanged original only when byte sniffing identifies MP4; the browser independently verifies decoding. No transcoder, generated poster or model transcript is introduced. The original remains independently downloadable even when its playback format is refused. Covers are acquired only after preparation, through their own exact source descriptor; missing or failed covers do not block video. Browser resources cancel and release URLs on unmount or authority changes. The CLI requests the same representation explicitly. Mobile currently offers the original as a file action and does not automatically load the video cover. Audio and direct native media messages remain subsequent qualification scopes. + +The candidate's automated regressions do not establish native intake, rendering, playback or installed-package qualification. Those gates and the required first-slice Human feedback remain tracked in [#1023](https://github.com/BotHarness/DeepSeekBot/issues/1023). + The #1022 Lark parent-file candidate extends the same authority, without a new persistence or Provider contract. Its checked descriptor has an exact parent message association and no MIME or size. Projection and access share one eligibility function, expose a generic file card and retain unknown metadata. Explicit download uses the existing bounded attachment HTTP path. The application-defined `channelMediaTarget` RPC reuses that source access and resolves only the canonical Attachment file target; Client application discovery and opening use the existing DSH-native file capability, never a new launcher. A read-only capability check acquires no bytes, and the menu is mounted only after explicit action. The CLI supplies matching target and download commands. Native file/message/Client qualification remains separate from source tests. Automated tests verify native association/order, exact placement and access, stop/revoke/restart, cancellation, UI visibility/retry and absence of new Inbox/model activity. These are regression evidence, not real platform acceptance. The PR must separately record fresh native-message bytes and actual matched-theme UI evidence; current blockers remain explicit until Human QA completes. Official resource semantics require the original message ID and its matching image key ([Lark reference](https://github.com/larksuite/cli/blob/main/skills/lark-im/references/lark-im-messages-resources-download.md)). diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index cacf22b456..c34354fb9e 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -931,6 +931,8 @@ The #1155 ordinary-text extension uses the same exclusive checked QQ inbound con [ADR-0137](../adr/0137-human-bridge-media-uses-channel-source-authority.md) places Human external-image access in the existing Messaging and Attachment owners. The Source Event retains trusted native descriptors and order; realtime and historical Channel placement projections expose media references without resource keys. The existing attachment route under the authenticated API Gateway rechecks Human Channel access and independent recorded reception paths using Channel, Source Event and attachment IDs. The Provider verifies the original message/resource association before acquisition. Visible images acquire automatically, reuse the deterministic Attachment identity and enlarge in the native Modal. Reception stop refuses new acquisition; unbind/revocation refuses acquisition and cached serving through that path. Missing or purged originals are not recreated. Human viewing creates no Observation, Inbox Admission, model image input or send permission. #1021 is the Lark image tracer; files, audio/video and other providers extend the contract in subsequent slices, with real platform qualification tracked in each issue/PR. #1022 沿同一权威接通 Lark 经核验的单个引用文件:投影与取得共享媒体资格判断,未知 MIME/大小不作推断。Human 明确下载才取得有界原件;支持原生打开的 Host 复用既有应用菜单,`channelMediaTarget` 每次按当前 Channel/来源/文件授权解析 canonical Attachment 目标。CLI 提供同一授权路径的目标和下载命令;没有新增 Provider、消息存储或 Bot 注意力。直接文件、多文件/混排与真实平台验收保持独立资格边界。 + +#1023 的首个视频候选将这一边界扩展到真实 @ 请求引用的原生 Lark `media` 父消息。Provider 明确协商 `sourceVideos`,仅保留准确的视频资源、平台已知时长及同一原生消息给出的可选封面;普通文件名不授予视频资格。Channel 只投影不透明资源引用。Human 明确准备后,既有 attachment HTTP owner 在当前授权下取得不超过 25 MiB 的原件,MP4 字节才可作为 `playback` 返回,浏览器另行确认解码且不自动播放。封面独立受检,缺失或失败不阻断播放;不转码、不生成封面、不调用模型转写。CLI 可明确请求同一播放表示;移动端暂以原件文件操作呈现,不自动加载封面。真实平台、安装包和 Human 首片反馈仍是资格门槛,音频与直接媒体消息未在本片完成。 [#908](https://github.com/BotHarness/BotHarness/issues/908) extends the same WeChat Source Event authority with opt-in one-level native quote evidence: server message ID, item ID, embedded text, display summary and partial-reference metadata remain distinct. Missing bodies resolve only from readable canonical sources in the same currently authorized account/private conversation, or accepted Outbox records with genuine native server receipts; WeChat client acknowledgements never qualify. No Provider transcript store or standalone Session lookup is introduced. `bridge_context(retained|retained-nearby)` returns bounded canonical local records with explicit `retained-local-sources` coverage, current own-identity/Grant/lease inspection, initial row-boundary snapshot and authorization-bound opaque keyset cursors. These reads audit the anchor Source Event without creating Admissions or wake; unavailable references do not imply remote deletion or a Thread. Client source details and Orchestrator input distinguish native quoted text, local resolution and unavailable content. Remote WeChat history/search remains unavailable. [#909](https://github.com/BotHarness/BotHarness/issues/909) routes the explicitly authorized WeChat paired-owner DM through existing Channel connector records: local DM/Group placements and Inbox-only paths reference one canonical Source Event. Group members retain their independent Attention/harvest policy; private Inbox and explicitly connected local DM paths retain the `human-dm` policy. The native origin remains `dm`, never a fabricated WeChat group or Thread. Shared source readability does not transfer the receiving identity or its private continuation: each external reply still requires the acting Bot's own enabled identity and Grant. Existing intake boundaries, placement deduplication, membership checks and dispatch fences apply to switches, deletion, revocation and restart. No schema, Provider transcript store or new Session lifecycle is added. Fresh installed-product qualification is recorded on #909. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index 5666084175..3aa8d36ea9 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -130,6 +130,7 @@ These commands manage Channels and groups through the same Host owners and check deepseekbot channel-rename --name "Skill Lab" deepseekbot channel-media-target --source-event --attachment deepseekbot channel-media-download --source-event --attachment --out ./original.csv +deepseekbot channel-media-download --source-event --attachment --representation playback --out ./playback.mp4 deepseekbot group-avatar-set --avatar-file ./avatar.png deepseekbot group-avatar-set --clear deepseekbot group-invite --bot @@ -164,6 +165,8 @@ Renaming a Bot DM renames that Bot, as in the Web UI. `group-avatar-set` reads a `channel-media-target` acquires checked source media through current Human Channel authorization and returns its canonical Host file target. `channel-media-download` uses the same authenticated attachment route as the card, writes the original bytes to a new local `--out` file and returns size, MIME and SHA-256 in its JSON result. It refuses an existing output instead of overwriting it. Both commands use `channel + source-event + attachment` without a Bot selector or model call; source revocation remains a coded refusal. The default bound is 25 MiB. +Use `--representation playback` on `channel-media-download` to request the same explicit preparation as the Channel player. For the #1023 Lark parent-video candidate this serves an original whose bytes identify MP4; it does not transcode or validate browser decoding. Omitting the flag always keeps original-download semantics. Unsupported formats return a coded refusal, and the original remains separately downloadable. Real native and installed-product qualification is recorded in [the candidate checkpoint](dev/verification/lark-1023-channel-video.md). + ### Bot profile, roster and Part Library These commands set a Bot's look and wake rules, arrange the sidebar and manage Custom Parts through the same Host calls as the Web UI ([#1702](https://github.com/BotHarness/DeepSeekBot/issues/1702)): diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index f1cdb8c7a6..0b8f73b328 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -125,6 +125,7 @@ deepseekbot group-create "Skill Lab" --member --member deepseekbot channel-rename --name "Skill Lab" deepseekbot channel-media-target --source-event --attachment deepseekbot channel-media-download --source-event --attachment --out ./original.csv +deepseekbot channel-media-download --source-event --attachment --representation playback --out ./playback.mp4 deepseekbot group-avatar-set --avatar-file ./avatar.png deepseekbot group-avatar-set --clear deepseekbot group-invite --bot @@ -159,6 +160,8 @@ deepseekbot channel-purge-preview --source-event | jq '{token}' \ `channel-media-target` 通过当前 Human Channel 授权取得受检来源媒体,返回其 canonical Host 文件目标。`channel-media-download` 使用与卡片相同的认证附件路径,将原件字节写入新的本地 `--out` 文件,并在 JSON 中返回大小、MIME 和 SHA-256;目标已存在时拒绝覆盖。两条命令只使用 Channel、Source Event 和 attachment,不选择 Bot,也不调用模型;来源撤销仍返回明确错误码。默认上限为 25 MiB。 +对 `channel-media-download` 明确传入 `--representation playback`,可使用与 Channel 播放器相同的准备路径。#1023 Lark 引用视频候选只返回字节识别为 MP4 的原件,不转码,也不能代替浏览器解码验收。不传该参数始终下载原件;不支持播放的格式返回明确错误码,原件仍可独立下载。真实平台和安装包资格见[候选检查点](dev/verification/lark-1023-channel-video.md)。 + ### Bot 资料、侧边栏与部件库 这些命令设置 Bot 的外观和唤醒规则、整理侧边栏、管理自定义部件,走的是 Web 界面所用的同一组 Host 调用([#1702](https://github.com/BotHarness/DeepSeekBot/issues/1702)): diff --git a/docs/dev/verification/lark-1023-channel-video.md b/docs/dev/verification/lark-1023-channel-video.md new file mode 100644 index 0000000000..5893e9e748 --- /dev/null +++ b/docs/dev/verification/lark-1023-channel-video.md @@ -0,0 +1,40 @@ +# Lark parent-video tracer — #1023 candidate + +## Scope + +This is the first video slice of [#1023](https://github.com/BotHarness/DeepSeekBot/issues/1023), based on merged file PR [#1943](https://github.com/BotHarness/DeepSeekBot/pull/1943). It does not finish the audio/video issue. A native text request that genuinely mentions the receiving App and replies to a native `media` parent carries its exact video descriptor and optional native cover. Generic files retain file semantics, even with an `.mp4` name. Direct native video/audio messages, audio playback and model understanding remain outside this slice. + +The maintained Provider candidate is [dsh-im#31](https://github.com/DoodleBears/dsh-im/pull/31), source `f889a5808a342bb9b4abf586f115e57cb44b2b6b`. The development launcher pins its 421 runtime files with SHA-256 `02a4b38aaca218091e52bfa8a8b91be4c98f5d41458356ca9ecd0ee928633819`. The product's independently qualified Provider selection remains unchanged until native and installed-product qualification. No registry artifact has been published or deployed. + +## Implemented path + +The existing exclusive Provider Consumer negotiates `sourceVideos`, `sourceFiles` and optional `sourceImages`. Metadata enrichment verifies the exact parent in the original conversation/topic without downloading media. Native `duration` is preserved as milliseconds. Only an actual native image key associates a cover; no cover is generated. + +Canonical Source Event → Channel placement projection → explicit **Prepare video** → authenticated attachment HTTP route → existing Human/source/identity/independent-path authority → bounded checked Provider resource → canonical AttachmentStore → original MP4 bytes → browser decode and manual native playback controls. + +Video and cover share the existing three-request Client acquisition budget. Each request has a 30-second deadline and 25 MiB default limit. Unmount and authorization notifications cancel requests and revoke object URLs. Cover absence/failure is independent of playback. Unsupported byte formats and browser decoding errors retain the original download action. There is no runtime transcoding, automatic playback, model transcription or additional Bot attention/send authority. The mobile wire contract retains the new kind but presents only an original-file action and suppresses eager cover loading. + +`deepseekbot channel-media-download ... --representation playback` uses the same live Host route without the model. Omitting the flag still downloads the original; invalid representations and playback on the target-resolution command are usage errors. Both paths preserve coded refusals and refuse output overwrite. + +## Regression checkpoint + +- Provider: 60 focused tests passed; all Feishu tests passed 779 with five existing POSIX ffmpeg-fixture tests skipped on Windows. Build and package verification passed. +- Core and CLI: 22 tests passed, including video/cover projection, byte-exact original/playback reuse, no eager acquisition, restart, stop-before-acquisition, cached access after stop, unbind, wrong parent/resource/cover refusal, unsupported playback with original still downloadable, and explicit CLI representation. +- Client and mobile compatibility: 61 tests passed, covering explicit preparation, no autoplay, independent cover, loading/refusal/retry, late response disposal, identity-change cancellation, existing image/file/audio regressions and mobile original-only presentation. The first Client run failed two tests because their expected download label omitted “to this device”; the selector was corrected to the existing visible label and the full focused rerun passed. +- Production build and typecheck passed. Additional checks and native evidence will be appended as they complete. + +The broader 90-test run passed 89 tests and timed out at the existing image unsupported/oversize case's 15-second limit. Its original failure is retained. After two additional video authority regressions were added, the entire Core media file passed all 17 tests serially, including that image case; no timeout was increased. The precise cause of the earlier timeout is unconfirmed. + +A subsequent 47-test serial run passed 46 and timed out in the existing `messaging-attachments` wrong-source/replay test, followed by its 10-second temporary-root cleanup hook. This broader run is not green. The failure and cleanup timeout remain preserved; they are not attributed to the candidate or the environment without further evidence. The new video resource tests separately cover both declared and streamed oversize bodies and the independent cover decoding failure. + +An isolated DSH 0.2.0-rc.2 Host passed authenticated doctor checks before and after a headless shell smoke. T3 was explicitly unavailable, so the repository verifier drove only the isolated Web shell; its empty Bot-mode screenshot was inspected and its console had zero issues. It is not video or native Lark evidence. The actual built product CLI returned one JSON `source-unavailable` refusal for both original and explicit playback requests to a nonexistent Channel, without creating output or calling a model. The private proof's initial expectation was `channel-unavailable`; it was corrected to the existing HTTP owner's source-refusal contract, with no product authorization change. This confirms live command/route operation only, not positive media delivery. + +These are controlled automated regressions, not native rendering or playback evidence. + +## Native gate + +T3 reported an attached signed-in Lark tab, but reading it timed out. The required `preview_open` recovery then explicitly returned **No preview automation host is available**. The Human was asked to keep T3 Code connected. No #1023 QA2 receiver has started, no permissions changed, and the previously released QA2 receiver remains released. + +After browser recovery, verify the Human account and designated QA2 group, inspect receiver ownership, prepare an automatic stop guard and coordinate the bounded window before binding. Upload a uniquely marked synthetic video through the native **Image & video** UI, quote that actual message and select the Bot from the native @ menu. Match the visible request/reply, Source Event, exact parent resource, Admission and Outbox facts. Verify zero managed media before preparation; then compare original bytes and hashes, manually play and check advancing decoded frames, pause/seek, refresh/restart and authority lifecycle. Capture matched light/dark before/after and a short recording. A missing cover is an observed metadata condition only when the native response actually omits it. + +Native Lark intake, real playback, fresh installed-product E2E, live CLI execution, screenshots/recording and first-slice Human feedback remain pending. A successful API response alone will not close these gates. #1023 stays open; audio and other native shapes must not be expanded before the first working slice is reviewed. diff --git a/docs/lark-connection.md b/docs/lark-connection.md index 9c39b2d615..57f5ebb707 100644 --- a/docs/lark-connection.md +++ b/docs/lark-connection.md @@ -497,3 +497,11 @@ The #1022 candidate reuses the shared Channel file card for a checked Lark paren Select the filename to download the original. The card shows its known filename and size; absent MIME and size remain unknown. Download starts only on explicit action, shows progress or a refusal, and offers **Retry**. The default limit is 25 MiB. When the Host supports native file opening, the card also offers the existing detected-application menu. Choosing an application opens the acquired Host copy; it never uploads edits to Lark. Unsupported devices retain download. Every action checks current Human Channel membership, the original placement and source, receiving identity and independent valid source paths. Pausing sync prevents new acquisition through that path while retaining authorized acquired files. Unbind or revocation prevents acquisition and cached access through that authorization. Refresh and restart preserve acquired history; missing or purged bytes are not reconstructed. Human access adds no Inbox admission, model work or external send authority. Real Lark/file/Client verification remains a separate gate tracked in [#1022](https://github.com/BotHarness/DeepSeekBot/issues/1022). + +## Referenced video in Channel history (candidate) + +The first #1023 slice uses a native Lark video sent with **Image & video**, quoted by a text request with a real Bot mention. With the conversation synced to a Channel, the request bubble offers original download and **Prepare video**. A file named `.mp4` remains a file unless Lark identifies its parent as native video. + +Preparation acquires up to 25 MiB under current source authorization. Playback then requires a separate manual action; it never starts automatically. Known duration is labeled **Platform duration**. A cover appears only if the native parent supplies one and it loads successfully; cover absence or failure does not block playback. Unsupported formats and transfer failures have separate messages, and the original remains downloadable. Refreshing requires preparation again; already acquired originals remain in the existing authorized Attachment store. Stop, unbind and revocation follow the file rules above. + +This is a draft video tracer. Native intake, decoded playback, installed-product E2E, matched-theme captures and Human feedback remain pending in [the verification checkpoint](dev/verification/lark-1023-channel-video.md). Audio, direct media messages, runtime transcoding, generated covers and model transcription are not delivered by this slice. The mobile app currently offers the original as a file action. diff --git a/docs/lark-connection.zh.md b/docs/lark-connection.zh.md index 5cf1d4575c..b37b0f0afb 100644 --- a/docs/lark-connection.zh.md +++ b/docs/lark-connection.zh.md @@ -462,3 +462,11 @@ Human 查看图片不会让 Bot 获得图片理解能力,也不改变其注意 点击文件名下载原件。卡片显示已知文件名和大小;缺失的 MIME 和大小保持未知。下载只由明确操作触发,显示加载、拒绝和「重试」,默认上限为 25 MiB。Host 支持原生打开时,卡片还提供既有的应用选择菜单;选择应用只打开已取得的 Host 副本,不会将编辑结果上传回 Lark。不支持打开的设备仍可下载。 每次操作都核对当前 Human Channel 成员关系、原始 placement 与来源、接收身份及独立有效的来源路径。暂停同步后,该路径不再取得新文件,已有授权下取得的文件仍可访问;解绑或撤销使该路径连缓存文件也不可访问。刷新和重启保留已取得的历史;缺失或清除的原件不会重建。Human 访问不增加 Inbox admission、模型工作或外部发送权限。真实 Lark 文件和 Client 验收仍由 [#1022](https://github.com/BotHarness/DeepSeekBot/issues/1022) 独立记录。 + +## Channel 历史中的引用视频(候选) + +#1023 首片使用 Lark「图片和视频」发送的原生视频,再以真正 @ Bot 的文字请求引用该视频。会话同步到 Channel 后,请求气泡提供原件下载和「准备视频」。只有平台明确识别的原生视频才进入此路径,名称为 `.mp4` 的普通文件仍是文件。 + +准备操作在当前来源授权下取得不超过 25 MiB 的媒体,随后需再次手动点击播放,不会自动播放。已知时长标注为「平台时长」。封面仅在原生父消息提供且加载成功时显示;封面缺失或失败不阻断播放。不支持的格式与传输失败分别提示,原件仍可下载。刷新后需重新准备播放;已取得的原件仍由既有 Attachment 存储保管,并继续核对授权。暂停、解绑和撤销遵守上方文件规则。 + +本片仍是视频草稿候选。真实入站、实际解码播放、安装包 E2E、明暗截图和 Human 反馈待[验收检查点](dev/verification/lark-1023-channel-video.md)补齐。音频、直接媒体消息、运行时转码、生成封面和模型转写未在本片交付;移动端目前提供原件文件操作。 diff --git a/packages/client/src/client/bridge-video-resource.ts b/packages/client/src/client/bridge-video-resource.ts new file mode 100644 index 0000000000..64511e67b9 --- /dev/null +++ b/packages/client/src/client/bridge-video-resource.ts @@ -0,0 +1,146 @@ +import { subscribeMessagingDefaults } from './messaging-defaults-live.js'; +import { boundedBridgeMediaRequest } from './bridge-media-request.js'; + +type Failure = 'unavailable' | 'tooLarge' | 'format' | 'interrupted' | 'failed'; +interface VideoState { + loading?: boolean; + url?: string; + posterUrl?: string; + failure?: Failure; + posterFailure?: Failure; +} +const EMPTY: VideoState = {}; +const MAX_BYTES = 25 * 1024 * 1024; + +export function createBridgeVideoResource( + channelId: string, + sourceEventId: string, + attachmentId: string, + posterAttachmentId?: string, +) { + let state = EMPTY; + let controller: AbortController | undefined; + let stopLive: (() => void) | undefined; + const listeners = new Set<() => void>(); + const update = (value: VideoState) => { + state = value; + for (const listener of listeners) listener(); + }; + const reset = () => { + controller?.abort(); + controller = undefined; + if (state.url) URL.revokeObjectURL(state.url); + if (state.posterUrl) URL.revokeObjectURL(state.posterUrl); + update(EMPTY); + }; + const prepare = async () => { + if (state.loading || !listeners.size) return; + reset(); + const request = new AbortController(); + controller = request; + const signal = AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]); + const current = () => controller === request && !request.signal.aborted && listeners.size > 0; + const publish = (value: VideoState) => { + if (current()) update({ ...state, ...value }); + }; + const load = async (id: string, poster: boolean) => { + try { + await boundedBridgeMediaRequest(signal, async () => { + const response = await fetch( + './api/botharness/attachment?' + + new URLSearchParams({ + channelId, + sourceEventId, + attachmentId: id, + ...(poster ? {} : { representation: 'playback' }), + }), + { signal }, + ); + signal.throwIfAborted(); + const type = response.headers.get('content-type')?.split(';', 1)[0] ?? ''; + const failure: Failure | undefined = !response.ok + ? response.status === 403 || response.status === 404 + ? 'unavailable' + : response.status === 413 + ? 'tooLarge' + : response.status === 422 + ? 'format' + : 'failed' + : !( + poster ? ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] : ['video/mp4'] + ).includes(type) + ? 'format' + : Number(response.headers.get('content-length')) > MAX_BYTES + ? 'tooLarge' + : undefined; + if (failure) { + await response.body?.cancel(); + publish(poster ? { posterFailure: failure } : { failure, loading: false }); + return; + } + if (!response.body) throw new Error('Missing media body'); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + signal.throwIfAborted(); + if (next.done) break; + size += next.value.byteLength; + if (size > MAX_BYTES) { + publish( + poster ? { posterFailure: 'tooLarge' } : { failure: 'tooLarge', loading: false }, + ); + return; + } + chunks.push(new Uint8Array(next.value)); + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + signal.throwIfAborted(); + if (!current()) return; + const url = URL.createObjectURL(new Blob(chunks, { type })); + publish(poster ? { posterUrl: url } : { url, loading: false }); + }); + } catch { + const failure = signal.aborted ? 'interrupted' : 'failed'; + publish(poster ? { posterFailure: failure } : { failure, loading: false }); + } + }; + update({ loading: true }); + await Promise.all([ + load(attachmentId, false), + ...(posterAttachmentId ? [load(posterAttachmentId, true)] : []), + ]); + }; + return { + getSnapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1) stopLive = subscribeMessagingDefaults(reset); + return () => { + listeners.delete(listener); + if (!listeners.size) { + stopLive?.(); + stopLive = undefined; + reset(); + } + }; + }, + prepare, + failPoster(url: string) { + if (state.posterUrl !== url) return; + const { posterUrl, ...current } = state; + if (posterUrl) URL.revokeObjectURL(posterUrl); + update({ ...current, posterFailure: 'format' }); + }, + fail(url: string) { + if (state.url !== url) return; + reset(); + update({ failure: 'format' }); + }, + }; +} diff --git a/packages/client/src/client/bridge-video.tsx b/packages/client/src/client/bridge-video.tsx new file mode 100644 index 0000000000..839031a663 --- /dev/null +++ b/packages/client/src/client/bridge-video.tsx @@ -0,0 +1,98 @@ +import { useMemo, useSyncExternalStore, type ReactElement } from 'react'; +import { Button } from '@deepseek-ai/dsh-client-ui-primitives'; +import { createBridgeVideoResource } from './bridge-video-resource.js'; +import { BridgeFile } from './bridge-file.js'; +import { formatAudioTime } from './message-audio.js'; +import type { BotHarnessTranslate } from './locale.js'; + +export function BridgeVideo({ + channelId, + sourceEventId, + attachmentId, + name, + sizeBytes, + durationMs, + posterAttachmentId, + t, +}: { + channelId: string; + sourceEventId: string; + attachmentId: string; + name: string; + sizeBytes?: number | undefined; + durationMs?: number | undefined; + posterAttachmentId?: string | undefined; + t: BotHarnessTranslate; +}): ReactElement { + const resource = useMemo( + () => createBridgeVideoResource(channelId, sourceEventId, attachmentId, posterAttachmentId), + [channelId, sourceEventId, attachmentId, posterAttachmentId], + ); + const state = useSyncExternalStore( + resource.subscribe, + resource.getSnapshot, + resource.getSnapshot, + ); + const failures = { + unavailable: 'bridgeVideo.unavailable', + tooLarge: 'bridgeMedia.tooLarge', + format: 'bridgeVideo.format', + interrupted: 'bridgeVideo.interrupted', + failed: 'bridgeMedia.failed', + } as const; + return ( +
+ + {durationMs === undefined ? null : ( +

+ {t('bridgeVideo.platformDuration')}: {formatAudioTime(durationMs / 1000)} +

+ )} + {state.url ? ( +
+ ); +} diff --git a/packages/client/src/client/bridge.ts b/packages/client/src/client/bridge.ts index 59cc850184..fba6b19c82 100644 --- a/packages/client/src/client/bridge.ts +++ b/packages/client/src/client/bridge.ts @@ -1280,7 +1280,7 @@ export function parseChannelMessage(value: unknown): ChannelMessage | undefined if ( !bridgeOrigin || !media || - Object.keys(media).some((key) => !['items', 'parts', 'voice'].includes(key)) || + Object.keys(media).some((key) => !['items', 'parts', 'voice', 'video'].includes(key)) || !Array.isArray(media['items']) || media['items'].length > 32 ) @@ -1305,7 +1305,8 @@ export function parseChannelMessage(value: unknown): ChannelMessage | undefined if ( !item || (item['kind'] === 'audio' && !media['voice']) || - (item['kind'] !== 'image' && item['kind'] !== 'file' && item['kind'] !== 'audio') || + (item['kind'] === 'video' && !media['video']) || + !['image', 'file', 'audio', 'video'].includes(String(item['kind'])) || typeof item['id'] !== 'string' || !/^[a-f0-9]{64}$/.test(item['id']) || typeof item['name'] !== 'string' || @@ -1319,6 +1320,39 @@ export function parseChannelMessage(value: unknown): ChannelMessage | undefined return; ids.add(item['id']); } + if (media['video'] !== undefined) { + const video = asRecord(media['video']); + if ( + bridgeOrigin.platform !== 'feishu' || + !video || + media['voice'] || + media['parts'] || + Object.keys(video).some( + (key) => !['attachmentId', 'durationMs', 'posterAttachmentId'].includes(key), + ) || + (video['durationMs'] !== undefined && + (typeof video['durationMs'] !== 'number' || + !Number.isSafeInteger(video['durationMs']) || + video['durationMs'] < 0)) || + media['items'].length !== (video['posterAttachmentId'] === undefined ? 1 : 2) || + !media['items'].some((raw) => { + const item = asRecord(raw); + return ( + item !== undefined && item['id'] === video['attachmentId'] && item['kind'] === 'video' + ); + }) || + (video['posterAttachmentId'] !== undefined && + !media['items'].some((raw) => { + const item = asRecord(raw); + return ( + item !== undefined && + item['id'] === video['posterAttachmentId'] && + item['kind'] === 'image' + ); + })) + ) + return; + } if (media['parts'] !== undefined) { if (!Array.isArray(media['parts']) || media['parts'].length > 256) return; for (const raw of media['parts']) { diff --git a/packages/client/src/client/channel-message-body.tsx b/packages/client/src/client/channel-message-body.tsx index 17e99837ef..b89e893327 100644 --- a/packages/client/src/client/channel-message-body.tsx +++ b/packages/client/src/client/channel-message-body.tsx @@ -1,5 +1,6 @@ import { BridgeAudio } from './bridge-audio.js'; import { BridgeFile } from './bridge-file.js'; +import { BridgeVideo } from './bridge-video.js'; import { UserQuestionCard } from './user-question-card.js'; import { PrivateInputCard } from './private-input-card.js'; import { ToolApprovalCard } from './tool-approval-card.js'; @@ -412,7 +413,20 @@ export function ChannelMessageBody({ const media = message.bridgeMedia; const renderMedia = (id: string, index: number) => { const item = media.items.find((item) => item.id === id); - return item?.kind === 'audio' ? ( + if (id === media.video?.posterAttachmentId) return null; + return item?.kind === 'video' ? ( + + ) : item?.kind === 'audio' ? ( { + const stub = () => null; + return { + Tag: ({ children }: { children: ReactNode }) => createElement('span', {}, children), + Button: ({ + children, + variant: _variant, + ...props + }: { + children: ReactNode; + variant?: string; + }) => createElement('button', props, children), + Modal: stub, + FileTypeIcon: stub, + MarkdownText: stub, + StateDot: stub, + IconDownloadOutlineRegular: stub, + IconFolderOpenOutlineRegular: stub, + IconPlayOutlineRegular: stub, + IconPauseOutlineRegular: stub, + Tooltip: ({ children }: { children: ReactNode }) => children, + fileSizeText: (size: number) => `${size} B`, + }; +}); +let changed: () => void; +vi.mock('../src/client/messaging-defaults-live.js', () => ({ + subscribeMessagingDefaults: (callback: () => void) => { + changed = callback; + return () => {}; + }, +})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const videoId = 'a'.repeat(64); +const posterId = 'b'.repeat(64); +const message = (poster: boolean) => ({ + id: 'source', + at: '2026-10-16T00:00:00.000Z', + body: 'inspect video', + format: 'text', + author: { kind: 'bridged', source: 'Human' }, + bridgeOrigin: { + sourceEventId: 'source', + platform: 'feishu', + conversationId: 'group', + conversationName: 'Group', + messageId: 'mention', + senderId: 'human', + }, + bridgeMedia: { + video: { + attachmentId: videoId, + durationMs: 4000, + ...(poster ? { posterAttachmentId: posterId } : {}), + }, + items: [ + { kind: 'video', id: videoId, name: 'clip.mp4' }, + ...(poster ? [{ kind: 'image', id: posterId, name: 'video-cover' }] : []), + ], + }, +}); +const urls = () => { + const create = vi.fn((blob: Blob) => `blob:${blob.type}`); + const revoke = vi.fn(); + vi.stubGlobal( + 'URL', + class extends URL { + static override createObjectURL = create; + static override revokeObjectURL = revoke; + }, + ); + return { create, revoke }; +}; + +it.each([false, true])( + 'renders a single native video with lazy playback, original download and independent poster (poster=%s)', + async (poster) => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const fetchMedia = vi.fn( + async (url: string) => + new Response(new Uint8Array([1, 2, 3]), { + headers: { 'content-type': url.includes(posterId) ? 'image/png' : 'video/mp4' }, + }), + ); + vi.stubGlobal('fetch', fetchMedia); + const created = urls(); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + try { + const parsed = parseChannelMessage(message(poster)); + expect(parsed).toBeDefined(); + await act(async () => + root.render( + createElement(ChannelMessageBody, { + message: parsed!, + channelId: 'local-group', + t: zhTranslate, + }), + ), + ); + expect(container.textContent).toContain('平台时长: 0:04'); + expect(container.querySelector('video')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(fetchMedia).not.toHaveBeenCalled(); + expect(container.querySelectorAll('.bh-message-file')).toHaveLength(1); + expect(container.querySelector('button[aria-label="下载到此设备: clip.mp4"]')).not.toBeNull(); + if (!poster) expect(container.textContent).toContain('平台未提供封面'); + await act(async () => { + [...container.querySelectorAll('button')] + .find((b) => b.textContent === '准备视频')! + .click(); + }); + expect(fetchMedia).toHaveBeenCalledTimes(poster ? 2 : 1); + const player = container.querySelector('video')!; + expect(player.getAttribute('src')).toBe('blob:video/mp4'); + expect(player.getAttribute('poster')).toBe(poster ? 'blob:image/png' : null); + expect(player.getAttribute('preload')).toBe('metadata'); + expect(player.controls).toBe(true); + expect(player.autoplay).toBe(false); + expect(player.getAttribute('playsinline')).not.toBeNull(); + if (poster) { + await act(async () => container.querySelector('img')!.dispatchEvent(new Event('error'))); + expect(container.querySelector('video')).not.toBeNull(); + expect(container.querySelector('video')?.hasAttribute('poster')).toBe(false); + expect(container.textContent).toContain('封面未能加载'); + } + expect(fetchMedia.mock.calls[0]?.[0]).toContain('representation=playback'); + await act(async () => player.dispatchEvent(new Event('error'))); + expect(container.querySelector('video')).toBeNull(); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('格式无法播放'); + expect(container.querySelector('.bh-message-file')).not.toBeNull(); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + expect(created.revoke).toHaveBeenCalledWith('blob:video/mp4'); + }, +); + +it('a missing or refused cover does not block the prepared video', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => + url.includes(posterId) + ? new Response(null, { status: 404 }) + : new Response(new Uint8Array([1]), { headers: { 'content-type': 'video/mp4' } }), + ), + ); + urls(); + const resource = createBridgeVideoResource('channel', 'source', videoId, posterId); + const stop = resource.subscribe(() => {}); + try { + await resource.prepare(); + expect(resource.getSnapshot()).toMatchObject({ + url: 'blob:video/mp4', + posterFailure: 'unavailable', + loading: false, + }); + } finally { + stop(); + } +}); + +it.each([ + [403, 'unavailable'], + [413, 'tooLarge'], + [422, 'format'], + [500, 'failed'], +] as const)( + 'reports coded playback refusal %s and permits explicit retry', + async (status, failure) => { + const fetched = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status })) + .mockResolvedValueOnce( + new Response(new Uint8Array([1]), { headers: { 'content-type': 'video/mp4' } }), + ); + vi.stubGlobal('fetch', fetched); + urls(); + const resource = createBridgeVideoResource('channel', 'source', videoId); + const stop = resource.subscribe(() => {}); + try { + await resource.prepare(); + expect(resource.getSnapshot()).toEqual({ loading: false, failure }); + await resource.prepare(); + expect(resource.getSnapshot().url).toBe('blob:video/mp4'); + } finally { + stop(); + } + }, +); + +it.each(['unmount', 'authority'] as const)( + 'cancels %s and discards late bytes and object URLs', + async (action) => { + let resolve!: (response: Response) => void; + let signal: AbortSignal | null | undefined; + vi.stubGlobal( + 'fetch', + vi.fn((_url: string, options?: RequestInit) => { + signal = options?.signal; + return new Promise((done) => { + resolve = done; + }); + }), + ); + const created = urls(); + const resource = createBridgeVideoResource('channel', 'source', videoId); + const stop = resource.subscribe(() => {}); + const pending = resource.prepare(); + if (action === 'unmount') stop(); + else changed(); + expect(signal?.aborted).toBe(true); + resolve(new Response(new Uint8Array([1]), { headers: { 'content-type': 'video/mp4' } })); + await pending; + expect(resource.getSnapshot()).toEqual({}); + expect(created.create).not.toHaveBeenCalled(); + stop(); + }, +); + +it('rejects forged video and poster projection associations', () => { + const candidate = message(true); + expect( + parseChannelMessage({ + ...candidate, + bridgeOrigin: { ...candidate.bridgeOrigin, platform: 'qq' }, + }), + ).toBeUndefined(); + expect( + parseChannelMessage({ + ...candidate, + bridgeMedia: { ...candidate.bridgeMedia, video: { attachmentId: posterId } }, + }), + ).toBeUndefined(); + expect( + parseChannelMessage({ + ...candidate, + bridgeMedia: { + ...candidate.bridgeMedia, + video: { attachmentId: videoId, posterAttachmentId: videoId }, + }, + }), + ).toBeUndefined(); +}); + +it.each(['declared', 'streamed'] as const)( + 'bounds %s media bytes before retaining an object URL', + async (size) => { + const body = size === 'streamed' ? new Uint8Array(25 * 1024 * 1024 + 1) : new Uint8Array([1]); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(body, { + headers: { + 'content-type': 'video/mp4', + ...(size === 'declared' ? { 'content-length': String(25 * 1024 * 1024 + 1) } : {}), + }, + }), + ), + ); + const created = urls(); + const resource = createBridgeVideoResource('channel', 'source', videoId); + const stop = resource.subscribe(() => {}); + try { + await resource.prepare(); + expect(resource.getSnapshot()).toEqual({ loading: false, failure: 'tooLarge' }); + expect(created.create).not.toHaveBeenCalled(); + } finally { + stop(); + } + }, +); diff --git a/packages/core/src/attachments/http.ts b/packages/core/src/attachments/http.ts index c55d22830c..3163caea9a 100644 --- a/packages/core/src/attachments/http.ts +++ b/packages/core/src/attachments/http.ts @@ -51,7 +51,7 @@ function errorResponse(error: unknown): Response { ); if (error instanceof MessagingError && error.code === 'media-format-unsupported') return Response.json( - { error: { code: error.code, message: 'Image format cannot be previewed' } }, + { error: { code: error.code, message: 'Media format cannot be previewed' } }, { status: 422, headers: { 'cache-control': 'no-store' } }, ); if (error instanceof MessagingError) diff --git a/packages/core/src/bots/bot-create-cli.ts b/packages/core/src/bots/bot-create-cli.ts index 9e83c47c06..c36110433e 100644 --- a/packages/core/src/bots/bot-create-cli.ts +++ b/packages/core/src/bots/bot-create-cli.ts @@ -151,7 +151,7 @@ Usage: deepseekbot channels [--home ] deepseekbot channel-messages [--limit ] [--before ] [--home ] deepseekbot channel-media-target --source-event --attachment - deepseekbot channel-media-download --source-event --attachment --out + deepseekbot channel-media-download --source-event --attachment --out [--representation playback] deepseekbot grants [--home ] deepseekbot grant-revoke --grant [--home ] online: add --confirm deepseekbot grant-write-set --grant (--enabled | --disabled) [--home ] diff --git a/packages/core/src/bots/cli-channels.ts b/packages/core/src/bots/cli-channels.ts index 855bf2d334..589f7f09c2 100644 --- a/packages/core/src/bots/cli-channels.ts +++ b/packages/core/src/bots/cli-channels.ts @@ -24,6 +24,7 @@ export const CHANNEL_OPTIONS = { remove: { type: 'boolean' }, off: { type: 'boolean' }, attachment: { type: 'string' }, + representation: { type: 'string' }, } as const; export const CHANNEL_COMMANDS = [ @@ -120,6 +121,7 @@ interface ChannelValues { query?: string; off?: boolean; attachment?: string; + representation?: string; out?: string; } @@ -220,6 +222,11 @@ export async function runChannelCommand( args.sourceEventId = required(values['source-event'][0], '--source-event'); args.attachmentId = required(values.attachment, '--attachment'); if (command === 'channel-media-download') required(values.out, '--out'); + if ( + values.representation !== undefined && + (command !== 'channel-media-download' || values.representation !== 'playback') + ) + usage('--representation playback is only supported by channel-media-download.'); break; case 'channel-rename': method = 'channelRename'; @@ -381,6 +388,7 @@ export async function runChannelCommand( channelId, sourceEventId: String(args.sourceEventId), attachmentId: String(args.attachmentId), + ...(values.representation === 'playback' ? { representation: 'playback' } : {}), }), ); if (bytes.byteLength > 25 * 1024 * 1024) diff --git a/packages/core/src/channels/channel.ts b/packages/core/src/channels/channel.ts index 3acf54b74f..b29b4644d9 100644 --- a/packages/core/src/channels/channel.ts +++ b/packages/core/src/channels/channel.ts @@ -335,7 +335,13 @@ export interface ChannelMessage { attachments?: ChannelAttachmentRef[]; bridgeMedia?: { voice?: Pick; - items: { id: string; kind: 'image' | 'file' | 'audio'; name: string; sizeBytes?: number }[]; + video?: { attachmentId: string; durationMs?: number; posterAttachmentId?: string }; + items: { + id: string; + kind: 'image' | 'file' | 'audio' | 'video'; + name: string; + sizeBytes?: number; + }[]; parts?: import('../messaging/provider.js').MessagingContentPart[]; }; bridgeOrigin?: { diff --git a/packages/core/src/messaging/channel-media.ts b/packages/core/src/messaging/channel-media.ts index 30ea44e52a..26f781362a 100644 --- a/packages/core/src/messaging/channel-media.ts +++ b/packages/core/src/messaging/channel-media.ts @@ -228,7 +228,8 @@ export function createChannelMediaAccess(options: { ); } validate(); - if (input.representation === 'playback') { + const video = bridgeAttachmentKind(initial.source, initial.attachment) === 'video'; + if (input.representation === 'playback' && !video) { if ( !initial.source.event.voice || initial.attachment.mediaType !== 'audio/unknown' || @@ -252,8 +253,9 @@ export function createChannelMediaAccess(options: { throw new ChannelAttachmentError('Attachment exceeds limit', 'too-large'); } if ( - initial.attachment.mediaType?.startsWith('image/') && - !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(downloaded.ref.mime) + (initial.attachment.mediaType?.startsWith('image/') && + !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(downloaded.ref.mime)) || + (input.representation === 'playback' && video && downloaded.ref.mime !== 'video/mp4') ) { await downloaded.body.cancel(); throw new MessagingError('media-format-unsupported'); diff --git a/packages/core/src/messaging/channel-target.ts b/packages/core/src/messaging/channel-target.ts index 521f6290d8..b6b1817fd5 100644 --- a/packages/core/src/messaging/channel-target.ts +++ b/packages/core/src/messaging/channel-target.ts @@ -64,18 +64,17 @@ export function bridgeChannel( export function bridgeAttachmentKind( source: ExternalSource, attachment: MessagingAttachment, -): 'image' | 'audio' | 'file' | undefined { +): 'image' | 'audio' | 'file' | 'video' | undefined { if (attachment.mediaType?.startsWith('image/')) return 'image'; if (source.platform === 'qq') { if (source.event.voice && attachment.mediaType === 'audio/unknown') return 'audio'; if (attachment.mediaType === 'application/octet-stream') return 'file'; } - if ( - source.platform === 'feishu' && - attachment.mediaType === undefined && - source.event.reply.parentId === attachment.messageId - ) - return 'file'; + if (source.platform === 'feishu' && source.event.reply.parentId === attachment.messageId) { + if (attachment.mediaType === undefined) return 'file'; + if (source.event.video?.itemId === attachment.id && attachment.mediaType === 'video/unknown') + return 'video'; + } return undefined; } @@ -90,6 +89,19 @@ export function projectBridgeMessage(source: ExternalSource, body: string): Chan source.event.attachments?.some((item) => bridgeAttachmentKind(source, item) !== undefined) ? { bridgeMedia: { + ...(source.platform === 'feishu' && source.event.video + ? { + video: { + attachmentId: source.event.video.itemId!, + ...(source.event.video.durationMs === undefined + ? {} + : { durationMs: source.event.video.durationMs }), + ...(source.event.video.posterAttachmentId === undefined + ? {} + : { posterAttachmentId: source.event.video.posterAttachmentId }), + }, + } + : {}), items: (source.event.attachments ?? []).flatMap((item) => { const kind = bridgeAttachmentKind(source, item); return kind === undefined diff --git a/packages/core/src/messaging/dsh-im.ts b/packages/core/src/messaging/dsh-im.ts index a25976800c..9d80a74880 100644 --- a/packages/core/src/messaging/dsh-im.ts +++ b/packages/core/src/messaging/dsh-im.ts @@ -247,6 +247,11 @@ const inboundSchema = z itemId: identifier.optional(), reportedSizeBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), playLength: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + durationMs: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + posterAttachmentId: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), }) .strict() .optional(), @@ -304,6 +309,32 @@ const inboundSchema = z }, ); +function isLarkVideo(event: z.infer, images: boolean): boolean { + const video = event.video; + const items = event.attachments ?? []; + if ( + !video || + !event.mentionedAccount || + !event.reply.parentId || + event.reply.parentId === event.messageId || + event.contentParts || + event.voice || + video.playLength !== undefined || + video.reportedSizeBytes !== undefined || + items.length !== (video.posterAttachmentId === undefined ? 1 : 2) + ) + return false; + return ( + items.every((item) => + item.messageId !== event.reply.parentId + ? false + : item.id === video.itemId + ? item.mediaType === 'video/unknown' + : images && item.id === video.posterAttachmentId && item.mediaType === 'image/unknown', + ) && items.some((item) => item.id === video.itemId) + ); +} + function providerFailure(error: unknown): MessagingProviderError { if (error instanceof MessagingProviderError) return error; const code = @@ -779,6 +810,10 @@ export function createDshImProvider( (platform !== 'qq' || (info.capabilities.includes('source-generic-file-checked') && info.capabilities.includes('reply-file-fence-checked'))); + const larkVideos = + platform === 'feishu' && + sourceFiles && + info.capabilities.includes('source-video-checked'); return host.consumeInbound!(input.accountRef, { expectedFingerprint: input.fingerprint, signal: input.signal, @@ -799,10 +834,11 @@ export function createDshImProvider( info.capabilities.includes('source-voice-audio-checked') ? { sourceVoiceAudio: true } : {}), - ...(platform === 'weixin' && - host.fileVersion === 1 && - info.capabilities.includes('source-video-checked') && - info.capabilities.includes('reply-video-fence-checked') + ...(larkVideos || + (platform === 'weixin' && + host.fileVersion === 1 && + info.capabilities.includes('source-video-checked') && + info.capabilities.includes('reply-video-fence-checked')) ? { sourceVideos: true } : {}), ...(platform === 'feishu' && @@ -927,6 +963,7 @@ export function createDshImProvider( : {}), onEvent: async (raw, context) => { const parsed = inboundSchema.parse(raw); + const larkVideo = larkVideos && isLarkVideo(parsed, sourceImages); const qqAttachments = parsed.attachments?.every((item) => item.mediaType?.startsWith('image/') ? sourceImages @@ -947,6 +984,7 @@ export function createDshImProvider( (parsed.attachments?.length ?? 0) > 1 || (platform === 'feishu' && parsed.attachments?.some((item) => item.mediaType?.startsWith('image/')))) && + !larkVideo && (platform === 'qq' ? !qqAttachments : platform !== 'feishu' || @@ -969,7 +1007,11 @@ export function createDshImProvider( (parsed.voice && parsed.attachments?.length && !info.capabilities.includes('source-voice-audio-checked')) || + (platform !== 'feishu' && + (parsed.video?.durationMs !== undefined || + parsed.video?.posterAttachmentId !== undefined)) || (parsed.video && + !larkVideo && (platform !== 'weixin' || !info.capabilities.includes('source-video-checked') || !info.capabilities.includes('reply-video-fence-checked') || @@ -1055,7 +1097,7 @@ export function createDshImProvider( event.attachments?.some( (item) => item.messageId !== - (platform === 'feishu' && !item.mediaType?.startsWith('image/') + (platform === 'feishu' && (larkVideo || !item.mediaType?.startsWith('image/')) ? event.reply.parentId : event.messageId), ) @@ -1229,6 +1271,9 @@ export function createDshImProvider( if ( info.account.fingerprint !== input.fingerprint || !info.capabilities.includes('source-file-checked') || + (platform === 'feishu' && + input.attachment.mediaType === 'video/unknown' && + !info.capabilities.includes('source-video-checked')) || (platform === 'qq' && !input.attachment.mediaType?.startsWith('image/') && (input.attachment.mediaType === 'audio/unknown' diff --git a/packages/core/src/messaging/provider.ts b/packages/core/src/messaging/provider.ts index b54f2add20..c198ea63b7 100644 --- a/packages/core/src/messaging/provider.ts +++ b/packages/core/src/messaging/provider.ts @@ -60,6 +60,8 @@ export interface MessagingVideo { itemId?: string; reportedSizeBytes?: number; playLength?: number; + durationMs?: number; + posterAttachmentId?: string; } export interface MessagingQuote { diff --git a/packages/core/test/channel-bridge-media.test.ts b/packages/core/test/channel-bridge-media.test.ts index 3b2463b60e..b171bf2d57 100644 --- a/packages/core/test/channel-bridge-media.test.ts +++ b/packages/core/test/channel-bridge-media.test.ts @@ -33,16 +33,19 @@ afterEach(async () => { } }); -async function fixture(inboxOnly = false, twoPaths = false, file = false) { - const bytes = file ? Buffer.from('name,quantity\nalpha,17\n') : png; - const attachment = file - ? { - id: 'c'.repeat(64), - messageId: 'file-parent-message', - resourceKey: 'private-key', - name: 'qa-source.csv', - } - : image; +async function fixture(inboxOnly = false, twoPaths = false, file = false, video = false) { + const mp4 = Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex'); + const bytes = video ? mp4 : file ? Buffer.from('name,quantity\nalpha,17\n') : png; + const attachment = + file || video + ? { + id: 'c'.repeat(64), + messageId: 'file-parent-message', + resourceKey: 'private-key', + name: video ? 'qa-video.mp4' : 'qa-source.csv', + ...(video ? { mediaType: 'video/unknown' } : {}), + } + : image; const home = createTempRoot('bh-channel-media-'); let consumer: Parameters>[1] | undefined; const run = vi.fn(async () => {}); @@ -93,6 +96,7 @@ async function fixture(inboxOnly = false, twoPaths = false, file = false) { 'source-file-checked', 'reply-file-checked', 'source-image-checked', + ...(video ? ['source-video-checked'] : []), ], }), sendChecked: async () => ({ sent: true }), @@ -101,7 +105,12 @@ async function fixture(inboxOnly = false, twoPaths = false, file = false) { consumer = input; return () => {}; }, - readSourceFile: async () => download(), + readSourceFile: async (_id, _route, item) => + item.id === image.id && video + ? (async function* () { + yield png; + })() + : download(), replyFileChecked: async () => ({ sent: true }), }; core.externalMessaging.register(createDshImProvider(service)!); @@ -166,8 +175,11 @@ async function fixture(inboxOnly = false, twoPaths = false, file = false) { mentionedAccount: true, at: new Date(Date.now() + 100).toISOString(), text: 'Before\nAfter', - attachments: [attachment], - ...(file + attachments: [attachment, ...(video ? [{ ...image, messageId: attachment.messageId }] : [])], + ...(video + ? { video: { itemId: attachment.id, durationMs: 4000, posterAttachmentId: image.id } } + : {}), + ...(file || video ? {} : { contentParts: [ @@ -180,7 +192,7 @@ async function fixture(inboxOnly = false, twoPaths = false, file = false) { messageId: image.messageId, conversationId: 'team', actorId: 'human', - ...(file ? { parentId: attachment.messageId } : {}), + ...(file || video ? { parentId: attachment.messageId } : {}), }, replay: { kind: 'provider-redelivery', resumeCursor: false, gapPossible: true }, }; @@ -244,6 +256,14 @@ async function fixture(inboxOnly = false, twoPaths = false, file = false) { attachment, bytes, read, + playback: () => + core.externalMessaging.readChannelMedia({ + channelId, + sourceEventId, + attachmentId: attachment.id, + representation: 'playback', + signal: new AbortController().signal, + }), counts, run, stop, @@ -261,6 +281,134 @@ async function fixture(inboxOnly = false, twoPaths = false, file = false) { }; } +it('projects a checked Lark native video and platform cover once; explicit playback shares original bytes without attention or transcoding', async () => { + const fx = await fixture(false, false, false, true); + expect(fx.consumer?.sourceVideos).toBe(true); + const messages = fx.core.channels.readMessages(fx.channelId); + expect(messages).toHaveLength(1); + expect(messages[0]?.bridgeMedia).toMatchObject({ + video: { attachmentId: fx.attachment.id, durationMs: 4000, posterAttachmentId: image.id }, + items: [{ kind: 'video', name: 'qa-video.mp4' }, { kind: 'image' }], + }); + expect(JSON.stringify(messages)).not.toContain('private-key'); + expect(fx.download).not.toHaveBeenCalled(); + const before = fx.counts(); + const runs = fx.run.mock.calls.length; + const playback = await fx.playback(); + expect(playback.inline).toBe(true); + expect(playback.ref.mime).toBe('video/mp4'); + expect(Buffer.from(await new Response(playback.body).arrayBuffer())).toEqual(fx.bytes); + const original = await fx.read(); + expect(original.inline).toBe(false); + expect(Buffer.from(await new Response(original.body).arrayBuffer())).toEqual(fx.bytes); + const cover = await fx.read(image.id); + expect(cover.ref.mime).toBe('image/png'); + expect(Buffer.from(await new Response(cover.body).arrayBuffer())).toEqual(png); + expect(fx.download).toHaveBeenCalledTimes(1); + await fx.restart(); + expect(fx.core.channels.readMessages(fx.channelId)[0]?.bridgeMedia).toEqual( + messages[0]?.bridgeMedia, + ); + const cached = await fx.playback(); + await cached.body.cancel(); + expect(fx.download).toHaveBeenCalledTimes(1); + expect(fx.counts()).toEqual(before); + expect(fx.run).toHaveBeenCalledTimes(runs); +}); + +it('refuses a Lark video association changed to another parent, primary resource, poster or unsupported content', async () => { + const fx = await fixture(false, false, false, true); + for (const event of [ + { ...fx.event, video: { ...fx.event.video!, itemId: image.id } }, + { ...fx.event, video: { ...fx.event.video!, posterAttachmentId: fx.attachment.id } }, + { ...fx.event, reply: { ...fx.event.reply, parentId: 'other-parent' } }, + { ...fx.event, mentionedAccount: false }, + ]) { + await expect(fx.consumer!.onEvent(event, { signal: fx.consumer!.signal })).rejects.toThrow( + 'untrusted-source', + ); + } + fx.download.mockImplementation(async function* () { + yield Buffer.from('not a video'); + }); + await expect(fx.playback()).rejects.toThrow('media-format-unsupported'); + const original = await fx.read(); + expect(Buffer.from(await new Response(original.body).arrayBuffer()).toString()).toBe( + 'not a video', + ); + expect(fx.download).toHaveBeenCalledTimes(1); +}); + +it('video playback follows stop-before-acquire, stopped cache and identity revocation rules', async () => { + const fx = await fixture(false, false, false, true); + await fx.stop(); + await expect(fx.playback()).rejects.toThrow('source-unavailable'); + expect(fx.download).not.toHaveBeenCalled(); + const second = await fixture(false, false, false, true); + const first = await second.playback(); + await first.body.cancel(); + await second.stop(); + const cached = await second.playback(); + await cached.body.cancel(); + const identity = (await second.core.externalMessaging.snapshot('ada')).identities![0]!; + await second.core.externalMessaging.identity('ada', { + kind: 'unbind', + id: identity.id, + expectedRevision: identity.revision, + }); + await expect(second.playback()).rejects.toThrow('source-unavailable'); + expect(second.download).toHaveBeenCalledTimes(1); +}); + +it('requires the Lark video capability instead of borrowing file or image qualification', async () => { + const fx = await fixture(false, false, false, true); + const info = await fx.service.describeBot('account'); + let input: Parameters>[1] | undefined; + const provider = createDshImProvider({ + ...fx.service, + describeBot: async () => ({ + ...info, + capabilities: info.capabilities.filter((cap) => cap !== 'source-video-checked'), + }), + consumeInbound: async ( + _id: string, + options: Parameters>[1], + ) => { + input = options; + return () => {}; + }, + })!; + await provider.consume!({ + accountRef: 'account', + fingerprint, + signal: new AbortController().signal, + onEvent: async () => ({ accepted: true }), + }); + expect(input?.sourceVideos).toBeUndefined(); + await expect(input!.onEvent(fx.event, { signal: input!.signal })).rejects.toThrow( + 'untrusted-source', + ); + fx.service.describeBot = async () => ({ + ...info, + capabilities: info.capabilities.filter((cap) => cap !== 'source-video-checked'), + }); + await expect(fx.playback()).rejects.toThrow('provider-incompatible'); + expect(fx.download).not.toHaveBeenCalled(); +}); + +it('retains an independently valid video path but does not resurrect missing original bytes', async () => { + const fx = await fixture(false, true, false, true); + const first = await fx.playback(); + await first.body.cancel(); + fx.core.externalMessaging.revoke('ada', fx.grant.id); + const independent = await fx.playback(); + await independent.body.cancel(); + expect(independent.ref.fileId).toBe(first.ref.fileId); + rmSync(fx.core.attachments.fileTarget(first.ref.fileId!).path); + await expect(fx.playback()).rejects.toThrow('unavailable'); + expect(fx.download).toHaveBeenCalledTimes(1); +}); + it('projects a Lark source file in its original bubble and downloads its exact original without new attention', async () => { const fx = await fixture(false, false, true); const message = fx.core.channels.readMessages(fx.channelId)[0]!; diff --git a/packages/core/test/cli-channels.test.ts b/packages/core/test/cli-channels.test.ts index 9e6cf10429..79a5561fd0 100644 --- a/packages/core/test/cli-channels.test.ts +++ b/packages/core/test/cli-channels.test.ts @@ -78,6 +78,44 @@ const ok = (method: string, args: Record) => ({ }); describe('Channel and group CLI commands', () => { + it('requests playback explicitly without changing the original download default or allowing arbitrary representations', async () => { + const live = await host(ok, { status: 200, body: Buffer.from('video') }); + const path = join(mkdtempSync(join(tmpdir(), 'cli-playback-')), 'playback.mp4'); + const args = [ + 'channel-media-download', + 'group', + '--source-event', + 'source', + '--attachment', + 'video', + '--out', + path, + ]; + expect((await invoke([...args, '--representation', 'playback'], live.url)).code).toBe(0); + expect(live.downloads[0]?.url).toContain('&representation=playback'); + expect(live.downloads[0]?.authorized).toBe(true); + expect( + (await invoke([...args, '--representation', 'transcode'], live.url)).json.error.code, + ).toBe('usage'); + expect( + ( + await invoke( + [ + 'channel-media-target', + 'group', + '--source-event', + 'source', + '--attachment', + 'video', + '--representation', + 'playback', + ], + live.url, + ) + ).json.error.code, + ).toBe('usage'); + expect(live.downloads).toHaveLength(1); + }); it('downloads exact original bytes with owner authorization without overwriting an existing file', async () => { const bytes = Buffer.from('name,quantity\nalpha,17\n'); const live = await host(ok, { status: 200, body: bytes }); diff --git a/scripts/dev-im-provider.mjs b/scripts/dev-im-provider.mjs index 0c563407b3..0587c719ec 100644 --- a/scripts/dev-im-provider.mjs +++ b/scripts/dev-im-provider.mjs @@ -5,11 +5,11 @@ import { join } from 'node:path'; export const qualifiedImProvider = Object.freeze({ package: '@xmanrui/dsh-im', packageVersion: '4.32.0', - source: 'beaed4a6dabb583654d28fb09298716267039b4a', - spec: 'github:DoodleBears/dsh-im#beaed4a6dabb583654d28fb09298716267039b4a', + source: 'f889a5808a342bb9b4abf586f115e57cb44b2b6b', + spec: 'github:DoodleBears/dsh-im#f889a5808a342bb9b4abf586f115e57cb44b2b6b', dsh: '0.2.0-rc.2', runtimeFiles: 421, - runtimeSha256: '42e1140e67ba2f74122db3d49bb2c8aece239b7d75f9564ce713a8a491902706', + runtimeSha256: '02a4b38aaca218091e52bfa8a8b91be4c98f5d41458356ca9ecd0ee928633819', }); export function withQualifiedImProvider(manifest) { From a120639a8d915719ba85392d76ee0ac0ed3038f4 Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Fri, 16 Oct 2026 06:47:57 +0900 Subject: [PATCH 2/5] docs: record final Lark video regression and native QA gates Issue: #1023 Agent-Task: codex/local/01a13902-0d72-7872-ac0f-440e152155d5 --- docs/dev/verification/lark-1023-channel-video.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/dev/verification/lark-1023-channel-video.md b/docs/dev/verification/lark-1023-channel-video.md index 5893e9e748..6f9c660029 100644 --- a/docs/dev/verification/lark-1023-channel-video.md +++ b/docs/dev/verification/lark-1023-channel-video.md @@ -27,6 +27,10 @@ The broader 90-test run passed 89 tests and timed out at the existing image unsu A subsequent 47-test serial run passed 46 and timed out in the existing `messaging-attachments` wrong-source/replay test, followed by its 10-second temporary-root cleanup hook. This broader run is not green. The failure and cleanup timeout remain preserved; they are not attributed to the candidate or the environment without further evidence. The new video resource tests separately cover both declared and streamed oversize bodies and the independent cover decoding failure. +That exact wrong-source/replay case passed in isolation in 2.775 seconds, with its cleanup also completing; the other 20 tests were filtered out rather than counted as passed. The final Client video file passed all 12 tests, including an undecodable cover that leaves video available. Final format, lint/source policy/CLI parity, root/mobile typecheck and production build passed. The broader-run failures above remain part of the record despite these narrower successes. + +The dedicated docs workspace build passed with 560 pages. The Windows top-level slides-spawn limitation already recorded for #1022 was not reclassified as a successful top-level docs command. Core [PR #1950](https://github.com/BotHarness/DeepSeekBot/pull/1950) is draft with a full CI run requested; CI and real-model scenario status are separate from local qualification. Provider [PR #31](https://github.com/DoodleBears/dsh-im/pull/31) remains draft. No #1023 merge was authorized or performed. + An isolated DSH 0.2.0-rc.2 Host passed authenticated doctor checks before and after a headless shell smoke. T3 was explicitly unavailable, so the repository verifier drove only the isolated Web shell; its empty Bot-mode screenshot was inspected and its console had zero issues. It is not video or native Lark evidence. The actual built product CLI returned one JSON `source-unavailable` refusal for both original and explicit playback requests to a nonexistent Channel, without creating output or calling a model. The private proof's initial expectation was `channel-unavailable`; it was corrected to the existing HTTP owner's source-refusal contract, with no product authorization change. This confirms live command/route operation only, not positive media delivery. These are controlled automated regressions, not native rendering or playback evidence. From 5e9f2a9f067aea06f629bfa1c72bbabd178e338d Mon Sep 17 00:00:00 2001 From: DoodleBears Date: Fri, 16 Oct 2026 06:58:28 +0900 Subject: [PATCH 3/5] fix: keep Lark source-inspector media acquisition explicit Issue: #1023 Agent-Task: codex/local/01a13902-0d72-7872-ac0f-440e152155d5 --- .../features/conversation-timeline.md | 1 + ...dge-media-uses-channel-source-authority.md | 2 + .../verification/lark-1023-channel-video.md | 4 + docs/lark-connection.md | 2 + docs/lark-connection.zh.md | 2 + .../src/client/channel-sidebar-builtins.tsx | 23 +++- packages/client/test/bot-inbox-entry.test.ts | 130 ++++++++++++++++++ 7 files changed, 158 insertions(+), 6 deletions(-) diff --git a/.agents/skills/verify-deepseekbot/features/conversation-timeline.md b/.agents/skills/verify-deepseekbot/features/conversation-timeline.md index 60d571ed33..52a8ddf64c 100644 --- a/.agents/skills/verify-deepseekbot/features/conversation-timeline.md +++ b/.agents/skills/verify-deepseekbot/features/conversation-timeline.md @@ -33,6 +33,7 @@ Preconditions: a model credential, a Bot with a few replies (one long list), thr ## Gotchas - The #1023 Lark parent-video candidate needs a native `media` parent quoted by an actual Bot-mentioned Human request in a synced conversation. Open that Channel and confirm there is no video/cover request before **Prepare video**. Prepare, then manually play, pause and seek; verify advancing decoded frames rather than only a successful HTTP response. Original download is independent, and a missing/failed native cover must not block playback. Use a fresh native resource to verify stop-before-acquisition; reusing an acquired parent tests cached access instead. Native and installed-product qualification remain pending in `docs/dev/verification/lark-1023-channel-video.md`; do not fabricate a Source Event or rename an ordinary file to qualify video. +- Open the same event's Bot Inbox source inspector before preparing the Channel video. It must acquire neither video nor cover automatically; an explicit original download should still work. An automatic request here defeats the stop-before-acquisition precondition even when the Channel player itself is lazy. - Read positions only move forward. To see the divider again, post a new message so the Bot replies after the anchor. - Headless Chromium reports `hover: none`, which hides hover-only times and shows the hover actions permanently. Launch it with `--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4` to capture desktop hover states. diff --git a/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md b/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md index 2554d870d3..9393a00c3e 100644 --- a/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md +++ b/docs/adr/0137-human-bridge-media-uses-channel-source-authority.md @@ -35,6 +35,8 @@ Human preparation reuses the existing authenticated attachment route, source aut The candidate's automated regressions do not establish native intake, rendering, playback or installed-package qualification. Those gates and the required first-slice Human feedback remain tracked in [#1023](https://github.com/BotHarness/DeepSeekBot/issues/1023). +Opening a Bot Inbox source inspector must not bypass the Lark video's explicit acquisition policy. That existing inspector keeps original-download actions for video and cover, but excludes both from its legacy automatic image/video preview loop. Existing qualified WeChat video previews and unrelated Lark images keep their own presentation behavior. + The #1022 Lark parent-file candidate extends the same authority, without a new persistence or Provider contract. Its checked descriptor has an exact parent message association and no MIME or size. Projection and access share one eligibility function, expose a generic file card and retain unknown metadata. Explicit download uses the existing bounded attachment HTTP path. The application-defined `channelMediaTarget` RPC reuses that source access and resolves only the canonical Attachment file target; Client application discovery and opening use the existing DSH-native file capability, never a new launcher. A read-only capability check acquires no bytes, and the menu is mounted only after explicit action. The CLI supplies matching target and download commands. Native file/message/Client qualification remains separate from source tests. Automated tests verify native association/order, exact placement and access, stop/revoke/restart, cancellation, UI visibility/retry and absence of new Inbox/model activity. These are regression evidence, not real platform acceptance. The PR must separately record fresh native-message bytes and actual matched-theme UI evidence; current blockers remain explicit until Human QA completes. Official resource semantics require the original message ID and its matching image key ([Lark reference](https://github.com/larksuite/cli/blob/main/skills/lark-im/references/lark-im-messages-resources-download.md)). diff --git a/docs/dev/verification/lark-1023-channel-video.md b/docs/dev/verification/lark-1023-channel-video.md index 6f9c660029..b19e54c6d2 100644 --- a/docs/dev/verification/lark-1023-channel-video.md +++ b/docs/dev/verification/lark-1023-channel-video.md @@ -14,6 +14,8 @@ Canonical Source Event → Channel placement projection → explicit **Prepare v Video and cover share the existing three-request Client acquisition budget. Each request has a 30-second deadline and 25 MiB default limit. Unmount and authorization notifications cancel requests and revoke object URLs. Cover absence/failure is independent of playback. Unsupported byte formats and browser decoding errors retain the original download action. There is no runtime transcoding, automatic playback, model transcription or additional Bot attention/send authority. The mobile wire contract retains the new kind but presents only an original-file action and suppresses eager cover loading. +The final adjacent-surface audit found that the existing Bot Inbox source inspector automatically previewed every image and video descriptor. The Lark video and its cover now opt out of that loop; opening source metadata acquires neither. Both retain existing explicit original-download actions. The dedicated regression also retains the previously qualified WeChat auto-preview behavior, rather than changing another provider's scope. + `deepseekbot channel-media-download ... --representation playback` uses the same live Host route without the model. Omitting the flag still downloads the original; invalid representations and playback on the target-resolution command are usage errors. Both paths preserve coded refusals and refuse output overwrite. ## Regression checkpoint @@ -31,6 +33,8 @@ That exact wrong-source/replay case passed in isolation in 2.775 seconds, with i The dedicated docs workspace build passed with 560 pages. The Windows top-level slides-spawn limitation already recorded for #1022 was not reclassified as a successful top-level docs command. Core [PR #1950](https://github.com/BotHarness/DeepSeekBot/pull/1950) is draft with a full CI run requested; CI and real-model scenario status are separate from local qualification. Provider [PR #31](https://github.com/DoodleBears/dsh-im/pull/31) remains draft. No #1023 merge was authorized or performed. +Full CI passed at head `a120639a8d915719ba85392d76ee0ac0ed3038f4` ([verify run](https://github.com/BotHarness/DeepSeekBot/actions/runs/38812593496)), including all three full test shards, lint/format/typecheck/build, docs site and isolated DSH smoke. Real-model scenarios were skipped. The subsequent Inbox source-inspector safeguard changes that head: its focused Inbox/video run passed all 23 tests, including existing WeChat video behavior and zero acquisition on opening Lark video/cover metadata. Final-head checks are tracked separately; the earlier CI result does not certify later code. + An isolated DSH 0.2.0-rc.2 Host passed authenticated doctor checks before and after a headless shell smoke. T3 was explicitly unavailable, so the repository verifier drove only the isolated Web shell; its empty Bot-mode screenshot was inspected and its console had zero issues. It is not video or native Lark evidence. The actual built product CLI returned one JSON `source-unavailable` refusal for both original and explicit playback requests to a nonexistent Channel, without creating output or calling a model. The private proof's initial expectation was `channel-unavailable`; it was corrected to the existing HTTP owner's source-refusal contract, with no product authorization change. This confirms live command/route operation only, not positive media delivery. These are controlled automated regressions, not native rendering or playback evidence. diff --git a/docs/lark-connection.md b/docs/lark-connection.md index 57f5ebb707..7a7af4d9c3 100644 --- a/docs/lark-connection.md +++ b/docs/lark-connection.md @@ -505,3 +505,5 @@ The first #1023 slice uses a native Lark video sent with **Image & video**, quot Preparation acquires up to 25 MiB under current source authorization. Playback then requires a separate manual action; it never starts automatically. Known duration is labeled **Platform duration**. A cover appears only if the native parent supplies one and it loads successfully; cover absence or failure does not block playback. Unsupported formats and transfer failures have separate messages, and the original remains downloadable. Refreshing requires preparation again; already acquired originals remain in the existing authorized Attachment store. Stop, unbind and revocation follow the file rules above. This is a draft video tracer. Native intake, decoded playback, installed-product E2E, matched-theme captures and Human feedback remain pending in [the verification checkpoint](dev/verification/lark-1023-channel-video.md). Audio, direct media messages, runtime transcoding, generated covers and model transcription are not delivered by this slice. The mobile app currently offers the original as a file action. + +Opening the Bot Inbox's source details does not fetch this video or its cover. That inspector retains explicit original-download actions; prepare playback from the Channel bubble. diff --git a/docs/lark-connection.zh.md b/docs/lark-connection.zh.md index b37b0f0afb..5692c0313e 100644 --- a/docs/lark-connection.zh.md +++ b/docs/lark-connection.zh.md @@ -470,3 +470,5 @@ Human 查看图片不会让 Bot 获得图片理解能力,也不改变其注意 准备操作在当前来源授权下取得不超过 25 MiB 的媒体,随后需再次手动点击播放,不会自动播放。已知时长标注为「平台时长」。封面仅在原生父消息提供且加载成功时显示;封面缺失或失败不阻断播放。不支持的格式与传输失败分别提示,原件仍可下载。刷新后需重新准备播放;已取得的原件仍由既有 Attachment 存储保管,并继续核对授权。暂停、解绑和撤销遵守上方文件规则。 本片仍是视频草稿候选。真实入站、实际解码播放、安装包 E2E、明暗截图和 Human 反馈待[验收检查点](dev/verification/lark-1023-channel-video.md)补齐。音频、直接媒体消息、运行时转码、生成封面和模型转写未在本片交付;移动端目前提供原件文件操作。 + +打开 Bot Inbox 的来源详情不会取回此视频或封面;详情中保留明确的原件下载操作,播放请从 Channel 气泡准备。 diff --git a/packages/client/src/client/channel-sidebar-builtins.tsx b/packages/client/src/client/channel-sidebar-builtins.tsx index e7ff9b4bb7..5972fc3b2f 100644 --- a/packages/client/src/client/channel-sidebar-builtins.tsx +++ b/packages/client/src/client/channel-sidebar-builtins.tsx @@ -198,6 +198,16 @@ function MembersBadge(): ReactElement { return {useClientState().conversation.channel?.members.length ?? 0}; } +function sourcePreviewKind( + source: ExternalSource, + file: { mediaType?: string }, +): 'image' | 'video' | undefined { + if (source.platform === 'feishu' && source.event.video) return; + if (file.mediaType?.startsWith('image/')) return 'image'; + if (source.event.video && file.mediaType?.startsWith('video/')) return 'video'; + return; +} + function BotInboxItemRow({ item, source, @@ -354,8 +364,8 @@ function BotInboxItemRow({ setExternal(source); for (const file of source.event.attachments ?? []) { if (request !== externalRequest.current) return; - if (file.mediaType?.startsWith('image/')) await download(file.id, file.name, true); - else if (source.event.video && file.mediaType?.startsWith('video/')) + if (sourcePreviewKind(source, file) === 'image') await download(file.id, file.name, true); + else if (sourcePreviewKind(source, file) === 'video') await download(file.id, file.name, 'video'); } } catch { @@ -372,10 +382,11 @@ function BotInboxItemRow({ await actions.openAround(item.sourceChannelId, item.sourceMessageId); }; const imageAttachments = - external?.event.attachments?.filter((file) => file.mediaType?.startsWith('image/')) ?? []; - const videoAttachments = external?.event.video - ? (external.event.attachments?.filter((file) => file.mediaType?.startsWith('video/')) ?? []) - : []; + external?.event.attachments?.filter((file) => sourcePreviewKind(external, file) === 'image') ?? + []; + const videoAttachments = + external?.event.attachments?.filter((file) => sourcePreviewKind(external, file) === 'video') ?? + []; const unavailable = !memoryChange && !item.sourceAvailable; const chips = [ item.externalOrigin?.voice ? ( diff --git a/packages/client/test/bot-inbox-entry.test.ts b/packages/client/test/bot-inbox-entry.test.ts index a0d0b7e55c..752321ad72 100644 --- a/packages/client/test/bot-inbox-entry.test.ts +++ b/packages/client/test/bot-inbox-entry.test.ts @@ -690,6 +690,136 @@ it('loads checked images into their message bubble on open, retries refusals and } }); +it('opening a Lark video source does not acquire the video or cover before an explicit original download', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const previous = store.getSnapshot().botInbox; + const videoId = 'a'.repeat(64); + const coverId = 'b'.repeat(64); + store.setBotInbox({ + status: 'ready', + items: [ + { + ...item, + sourceKind: 'bridge-message', + externalOrigin: { + platform: 'feishu', + accountName: 'Lark QA', + conversationName: 'Group', + conversationId: 'group', + senderId: 'human', + }, + }, + ], + }); + const source: ExternalSource = { + id: item.id, + body: 'Please inspect the quoted video', + platform: 'feishu', + accountName: 'Lark QA', + conversationName: 'Group', + at: item.createdAt, + grantId: 'grant', + grantRevision: 1, + event: { + version: 1, + channel: 'feishu', + botId: 'bot', + fingerprint: 'c'.repeat(64), + eventId: 'event', + messageId: 'mention', + actor: { kind: 'user', id: 'human' }, + conversation: { kind: 'group', id: 'group' }, + mentions: [], + mentionedAccount: true, + at: item.createdAt, + reply: { + messageId: 'mention', + conversationId: 'group', + actorId: 'human', + parentId: 'parent-video', + }, + video: { itemId: videoId, durationMs: 6000, posterAttachmentId: coverId }, + replay: { kind: 'provider-redelivery', resumeCursor: false, gapPossible: true }, + attachments: [ + { + id: videoId, + messageId: 'parent-video', + name: 'clip.mp4', + resourceKey: 'native-video', + mediaType: 'video/unknown', + }, + { + id: coverId, + messageId: 'parent-video', + name: 'video-cover', + resourceKey: 'native-cover', + mediaType: 'image/unknown', + }, + ], + }, + }; + const fetchMedia = vi.fn(async (_url: string, _request?: RequestInit) => ({ + ok: true, + blob: async () => new Blob(['original'], { type: 'video/mp4' }), + })); + vi.stubGlobal('fetch', fetchMedia); + const originalCreate = Object.getOwnPropertyDescriptor(URL, 'createObjectURL'); + const originalRevoke = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL'); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: () => 'blob:original', + }); + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: () => {} }); + const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + try { + const entry = createChannelSidebarBuiltins(zhTranslate).find( + (entry) => entry.id === 'bot-inbox', + )!; + await act(async () => + root.render( + createElement(ChannelSidebarEntrySection, { + entry, + expanded: true, + onToggle: () => {}, + entryProps: { + scope: 'personabot', + channelId: 'dm-ada', + botSlug: 'ada', + actions: { messagingSource: async () => source } as unknown as BridgeActions, + t: zhTranslate, + }, + }), + ), + ); + await act(async () => container.querySelector('.bh-inbox-item')!.click()); + expect(container.textContent).toContain('Please inspect the quoted video'); + expect(fetchMedia).not.toHaveBeenCalled(); + expect(container.querySelector('video')).toBeNull(); + expect(container.querySelector('.bh-external-message-text img')).toBeNull(); + await act(async () => + container.querySelector('.bh-external-source-file button')!.click(), + ); + expect(fetchMedia).toHaveBeenCalledTimes(1); + expect(fetchMedia.mock.calls[0]?.[0]).toBe( + './api/botharness/attachment?slug=ada&sourceEventId=source-1&attachmentId=' + videoId, + ); + expect(clicked).toHaveBeenCalledTimes(1); + } finally { + await act(async () => root.unmount()); + container.remove(); + store.setBotInbox(previous); + clicked.mockRestore(); + vi.unstubAllGlobals(); + if (originalCreate) Object.defineProperty(URL, 'createObjectURL', originalCreate); + else Reflect.deleteProperty(URL, 'createObjectURL'); + if (originalRevoke) Object.defineProperty(URL, 'revokeObjectURL', originalRevoke); + else Reflect.deleteProperty(URL, 'revokeObjectURL'); + } +}); + it('loads checked video into its bubble without autoplay, retries refusals and preserves captions', async () => { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const previous = store.getSnapshot().botInbox; From d794515b962c6adb3c46a3e24c472045f57ed1c0 Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Tue, 3 Nov 2026 01:15:47 +0900 Subject: [PATCH 4/5] fix(messaging): serve source media to Channels connected by conversation Media authority only knew bridge routes, so a Channel connected with channel-connect could not reach any source media. Record native video qualification. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../verification/lark-1023-channel-video.md | 12 ++++ packages/core/src/messaging/channel-media.ts | 20 +++++- .../core/test/channel-bridge-media.test.ts | 64 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/docs/dev/verification/lark-1023-channel-video.md b/docs/dev/verification/lark-1023-channel-video.md index b19e54c6d2..dd05f58ab1 100644 --- a/docs/dev/verification/lark-1023-channel-video.md +++ b/docs/dev/verification/lark-1023-channel-video.md @@ -46,3 +46,15 @@ T3 reported an attached signed-in Lark tab, but reading it timed out. The requir After browser recovery, verify the Human account and designated QA2 group, inspect receiver ownership, prepare an automatic stop guard and coordinate the bounded window before binding. Upload a uniquely marked synthetic video through the native **Image & video** UI, quote that actual message and select the Bot from the native @ menu. Match the visible request/reply, Source Event, exact parent resource, Admission and Outbox facts. Verify zero managed media before preparation; then compare original bytes and hashes, manually play and check advancing decoded frames, pause/seek, refresh/restart and authority lifecycle. Capture matched light/dark before/after and a short recording. A missing cover is an observed metadata condition only when the native response actually omits it. Native Lark intake, real playback, fresh installed-product E2E, live CLI execution, screenshots/recording and first-slice Human feedback remain pending. A successful API response alone will not close these gates. #1023 stays open; audio and other native shapes must not be expanded before the first working slice is reviewed. + +## Native qualification — 2026-11-03 JST + +An isolated Host on DSH `0.2.1-rc.3` ran Provider `fdbba5a95dcf2731f623b01ceaeae50ed7c532f2` with the dedicated `BotHarness Lark QA1` app, in a new QA group with only the test account and the Bot. A 5-second synthetic H.264 MP4 was sent as a native `media` message through the Bot API, so the parent's sender was the Bot. A real Human reply in the Lark web client quoted it and mentioned the Bot. + +- Intake recorded `durationMs: 5000`, the video item and the cover as attachments, with no download before an explicit action. The Bot replied once in Lark. +- `channel-media-download`, with and without `--representation playback`, returned the exact bytes (`0f9b888b…`), MIME `video/mp4`. The cover downloaded as the original PNG. +- The Web Client made no attachment request before **Prepare video**. After it, Chromium played the video with advancing time, 34 decoded frames, a seek to 0:04 and no media error, in light and dark. + +This run found and fixed one defect: a Channel connected with `channel-connect` could not reach any source media, because media authority only knew the older bridge routes. Authority now also accepts an enabled ingest for the exact account and conversation. + +A video uploaded by a Human through Lark's own **Image & video** UI was not exercised, because the browser tool cannot upload files. Audio remains out of scope, so #1023 stays open. diff --git a/packages/core/src/messaging/channel-media.ts b/packages/core/src/messaging/channel-media.ts index 26f781362a..d337ec83c4 100644 --- a/packages/core/src/messaging/channel-media.ts +++ b/packages/core/src/messaging/channel-media.ts @@ -8,6 +8,7 @@ import { MessagingError, type MessagingProvider } from './provider.js'; import { readMessagingIdentity } from './identity.js'; import { bridgeAttachmentKind, bridgeChannel, humanBridgeChannel } from './channel-target.js'; import { channelBridgeRoutes } from './channel-bridge.js'; +import { channelIngests } from './conversation-ingest.js'; import { sourceMediaUploadId } from './media-identity.js'; interface MediaRequest { @@ -45,12 +46,25 @@ function authority(db: DatabaseSync, input: MediaRequest, active: (slug: string) } } } + const ingested = channelIngests(db, input.channelId).some( + (ingest) => + ingest.enabled && + ingest.accountRef === source.event.botId && + ingest.fingerprint === source.event.fingerprint && + ingest.conversation.kind === source.event.conversation.kind && + ingest.conversation.id === source.event.conversation.id, + ); + if (!rows.length && ingested) { + const row = db.prepare('SELECT body FROM messaging_grants WHERE id = ?').get(source.grantId) as + | { body: string } + | undefined; + if (row) rows.push({ body: row.body, route_id: source.grantId }); + } const candidates = rows.flatMap((row) => { const grant = JSON.parse(row.body) as MessagingGrant; const route = channelBridgeRoutes(grant).find((item) => item.id === row.route_id); if ( - !route || - route.channelId !== input.channelId || + ((!route || route.channelId !== input.channelId) && !ingested) || grant.revokedAt || grant.suspendedReason || !grant.receiveScope || @@ -77,7 +91,7 @@ function authority(db: DatabaseSync, input: MediaRequest, active: (slug: string) if (!(error instanceof MessagingError)) throw error; return []; } - return [{ grant, enabled: route.enabled }]; + return [{ grant, enabled: ingested || route!.enabled }]; }); if (!candidates.length) throw new MessagingError('source-unavailable'); return { source, attachment, candidates }; diff --git a/packages/core/test/channel-bridge-media.test.ts b/packages/core/test/channel-bridge-media.test.ts index b171bf2d57..7b369005a7 100644 --- a/packages/core/test/channel-bridge-media.test.ts +++ b/packages/core/test/channel-bridge-media.test.ts @@ -1,3 +1,4 @@ +import { writeIngest } from '../src/messaging/conversation-ingest.js'; import { rmSync } from 'node:fs'; import { setImmediate as tick } from 'node:timers/promises'; import { afterEach, expect, it, vi } from 'vitest'; @@ -360,6 +361,69 @@ it('video playback follows stop-before-acquire, stopped cache and identity revoc expect(second.download).toHaveBeenCalledTimes(1); }); +it('a connected conversation grants Channel media only while its ingest is enabled', async () => { + const fx = await fixture(false, false, false, true); + const database = attachOperationalModule(fx.core.operationalDatabase, 'media-test'); + const ingestFor = (connected: string) => + database.read((db) => { + const { payload_json } = db + .prepare('SELECT payload_json FROM source_events WHERE source_event_id = ?') + .get(fx.sourceEventId) as { payload_json: string }; + const { event, grantId } = ( + JSON.parse(payload_json) as { + external: { + grantId: string; + event: { + botId: string; + fingerprint: string; + conversation: { kind: 'dm' | 'group'; id: string }; + }; + }; + } + ).external; + return { + grantId, + value: { + id: 'ingest-1', + channelId: connected, + providerId: 'dsh-im/feishu', + accountRef: event.botId, + fingerprint: event.fingerprint, + platform: 'feishu', + accountName: 'QA', + conversation: { ...event.conversation, name: 'QA' }, + revision: 1, + enabled: true, + intakeAfter: new Date(0).toISOString(), + wake: { mode: 'mentions' as const, count: 1, intervalSeconds: 0 }, + createdAt: new Date(0).toISOString(), + }, + }; + }); + const created = createBridgeMethods({ ...fx.core }).channelCreate({ + name: 'Connected', + members: ['ada'], + }); + if (!created.ok) throw new Error(created.error.message); + const connected = created.value.channel.id; + database.transaction((db) => { + db.prepare( + 'INSERT INTO channel_placements (channel_id, revision, source_event_id, message_id) VALUES (?, 1, ?, ?)', + ).run(connected, fx.sourceEventId, fx.sourceEventId); + }); + const ingest = ingestFor(connected); + await expect(fx.read(fx.attachment.id, connected)).rejects.toMatchObject({ + code: 'source-unavailable', + }); + database.transaction((db) => writeIngest(db, ingest.value)); + const result = await fx.read(fx.attachment.id, connected); + expect(Buffer.from(await new Response(result.body).arrayBuffer())).toEqual(fx.bytes); + database.transaction((db) => writeIngest(db, { ...ingest.value, enabled: false, revision: 2 })); + await expect(fx.read(fx.attachment.id, connected)).rejects.toMatchObject({ + code: 'source-unavailable', + }); +}); + it('requires the Lark video capability instead of borrowing file or image qualification', async () => { const fx = await fixture(false, false, false, true); const info = await fx.service.describeBot('account'); From 89868a7060679c8f8a6cbc5951e327104e9feb7f Mon Sep 17 00:00:00 2001 From: DoodleBear Date: Tue, 3 Nov 2026 01:18:48 +0900 Subject: [PATCH 5/5] merge main and pin the merged Provider for release Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/client-bridge.md | 2 +- docs/product-im-installation.md | 2 +- scripts/dev-im-provider.mjs | 8 ++++---- scripts/product-artifacts.mjs | 8 ++++---- scripts/test/product-artifacts.test.mjs | 14 +++++++------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/client-bridge.md b/docs/client-bridge.md index 543e06f1ef..8eec99185e 100644 --- a/docs/client-bridge.md +++ b/docs/client-bridge.md @@ -144,7 +144,7 @@ resolver cannot substitute workspace-linked Core/Client for the tarballs. node scripts/dev-instance.mjs --home /tmp/bh-im-qa --port 31968 --im-provider --json ``` -仅对已停止的隔离 Profile 执行;重启同一 Profile 时仍传入 `--im-provider`,以重复校验。当前 Provider 来源记录为 Git 完整提交 `518314f6cf495aea093c0a93b0107de25b66e60d`,开发资格记录为 DSH `0.2.1-rc.2`,这些来源记录不限制实际启动的 DSH 版本;校验 Bundle、入口及运行时代码 digest 后才启动 Host;不依赖另一份 provider 本地源码。安装摘要明确标注 `upstreamReleased: false`。此完整 fork 保留附件、回显与独立回复身份契约;群/话题上下文读取绑定当前独占 Consumer 生命周期,释放或替换后拒绝返回正在读取的结果;上游 PR 是否合并不阻挡此固定提交使用([#789](https://github.com/BotHarness/BotHarness/issues/789))。默认启动不安装它,原 npm `4.32.0` 仍不满足账号校验和条件发送契约。此入口不会发布包,也不是生产启用许可([ADR-0104](adr/0104-isolated-im-profiles-pin-a-qualified-temporary-provider-fork.md))。 +仅对已停止的隔离 Profile 执行;重启同一 Profile 时仍传入 `--im-provider`,以重复校验。当前 Provider 来源记录为 Git 完整提交 `e655636c061fa0aed8c77c23f7e87cb5abf79b65`,开发资格记录为 DSH `0.2.1-rc.2`,这些来源记录不限制实际启动的 DSH 版本;校验 Bundle、入口及运行时代码 digest 后才启动 Host;不依赖另一份 provider 本地源码。安装摘要明确标注 `upstreamReleased: false`。此完整 fork 保留附件、回显与独立回复身份契约;群/话题上下文读取绑定当前独占 Consumer 生命周期,释放或替换后拒绝返回正在读取的结果;上游 PR 是否合并不阻挡此固定提交使用([#789](https://github.com/BotHarness/BotHarness/issues/789))。默认启动不安装它,原 npm `4.32.0` 仍不满足账号校验和条件发送契约。此入口不会发布包,也不是生产启用许可([ADR-0104](adr/0104-isolated-im-profiles-pin-a-qualified-temporary-provider-fork.md))。 在 dsh-im 原设置中配置测试应用,并保存、测试仅含测试者与 Bot 的目标群;凭据交由 DSH credentials service,不复制到 BotHarness。打开 PersonaBot Profile → IM connection,选择已认证账号与已测试目标,显式授权后发送一条唯一测试文本。预期 Recent sends 显示 Platform accepted,并在目标群核对同一文本;这不代表送达或已读。停止本次启动摘要中的确切 PID,再以相同参数启动:绑定与发送历史应保留,不自动重发。 diff --git a/docs/product-im-installation.md b/docs/product-im-installation.md index 171e0bf19f..2f8ac5997a 100644 --- a/docs/product-im-installation.md +++ b/docs/product-im-installation.md @@ -35,7 +35,7 @@ fails before packing. ```bash git clone https://github.com/DoodleBears/dsh-im.git /tmp/bh-im-source -git -C /tmp/bh-im-source checkout 518314f6cf495aea093c0a93b0107de25b66e60d +git -C /tmp/bh-im-source checkout e655636c061fa0aed8c77c23f7e87cb5abf79b65 npm ci --prefix /tmp/bh-im-source --ignore-scripts --no-audit --no-fund pnpm install --frozen-lockfile pnpm build diff --git a/scripts/dev-im-provider.mjs b/scripts/dev-im-provider.mjs index b8bdb60f4b..09b8d21ae8 100644 --- a/scripts/dev-im-provider.mjs +++ b/scripts/dev-im-provider.mjs @@ -5,11 +5,11 @@ import { join } from 'node:path'; export const qualifiedImProvider = Object.freeze({ package: '@xmanrui/dsh-im', packageVersion: '4.32.0', - source: 'fdbba5a95dcf2731f623b01ceaeae50ed7c532f2', - spec: 'github:DoodleBears/dsh-im#fdbba5a95dcf2731f623b01ceaeae50ed7c532f2', + source: 'e655636c061fa0aed8c77c23f7e87cb5abf79b65', + spec: 'github:DoodleBears/dsh-im#e655636c061fa0aed8c77c23f7e87cb5abf79b65', dsh: '0.2.1-rc.2', - runtimeFiles: 424, - runtimeSha256: '03e765b26a087c870970041db2d90224a0e207beaacc0f3fd17ed8170db967c9', + runtimeFiles: 425, + runtimeSha256: '7b9cbc1706ed42a114d10d106ddf41d1fcbbc3f93dcfbe358950802e695f1647', }); export function withQualifiedImProvider(manifest) { diff --git a/scripts/product-artifacts.mjs b/scripts/product-artifacts.mjs index a47b0031be..14d89c1999 100644 --- a/scripts/product-artifacts.mjs +++ b/scripts/product-artifacts.mjs @@ -21,16 +21,16 @@ export const productDshVersions = Object.freeze(['0.2.0-rc.2', '0.2.1-rc.2', pro export const productImProvider = Object.freeze({ name: '@botharness/im-provider', - version: '4.32.0-botharness.25', + version: '4.32.0-botharness.26', sourceManifestSha256: '3331e8f771a91abf396eb4e7ed5c0acc4c3b504cdccc7d715e37062708384cc2', sourceLockSha256: '3476c34c7d4515000d2617beaf2568b2a794b60de9b350cd8eafb7a2bf21adef', upstream: Object.freeze({ package: '@xmanrui/dsh-im', packageVersion: '4.32.0', - source: '518314f6cf495aea093c0a93b0107de25b66e60d', + source: 'e655636c061fa0aed8c77c23f7e87cb5abf79b65', dsh: '0.2.0-rc.2', - runtimeFiles: 424, - runtimeSha256: 'bc54309720ce66906b562d7afaae99669c2177d67717623f6999e906abe473d1', + runtimeFiles: 425, + runtimeSha256: '7b9cbc1706ed42a114d10d106ddf41d1fcbbc3f93dcfbe358950802e695f1647', }), }); diff --git a/scripts/test/product-artifacts.test.mjs b/scripts/test/product-artifacts.test.mjs index 1100e86d1e..57bb5f6ad7 100644 --- a/scripts/test/product-artifacts.test.mjs +++ b/scripts/test/product-artifacts.test.mjs @@ -324,7 +324,7 @@ describe('release composition', () => { expect(() => verifyProductComposition(entries)).toThrow('conflicts'); }); it('keeps product provenance independent when the development Provider selection changes', () => { - expect(productImProvider.upstream.source).toBe('518314f6cf495aea093c0a93b0107de25b66e60d'); + expect(productImProvider.upstream.source).toBe('e655636c061fa0aed8c77c23f7e87cb5abf79b65'); expect(productImProvider.upstream.dsh).toBe('0.2.0-rc.2'); }); @@ -339,7 +339,7 @@ describe('release composition', () => { '@botharness/computer': '0.0.0-test.823', '@botharness/core': '0.0.0-test.823', '@botharness/ui': '0.0.0-test.823', - '@botharness/im-provider': '4.32.0-botharness.25', + '@botharness/im-provider': '4.32.0-botharness.26', }); expect(release.optionalDependencies).toEqual( Object.fromEntries(cloudflaredPackageNames.map((name) => [name, '0.0.0-test.823'])), @@ -377,11 +377,11 @@ describe('release composition', () => { describe('product-managed Provider updates', () => { it('reports the running artifact without starting any independent check or installation job', () => { - expect(productManagedUpdate('update.status', {}, undefined, '4.32.0-botharness.3')).toEqual({ + expect(productManagedUpdate('update.status', {}, undefined, '4.32.0-botharness.26')).toEqual({ ok: true, value: { - runningVersion: '4.32.0-botharness.3', - installedVersion: '4.32.0-botharness.3', + runningVersion: '4.32.0-botharness.26', + installedVersion: '4.32.0-botharness.26', latestVersion: null, canInstall: false, sourceInstall: false, @@ -405,7 +405,7 @@ describe('product-managed Provider updates', () => { 'refuses %s so upstream update paths cannot replace the qualified artifact', (endpoint, payload) => { expect( - productManagedUpdate(endpoint, payload, undefined, '4.32.0-botharness.3'), + productManagedUpdate(endpoint, payload, undefined, '4.32.0-botharness.26'), ).toMatchObject({ ok: false, error: { code: 'product-managed' } }); }, ); @@ -413,7 +413,7 @@ describe('product-managed Provider updates', () => { const controller = new AbortController(); controller.abort(); expect( - productManagedUpdate('update.status', {}, controller.signal, '4.32.0-botharness.3'), + productManagedUpdate('update.status', {}, controller.signal, '4.32.0-botharness.26'), ).toMatchObject({ ok: false, error: { code: 'cancelled' } }); }); });