snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
This commit is contained in:
116
docs/features/ban-user.md
Normal file
116
docs/features/ban-user.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Ban User
|
||||
|
||||
## Overview
|
||||
|
||||
Banning prevents a user from participating in a specific room. Unlike kicking (which deletes the subscription), banning **keeps the subscription record** with `status: 'BANNED'`, creating a persistent access barrier.
|
||||
|
||||
## Ban Flow
|
||||
|
||||
1. A user with `ban-user` permission (roles: `admin`, `owner`, `moderator`) triggers the ban via UI, API (`POST /v1/rooms.banUser`), or slash command (`/ban @username`).
|
||||
2. **Validations** (`banUserFromRoomMethod` in `server/lib/banUserFromRoom.ts`):
|
||||
- Checks `ban-user` permission scoped to the room.
|
||||
- Checks if the room type allows the action (via `roomDirectives.allowMemberAction`).
|
||||
- Checks if the banning user has access to the room.
|
||||
- Checks if the target user exists and is in the room.
|
||||
- Rejects ban if the target is already banned.
|
||||
- Rejects ban if the target is the last owner.
|
||||
3. **Execution** (`performUserBan` in `app/lib/server/functions/banUserFromRoom.ts`):
|
||||
- Updates the subscription to `status: 'BANNED'` (does not delete the record).
|
||||
- Removes the room from the user's `__rooms` array.
|
||||
- Decrements the room's `usersCount`.
|
||||
- Removes room-scoped roles (`moderator`, `owner`, `leader`) in channels and groups.
|
||||
- If the room is a team's main room, removes the member from the team.
|
||||
- Saves a `user-banned` system message.
|
||||
- Notifies the client with a `removed` event on the subscription (so the client drops the stream).
|
||||
4. **Callback** `afterBanFromRoom` fires (used by Matrix federation to propagate the ban).
|
||||
|
||||
## Unban Flow
|
||||
|
||||
1. Triggered via UI (contextual bar "Banned Users"), API (`POST /v1/rooms.unbanUser`), or slash command (`/unban @username`).
|
||||
2. Finds the subscription via `findOneBannedSubscription`.
|
||||
3. **Removes the subscription entirely** (`Subscriptions.removeById`) — does not restore it to active status.
|
||||
4. Saves a `user-unbanned` system message.
|
||||
5. **Callback** `afterUnbanFromRoom` fires (federation).
|
||||
|
||||
**Important:** after unban the user **does not become a member** of the room again. The banned subscription is deleted. The user must be invited or join again.
|
||||
|
||||
## Join / Invite / Re-entry Behavior
|
||||
|
||||
A banned user **cannot** re-enter the room through any path. The ban must be explicitly lifted first. Below is how each entry point enforces this for both normal and federated rooms.
|
||||
|
||||
### Invite via API / UI (`groups.invite`, `channels.invite`, "Add Users")
|
||||
|
||||
`addUsersToRoom` checks for a `BANNED` subscription before calling `addUserToRoom`:
|
||||
- Returns `error-user-is-banned` — the invite is rejected.
|
||||
- The UI shows a warning modal asking the admin to unban first.
|
||||
- Applies equally to normal and federated rooms (the check is in the method layer, before the room-type branch).
|
||||
|
||||
### Invite link (`useInviteToken`)
|
||||
|
||||
`useInviteToken` checks for a `BANNED` subscription before saving the invite token or calling `addUserToRoom`:
|
||||
- Returns `error-user-is-banned` — the token is not consumed.
|
||||
- Because the check runs before `Users.updateInviteToken`, the secondary path through `setUsername` (for users who register via invite link) is also blocked.
|
||||
|
||||
### Direct join (`channels.join`, `joinRoom`)
|
||||
|
||||
`Room.join` calls `canAccessRoom` before `addUserToRoom`:
|
||||
- For **public rooms** and **public rooms inside teams**, the `canAccessRoom` validators explicitly check `findOneBannedSubscription` and deny access.
|
||||
- For **private rooms**, `countByRoomIdAndUserId` excludes `BANNED` subscriptions (`status: { $exists: false }`), so the "already joined" validator returns false and access is denied.
|
||||
|
||||
### Federation invite events
|
||||
|
||||
When a Matrix homeserver sends an invite for a user who is banned locally:
|
||||
- `handleInvite` in `federation-matrix/src/events/member.ts` finds the existing (banned) subscription and returns early without creating a new one.
|
||||
- The user never receives an `INVITED` subscription, so `handleJoin` is never reached.
|
||||
|
||||
### Expected flow
|
||||
|
||||
1. **Unban** the user via `POST /v1/rooms.unbanUser`, `/unban @username`, or the "Banned Users" contextual bar. This deletes the banned subscription.
|
||||
2. **Invite or join** — the user can now be invited (API, UI, invite link) or join (public rooms) normally.
|
||||
|
||||
## Access Control
|
||||
|
||||
The `canAccessRoom` validators check for bans in two public room scenarios:
|
||||
- **Public rooms inside teams** — if banned, access is denied.
|
||||
- **Regular public rooms** — if banned, access is denied.
|
||||
|
||||
For private rooms, access is controlled by the subscription: `countByRoomIdAndUserId` excludes `BANNED` subscriptions, so a banned user has no valid subscription and cannot access the room.
|
||||
|
||||
## UI
|
||||
|
||||
- **Ban action:** appears in the user info panel (inside a room), gated by `ban-user` permission + `roomCanBan` + federation rules.
|
||||
- **Banned users list:** "Banned Users" tab in the room toolbox (icon: `ban`, order: 13, requires `ban-user`), with virtualized scroll and infinite pagination via `GET /v1/rooms.bannedUsers`.
|
||||
- **Unban action:** context menu on each item in the banned users list.
|
||||
- **Confirmation:** both actions show a `GenericModal` with `danger` variant.
|
||||
|
||||
## System Messages
|
||||
|
||||
| Key | When |
|
||||
|-----|------|
|
||||
| `user-banned` | A user is banned from the room |
|
||||
| `user-unbanned` | A user is unbanned (including via re-addition) |
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/v1/rooms.banUser` | Ban a user (accepts `userId` or `username` + `roomId`) |
|
||||
| POST | `/v1/rooms.unbanUser` | Unban a user |
|
||||
| GET | `/v1/rooms.bannedUsers` | List banned users (paginated) |
|
||||
|
||||
## Key Files
|
||||
|
||||
| Layer | File |
|
||||
|-------|------|
|
||||
| API routes | `app/api/server/v1/rooms.ts` |
|
||||
| Validation & permissions | `server/lib/banUserFromRoom.ts` |
|
||||
| Core ban logic | `app/lib/server/functions/banUserFromRoom.ts` |
|
||||
| Core unban logic | `app/lib/server/functions/executeUnbanUserFromRoom.ts` |
|
||||
| Slash commands | `app/slashcommands-ban/server/ban.ts`, `unban.ts` |
|
||||
| Client ban hook | `client/views/room/hooks/useBanUser.tsx` |
|
||||
| Client unban hook | `client/views/room/hooks/useUnbanUser.tsx` |
|
||||
| Ban action (user info) | `client/views/room/hooks/useUserInfoActions/actions/useBanUserAction.tsx` |
|
||||
| Banned users UI | `client/views/room/contextualBar/BannedUsers/` |
|
||||
| Subscription types | `packages/core-typings/src/ISubscription.ts` |
|
||||
| REST typings | `packages/rest-typings/src/v1/rooms.ts` |
|
||||
| Model typings | `packages/model-typings/src/models/ISubscriptionsModel.ts` |
|
||||
243
docs/features/custom-sidebar-categories.md
Normal file
243
docs/features/custom-sidebar-categories.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Custom Sidebar Categories
|
||||
|
||||
## Overview
|
||||
|
||||
Custom categories let each user group their sidebar rooms into personal, named collections that appear
|
||||
alongside the system groups (Favorites, Teams, Channels, Direct messages, …). A user can create, rename,
|
||||
delete and reorder categories; move rooms in and out of them via context menus; and toggle whether each
|
||||
category keeps showing unread rooms while collapsed.
|
||||
|
||||
Categories are **per-user** and **client-only in meaning** — they are a presentation grouping of the user's
|
||||
own subscriptions. They carry **no access, security or membership semantics**: moving a room into a category
|
||||
never changes who can see the room or the room itself. Assignment is **exclusive** — a room lives in exactly
|
||||
one place at a time (one custom category, *or* Favorites, *or* its system group).
|
||||
|
||||
Requires the `experimental-enterprise-features` license module.
|
||||
|
||||
## Data Model
|
||||
|
||||
### Category record — `ISidebarCategory`
|
||||
|
||||
```ts
|
||||
// packages/core-typings/src/IUser.ts
|
||||
export interface ISidebarCategory {
|
||||
_id: string; // Random.id() for custom; system group key for defaults
|
||||
name: string; // display name (trimmed; max 30 chars)
|
||||
default?: boolean; // true for system-group entries managed by the platform
|
||||
showUnreads?: boolean; // when collapsed, keep listing unread rooms (default: false)
|
||||
keepUnreadsOnTop?: boolean; // when expanded, sort unread rooms to the top (default: false)
|
||||
}
|
||||
```
|
||||
|
||||
### Room assignment
|
||||
|
||||
Room-to-category assignment is stored on the **subscription**, not on the category:
|
||||
|
||||
```ts
|
||||
// Subscription.category: string | null
|
||||
```
|
||||
|
||||
Set via `POST /experimental/rooms.setCategory`. The subscription field is the source of truth for which
|
||||
category a room belongs to; the category record only carries display metadata and ordering.
|
||||
|
||||
## Persistence
|
||||
|
||||
### `sidebarCategories` preference (unified group ordering + category metadata)
|
||||
|
||||
The preference `sidebarCategories: ISidebarCategory[]` is the **single source of truth** for both the
|
||||
ordered list of all visible groups and the metadata of custom categories. It contains entries for custom
|
||||
categories *and* for any system group whose metadata has been touched (show-unreads, keep-unreads-on-top,
|
||||
or position).
|
||||
|
||||
**Invariant enforced on every write:** all five dynamic groups (`Incoming_Calls`, `Incoming_Livechats`,
|
||||
`Open_Livechats`, `On_Hold_Chats`, `Unread`) are always stored first, in the order prescribed by
|
||||
`sidebarSectionsOrder`. Custom and static system groups follow. This is enforced by `withDynamicFirst`
|
||||
in `useCategoryList.ts`, called by every write hook.
|
||||
|
||||
All mutations go through `usePersistCategoriesMutation`, which calls:
|
||||
|
||||
```http
|
||||
POST /v1/users.setPreferences { data: { sidebarCategories: ISidebarCategory[] } }
|
||||
```
|
||||
|
||||
### Room assignment — `POST /experimental/rooms.setCategory`
|
||||
|
||||
```http
|
||||
POST /experimental/rooms.setCategory
|
||||
{ roomIds: string[], category: string | null }
|
||||
```
|
||||
|
||||
- Requires `experimental-enterprise-features` license.
|
||||
- Rejects system group keys (`SIDEBAR_SYSTEM_GROUP_KEYS`) as the target category.
|
||||
- Verifies the target category exists in the user's `sidebarCategories` preference before writing.
|
||||
- Writes to `subscription.category` and fires change notifications.
|
||||
- `category: null` unassigns the room, returning it to its system group.
|
||||
|
||||
## Group Ordering
|
||||
|
||||
### `sidebarSectionsOrder` preference
|
||||
|
||||
An **admin-configurable** ordered list of system group keys that defines the default group order when
|
||||
`sidebarCategories` has no explicit entry for a key. Falls back to `SIDEBAR_SYSTEM_GROUP_KEYS`.
|
||||
|
||||
### `mergeWithSectionsOrder(explicitIds, sectionsOrder)` — READ side
|
||||
|
||||
Used in `useCategoryList` when building the visible category list. Inserts any key missing from
|
||||
`explicitIds` before its first successor that is already present, maintaining the `sectionsOrder`
|
||||
relative ordering for system groups.
|
||||
|
||||
### `withDynamicFirst(ids, sectionsOrder)` — WRITE side
|
||||
|
||||
Called by every write hook before persisting `sidebarCategories`. Guarantees:
|
||||
1. All five dynamic group keys appear first (in `sectionsOrder` canonical order), regardless of whether
|
||||
they are currently visible.
|
||||
2. Static ids (custom + system) follow, merged with `sectionsOrder` via `mergeWithSectionsOrder`.
|
||||
|
||||
This makes moving logic simple: swapping two adjacent static groups is always a valid operation — dynamic
|
||||
groups can never appear below a static group in the stored list, so a DOWN move can never cross one.
|
||||
|
||||
### `SIDEBAR_DYNAMIC_GROUP_KEYS`
|
||||
|
||||
```ts
|
||||
export const SIDEBAR_DYNAMIC_GROUP_KEYS: readonly string[] = [
|
||||
'Incoming_Calls', 'Incoming_Livechats', 'Open_Livechats', 'On_Hold_Chats', 'Unread',
|
||||
];
|
||||
```
|
||||
|
||||
Groups that are hidden from the sidebar when empty. They are excluded from user reordering
|
||||
(`canMoveGroup` returns `false` for them) and are always stored first by `withDynamicFirst`.
|
||||
|
||||
## Behaviors / Flows
|
||||
|
||||
### Create — `useCreateCustomCategory`
|
||||
|
||||
1. Generates `_id` with `Random.id()`.
|
||||
2. Inserts the new entry at the first position after the last dynamic group entry in the stored list
|
||||
(using `findLastIndex`), so it appears first among static groups.
|
||||
3. Optionally assigns rooms via `useSetCategory` in the same call (`Create and move` flow).
|
||||
|
||||
### Move room — `useMoveRoomCategory`
|
||||
|
||||
Accepts a `target` (category `_id`, `'favorites'` sentinel, or `undefined` to remove).
|
||||
- `'favorites'`: calls `POST /v1/rooms.favorite` and removes the category assignment.
|
||||
- Custom category `_id`: calls `POST /experimental/rooms.setCategory`.
|
||||
- `undefined` / same as current: removes assignment (unfavorites or sets category to `null`).
|
||||
- Dispatches a toast unless `silent: true` or the room has no name.
|
||||
|
||||
### Move category position — `useMoveCategoryPosition`
|
||||
|
||||
Takes `currentKeys` (visible group keys in render order), a `key` to move, and a direction.
|
||||
Performs an adjacent swap in `currentKeys`, then calls `withDynamicFirst` on the result before
|
||||
persisting — so dynamic groups are always re-sorted to the front regardless of what the swap produced.
|
||||
|
||||
### Toggle show-unreads / keep-unreads-on-top — `useToggleUnreads`
|
||||
|
||||
`upsertGroupEntry(id, patch)` — updates the matching `ISidebarCategory` entry in `sidebarCategories`
|
||||
(or creates a new one if the entry doesn't exist yet). Always calls `withDynamicFirst` before persisting.
|
||||
|
||||
### Delete — `useDeleteCategory`
|
||||
|
||||
Removes the category entry from `sidebarCategories` and calls `POST /experimental/rooms.setCategory`
|
||||
with `category: null` for all rooms that were in it — returning them to their system group.
|
||||
|
||||
### Name validation — `useValidateCategoryName`
|
||||
|
||||
Returns an i18n error string or `undefined`. Checks in order:
|
||||
1. Empty / whitespace → `Please_enter_a_category_name`
|
||||
2. Longer than `MAX_CATEGORY_NAME_LENGTH` (30) → `Category_name_is_too_long__max__maxLength__characters`
|
||||
3. Matches a translated system group name (case-insensitive) → `Category_name_conflicts_with_system_group`
|
||||
4. Duplicate among existing custom categories (case-insensitive, excluding `excludeId`) → `A_category_with_this_name_already_exists`
|
||||
|
||||
### Favorites interaction
|
||||
|
||||
`FAVORITES_TARGET = 'favorites'` is a sentinel for the Favorites system group. Moving a room to Favorites
|
||||
calls `POST /v1/rooms.favorite` and strips the category assignment. Moving a room from Favorites into a
|
||||
category unfavorites it. Assignment is exclusive.
|
||||
|
||||
### Visibility in the room list
|
||||
|
||||
`filterGroupVisibility` (in `useCategoryList.ts`) applies when building the room list:
|
||||
- **Dynamic groups** — only shown when non-empty.
|
||||
- **All other groups** (custom + static system) — always shown when EE is active, even if empty.
|
||||
|
||||
This means custom categories are always present in the visible group list with EE, so no category entry
|
||||
in `sidebarCategories` can be silently dropped during a move.
|
||||
|
||||
## Sidebar UI
|
||||
|
||||
### Category collapser kebab — `CategoryMenu.tsx`
|
||||
|
||||
**Custom category:**
|
||||
- Order: Move up / Move down / New channel
|
||||
- Manage: Rename / Delete / New category
|
||||
- When collapsed: Show unreads toggle
|
||||
- When expanded: Keep unreads on top toggle
|
||||
|
||||
**System group:**
|
||||
- Order: Move up / Move down (dynamic groups have both disabled)
|
||||
- When collapsed: Show unreads toggle
|
||||
- When expanded: Keep unreads on top toggle
|
||||
|
||||
### Room context menu — `CategoryRoomMenu.tsx`
|
||||
|
||||
For rooms inside a custom category, adds a "Remove from {category}" item.
|
||||
|
||||
### Room kebab — `useCategoryMenuItems.ts`
|
||||
|
||||
Builds the "Move to" submenu: Favorites + each custom category with a checkmark on the current one.
|
||||
Appended with a "New category" item and "Remove from {category}" when applicable.
|
||||
|
||||
### Room-header grouping menu — `RoomHeaderCategoryMenu.tsx`
|
||||
|
||||
Placed before the room title in `RoomHeader`. Icon: `star-filled` (favorited) / `folder` (custom category) / `star` (default). Same item list as the room kebab submenu.
|
||||
|
||||
### Modals — `categories/`
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `CreateCategoryModal.tsx` | Create or "create and move" — single name field, max 30 chars. |
|
||||
| `ManageCategoryModal.tsx` | Rename with duplicate/empty validation. |
|
||||
| `DeleteCategoryModal.tsx` | Confirmation + "rooms will return to default groups" warning. |
|
||||
| `hooks/useCategoryModals.tsx` | Single entry point: `openCreate(room?)` / `openManage(category)` / `openDelete(category)`. |
|
||||
|
||||
### Navbar create menu
|
||||
|
||||
`useCreateNewMenu.ts` adds a "Category" item (icon: `folder`) in the navbar `+` menu.
|
||||
|
||||
## REST Contract
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| POST | `/v1/users.setPreferences` | Persist `sidebarCategories` (ordering + metadata) |
|
||||
| POST | `/experimental/rooms.setCategory` | Assign / unassign rooms to a category |
|
||||
| POST | `/v1/rooms.favorite` | Favorite / unfavorite (used by the Favorites target) |
|
||||
|
||||
## Key Files
|
||||
|
||||
| Layer | File |
|
||||
|-------|------|
|
||||
| Types | `packages/core-typings/src/IUser.ts` (`ISidebarCategory`, `SIDEBAR_SYSTEM_GROUP_KEYS`) |
|
||||
| Category list + ordering | `apps/meteor/client/sidebar/hooks/useCategoryList.ts` |
|
||||
| Room list | `apps/meteor/client/sidebar/hooks/useRoomList.ts` |
|
||||
| Persist mutation | `apps/meteor/client/sidebar/categories/hooks/usePersistCategoriesMutation.ts` |
|
||||
| Create category | `apps/meteor/client/sidebar/categories/hooks/useCreateCustomCategory.ts` |
|
||||
| Move room | `apps/meteor/client/sidebar/categories/hooks/useMoveRoomCategory.ts` |
|
||||
| Move category position | `apps/meteor/client/sidebar/categories/hooks/useMoveCategoryPosition.ts` |
|
||||
| Toggle unreads | `apps/meteor/client/sidebar/categories/hooks/useToggleUnreads.ts` |
|
||||
| Name validation | `apps/meteor/client/sidebar/categories/hooks/useValidateCategoryName.ts` |
|
||||
| Custom categories list | `apps/meteor/client/sidebar/categories/hooks/useCustomCategories.ts` |
|
||||
| Set category (client) | `apps/meteor/client/sidebar/categories/hooks/useSetCategory.ts` |
|
||||
| Server endpoint | `apps/meteor/server/api/experimental/rooms.setCategory.ts` |
|
||||
| Category UI | `apps/meteor/client/sidebar/categories/` |
|
||||
| Room kebab | `apps/meteor/client/sidebar/RoomMenu.tsx` |
|
||||
| Navbar create menu | `apps/meteor/client/navbar/NavBarPagesGroup/hooks/useCreateNewMenu.ts` |
|
||||
| Room-header grouping | `apps/meteor/client/views/room/Header/icons/RoomGroupingMenu.tsx` |
|
||||
| i18n | `packages/i18n/src/locales/en.i18n.json` |
|
||||
|
||||
## Tests
|
||||
|
||||
| Kind | File |
|
||||
|------|------|
|
||||
| API (preference persistence + validation) | `apps/meteor/tests/end-to-end/api/sidebar-custom-categories.ts` |
|
||||
| E2E (Playwright) | `apps/meteor/tests/e2e/sidebar-custom-categories.spec.ts` |
|
||||
| E2E page object | `apps/meteor/tests/e2e/page-objects/fragments/sidebar.ts` (custom-category helpers) |
|
||||
150
docs/features/mcp-server.md
Normal file
150
docs/features/mcp-server.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# MCP Server (Model Context Protocol)
|
||||
|
||||
> **Status: Alpha.** Off by default. Its capabilities and configuration may evolve as we gather feedback.
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP server exposes the Rocket.Chat REST API to [Model Context Protocol](https://modelcontextprotocol.io) clients (Claude Desktop/Code, IDE agents, custom agents). It speaks **JSON-RPC 2.0 over Streamable HTTP** and turns existing REST endpoints into MCP **tools**, so an AI client can drive a workspace (post messages, search, manage rooms, look up users, …) using the user's own credentials and permissions.
|
||||
|
||||
It is **enterprise (protected) code** under `apps/meteor/ee/`. The design goal is _native + minimum changes_: it mounts on the existing Hono API router and reuses its authentication, permissions, license checks, remote-address resolution, logging, metrics, tracing, CORS, and generated typed-route metadata. Tool execution goes through the REST API, including each target endpoint's validation, authorization, and rate limiting. The endpoint adds MCP-specific Origin validation for DNS-rebinding protection. There is **no new runtime dependency** — the JSON-RPC layer is implemented directly.
|
||||
|
||||
## Endpoint
|
||||
|
||||
| Method | Endpoint | Behavior |
|
||||
| ------ | ------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| POST | `/api/v1/mcp` | JSON-RPC 2.0 message. The `2025-03-26` revision also accepts batch arrays. Response is `application/json`. |
|
||||
| GET | `/api/v1/mcp` | `405` with `Allow: POST` — this transport does not offer a server-initiated SSE stream. |
|
||||
|
||||
The endpoint is attached directly to the existing `/api/v1` Hono router because its JSON-RPC envelopes are defined by the MCP specification rather than Rocket.Chat's REST response contract. It still uses the standard authentication, `access-mcp` permission, and AI license middleware. When `MCP_Enabled` is off the action returns `404`.
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
The MCP handshake is the standard JSON-RPC flow:
|
||||
|
||||
1. **`initialize`** — client and server exchange protocol version + capabilities. The server negotiates the handshake-based Streamable HTTP revisions it supports (`2025-03-26`, `2025-06-18`, and `2025-11-25`) and advertises `{ tools: { listChanged: false } }`. The transport is stateless and does not issue a session id.
|
||||
2. **`tools/list`** — returns the available tools (name, description, `inputSchema`).
|
||||
3. **`tools/call`** — runs a tool by name with arguments and returns its result as `content`.
|
||||
|
||||
Also handled: `ping`, and the `notifications/initialized` / `notifications/cancelled` notifications (acknowledged with `202`, no body).
|
||||
|
||||
After initialization, clients should send the negotiated version in the `MCP-Protocol-Version` header. Requests with an unsupported version are rejected with `400`; when the header is omitted, the transport follows the specification's `2025-03-26` compatibility default. JSON-RPC batching is accepted only for that revision because later Streamable HTTP revisions require one message per POST.
|
||||
The shared CORS middleware advertises this request header only while MCP is enabled.
|
||||
|
||||
## Authentication
|
||||
|
||||
Authentication is **reused from the REST layer** — no MCP-specific credential type:
|
||||
|
||||
- The client sends a **Personal Access Token** as `X-User-Id` + `X-Auth-Token` headers on every request (including `initialize`), because the route is `authRequired: true`.
|
||||
- For POST requests, the standard auth middleware resolves the user; the MCP action then verifies that the matching login-token record is a Personal Access Token. Missing, invalid, and session tokens are rejected with `401`. GET is still covered by route authentication but returns `405` without performing the additional token-type lookup because it cannot execute tools.
|
||||
- Every MCP action additionally requires the **`access-mcp`** permission (`permissionsRequired: ['access-mcp']`), enforced by the standard permissions middleware. Without it the request is rejected with `403`. The permission is granted to `admin` by default; admins can grant it to other roles from the Permissions admin page.
|
||||
|
||||
> When creating the PAT, tick **"Ignore Two Factor Authentication"**, otherwise header auth is rejected with a 2FA challenge.
|
||||
|
||||
## Transport security
|
||||
|
||||
The endpoint validates the `Origin` header to protect browser-accessible deployments from DNS-rebinding attacks. Requests without `Origin` are accepted for native MCP clients. Browser requests are accepted only when their normalized origin matches `Site_Url` or an explicit entry in `API_CORS_Origin` while CORS is enabled. The wildcard (`*`) does not authorize a browser origin for MCP.
|
||||
|
||||
Tool dispatch is restricted to server-generated, allow-listed REST paths on `127.0.0.1`; client input cannot select a URL. Redirects are rejected, calls time out after 20 seconds, and each REST response and final encoded MCP response is capped at 5 MiB. Batches run at most four calls concurrently and share the same 5 MiB streaming response budget.
|
||||
|
||||
## Licensing
|
||||
|
||||
The feature is gated behind the **Rocket.Chat AI add-on** (`chat.rocket.rc-ai`):
|
||||
|
||||
- The route is registered with `license: [AI_LICENSE_MODULE]`, so requests are rejected unless the workspace license includes the module.
|
||||
- The `MCP_*` settings use the same module and an `invalidValue` of `false`, so without the add-on they fall back to **off** and the feature cannot be enabled.
|
||||
|
||||
## Tool catalog
|
||||
|
||||
Two **bounded** sets are exposed, selected by the `MCP_Expose_Extended_API` setting. The full unfiltered API is **never** exposed.
|
||||
|
||||
| `MCP_Expose_Extended_API` | Exposed tools |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **off** (default) | **Minimal curated set** — a small hand-picked list (`post_chat_postMessage`, `get_chat_getMessage`, `post_channels_create`, `get_channels_list_joined`, `get_rooms_get`, `get_users_info`). |
|
||||
| **on** | **Extended set** — the full catalog filtered by the `ALLOWED_TOOL_NAMES` allow-list (~100 routes), still excluding routes tagged `Missing Documentation`. |
|
||||
|
||||
Both sets are built from registered typed-route metadata. The extended set filters generated base names through the allow-list, while the curated set filters routes through its smaller explicit list and supplies fallback descriptions where metadata is incomplete.
|
||||
|
||||
### Tool naming & variant expansion
|
||||
|
||||
A route's base tool name is `toolNameFor(path, method)` — e.g. `GET /api/v1/users.info` → `get_users_info`. Curated and extended catalogs use the same names, so enabling the extended catalog only adds tools.
|
||||
|
||||
When a route's request schema is a `oneOf`/`anyOf` of object sub-schemas with **distinct discriminators** (its `required` keys), each branch becomes its **own tool**, named `<base>_by_<discriminator>`:
|
||||
|
||||
- `chat.postMessage` → `post_chat_postMessage_by_channel`, `post_chat_postMessage_by_roomId`
|
||||
- `users.info` → `get_users_info_by_userId`, `get_users_info_by_username`, `get_users_info_by_importId`, …
|
||||
|
||||
The allow-list is matched on the **base name**, so a single entry (`get_users_info`) admits all of that route's variants.
|
||||
|
||||
### Schema handling
|
||||
|
||||
MCP / the Anthropic tools API require each `inputSchema` to be a plain object schema and reject several JSON-Schema constructs. `mcpSafeSchema` normalizes a route's schema for the tool view (the REST validator is untouched):
|
||||
|
||||
- strips OpenAPI-only `nullable` and `not`;
|
||||
- resolves `oneOf`/`anyOf`/`allOf` by adopting the first branch (so a value union like `string | string[]` keeps a concrete type, and a sub-schema union yields a clean object);
|
||||
- forces a top-level `type: "object"`.
|
||||
|
||||
Tool **descriptions** are sourced from the route schema's own `description` (added in `rest-typings`), falling back to a per-route string.
|
||||
|
||||
## Dispatch
|
||||
|
||||
`tools/call` executes the target endpoint **as the authenticated user** via a loopback HTTP call to the local REST API (`http://127.0.0.1:<PORT><ROOT_URL_PATH_PREFIX>/api/v1/<route>`), forwarding:
|
||||
|
||||
- `X-User-Id` + `X-Auth-Token` (the caller's PAT), so all validation and permission checks run exactly as for a real REST client — **zero duplicated business logic**;
|
||||
- `X-Real-IP` set to the resolved client address (`this.requestIp`), so the target endpoint's per-route rate limiter keys on the real client rather than the loopback address.
|
||||
|
||||
The REST response is wrapped as MCP `content` (`type: "text"`); a non-2xx REST response is returned with `isError: true`. Internal calls time out after 20 seconds. REST bodies and final encoded MCP responses are capped at 5 MiB; batched calls use bounded concurrency and share a single streaming body budget to limit retained response data.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
The MCP endpoint uses Rocket.Chat's **built-in per-route rate limiter** with a limit of 60 requests per minute (enabled by `API_Enable_Rate_Limiter`, honoring `api-bypass-rate-limit`). Every tool call is also subject to its target REST endpoint's rate limit. The resolved client address is propagated to that endpoint rather than being counted as loopback traffic. MCP protocol requests are additionally bounded to 20 batch entries, four concurrent dispatches, 20-second calls, and a shared 5 MiB response budget. As with every Rocket.Chat API route, deployments behind a proxy must configure `HTTP_FORWARDED_COUNT` and trusted forwarding headers correctly.
|
||||
|
||||
## Settings
|
||||
|
||||
Registered under **Admin → AI Center → MCP** (`server/settings/ai.ts`):
|
||||
|
||||
| Setting | Default | Description |
|
||||
| ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MCP_Enabled` | `false` | Enables the `/api/v1/mcp` endpoint. Flagged **alpha** via an admin warning callout (`MCP_Alpha_Alert`). |
|
||||
| `MCP_Expose_Extended_API` | `false` | When on, exposes the extended allow-listed toolset instead of the minimal curated one. Gated behind `MCP_Enabled`. The full API is never exposed. |
|
||||
|
||||
## Connecting a client
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http rocketchat http://<host>/api/v1/mcp \
|
||||
--header "X-User-Id: <userId>" \
|
||||
--header "X-Auth-Token: <personalAccessToken>"
|
||||
```
|
||||
|
||||
Raw smoke test:
|
||||
|
||||
```bash
|
||||
H=(-H "Content-Type: application/json" -H "X-User-Id: <uid>" -H "X-Auth-Token: <token>")
|
||||
curl -s "${H[@]}" http://localhost:3000/api/v1/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||
curl -s "${H[@]}" http://localhost:3000/api/v1/mcp \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"post_chat_postMessage_by_channel","arguments":{"channel":"#general","text":"hello from MCP"}}}'
|
||||
```
|
||||
|
||||
> The client **must** send `Content-Type: application/json` on POST — the shared router only parses the body for that content type.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Alpha**, off by default.
|
||||
- Implements the handshake-based Streamable HTTP lifecycle through protocol version `2025-11-25`; newer lifecycle methods are not yet supported.
|
||||
- Requires the **Rocket.Chat AI add-on** — both the route and the settings are gated by it.
|
||||
- One HTTP response per POST. The `2025-03-26` compatibility revision accepts a bounded batch of up to 20 messages; later revisions accept one message per POST. There is no server-initiated SSE stream (`GET` → `405`).
|
||||
- The official `@modelcontextprotocol/sdk` is intentionally not used; the files are structured so it can be dropped into the transport/server layer later for SSE and richer session handling.
|
||||
|
||||
## Key Files
|
||||
|
||||
| Layer | File |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Hono route registration (`/api/v1/mcp`) + `MCP_Enabled` gate | `apps/meteor/ee/server/api/mcp/index.ts` |
|
||||
| JSON-RPC handlers (`initialize`/`tools/list`/`tools/call`/…) | `apps/meteor/ee/server/api/mcp/server.ts` |
|
||||
| Tool catalog (curated + extended allow-list, variants, schema normalization) | `apps/meteor/ee/server/api/mcp/catalog.ts` |
|
||||
| Tool dispatch (loopback to REST as the user) | `apps/meteor/ee/server/api/mcp/dispatch.ts` |
|
||||
| License-gated permission seed (`access-mcp`) | `apps/meteor/ee/server/startup/mcp.ts` |
|
||||
| EE module load | `apps/meteor/ee/server/api/index.ts` |
|
||||
| Settings (license-gated) | `apps/meteor/server/settings/ai.ts` |
|
||||
| License module | `packages/ai-search/src/constants.ts` (`AI_LICENSE_MODULE`) |
|
||||
| Schema descriptions reused as tool docs | `packages/rest-typings/src/v1/chat.ts`, `packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts` |
|
||||
| i18n | `packages/i18n/src/locales/en.i18n.json` (`MCP_*` keys) |
|
||||
182
docs/features/slackbridge-manual-tests.md
Normal file
182
docs/features/slackbridge-manual-tests.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# SlackBridge Manual Test Plan
|
||||
|
||||
The SlackBridge is a bi-directional bridge between a Rocket.Chat workspace and one or more Slack
|
||||
workspaces — each token line in the SlackBridge settings gets its own connection and adapter. It
|
||||
has no automated end-to-end coverage: exercising it requires a real Slack workspace, a real bot
|
||||
installation, and a human watching both sides at once. This document is the checklist to walk
|
||||
through when touching the bridge.
|
||||
|
||||
**Scope:** the matrices below assume a **single** bridged Slack workspace — configure exactly one
|
||||
token per setting while running them. Bridging several workspaces at once is a supported
|
||||
configuration that this plan does not cover; see [Coverage gaps](#coverage-gaps).
|
||||
|
||||
Code lives in [apps/meteor/server/bridges/slack/](../../apps/meteor/server/bridges/slack/) —
|
||||
`SlackAdapter.ts` handles Slack → Rocket.Chat, `RocketAdapter.ts` handles Rocket.Chat → Slack
|
||||
(hooked in through the `afterSaveMessage`, `afterDeleteMessage`, `afterSetReaction` and
|
||||
`afterUnsetReaction` callbacks). Settings are declared in
|
||||
[apps/meteor/server/settings/slackbridge.ts](../../apps/meteor/server/settings/slackbridge.ts).
|
||||
|
||||
## Setup
|
||||
|
||||
Configure the bridge in **Admin → Settings → SlackBridge**:
|
||||
|
||||
| Setting | Notes |
|
||||
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `SlackBridge_Enabled` | Master switch. Must be on. |
|
||||
| `SlackBridge_UseLegacy` | Chooses the connection mode — see below. Run the matrices in the mode you are testing. |
|
||||
| `SlackBridge_APIToken` | Legacy mode only (RTM). |
|
||||
| `SlackBridge_BotToken`, `SlackBridge_SigningSecret`, `SlackBridge_AppToken` | App mode only (Bolt / Socket Mode). |
|
||||
| `SlackBridge_Out_Enabled` | Required for anything to flow Rocket.Chat → Slack. |
|
||||
| `SlackBridge_Out_All` / `SlackBridge_Out_Channels` | Which Rocket.Chat rooms are bridged out. |
|
||||
| `SlackBridge_Reactions_Enabled` | Gates reaction propagation in both directions. |
|
||||
| `SlackBridge_AliasFormat` | Optional, Slack → Rocket.Chat only. `%s` is the Slack username; test with `%s (via Slack)`. |
|
||||
| `SlackBridge_FileUpload_Enabled` | Gates file transfer. |
|
||||
|
||||
Two connection modes exist and they use different Slack event plumbing, so a regression can
|
||||
appear in one and not the other. **Run the full matrix in both** when changing anything in the
|
||||
connect/event-subscription path:
|
||||
|
||||
- **Legacy (`SlackBridge_UseLegacy: true`)** — RTM connection with an API token.
|
||||
- **App (`SlackBridge_UseLegacy: false`)** — Slack app using bot token, signing secret and app
|
||||
token.
|
||||
|
||||
You also need:
|
||||
|
||||
- A Slack workspace where you can create channels and install/invite the bot (referred to below
|
||||
as **rocketbot**).
|
||||
- At least one channel that exists on both sides **with** rocketbot in it, and one that exists on
|
||||
both sides **without** rocketbot in it. Several cases below hinge on that difference.
|
||||
- A second Slack user, if possible — some behaviors only show up when the message author is not
|
||||
the same account as the bot.
|
||||
|
||||
Watch `SlackBridge` logs (`slackLogger` / `rocketLogger`) while testing; several expected
|
||||
failures are only visible there.
|
||||
|
||||
## How to read the matrices
|
||||
|
||||
- **Origin** — where the message being acted on was originally posted.
|
||||
- **Action performed on** — the side where the tester performs the action.
|
||||
- Every matrix must also be repeated **on a channel that rocketbot is not a member of**. Nothing
|
||||
should propagate in either direction for those channels, and nothing should error.
|
||||
|
||||
## Sending messages
|
||||
|
||||
| # | Origin | Action performed on | Expected result |
|
||||
| --- | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Slack | Slack | Message appears in the linked Rocket.Chat room, under the alias `SlackBridge_AliasFormat` produces. |
|
||||
| 2 | Rocket.Chat | Rocket.Chat | Message appears in the linked Slack channel, posted by rocketbot with the sender's username and avatar. |
|
||||
|
||||
`SlackBridge_AliasFormat` only affects case 1: `addAliasToMsg` sets `alias` on the **Rocket.Chat**
|
||||
message, so with `%s (via Slack)` a message from Slack user `alice` shows as `alice (via Slack)`.
|
||||
Case 2 carries the sender's identity through `chat.postMessage`'s `username`/`icon_url` and ignores
|
||||
the setting entirely. Set the format, run case 1 with it set and again with it empty, then **restore
|
||||
the original value** before moving on — the later matrices assume the workspace's normal setup.
|
||||
|
||||
Repeat both on a channel without rocketbot: neither message crosses over.
|
||||
|
||||
## Editing messages
|
||||
|
||||
Slack does not allow an app to edit messages it did not post, and it does not allow humans to
|
||||
edit bot messages. Two of the four cases are therefore **expected failures** — they are listed
|
||||
because "nothing crashes and the local side stays consistent" is the requirement.
|
||||
|
||||
| # | Origin | Action performed on | Expected result |
|
||||
| --- | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Slack | Slack | Edit propagates to Rocket.Chat. |
|
||||
| 2 | Rocket.Chat | Rocket.Chat | Edit propagates to Slack. |
|
||||
| 3 | Slack | Rocket.Chat | **Expected failure.** Rocket.Chat shows the edit; Slack is unchanged. `chat.update` is rejected because the message is not the bot's. |
|
||||
| 4 | Rocket.Chat | Slack | **Expected failure.** Slack does not even offer the edit affordance for a bot message. |
|
||||
|
||||
Repeat on a channel without rocketbot: edits stay local on both sides.
|
||||
|
||||
## Deleting messages
|
||||
|
||||
Case 3 needs care about _who_ deletes. A Rocket.Chat-origin message is authored on Slack by
|
||||
rocketbot, and Slack offers no delete affordance for an app's message to a regular member, so
|
||||
pick one of:
|
||||
|
||||
- Delete it from the Slack UI as a **workspace owner or admin** — they can delete any message,
|
||||
including an app's.
|
||||
- Call `chat.delete` with the very token the bridge is configured with, so the caller is
|
||||
rocketbot itself: `SlackBridge_APIToken` in legacy/RTM mode, `SlackBridge_BotToken` in
|
||||
app/Socket Mode. Any other token fails with `cant_delete_message`, which is case 4's failure,
|
||||
not case 3's.
|
||||
|
||||
Both routes emit the same `message_deleted` event, so either is a valid way to run the case.
|
||||
|
||||
| # | Origin | Action performed on | Expected result |
|
||||
| --- | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Slack | Slack | Deletion propagates to Rocket.Chat. |
|
||||
| 2 | Rocket.Chat | Rocket.Chat | Deletion propagates to Slack. |
|
||||
| 3 | Rocket.Chat | Slack | Deletion propagates to Rocket.Chat — deleted as a workspace admin or via `chat.delete` with rocketbot's own token, per the note above. |
|
||||
| 4 | Slack | Rocket.Chat | **Expected failure.** The message is deleted in Rocket.Chat only; Slack keeps it, since `chat.delete` cannot remove another user's message. |
|
||||
|
||||
Repeat on a channel without rocketbot: deletions stay local on both sides.
|
||||
|
||||
## Reactions
|
||||
|
||||
Requires `SlackBridge_Reactions_Enabled`. Reactions sent to Slack are added by the bot account,
|
||||
so on the Slack side every Rocket.Chat reaction shows up as rocketbot's reaction regardless of
|
||||
who actually reacted.
|
||||
|
||||
| # | Origin | Action performed on | Expected result |
|
||||
| --- | ----------- | ------------------- | ----------------------------------------------------- |
|
||||
| 1 | Slack | Slack | Reaction appears on the Rocket.Chat message. |
|
||||
| 2 | Rocket.Chat | Slack | Reaction appears on the Rocket.Chat message. |
|
||||
| 3 | Rocket.Chat | Rocket.Chat | Reaction appears on the Slack message (as rocketbot). |
|
||||
| 4 | Slack | Rocket.Chat | Reaction appears on the Slack message (as rocketbot). |
|
||||
|
||||
Also check removal of each reaction, and confirm a reaction is not echoed back to its origin
|
||||
(the bridge de-duplicates through its reactions map — a double-count means that broke).
|
||||
|
||||
Repeat on a channel without rocketbot: reactions stay local.
|
||||
|
||||
### `SlackBridge_Reactions_Enabled` toggle
|
||||
|
||||
| Setting state | Expected result |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Disabled | React to both a Slack-origin and a Rocket.Chat-origin message from both sides — nothing propagates, no errors. |
|
||||
| Enabled | Re-run the same reactions — they propagate as in the matrix above, without restarting the server. |
|
||||
|
||||
## Channel lifecycle
|
||||
|
||||
These cases cover how the bridge reacts to channels appearing, disappearing, and the bot joining
|
||||
or leaving. Send a message **from each side** after every step and verify propagation.
|
||||
|
||||
### Channel exists on both sides, rocketbot not in the Slack channel
|
||||
|
||||
1. Confirm nothing propagates in either direction.
|
||||
2. Invite rocketbot to the Slack channel.
|
||||
3. Send from Slack and from Rocket.Chat — both directions now work.
|
||||
|
||||
### Channel exists on Rocket.Chat only
|
||||
|
||||
1. Create the matching channel in Slack and invite rocketbot.
|
||||
2. Send from Rocket.Chat — it reaches Slack.
|
||||
3. Send from Slack — it reaches Rocket.Chat.
|
||||
|
||||
### Channel exists on Slack only
|
||||
|
||||
1. Invite rocketbot to the Slack channel.
|
||||
2. Send from Slack — the Rocket.Chat channel is created and receives the message.
|
||||
3. Send from Rocket.Chat — it reaches Slack.
|
||||
|
||||
### Channel exists on both sides with rocketbot
|
||||
|
||||
1. Delete the Rocket.Chat channel, then send from Slack — behaves like the "Slack only" case
|
||||
above (the channel is recreated).
|
||||
2. Remove rocketbot from the Slack channel, then send from Slack and from Rocket.Chat — nothing
|
||||
propagates in either direction, and no errors are logged.
|
||||
|
||||
## Coverage gaps
|
||||
|
||||
The matrices above are the historical manual suite and deliberately mirror it. They do **not**
|
||||
cover, and it is worth checking manually when relevant:
|
||||
|
||||
- File uploads (`SlackBridge_FileUpload_Enabled`).
|
||||
- Slack history import (`/slackbridge-import`, `slackbridge_import.server.ts`).
|
||||
- Threads (`thread_ts` mapping in `postMessage`).
|
||||
- Private channels and DMs.
|
||||
- `SlackBridge_ExcludeBotnames` filtering.
|
||||
- The **Remove Channel Links** admin action (`SlackBridge_Remove_Channel_Links`).
|
||||
- Multiple Slack workspaces bridged at once (one token per line in each token setting).
|
||||
Reference in New Issue
Block a user