Rocket.Chat Apps 引擎:动作按钮的角色过滤 now 支持按角色名(hasOneRole / hasAllRoles)
本篇基于仓库中的变更集 .changeset/app-action-button-role-names.md 展开:Rocket.Chat 的 Apps 引擎在应用动作按钮(app action button)的 when 过滤条件中,让 hasOneRole 与 hasAllRoles 两个过滤器除了接受角色 ID 之外,新增接受角色名(role name)。你将理解这一变更涉及哪两个包(@rocket.chat/apps-engine、@rocket.chat/meteor,均为 minor 级更新)、为什么自定义角色必须用名字而非 ID 来匹配、客户端如何在渲染时完成"名字 → ID"解析,以及"按房间作用域授予的角色"(owner、moderator、leader 等)在哪些界面才能命中。
一、变更集说了什么
.changeset/app-action-button-role-names.md 的完整内容如下:
---
'@rocket.chat/apps-engine': minor
'@rocket.chat/meteor': minor
---
Accepts a role name in the `when.hasOneRole` and `when.hasAllRoles` filters of an app action button
要点拆解:
- 变更语义:应用动作按钮的
when.hasOneRole(持有列表中任意一个角色即显示按钮)与when.hasAllRoles(必须持有列表中全部角色才显示按钮)过滤器,条目现在既可以是角色 ID,也可以是角色名。 - 涉及包:
@rocket.chat/apps-engine(minor)——负责动作按钮描述符的类型定义,即IUActionButtonWhen接口的文档与约束更新;@rocket.chat/meteor(minor)——负责 Web 客户端在渲染按钮时的实际过滤逻辑更新。
- 兼容性:两个包均为 minor 版本提升,说明这是向后兼容的增量能力——原来用角色 ID 写法的 App 不受影响,新写法只是拓宽了可接受的取值。
二、类型定义层:IUActionButtonWhen 的角色过滤器
变更落在 IUIActionButtonDescriptor.ts 中的 IUActionButtonWhen 接口。与角色相关的两个字段及其官方注释(JSDoc)如下:
export interface IUActionButtonWhen {
roomTypes?: Array<RoomTypeFilter>;
messageActionContext?: Array<MessageActionContext>;
hasOnePermission?: Array<string>;
hasAllPermissions?: Array<string>;
/**
* Show the button when the user holds at least one of these roles.
*
* 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 to `Users` match.
*/
hasOneRole?: Array<string>;
/**
* Show the button when the user holds every one of these roles.
* …(语义同上,要求全部命中)
*/
hasAllRoles?: Array<string>;
}
注释明确了三条关键规则:
- 每个条目可以是角色 ID 或角色名;对自定义角色推荐用名字,因为同一个自定义角色在不同工作空间(workspace)里的
_id不同,而名字才是可移植的标识。 - 作用域为
Subscriptions的角色按房间授予——例如owner、moderator、leader以及自定义的按房间角色,它们只对"绑定到某个房间"的界面生效。 - 没有房间上下文的界面(典型如用户下拉菜单 user dropdown)只能匹配作用域为
Users的工作空间级角色。
外层按钮描述符 IUIActionButtonDescriptor 则通过可选的 when 字段挂接这些过滤条件,并带有 actionId、context、labelI18n、variant、category 等字段;运行时由 App 注入 appId 后成为 IUIActionButton。
三、客户端实现:useApplyButtonAuthFilter 如何解析角色名
实际过滤发生在 Meteor 客户端的 useApplyButtonFilters.ts。核心是 useApplyButtonAuthFilter,它从 AuthorizationContext 取出权限查询函数与 useRoleIdResolver,对按钮执行四组检查并按 AND 逻辑聚合:
export const useApplyButtonAuthFilter = (): ((button: IUIActionButton, room?: IRoom) => boolean) => {
const uid = useUserId();
const { queryAllPermissions, queryAtLeastOnePermission, queryRole } = useContext(AuthorizationContext);
// An app knows the name of a custom role, not the random id the workspace gave it,
// so accept either form in the role filters.
const resolveRoleId = useRoleIdResolver();
return useCallback(
(button: IUIActionButton, room?: IRoom) => {
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};
const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true;
const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true;
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;
return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult;
},
[queryAllPermissions, queryAtLeastOnePermission, queryRole, resolveRoleId, uid],
);
};
逐行看与本次变更直接相关的部分:
hasAllRoles使用Array.prototype.every,hasOneRole使用Array.prototype.some,分别对应"全部命中"与"任一命中"的语义;未配置过滤器时默认放行(true)。!!uid前置判断保证了未登录用户只要按钮声明了角色要求,就一律被过滤。- 每个角色条目在进入
queryRole之前先经过resolveRoleId(role)解析——这正是"接受角色名"能力的落点;room?._id作为第二个参数传入,把按房间授予的角色检查限定在当前房间内。
同一文件中的 useApplyButtonFilters 则在房间上下文中组合三类过滤器(权限/角色、房间类型 roomTypes、category),源码中的注释同样点明了作用域规则:
export const useApplyButtonFilters = (category = 'default'): ((button: IUIActionButton) => boolean) => {
const room = useRoom();
if (!room) {
throw new Error('useApplyButtonFilters must be used inside a room context');
}
const applyAuthFilter = useApplyButtonAuthFilter();
return useCallback(
// 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, category, room],
);
};
其中 applyRoomFilter 通过 enumToFilter 把 RoomTypeFilter 枚举映射到对 IRoom 的判断函数(公开/私有频道、团队、讨论、私信、Live Chat 等),与角色过滤互为 AND 条件。
四、名字到 ID 的解析:useRoleIdResolver
"条目既可以是 ID 也可以是名字"之所以成立,依赖 useRoleIdResolver.ts(由 ui-contexts 包导出)。其实现:
export const useRoleIdResolver = (): ((role: string) => IRole['_id']) => {
const { getRoles, subscribeToRoles } = useContext(AuthorizationContext);
const roles = useSyncExternalStore(subscribeToRoles, getRoles);
const idsByName = useMemo(() => {
const index = new Map<IRole['name'], IRole['_id']>();
for (const role of roles.values()) {
// `roles.create` and `roles.update` reject a name already taken by another role, so a
// name maps to at most one role. Should duplicates still exist (e.g. written straight
// to the database), the first one iterated wins instead of the last, so adding another
// duplicate later does not silently repoint every check that names it.
if (index.has(role.name)) {
continue;
}
index.set(role.name, role._id);
}
return index;
}, [roles]);
return useCallback((role: string) => (roles.has(role) ? role : (idsByName.get(role) ?? role)), [idsByName, roles]);
};
三个值得注意的设计决策:
- ID 优先:如果传入字符串本身已是某角色的
_id(roles.has(role)),直接原样返回;否则再查"名字 → ID"索引;查不到则原样返回(后续queryRole会判定为不匹配,按钮被过滤,而不是抛错)。 - 名字唯一性依赖服务端约束:注释指出
roles.create/roles.update会拒绝已被占用的名字,因此名字最多映射到一个角色;即便数据库里出现重复,索引也采用"先遇到的名字胜"策略,避免后来者静默改变所有按名检查的指向。 - 响应式订阅:通过
useSyncExternalStore订阅角色集合,角色增删后索引自动重建,过滤器结果随之更新。
五、测试用例:行为边界的可验证依据
useApplyButtonFilters.spec.ts 用 mockAppRoot() 覆盖了完整的角色过滤矩阵,与上述实现一一对应:
- 基础过滤:用户持有
admin时hasAllRoles: ['admin']通过;仅有user角色时不通过;hasOneRole: ['admin', 'moderator']命中moderator即通过,两个都不持有则不通过;未声明角色过滤器的按钮默认显示;匿名(未登录)用户遇到角色要求一律不通过。 - 自定义角色名解析:以
{ _id: 'aBcDeF1234567890x', name: 'Support Agent' }模拟自定义角色,验证——按名字(Support Agent)要求能命中持有该角色 ID 的用户;按 ID 要求同样命中(向下兼容);要求一个不存在的名字No Such Role时被过滤。 - ID 与重名冲突:构造一个"陷阱角色",其
name恰好等于另一角色的_id,且用户只持有陷阱角色——测试断言按该 ID 要求时不显示按钮,证明解析器"ID 优先"的规则不会因名字冲突误放行。 - 房间作用域角色:
owner按Subscriptions作用域授予到某房间后——传入该房间时hasOneRole: ['owner']通过;不传房间(如用户下拉)时被过滤;角色授予在别的房间时,在当前房间也被过滤;而工作空间级admin角色在房间上下文中依然能命中。 - 组合过滤:
hasAllRoles与hasAllPermissions同时存在时按 AND 逻辑叠加,全部满足才显示。
这些用例共同划定了功能边界:角色名解析只影响"按什么键查角色",不改变按房间作用域判定的既有语义。
六、对 App 开发者的实际影响
在应用的动作按钮描述符里,现在可以这样按名字约束按钮的可见性(示意,字段取值遵循 IUIActionButtonDescriptor.ts 定义):
const button: IUIActionButtonDescriptor = {
actionId: 'close-support-ticket',
context: UIActionButtonContext.ROOM_ACTION,
labelI18n: 'close_ticket',
when: {
// 任一命中即显示;对自定义角色推荐使用名字而非 ID
hasOneRole: ['Support Agent', 'moderator'],
},
};
使用要点:
- 自定义角色优先写名字:自定义角色的
_id在不同工作空间各不相同,用名字可保证同一份 App 代码在多实例间可移植;系统内置角色(admin等)名字与 ID 一致,两种写法等效。 hasOneRole与hasAllRoles语义别混用:前者是some(任一命中),后者是every(全部命中),对应按钮"至少一个角色可见"和"必须同时具备多个角色"两类需求。- 房间作用域角色的界面限制:
owner、moderator、leader及自定义的按房间角色,只会在绑定房间的界面(如房间工具栏动作)命中;在用户下拉这类无房间上下文的表面,只有Users作用域的角色能命中。这是由 useApplyButtonFilters.ts 中把room?._id透传给queryRole的机制保证的。 - 未登录与未知名字均安全失败:未登录用户、以及解析不到任何角色的名字,都会让按钮被过滤,行为与"按 ID 要求一个不存在的 ID"一致。
七、小结
这条变更集对应的工作由三处源码支撑:类型层在 IUIActionButtonDescriptor.ts 中把两个角色过滤器声明为"ID 或名字";解析层在 useRoleIdResolver.ts 中以"ID 优先、名字索引、响应式重建"完成名字到 ID 的归一;执行层在 useApplyButtonFilters.ts 中把归一后的角色 ID 带入带房间作用域的 queryRole 检查。useApplyButtonFilters.spec.ts 中的测试矩阵(含名字/ID 双写、重名冲突、房间作用域)则给出了这一能力的可验证行为边界。对 App 作者而言,最直接的价值是:自定义角色第一次可以以跨工作空间可移植的名字形式,出现在动作按钮的可见性条件里。
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 StartedRust0623
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