用途
Internet Identity Notifications 用于让已登录 Internet Identity 的用户在应用关闭时仍收到来自 canister 的 Web Push 通知。推送订阅由 II 端到端持有,因此应用侧没有推送服务、没有 VAPID 密钥,也没有设备令牌。
前置条件
需先加载 internet-identity 技能。其中两项是本技能的基础:发送方列表由应用的主源提供,该源与读取 ii-app-metadata 的源相同;而该元数据决定应用在自身通知上显示的名称。同一源派生的所有应用共享一份发送方列表,并共享 10 个 canister 的上限。
主要能力
通知链路由三部分组成,缺任何一项都会表现为静默而非报错:
- 前端通过 II 请求用户同意。
- 源在 /.well-known/ii-notification-senders 声明发送方 canister。
- canister 通过 identity-notifications 库发送。
使用方式
1. 前端征得同意
需要 @icp-sdk/auth 11.1.0 或更高版本,更早的 11.x 没有这些方法。
- requestNotificationConsent():会打开 II,不需要已有会话(必要时会顺带登录),返回 { granted }。
- getNotificationConsentStatus():不打开 II,需要会话,返回未认证查询结果 { granted }。
- revokeNotificationConsent():不打开 II,需要会话,无返回值,幂等。
三个调用都返回记录对象而不是布尔值。granted: false 表示用户拒绝,应正常渲染,不应抛错。已持有同意时再次请求会直接返回 { granted: true } 而不显示界面。getNotificationConsentStatus 是查询,结果未认证,可用于渲染开关,但不要据此做权限门槛。
同意必须从点击触发,因为仪式需要渲染,不能在 prompt: 'none' 下运行。同意记录在身份上,用户作答的浏览器即为接收设备;要在第二台机器接收需在该机器再次作答。
可在登录按钮中同时发起登录与同意请求:
const [identity, { granted }] = await Promise.all([ authClient.signIn(), authClient.requestNotificationConsent(), ]);
后续想提供开关时,可用 getNotificationConsentStatus() 显示状态,用 revokeNotificationConsent() 撤销。撤销仅作用于本应用,会清除本源同意及该用户仍排队的通知,不影响其设备订阅或其他应用。因此从自己的接收者表中删除某人并不会撤销任何东西,撤销也不等于退订。
需分支处理的错误:InteractionRequiredError(客户端以 prompt: 'none' 构建,需从点击再次请求)、NotificationsUnsupportedError(该 II 部署不支持通知,应隐藏控件)、SessionGoneError(无活跃会话,仅状态与撤销会出现,需重新登录)。
2. 发送方文档
从主源提供,与 /.well-known/ii-app-metadata 同源:
{ "senders": ["ryjl3-tyaaa-aaaaa-aaaba-cai"] }
最多 10 个 canister,超长列表整体被拒,不会截断。II 会缓存所读内容,新增 canister 不会立即生效,移除后也会在一段时间内继续可用。若该文档未列出发送 canister,所有发送都会被拒绝,这是“什么也没到、什么也没报错”的最常见原因。
3. 发送
库为 identity-notifications,两种语言发送方式相同但接线不同。Rust 在 crates.io,Motoko 在 Mops(需 moc 2.0.0 或更高)。
Rust 需要 endpoints!() 以及三个稳定 MemoryId 传给 init,并在 post_upgrade 中再次调用 init。Motoko 两者都不需要:mixin 自带端点并维护自身状态,升级时无需操作,也不要让 Motoko 用户传 memory id。
Rust 示例:
identity-notifications = "0.1"
use identity_notifications as notifications; use identity_notifications::{Memories, Notification};
notifications::endpoints!();
#[init] fn init() { init_notifications(); } #[post_upgrade] fn post_upgrade() { init_notifications(); }
fn init_notifications() { MEMORIES.with_borrow(|manager| { notifications::init(manager, Memories { entries: MemoryId::new(10), content: MemoryId::new(11), metrics: MemoryId::new(12), }) }); }
notifications::send(to, Notification { title: "New message".into(), body: text, url: Some(format!("https://chat.example.com/chats/{from}")), key: Some(format!("chat-{from}")), ..Default::default() });
Motoko 示例:
import Notifications "mo:identity-notifications";
actor { include Notifications<system>();
notifications.send<system>(to, { title = "New message"; body = text; url = ?("https://chat.example.com/chats/" # caller.toText()); key = ?("chat-" # caller.toText()); urgency = null; expiresAt = null; funnel = null; }) }
配置通过环境变量而非 init 参数:
canisters:
- name: backend settings: environment_variables: notification_sender: "rdmx6-jaaaa-aaaaa-aaadq-cai" notification_origin: "https://your-app.icp0.io" notification_capacity_mb: "1024"
可选字段
- url:默认无,点击后的跳转位置,必须是发送源或其备用源之一,否则 II 拒绝。
- key:默认无,表示通知主题。同一 key 的第二次发送会更新该通知而非新增,也是 dismiss(recipient, key) 依据。id 为 hash(recipient, key),key 只对应该接收者自己的条目,并从通知主题派生而非调用方任意传入。
- urgency:默认 Normal,可选 VeryLow、Low、Normal、High,用于在源的其他流量中排队,不是渲染提示。
- expiresAt / expires_at:默认为较短窗口,表示该通知仍值得发送的时间。默认较短,重要性高的需显式设更长;库不会限制更长的值。
- funnel:默认无,用于统计所属漏斗,宜按活动或类型而非每次发送设置标签。
发送策略示例
- 安全警报、新登录:High + 数天,优先且晚看仍有效。
- 直接消息:Normal + 不设,现在有意义,很久之后无意义。
- “司机已到”:High + 数分钟,优先但过期即废。
- 全量活动:VeryLow + 数天,避免挤占真实通知,但每条仍应送达,并配 funnel 以便读取转化率。
- 摘要、提醒:Low + 数小时,让位于其他,昨天的无意义。
短过期不会让通知变紧急,High 也不会延长有效期。全部设为 High 会失去排序意义。
metrics() 按小时报告流水线情况,是查看通知去向的唯一途径,因为 send 不返回任何内容。
适用场景与边界
应用只能按用户寻址,无法更细。II 为每个已注册身份按浏览器维护队列,一次发送可同时出现在笔记本和手机上。通知如何送达由 II 与用户管理,应用不应假设。
展示内容由 II 组织:用户先看到应用名称,再看到 canister 发送的标题。名称来自 /.well-known/ii-app-metadata,未发布时回退到源主机名,因此不要把应用名写进 title。
发送不会让所在调用失败,send 不 trap、不 await,无法通知到的接收者不会给端点带来代价,也没有可分支的返回值,结果只在 metrics() 中体现。
iOS 需将 II 添加到主屏幕才能使用 Web Push,同意仪式会引导用户完成,应用无需处理。
升级
Motoko:无需操作,没有 post_upgrade 调用,也无需回传 id。
Rust:必须在 post_upgrade 中再次调用 init。升级不会保留定时器,若不调用,已排队内容会一直排队而永不发送;这不是可选操作,也不是空操作。每次都要传相同的 MemoryId,运行中重新编号会让库指向空内存,导致排队内容丢失。