feat(notify): IM 路由的主动消息也在非前台补浏览器通知(契约 v0.74)
主人裁定 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:
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`);
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user