feat(notify): IM 路由的主动消息也在非前台补浏览器通知(契约 v0.74)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人裁定 C 的必然推论:她该不该提醒我,与后端走了哪条路由无关。P0 如实报回的缺口 = 通知只挂在 proactive_message 帧上,而 auto 提示在有已连接 IM 渠道时优先走 IM ⇒ 最常发生的路径恰恰不弹。

判据(先查现状、不猜):行字段**区分不出**主动推送与普通回复(已用真库证据排除 platform/adapterId/turnId 反推)⇒ 新增**结构化的正向声明**:帧顶 data.proactive === true,只由主动推送那条通道打(gateway.BroadcastProactivePersisted);普通路径永远传 false。
- 后端:channelbridge 抽出唯一写入口径 persistMain + 新增 proactive_notify 接缝;gateway 抽出 broadcastPersisted(msg, proactive) 两入口共用
- 前端:handlers/proactive-notice.ts 两条帧路共用同一套提醒运行时(有界去重 + 降级说明 + DOM/Notification 取值);message.ts 读到判据才走提醒
- 对照断言(必须有):普通 IM 回复/主人提问/显式 proactive:false ⇒ **0 通知、3 气泡**(不误伤)
- 变异实测:去掉非主动保护 / 跨两路去重失效 / 前台也弹 / IM 不发判据帧 / 普通广播带判据 ⇒ 5 条全部打红
- 契约 v0.74(纯增量:message_persisted 新增可选 proactive:true)
- 边界(如实):判据**不落库**(翻历史不弹提醒,也不得从行字段反推);落库失败时不发判据帧(无权威 id 的帧前端整帧丢弃)
This commit is contained in:
AskaEth committed 2026-10-05 19:47:54 +08:00
1 parent a6cb44b8a8
commit f4adf0b526
13 files changed
+652 -122

No files matched your search

