Rocket.Chat Apps 引擎:修复 App 操作按钮对房间级角色(owner/moderator/leader)的匹配失效问题
在 Rocket.Chat 的 Apps 引擎中,App 可以通过 when 过滤器声明操作按钮的可见性条件,其中包括基于角色(hasOneRole / hasAllRoles)的过滤。本次变更记录(见 apps-action-button-room-scoped-roles.md)针对的是一个隐蔽且影响面广的缺陷:被 Subscriptions 作用域限定的角色——内置的 owner、moderator、leader 或任意自定义房间角色——在按钮的角色检查中永远无法命中。根因是前端在执行角色查询时没有把当前房间作为检查的作用域(scope)传入,导致即便用户确实在该房间持有该角色,按钮也依旧保持隐藏状态。本文围绕该修复展开:先讲清楚 Rocket.Chat 角色作用域的两种模式,再深入 useApplyButtonFilters.ts 的源码实现,剖析修复后的完整过滤链路、自定义角色的名称解析机制,以及配套的测试用例边界。
问题背景:角色作用域与按钮 when 过滤器
Rocket.Chat 的角色(Role)按作用域分为两类,这是理解本缺陷的前提:
Users作用域:角色授予给用户本身,与房间无关。例如admin、user,用户在整个工作空间内持有。Subscriptions作用域:角色授予用户对某个房间(订阅)的关系,是“按房间”生效的。内置的owner(房间所有者)、moderator(房间管理员)、leader(团队负责人)都属于此类;管理员也可以创建同样作用域为Subscriptions的自定义角色(例如support-agent)并指定到具体房间。
App 在注册 UI 操作按钮时,可以声明 when 过滤器来精确控制按钮在什么条件下出现。该过滤器的类型定义见 IUIActionButtonDescriptor.ts:
export interface IUActionButtonWhen {
roomTypes?: Array<RoomTypeFilter>; // 房间类型过滤
messageActionContext?: Array<MessageActionContext>;
hasOnePermission?: Array<string>; // 至少持有一个权限
hasAllPermissions?: Array<string>; // 持有全部权限
hasOneRole?: Array<string>; // 至少持有一个角色(角色 id 或名称)
hasAllRoles?: Array<string>; // 持有全部角色(角色 id 或名称)
}
其中 hasOneRole / hasAllRoles 的官方注释明确说明了作用域语义:
Each entry is a role id or a role name. Prefer the name for a custom role, because its id differs between workspaces. A role scoped to
Subscriptions—owner,moderator,leader, or a custom one — is granted per room, so it matches only on surfaces bound to a room. On a surface with no room of its own, the user dropdown for instance, only roles scoped toUsersmatch.
即:Subscriptions 级角色“按房间授予”,只有在绑定到房间的界面上才可能命中;而在用户下拉菜单这类没有所属房间的界面上,只有 Users 作用域的角色能匹配。问题就出在前端此前并没有把“房间绑定”真正落实——角色查询缺少房间参数。
根因分析:缺少房间作用域的角色查询
修复后的核心实现位于 useApplyButtonFilters.ts。它导出两个 Hook:
useApplyButtonAuthFilter():单独应用when中的权限与角色过滤;useApplyButtonFilters():在房间上下文中组合“认证过滤 + 房间类型过滤 + 分类过滤”。
角色过滤的关键实现在 useApplyButtonAuthFilter 内部:
// The room is the scope of the role check: without it a role scoped to
// `Subscriptions` — `owner`, `moderator`, `leader`, or a custom one — can never match.
(button: IUIActionButton) =>
applyAuthFilter(button, room) && applyRoomFilter(button, room) && applyCategoryFilter(button, category),
applyAuthFilter 对四个 when 字段分别执行查询:
const hasAllRolesResult = hasAllRoles
? !!uid && hasAllRoles.every((role) => queryRole(resolveRoleId(role), room?._id)[1]())
: true;
const hasOneRoleResult = hasOneRole
? !!uid && hasOneRole.some((role) => queryRole(resolveRoleId(role), room?._id)[1]())
: true;
三个要点:
queryRole(roleId, room?._id)的第二个参数就是修复本身。角色查询函数的签名定义在 AuthorizationContext.ts:queryRole(role, scope?: IRoom['_id'])。修复前调用侧没有传scope,查询退化为“工作空间级”判断——owner这类按房间授予的角色自然查不到,按钮恒被过滤。resolveRoleId(role)负责把 App 声明的角色名称解析为内部 id。App 只知道自定义角色的名字,而内部检查按IRole._id匹配(详见下文)。!!uid守卫:未登录(无用户 id)时任何角色过滤都直接判否,与权限过滤保持一致。
useApplyButtonFilters 则要求调用方必须位于房间上下文内,否则直接抛错,这从 API 层面保证了房间必然可用:
export const useApplyButtonFilters = (category = 'default') => {
const room = useRoom();
if (!room) {
throw new Error('useApplyButtonFilters must be used inside a room context');
}
...
};
同一文件中的房间类型过滤 applyRoomFilter 依据 RoomTypeFilter 枚举(public_channel、private_channel、direct、livechat 等 9 种)映射到房间类型判断函数,与认证过滤一起构成三重与(AND)关系——按钮只有在认证、房间类型、分类三项全部通过时才显示。
自定义角色的名称解析:useRoleIdResolver
内置角色以 _id === name 入库(例如 admin 的 id 就是字符串 admin),但自定义角色会分配一个随机 id,其名称与 id 不一致。App 声明按钮过滤器时只能知道角色名,因此需要一个“名称 → id”的解析层,即 useRoleIdResolver.ts:
return useCallback(
(role: string) => (roles.has(role) ? role : (idsByName.get(role) ?? role)),
[idsByName, roles],
);
解析优先级与容错策略(源码注释与实现一致):
- 已知 id 优先:如果传入值本身就是某个角色的
_id,直接返回,保证既有按 id 传入的调用方行为不受影响; - 按名称回退:通过
name → _id索引映射;由于roles.create/roles.update会拒绝重名,正常情况下一个名称最多映射到一个角色;若数据库中意外存在重复名称,取迭代到的第一个,避免后续新增重复项静默改变所有检查的结果指向; - 未知值原样透传:解析不出来的值不做转换,让
queryRole自身以“查不到”的方式失败——即保持检查原有的失败模式,不引入新的异常路径。
测试边界:从单测看修复的验证矩阵
配套测试 useApplyButtonFilters.spec.ts 使用 mock-providers 中的 mockAppRoot() 构建用户、角色、权限的仿真环境,覆盖了以下关键场景:
角色基础过滤(useApplyButtonAuthFilter):
hasAllRoles: ['admin']时,仅持有user角色的用户按钮被过滤;持有admin则显示;hasOneRole: ['admin', 'moderator']时,持有其中任一即显示,否则过滤;- 匿名(未登录)用户在声明角色要求时恒被过滤;
- 无
when过滤条件的按钮对任何已登录用户显示。
自定义角色按名称解析:
- 用户持有随机 id(如
aBcDeF1234567890x)的Support Agent角色,按钮按名称Support Agent声明时可正确显示; - 按 id 声明同样命中;名称未知(
No Such Role)时过滤; - id 与名称冲突场景:构造一个名称恰好等于另一角色 id 的“诱饵”角色,验证解析时 id 优先,不会误显示按钮。
权限过滤与组合:
hasAllPermissions要求全部持有、hasOnePermission要求至少一个,语义分别为every/some;- 角色与权限条件同时声明时按 AND 逻辑叠加:任一不满足即隐藏。
房间作用域角色(本缺陷的直接回归测试):
| 场景 | 预期 |
|---|---|
用户在给定房间持有 owner(withRoleScoped('owner', room._id)),检查传入该房间 |
显示 |
| 同上,但检查不传房间 | 过滤(修复前的行为) |
用户在另一个房间持有 owner,检查传入当前房间 |
过滤 |
在房间上下文中调用 useApplyButtonFilters(),用户在该房间持有 owner |
显示 |
| 同上,但角色授自其他房间 | 过滤 |
工作空间级角色(admin)在房间内 |
仍然显示(作用域角色与全局角色可共存) |
其中“检查不传房间即过滤”与“跨房间不命中”两条用例,正是对缺陷根因的精确刻画:Subscriptions 级角色必须携带正确的 room._id 才能通过检查。
对 App 开发者的实践意义
结合本次修复,App 开发者在为操作按钮声明 when 过滤器时需注意:
- 在房间绑定界面上(消息输入框操作栏、房间操作菜单、消息工具条等,对应
UIActionButtonContext.ROOM_ACTION等上下文),hasOneRole/hasAllRoles可以同时匹配Users级与Subscriptions级角色。例如为房间owner显示专属按钮、为团队leader显示管理入口,现在都能正确命中。 - 在无房间的界面(如用户下拉菜单),只有
Users作用域的角色可以匹配;owner这类角色在此处声明不会产生效果,应改用权限过滤或调整按钮所在上下文。 - 自定义角色优先使用名称声明。自定义角色的
_id是随机的、随工作空间变化,名称才是跨工作空间稳定的引用;解析层保证名称与 id 两种写法都能命中,且 id 优先。 - 房间类型过滤(
roomTypes)与认证过滤是 AND 关系,可组合出“仅在公开频道中、由 moderator 可见”这类精细条件。
小结
这条变更记录看似只有一句话,却触及了 Rocket.Chat Apps 引擎 UI 权限模型的一个关键细节:角色检查的作用域语义。修复通过 useApplyButtonFilters.ts 在角色查询中传入 room._id,让 owner、moderator、leader 及自定义房间角色终于在 App 操作按钮中按预期生效;useRoleIdResolver.ts 的名称解析与 AuthorizationContext.ts 中 queryRole(role, scope?) 的接口设计,共同支撑了“App 只知角色名、检查按内部 id 与房间作用域执行”的完整链路。useApplyButtonFilters.spec.ts 的回归测试则把“无房间即过滤”“跨房间不命中”“全局角色在房间仍生效”等边界全部固定下来,为后续改动提供了可靠的验证基线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00