Skip to content

独立文档页在无聊天上下文时仍启用全局 IM 提示音 #1137

Description

@labilio

Description

独立文档路由 /d/:docId 被设计为脱离主应用壳运行:Layout 通过 early return 直接渲染独立文档页,不挂载 MainPage / NavRail,页面内没有聊天列表、未读入口或消息来源展示。

但应用入口在 React 渲染和路由分支判断之前已经执行 WKApp.shared.startup()。登录态存在时,startup() 会继续调用 startMain() / connectIM()BaseModule 注册的全局 addCurrentImMessageListener 也仍然有效。只要收到一条通过 allowNotify() 的 IM 消息,该独立文档标签页就会调用 tipsAudio() 播放 msg-tip.mp3

这造成了一个底层生命周期边界问题:页面在产品形态上是无聊天上下文的独立分享/阅读/权限申请页面,在运行时却默认继承完整聊天通知能力。用户可能只听到聊天提示音,却无法在当前页面识别发送人、频道或点击返回对应会话。实际曾在无权限文档页提交访问申请并将页面置于后台后,出现连续提示音,但 Octo 聊天列表中没有可确认的对应消息;该次具体事件来源目前无法稳定复现,因此不把免打扰群、好友申请、通知机器人或事件重放写成已确认根因。

已验证的执行路径:

  1. apps/web/src/index.tsx 在渲染 <App /> 前注册 BaseModule 并调用 WKApp.shared.startup()
  2. 登录态下 startup() 调用 startMain(),随后执行 connectIM()
  3. /d/:docIdapps/web/src/Layout/index.tsx 通过 early return 渲染独立文档页,明确跳过 MainPage / NavRail
  4. packages/dmworkbase/src/module.tsx 的全局消息监听器不区分当前路由是否具有聊天上下文;allowNotify() 返回 true 后直接调用 tipsAudio()
  5. allowNotify() 没有独立页面策略,也没有 document.hidden、跨标签页协调或按 messageID/clientMsgNo 的提示音去重。

免打扰缓存缺失、Space 初始化竞态、子区 fail-open、IM 重连或事件重放可能放大该问题,但尚未证明它们是原始现场的具体事件来源。本 Issue 跟踪的是已经由代码确认的底层边界:无 App Shell 的独立文档页默认继承全局聊天提示音。

受影响的是通过分享链接打开文档的组织成员、无权限申请者和外部协作者。声音没有可见来源,会干扰阅读/权限申请流程,也无法让用户判断是工作消息、文档事件还是异常重复通知。

Steps to Reproduce

  1. 在已登录状态下直接打开任意独立文档链接 /d/:docId,包括无权限的 403 / 申请访问页面。
  2. 保持该页面打开,或将标签页置于后台。
  3. 从另一账号向当前账号发送一条会通过全局 allowNotify() 的 IM 消息。
  4. 观察独立文档标签页仍可播放聊天提示音,但当前页面没有聊天列表、消息来源或跳转入口。

原始“连续提示音且没有可确认聊天消息”的现场目前无法稳定复现;以上步骤用于验证底层通知边界,而不是声称已经复现原始事件来源。

Expected Behavior

无 App Shell 的独立文档页面应有明确的通知隔离策略。至少在无权限/申请访问终端页,不应播放与当前页面无关的聊天提示音。

如果产品决定在有权限的独立阅读/编辑页保留全局消息通知,也应保证通知来源可见、可跳转,并可靠遵循免打扰、Space 和多标签页策略;不能只播放一个无法追踪来源的声音。

Actual Behavior

独立文档页虽跳过聊天 App Shell,但全局 IM 连接和提示音监听器仍然运行。符合 allowNotify() 的消息事件会触发 msg-tip.mp3,当前页面没有能力说明声音来自哪个会话。

已观察到一次无权限文档页在后台连续播放提示音的现场,但具体上游事件尚未捕获;本 Issue 不将该现场归因到某种特定消息类型。

Client Version

Web main@f5145fe12e3b5ab04acdcc7cfd8f707abf038b8e(2026-07-28 代码核查);原始生产观察发生于 2026-07-24,页面未展示具体部署构建号。

Platform

Web

Affected Component

Other

Severity

P3 - UX issue