@@ -5,6 +5,8 @@ import {
parsePersistedMessage,
toRecord,
} from "../../wsParse";
import { noticeKey } from "./proactive";
import { notifyProactiveMessage } from "./proactive-notice";
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
/** The fields of a `message_edited` payload the chat needs: the edited message's
@@ -75,6 +77,14 @@ export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
if (msg.role === "assistant" && msg.turnId) {
s.clearGrey(msg.turnId, msg.sessionId ?? s.activeSessionId);
}
// 切片 P0b:帧顶的 `proactive:true` 是「这条是她**主动**说的话」的结构化判据
// (IM 路由的主动推送由 gateway 的 BroadcastProactivePersisted 打上)。带它才走
// 与 `proactive_message` **同一套**提醒逻辑 —— 非前台补一发通知、同一条只提醒
// 一次(去重键同为落库行 id)。普通消息的帧**永远没有**这个键,因此不可能被
// 误判成主动消息去弹通知。判据是结构化字段,绝不读正文/提示词(项目红线)。
if (r?.proactive === true) {
notifyProactiveMessage(msg.content, noticeKey(msg.id, msg.createdAt, msg.content), s);
}
}
return true;
}
@@ -0,0 +1,123 @@
import {
createSeenNotices,
decideProactiveNotice,
type NotifyPermission,
} from "../../notify/proactive";
import type { StoreState } from "./shared";
/**
* 主动消息的**通知运行时**(切片 P0b「IM 路由的主动消息也要在非前台补通知」)。
*
* 为什么单独一个模块:主人在裁定 C 里说清了「她说话该不该提醒我,与走哪条路由无关」,
* 而后端有**两条**路把「她主动说的话」推到本端 ——
*
* ① `proactive_message` 帧(活跃客户端路由,见 `handlers/proactive.ts`);
* ② `message_persisted` 帧顶带 `proactive:true` 的帧(IM 路由,见 `handlers/message.ts`:
* `auto` 提示在主人有已连接 IM 渠道时优先走 IM,因此这条其实才是最常见的)。
*
* 两条路必须走**同一套**提醒逻辑,否则「同一条既走实时帧、又被镜像回主会话」就会被
* 打扰两次。因此「已提醒表」与降级说明都住在这里、由两条路共用;判定本身仍是
* `lib/notify/proactive.ts` 的纯函数(不碰 DOM,可在纯 node 下逐条钉住)。
*
* 判据是**结构化**的:只有后端主动推送那条通道会在帧顶放 `proactive:true`,普通消息
* (Web 回合、IM 回复、IM 镜像)永远没有它 ⇒ 普通消息不可能被误判成主动消息。
*/
/** 本页存活期间的「已提醒」表:同一条消息只打扰一次(含同一条既走实时帧、又被 IM
* 镜像回主会话这种同时到达的情形)。气泡不参与去重 —— 那是消息 store 按 id 的事。 */
const seenNotices = createSeenNotices(200);
/** 最近一次已经说明过的降级状态:通知没开是**状态**,不是每次推送都要报告一遍的事故。 */
let degradeNote: NotifyPermission | null = null;
/** 按 `lib/notify/proactive.ts` 的裁定决定要不要补一发系统通知,并把「同一条只打扰
* 一次」记进去重表。
*
* `content` 是通知正文(= 她说的那句话),`key` 是这条消息的权威去重键(落库行 id)。
* 两条帧路给出**同一个** key,因此同一条消息到达两次也只有一次提醒。 */
export function notifyProactiveMessage(content: string, key: string, s: StoreState): void {
const permission = notifyPermission();
const decision = decideProactiveNotice({
seen: seenNotices.has(key),
visibility: documentVisibility(),
focused: documentFocused(),
permission,
});
// 无论结论如何都记下「这条已经露过面」:前台看过、或提醒过,都不该被第二次打扰。
seenNotices.add(key);
if (decision === "not-granted") {
noteDegrade(s, permission);
return;
}
if (decision !== "show") return;
if (!showSystemNotification(content, key)) {
// 构造通知本身失败(浏览器策略/权限收紧等)也算降级:留一句可见说明,
// 绝不让它冒泡进帧处理把气泡也带崩。
noteDegrade(s, notifyPermission());
}
}
/** 通知没开时留一句**可见说明**(同一状态只说一次,不刷屏):让主人知道「不是她没说,
* 是浏览器通知没开」,而不是以为主动消息根本没来。 */
function noteDegrade(s: StoreState, permission: NotifyPermission): void {
if (degradeNote === permission) return;
degradeNote = permission;
s.addDeviceUpdate(
permission === "denied"
? "浏览器通知未授权(已拒绝):主动消息只出现在气泡里,可在本站的站点设置里允许通知。"
: "浏览器通知尚未授权:主动消息只出现在气泡里,可在本站允许通知。"
);
}
/** `Notification` 构造器(不存在时 null)。**调用时**才解引用全局,因此纯 node
* 测试可以安全导入本模块(模块加载期不碰 DOM)。 */
type NotificationCtor = (new (title: string, options?: { body?: string; tag?: string }) => unknown) & {
permission?: string;
};
function notificationCtor(): NotificationCtor | null {
const g = globalThis as unknown as { Notification?: NotificationCtor };
return typeof g.Notification === "function" ? g.Notification : null;
}
/** 当前通知权限。运行时没有该 API ⇒ 归到 `"denied"`(拿不到通知就是没授权,
* 绝不假装已授权)。 */
function notifyPermission(): NotifyPermission {
const ctor = notificationCtor();
if (!ctor) return "denied";
if (ctor.permission === "granted") return "granted";
if (ctor.permission === "denied") return "denied";
return "default";
}
/** `document.visibilityState`;取不到时按 `"visible"`(= 不去打扰)。 */
function documentVisibility(): string {
const g = globalThis as unknown as { document?: { visibilityState?: string } };
const v = g.document?.visibilityState;
return typeof v === "string" ? v : "visible";
}
/** `document.hasFocus()`;取不到或抛错时按「已聚焦」处理(= 不去打扰)。 */
function documentFocused(): boolean {
const g = globalThis as unknown as { document?: { hasFocus?: () => boolean } };
const hasFocus = g.document?.hasFocus;
if (typeof hasFocus !== "function") return true;
try {
return hasFocus();
} catch {
return true;
}
}
/** 弹一发系统通知;失败(含构造抛错)返回 false,由调用方降级为「只留气泡 +
* 一句可见说明」。 */
function showSystemNotification(body: string, tag: string): boolean {
const ctor = notificationCtor();
if (!ctor) return false;
try {
new ctor("昔涟", { body, tag });
return true;
} catch {
return false;
}
}
@@ -8,6 +8,11 @@
// ③ **拒绝授权 ⇒ 如实降级**:只留气泡 + 一句可见说明,绝不报错崩掉、绝不静默;
// ④ **同一条只提醒一次**(含同一条既走实时帧、又被 IM 镜像回传的情形)。
//
// 切片 P0b 追加第 ⑤ 组:**IM 路由**的主动消息(`message_persisted` 帧顶带结构化判据
// `proactive:true`)同样要落气泡 + 非前台补通知 —— `auto` 提示在主人有已连接 IM 渠道时
// 优先走 IM,这条才是最常见的路径。其中包含「两路都到达仍只提醒一次」与「普通消息绝不
// 被误判」这两条对照断言。
//
// 项目没有前端测试框架/jsdom,所以这是零依赖的纯 node 检查:它驱动真实的帧解码器
// (lib/ws.ts 的 dispatchFrame)与真实的 store,并把浏览器 API(Notification /
// document)用桩装上(handler 只在**调用时**解引用它们,因此模块加载期不碰 DOM)。
@@ -250,4 +255,184 @@ function setDocument(visibility: string, focused: boolean): void {
ok(msgs()[0].id !== "", "本端给一个非空 id(渲染与 key 都需要它)");
}
// ── ⑤ 切片 P0b · IM 路由的主动消息:帧顶带结构化判据 `proactive:true` ─────────────
// 后端 IM 主动推送在落库之后发的是同一种 `message_persisted` 帧,只多一个结构化判据
// (`gateway.BroadcastProactivePersisted`)。走 IM 的主动消息因此也必须在非前台补通知
// —— `auto` 提示在主人有已连接 IM 渠道时优先走 IM,这条其实才是最常见的路径。
{
reset();
const shown = installNotification("granted");
setDocument("hidden", false);
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
proactive: true,
message: {
id: "im-row-1",
sessionId: MAIN,
role: "assistant",
content: "IM 路由的这条",
platform: "telegram",
adapterId: "telegram",
createdAt: 1_760_000_100_000,
},
},
});
same(msgs().length, 1, "IM 路由的主动消息 ⇒ 落气泡(与普通 IM 镜像同一条路)");
same(shown.length, 1, "非前台 ⇒ IM 路由的主动消息也补一发通知");
same(shown[0].body, "IM 路由的这条", "通知正文 = 这条主动消息的正文");
}
// ── ⑤ 两路都到达(同一落库行 id)⇒ 仍只提醒一次、仍只有一个气泡 ────────────────────
{
reset();
const shown = installNotification("granted");
setDocument("hidden", false);
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
proactive: true,
message: {
id: "im-row-2",
sessionId: MAIN,
role: "assistant",
content: "两路都到的那条",
createdAt: 1_760_000_200_000,
},
},
});
same(shown.length, 1, "先到带判据的 message_persisted ⇒ 补一发");
dispatchFrame(proactiveFrame({ id: "im-row-2", content: "两路都到的那条" }));
same(shown.length, 1, "同一条又走 proactive_message 到达 ⇒ 不打扰第二次");
same(msgs().length, 1, "两条帧说的是同一条(同 id)⇒ 仍只有一个气泡");
// 反序同样只一次:去重表按落库行 id 共用,与到达顺序无关。
reset();
const shown2 = installNotification("granted");
setDocument("hidden", false);
dispatchFrame(proactiveFrame({ id: "im-row-3", content: "反序也要一次" }));
same(shown2.length, 1, "反序:先 proactive_message ⇒ 补一发");
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
proactive: true,
message: {
id: "im-row-3",
sessionId: MAIN,
role: "assistant",
content: "反序也要一次",
createdAt: 1_760_000_300_000,
},
},
});
same(shown2.length, 1, "反序:随后带判据的 message_persisted ⇒ 仍只有一次");
same(msgs().length, 1, "反序也只有一个气泡");
}
// ── ⑤ 前台不弹(IM 路由那条帧同样适用) ──────────────────────────────────────────
{
reset();
const shown = installNotification("granted");
setDocument("visible", true);
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
proactive: true,
message: {
id: "im-row-fg",
sessionId: MAIN,
role: "assistant",
content: "前台这条 IM 主动消息",
createdAt: 1_760_000_400_000,
},
},
});
same(shown.length, 0, "可见且聚焦 ⇒ 不弹(气泡就是记录)");
same(msgs().length, 1, "前台照样落气泡");
}
// ── ⑤ 拒绝授权 ⇒ 只留气泡、不崩(IM 路由那条帧同样适用) ──────────────────────────
{
reset();
const shown = installNotification("denied");
setDocument("hidden", false);
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
proactive: true,
message: {
id: "im-row-denied",
sessionId: MAIN,
role: "assistant",
content: "通知被拒时的 IM 主动消息",
createdAt: 1_760_000_500_000,
},
},
});
same(shown.length, 0, "没有授权 ⇒ 不弹(绝不假装弹过)");
same(msgs().length, 1, "气泡必须仍然落地,且不抛错");
}
// ── ⑤ 对照(「不误伤」):普通消息没有判据 ⇒ 一条通知都不弹 ────────────────────────
// 这些是真实存在的普通消息形状:IM 助手回复(带 turnId/adapterId/platform)、主人的
// 提问、以及帧顶显式 `proactive:false` 的帧。判据是**结构化字段严格等于 true**,绝不是
// 「像不像主动消息」,因此普通消息不可能被误判。
{
reset();
const shown = installNotification("granted");
setDocument("hidden", false);
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
message: {
id: "im-normal-1",
sessionId: MAIN,
role: "assistant",
content: "普通 IM 回复",
platform: "telegram",
adapterId: "telegram:100",
turnId: "turn-abc",
createdAt: 1_760_000_600_000,
},
},
});
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
message: {
id: "im-normal-2",
sessionId: MAIN,
role: "user",
content: "在吗",
platform: "telegram",
turnId: "turn-abc",
createdAt: 1_760_000_600_001,
},
},
});
dispatchFrame({
type: "message_persisted",
sessionId: MAIN,
data: {
proactive: false,
message: {
id: "im-normal-3",
sessionId: MAIN,
role: "assistant",
content: "帧顶显式 false",
createdAt: 1_760_000_600_002,
},
},
});
same(shown.length, 0, "普通消息(没有结构化判据)绝不弹通知");
same(msgs().length, 3, "但三条都照常落气泡(气泡与判据无关)");
}
console.log(`proactive_message logic test: ${checks} checks passed`);
+14 -103
View File
@@ -2,11 +2,7 @@ import type { ChatMessage } from "../../types";
import { now, uid } from "../../util";
import { toRecord } from "../../wsParse";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import {
createSeenNotices,
decideProactiveNotice,
type NotifyPermission,
} from "../../notify/proactive";
import { notifyProactiveMessage } from "./proactive-notice";
import { frameText, type FrameEvent, type FramePayload, type StoreState } from "./shared";
/**
@@ -22,6 +18,11 @@ import { frameText, type FrameEvent, type FramePayload, type StoreState } from "
* ② **非前台才补通知**:见 `lib/notify/proactive.ts` 的裁定。前台不弹、同一条只打扰
* 一次、未授权就如实降级(只留气泡 + 一句可见说明),**绝不静默丢弃**。
*
* 提醒逻辑本身**不在本文件**:它与 IM 路由那条路(`message_persisted` 帧顶带
* `proactive:true`,见 `handlers/message.ts`)共用 `handlers/proactive-notice.ts` 的同一套
* 运行时 —— 「她该不该提醒我」因此与后端走了哪条路由无关,而同一条消息走两路也只会打扰
* 一次(去重表按落库行 id 共用)。
*
* 载荷畸形时也**不静默**:落到既有的「本端 WS 事件回显」可观测面(与未知帧同一去处)。
*
* 命中返回 true,未命中返回 false 交给下一组。
@@ -34,10 +35,17 @@ export function handleProactiveEvent(ev: FrameEvent, r: FramePayload, s: StoreSt
return true;
}
s.appendOrUpdateMessage(toChatMessage(payload));
notifyProactive(payload, s);
notifyProactiveMessage(payload.content, noticeKey(payload.id, payload.createdAt, payload.content), s);
return true;
}
/** 一条主动消息的提醒去重键:**落库行 id 优先**(两条帧路与握手回放/REST 拉取都用它
* 对齐同一条消息);落库失败(后端如实省略 id)时才退到「时间 + 正文」的本端键 ——
* 那种情况下没有权威 id 可用,但仍必须只打扰一次。 */
export function noticeKey(id: string, createdAt: number, content: string): string {
return id || `${createdAt}|${content}`;
}
/** 一帧主动消息的可用字段(已收窄)。 */
interface ProactiveMessage {
/** 主会话历史行的权威 id;后端落库失败时**省略**(本端那时只能造一个本地 id)。 */
@@ -90,103 +98,6 @@ function parseTimestamp(v: unknown): number | undefined {
return Number.isNaN(t) ? undefined : t;
}
/** 本页存活期间的「已提醒」表:同一条消息只打扰一次(含同一条既走实时帧、又被 IM
* 镜像回主会话这种同时到达的情形)。气泡不参与去重 —— 那是消息 store 按 id 的事。 */
const seenNotices = createSeenNotices(200);
/** 最近一次已经说明过的降级状态:通知没开是**状态**,不是每次推送都要报告一遍的事故。 */
let degradeNote: NotifyPermission | null = null;
/** 按 `lib/notify/proactive.ts` 的裁定决定要不要补一发系统通知,并把「同一条只打扰
* 一次」记进本页的去重表。 */
function notifyProactive(p: ProactiveMessage, s: StoreState): void {
const key = p.id || `${p.createdAt}|${p.content}`;
const permission = notifyPermission();
const decision = decideProactiveNotice({
seen: seenNotices.has(key),
visibility: documentVisibility(),
focused: documentFocused(),
permission,
});
// 无论结论如何都记下「这条已经露过面」:前台看过、或提醒过,都不该被第二次打扰。
seenNotices.add(key);
if (decision === "not-granted") {
noteDegrade(s, permission);
return;
}
if (decision !== "show") return;
if (!showSystemNotification(p.content, key)) {
// 构造通知本身失败(浏览器策略/权限收紧等)也算降级:留一句可见说明,
// 绝不让它冒泡进帧处理把气泡也带崩。
noteDegrade(s, notifyPermission());
}
}
/** 通知没开时留一句**可见说明**(同一状态只说一次,不刷屏):让主人知道「不是她没说,
* 是浏览器通知没开」,而不是以为主动消息根本没来。 */
function noteDegrade(s: StoreState, permission: NotifyPermission): void {
if (degradeNote === permission) return;
degradeNote = permission;
s.addDeviceUpdate(
permission === "denied"
? "浏览器通知未授权(已拒绝):主动消息只出现在气泡里,可在本站的站点设置里允许通知。"
: "浏览器通知尚未授权:主动消息只出现在气泡里,可在本站允许通知。"
);
}
/** `Notification` 构造器(不存在时 null)。**调用时**才解引用全局,因此纯 node
* 测试可以安全导入本模块(模块加载期不碰 DOM)。 */
type NotificationCtor = (new (title: string, options?: { body?: string; tag?: string }) => unknown) & {
permission?: string;
};
function notificationCtor(): NotificationCtor | null {
const g = globalThis as unknown as { Notification?: NotificationCtor };
return typeof g.Notification === "function" ? g.Notification : null;
}
/** 当前通知权限。运行时没有该 API ⇒ 归到 `"denied"`(拿不到通知就是没授权,
* 绝不假装已授权)。 */
function notifyPermission(): NotifyPermission {
const ctor = notificationCtor();
if (!ctor) return "denied";
if (ctor.permission === "granted") return "granted";
if (ctor.permission === "denied") return "denied";
return "default";
}
/** `document.visibilityState`;取不到时按 `"visible"`(= 不去打扰)。 */
function documentVisibility(): string {
const g = globalThis as unknown as { document?: { visibilityState?: string } };
const v = g.document?.visibilityState;
return typeof v === "string" ? v : "visible";
}
/** `document.hasFocus()`;取不到或抛错时按「已聚焦」处理(= 不去打扰)。 */
function documentFocused(): boolean {
const g = globalThis as unknown as { document?: { hasFocus?: () => boolean } };
const hasFocus = g.document?.hasFocus;
if (typeof hasFocus !== "function") return true;
try {
return hasFocus();
} catch {
return true;
}
}
/** 弹一发系统通知;失败(含构造抛错)返回 false,由调用方降级为「只留气泡 +
* 一句可见说明」。 */
function showSystemNotification(body: string, tag: string): boolean {
const ctor = notificationCtor();
if (!ctor) return false;
try {
new ctor("昔涟", { body, tag });
return true;
} catch {
return false;
}
}
/** 把无法解析的载荷转成一行可读文本(本端事件回显用)。 */
function rawText(v: unknown): string {
if (typeof v === "string") return v;