外观
通知、标题与分段消息
ctx.notifications 可以按玩家姓名准备通知、屏幕标题、副标题和快捷栏提示,也能把长消息拆成逐段等待回执的计划。所有发送均使用 framework.command.execute 权限;没有单独的 notifications.* 权限。
发送需要有效游戏连接。收件人必须来自当前已认证玩家名单;SDK 不负责判断这个玩家是否有资格领取奖励或查看私密消息。
长文本计划
text
ctx.notifications.prepare(name, text, *, idempotency_key, deadline,
max_command_bytes=320) -> NotificationPlan这是同步、无 IO 的准备方法。name 是当前名册中的玩家姓名,非空、最多 128 字符,UTF-8 编码不超过 256 字节。prepare 调用 player_target,统一拒绝前导 !、U+0000–U+001F、DEL(U+007F)、反斜杠和双引号;无效姓名抛出 ValueError("unsafe notification recipient")。该检查只保证姓名能安全用于命令目标,调用方仍需从当前已认证玩家名册取得姓名,并核对业务权限。不要把选择器或命令片段作为姓名传入。text 必须是可编码为 UTF-8 的字符串,空文本也会产生一段。key 和固定 deadline 使用动作意图的格式。
max_command_bytes 为正整数,预算计入完整 tellraw 命令的 UTF-8 字节,包括姓名、选择器、JSON 转义、正文和颜色延续。默认 320 是运行策略,不是测得的协议上限。若预算连收件人或一个格式对都容不下,整个准备失败,不会发送部分消息。
python
player = await ctx.resolve_player(uuid=player_uuid)
plan = ctx.notifications.prepare(
player["name"], "§a使用说明\n§r输入 !help 查看命令。",
idempotency_key=business_key,
deadline=fixed_deadline,
)片段在已激活回调中运行,player_uuid、business_key 与 fixed_deadline 由业务提供;解析玩家还需 permissions.players: ["list"]。本段只准备计划,下一节介绍保存与发送。prepare 已执行命令目标语法校验,但不会查询当前名册,也不会判断收件人的业务权限;这些仍由调用方核对。
NotificationPlan 与 NotificationPart
从 neomega_runtime.notifications 导入这两个不可变 dataclass:
| 类型 / 成员 | 含义 |
|---|---|
NotificationPlan.parts | 非空 tuple[NotificationPart, ...],顺序固定。 |
NotificationPlan.business_key | 原始业务键。由 prepare 创建时即为 idempotency_key。 |
NotificationPlan.payload_digest | 完整有序意图的 SHA-256,覆盖收件人、正文、会话、顺序和固定期限。它不是操作键。 |
NotificationPart.text / .prefix | 原文片段 / 从上一段延续的样式前缀。所有 .text 拼接等于原文。 |
NotificationPart.command / .intent | 完整 tellraw 命令 / OperationIntent,可读 .payload() 保存。 |
拆分优先保留整行,超长行按 Unicode 码点切分;§ 与其后的格式字符不会拆开。新段延续活动颜色及 §k/§l/§o,§r 清空样式。不保证组合字符簇或 emoji 序列完整,也不保证多条聊天气泡视觉上等同一条长消息。
分段操作键由业务键和段序号确定,不包含正文摘要。逐段幂等不等于整条消息幂等:给原文追加一段会产生新段键,所以插件必须先原子绑定 business_key -> payload_digest,发现同键不同摘要就拒绝。
发送与持久 journal
text
async ctx.notifications.send(plan, *, journal, timeout=30)journal 是一个接受完整快照的异步回调,必须在返回前持久化成功。timeout 是每段 submit 与 wait 各自的正数秒预算,不是整条计划共享的总预算。返回报告为普通字典,没有单独的 TypedDict 类型:
text
{
state: "pending" | "partial" | "succeeded",
business_key: str,
payload_digest: str,
parts: [{
intent: dict,
state: "not_submitted" | "submitting" | "admitted" | "rejected" |
"succeeded" | "failed" | "cancelled" | "unknown",
operation_id: str | None,
receipt: OperationReceipt | None,
error_type?: str
}]
}每段按顺序执行:先保存 submitting 和原意图,再单次提交,受理后保存 ID,终态后保存完整回执。只有本段 succeeded 才发送下一段。明确提交拒绝标记 rejected;提交或等待异常标记 unknown,整体返回 partial,后续段维持 not_submitted。
一次性业务准入示例
下面函数可放进插件模块。调用方传入已经准备的 plan;它用状态事务先独占业务键,再让 journal 保存每步快照。已有键一律转入核对,不恢复发送。
python
import hashlib
async def send_once(ctx, plan):
key = "notification/" + hashlib.sha256(
plan.business_key.encode("utf-8")
).hexdigest()
tx = await ctx.storage.transaction(keys=[key])
if tx.get(key) is not None:
raise RuntimeError("通知已有记录,需核对原计划")
tx.set(key, {
"business_key": plan.business_key,
"payload_digest": plan.payload_digest,
"state": "prepared",
"intents": [part.intent.payload() for part in plan.parts],
})
await tx.save()
async def journal(snapshot):
tx = await ctx.storage.transaction(keys=[key])
previous = tx.get(key)
if (previous is None
or previous["payload_digest"] != snapshot["payload_digest"]):
raise RuntimeError("通知业务键与原计划不一致")
tx.set(key, snapshot)
await tx.save()
return await ctx.notifications.send(plan, journal=journal)此示例额外依赖选择性状态读取,状态冲突直接失败,不重跑通知。首次保存回应不确定时不开始发送;journal 失败时停止当前发送,保留原记录进行核对。业务键只应分配给一份通知,不得先删除记录来重复调用。
中断与恢复边界
send 不读取 journal,也不提供恢复 API。调用被取消或 journal 抛错时异常直接传播,已经提交的段仍可能执行。持久记录为 submitting 且 ID 为空时,表示受理可能已经发生,不能当作未发送。
恢复时读取原计划、原 ID 和原期限,查询已知 ID 的回执;无 ID 的受理不确定状态需要按原意图核对。不要再次调用 send(plan) 恢复,也不要重新 prepare、延长期限或更换业务键。 若需要按业务规则继续尚未提交的段,应另行设计持久状态机,先证明旧段结果与准入状态。
将全部分段意图一次加入 tx.action 会同时登记所有段,并不具有 send 的“前一段失败就不发后续段”语义。succeeded 只证明各命令动作回执成功,不证明玩家客户端显示或阅读了文字。
title、subtitle 与 actionbar
text
ctx.notifications.title(name: str, text: str, *,
idempotency_key: str, deadline: str) -> OperationIntent
ctx.notifications.subtitle(name: str, text: str, *,
idempotency_key: str, deadline: str) -> OperationIntent
ctx.notifications.actionbar(name: str, text: str, *,
idempotency_key: str, deadline: str) -> OperationIntent这些同步方法只生成一个 titleraw 意图,固定 identity="ws"、wait=True;不会自动分段或提交。完整命令最多 32 KiB UTF-8,超限抛 ValueError。这些方法会调用 player_target 生成姓名选择器,与 prepare 一样拒绝前导 !、DEL 等不安全字符。下面的 current_player_name 应来自已核对的当前玩家名单。
python
intent = ctx.notifications.actionbar(
current_player_name, "§a任务已登记",
idempotency_key=business_key, deadline=fixed_deadline,
)
# 与当前事件状态和 ACK 原子登记;随后仍需核对动作终态。
tx = await event.transaction()
tx.action(intent)
receipt = await tx.save()片段用于事件处理器,变量来自已核验业务上下文。subtitle 只设置副标题文字,通常需要另发 title 才显示;这几个方法不提供淡入淡出时长配置。
与其他动作组合
标题方法返回普通 OperationIntent,可与 ctx.player_actions.playsound(...) 一起加入事件事务。工具包的 examples/native-feedback 提供完整的 !feedback 示例:先保存固定意图,再接纳标题与声音,并分别查询原始回执。用法见标题与声音示例。两个动作一起登记不保证同时执行;unknown 不触发重放。
常见错误
| 现象 | 处理 |
|---|---|
ValueError:收件人不安全或预算不足 | 在准备阶段修正姓名来源、换行或预算;此时还未发送。 |
| 权限拒绝 | 审阅 framework.command.execute 的申请和授权,不能通过直发包绕过。 |
报告 partial | 保存完整报告,先核对已提交段,不从第一段重新发送。 |
| journal 写入失败 | 当前调用停止;恢复从最后可靠快照开始,不能假定失败前无命令受理。 |
| 回执成功但玩家没看到 | 核对当前玩家、连接及客户端显示,不把命令成功当作用户已读。 |