Reproducibility

  • Consistently reproducible

Environment

  • Client: Octo Web
  • Route: /d/:docId
  • Original browser / OS version: 未记录
  • Original state: 无文档权限,已提交访问申请,标签页置于后台

Logs / Screenshots

当前没有原始现场日志或稳定复现录像。代码证据:

  • 应用在渲染路由前注册模块并启动:
    WKApp.shared.registerModule(new BaseModule()); // 基础模块
    WKApp.shared.registerModule(new DataSourceModule()) // 数据源模块
    WKApp.shared.registerModule(new LoginModule()); // 登录模块
    WKApp.shared.registerModule(new BindModule()); // OIDC 自助绑定页 (/oidc/bind)
    WKApp.shared.registerModule(new ContactsModule()); // 联系模块
    WKApp.shared.registerModule(new SummaryModule()); // 智能总结模块
    WKApp.shared.registerModule(new McpMarketModule()); // MCP 市场模块
    WKApp.shared.registerModule(new SkillMarketModule()); // Skill 市场模块
    WKApp.shared.registerModule(new AppBotModule()); // App Bot 模块
    WKApp.shared.registerModule(new DocsModule()); // Docs module
    WKApp.shared.registerModule(new LoopModule()); // Loop 面板(Issue/Skill/Project/Agent/Squad/Runtime)
    WKApp.shared.registerModule(new PersonalModule()); // 我的(Runtimes/Skills)

    async function main(): Promise<void> {
    await enableMocksIfE2E();
    await enableMockImIfE2E();
    WKApp.shared.startup(); // app启动
    const container = document.getElementById("root")!;
    const root = createRoot(container);
    root.render(
    <React.StrictMode>
  • 登录态启动全局 IM:
    registerImConnectAddressProvider(
    WKSDK.shared(),
    this.imConnectAddressManager.connectAddrCallback
    );
    WKApp.endpoints.addOnLogin(() => {
    this.startMain();
    });
    if (WKApp.loginInfo.isLogined()) {
    this.startMain();
    }
    registerImConnectStatusListener(WKSDK.shared(), {
    logout: () => WKApp.shared.logout(),
    resetTyping: () => TypingManager.shared.resetAll(),
    rotateConnectAddress: () =>
    this.imConnectAddressManager.rotateAfterDisconnect(),
    });
    // 通知设置
    const notificationIsClose = StorageService.shared.getItem(
    "NotificationIsClose"
    );
    if (notificationIsClose === "1") {
    this._notificationIsClose = true;
    } else {
    this._notificationIsClose = false;
    }
    WKApp.remoteConfig.startRequestConfig();
    this.setupRemoteConfigForegroundRefresh();

    startMain() {
    this.connectIM();
    WKApp.dataSource.contactsSync(); // 同步通讯录
    ProhibitwordsService.shared.sync(); // 同步敏感词
    // 设备记录不存在(status===400)或其它读取失败时,仅记录告警以消除
    // unhandled promise rejection;不写 clientMsgDeviceId,保持原值降级运行。
    // 服务端暂无设备注册端点,此处不做注册,仅兜底。
    syncClientMsgDeviceId({
    deviceId: WKApp.shared.deviceId,
    fetchDevice: (path) => WKApp.apiClient.get(path),
    setClientMsgDeviceId: (id) => {
    WKSDK.shared().config.clientMsgDeviceId = id;
    },
    });
    }
    connectIM() {
    // e2e: mock-im-runtime 已 short-circuit connect → Connected (fake-provider install 时做),
    // 这里跳过真实 sdk.connect() 免真去建 WebSocket / 覆盖 mock 的 connect status.
    // dev / prod 完全走 tree-shake 分支, 无副作用.
    if (import.meta.env.VITE_E2E_MOCK_IM === "1") {
    return;
    }
    connectImClient({
    sdk: WKSDK.shared(),
    loginInfo: WKApp.loginInfo,
    });
    }
  • 独立文档页跳过 MainPage / NavRail
    // Standalone document deep-link (`/d/:docId`, octo-web #512): a shareable full-window
    // doc view that lives OUTSIDE the app shell (no MainPage / NavRail), mounted with the
    // same early-return interception the `?invite=` landing uses below. We claim the whole
    // `/d` namespace (not just well-formed ids) so a malformed/empty id renders the
    // standalone not-found terminal instead of silently falling through to the shell (AC-9).
    //
    // Auth: this branch renders before the Provider, so ensure the octo session is loaded
    // for the page's GET /docs/{docId} preflight + collab-token exchange. A clean cold-load
    // in a fresh tab carries no `?sid=`, so load() (sid-keyed) misses even a signed-in user's
    // token — recover it from localStorage the way the invite branch does (AC-3). When the
    // visitor is genuinely anonymous, fall through to the login screen rendered IN PLACE: the
    // pathname stays `/d/:docId`, so onLogin derives basePath from it and bounces the user
    // straight back to the doc (now with `?sid=`) after sign-in — the deep-link resumes
    // instead of dead-ending (AC-11). SPA deep-link serving depends on the nginx try_files
    // fallback (deployment concern, out of scope for this frontend change).
    if (isStandaloneDocPath(window.location.pathname)) {
    if (!WKApp.loginInfo.token) {
    WKApp.loginInfo.load();
    }
    if (!WKApp.loginInfo.token) {
    recoverOctoSessionFromStorage(true);
    }
    if (WKApp.loginInfo.token) {
    const standaloneDocId = parseStandaloneDocId(window.location.pathname);
    return <StandaloneDocPage docId={standaloneDocId} onSessionExpired={clearExpiredStandaloneSessionAndReload} />;
    }
  • 全局消息监听器触发通知和提示音:
    if (this.allowNotify(message)) {
    let from = "";
    if (message.channel.channelType === ChannelTypeGroup) {
    const fromChannelInfo = getCurrentImChannelInfo(
    new Channel(message.fromUID, ChannelTypePerson)
    );
    if (fromChannelInfo) {
    from = `${fromChannelInfo?.orgData.displayName}: `;
    }
    }
    this.sendNotification(
    message,
    `${from}${message.content.conversationDigest}`
    );
    this.tipsAudio();
    }
  • tipsAudio() 与当前通知过滤条件:
    tipsAudio() {
    Howler.autoUnlock = false;
    if (!this.messageTone) {
    this.messageTone = new Howl({
    src: require("./assets/msg-tip.mp3"),
    });
    this.messageTone.play();
    } else {
    this.messageTone.play();
    }
    }
    allowNotify(message: Message) {
    if (WKApp.shared.notificationIsClose) {
    // 用户关闭了通知
    return false;
    }
    if (isCurrentImSystemMessage(message.contentType)) {
    // 系统消息不发通知
    return false;
    }
    if (message.fromUID === WKApp.loginInfo.uid) {
    // 自己发的消息不发通知
    return false;
    }
    // Space 隔离:不属于当前 Space 的消息不弹通知、不播提示音
    if (shouldSkipMessageForSpace(message)) {
    return false;
    }
    // BotFather 消息额外检查:channelType=Person 绕过了上面的过滤,
    // 需通过消息体 contentObj.space_id 判断是否属于当前 Space
    if (message.channel?.channelID === "botfather") {
    const curSpaceId = WKApp.shared.currentSpaceId;
    const msgSpaceId = (message.content as any)?.contentObj?.space_id;
    if (curSpaceId && msgSpaceId && msgSpaceId !== curSpaceId) {
    return false;
    }
    }
    // 已屏蔽(免打扰)的 channel 不播提示音、不发通知
    const channelInfo = getCurrentImChannelInfo(message.channel);
    if (channelInfo?.mute) {
    return false;
    }
    // 子区消息:额外检查父群聊 mute
    const parentGroupNo = channelInfo?.orgData?.parentGroupNo as
    | string
    | undefined;
    if (parentGroupNo) {
    const parentChannelInfo = getCurrentImChannelInfo(
    new Channel(parentGroupNo, ChannelTypeGroup)
    );
    if (parentChannelInfo?.mute) {
    return false;
    }
    }
    return true;
  • WuKongIM JS SDK v1.3.5 收到消息后直接通知监听器,不会先等待频道资料加载:
    https://github.com/WuKongIM/WuKongIMJSSDK/blob/v1.3.5/src/chat_manager.ts#L51-L74

历史关联:

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageNeeds priority and assignment

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions