// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's // src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the // deployed backend -- its inline comments flag three real doc/server mismatches this file follows // instead of the doc where they conflict (see each function's own comment): // - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields) // - group-files search param is `q`, not `query` // - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata // Unlike P2P conversation settings, almost the entire group management surface is REST, not native // Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which // never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing // WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts). import { fetchAllPages } from './client'; import { imDelete, imGet, imPatch, imPost, imPut } from './client'; export interface GroupPermissions { can_edit_profile?: boolean; can_invite_members?: boolean; can_manage_members?: boolean; can_manage_settings?: boolean; can_leave?: boolean; can_mention_all?: boolean; } export interface GroupSummaryItem { topic: string; name: string; avatar_file_id?: string; member_count: number; my_role: GroupRole; updated_at?: string; } export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') { const qs = new URLSearchParams(); if (category) qs.set('category', category); if (query) qs.set('query', query); if (cursor) qs.set('cursor', cursor); const suffix = qs.toString(); return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`); } export interface GroupProfile { topic: string; name: string; avatar_file_id?: string; description?: string; owner?: string; member_count: number; version: number; permissions?: GroupPermissions; } export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) { return imPost('/groups', body); } export function getGroup(topic: string) { return imGet(`/groups/${encodeURIComponent(topic)}`); } export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) { return imPatch(`/groups/${encodeURIComponent(topic)}`, patch); } /** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is * a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing * ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked * example for this response, so this was a guess until checked against the real payload. */ export interface GroupGovernance { join_approval: boolean; join_approval_editable?: boolean; mute_all: boolean; mute_all_editable?: boolean; member_upload_files: boolean; member_upload_files_editable?: boolean; member_private_chat: boolean; member_private_chat_editable?: boolean; member_invite: boolean; member_invite_editable?: boolean; version: number; } /** * Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this * type: there is no `announcement` field at all (the summary endpoint carries that instead, see * GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`, * `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and * `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`), * not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this * file hadn't typed at all until this pass. */ export interface GroupSettings { topic: string; name: string; description?: string; avatar_file_id?: string; owner: string; my_role: GroupRole; permissions?: GroupPermissions; member_count: number; pinned: boolean; mute_notifications: boolean; message_retention?: string; governance?: GroupGovernance; version: number; } export function getGroupSettings(topic: string) { return imGet(`/groups/${encodeURIComponent(topic)}/settings`); } export interface GroupMemberSettings { pinned: boolean; mute_notifications: boolean; version: number; updated_at: string; } /** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this * endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding * 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic * locking at all (the server bumps its own stored version unconditionally on every call); the * `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back. * This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */ export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) { return imPatch(`/groups/${encodeURIComponent(topic)}/member-settings`, patch); } export function leaveGroup(topic: string) { return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {}); } /** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via * `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live, * fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by * actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries * this client's own `Content-Type: application/json` header, and the server's JSON decoder treats * a truly empty body under that header as malformed rather than "no optional fields provided". */ export function disbandGroup(topic: string, actionToken: string) { return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, { body: {}, headers: { 'X-Action-Token': actionToken }, }); } export type GroupRole = 'owner' | 'admin' | 'member'; export interface GroupMember { user_id: string; display_name?: string; avatar_file_id?: string; role: GroupRole; mentionable?: boolean; eligible?: boolean; /** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the * protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner- * transfer pickers just filter ineligible candidates out rather than showing them grayed out * with a reason), but real and worth keeping typed since it's cheap and already observed. */ ineligible_reason?: string; acs?: { want?: string; given?: string; mode?: string }; } /** `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) -- * only getGroupMemberProfile() does. Don't read nickname off this list. */ export function getGroupMembers( topic: string, opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {}, ) { const qs = new URLSearchParams(); if (opts.query) qs.set('query', opts.query); if (opts.cursor) qs.set('cursor', opts.cursor); if (opts.role) qs.set('role', opts.role); if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole); if (opts.mentionable) qs.set('mentionable', 'true'); if (opts.muteEligible) qs.set('mute_eligible', 'true'); const suffix = qs.toString(); return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>( `/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`, ); } export function getAllGroupMembers( topic: string, opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {}, ) { return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor })); } /** * `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists * (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a * plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or * themselves. An earlier pass through the protocol doc found no such operation at all -- that was * true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time. */ export function removeGroupMember(topic: string, userId: string) { return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`); } export interface GroupMemberProfile { topic: string; user_id: string; display_name?: string; group_nickname?: string; role: GroupRole; relationship?: string; capabilities?: { can_send?: boolean; can_call?: boolean }; } /** `topic` must be the `grp...` id, never a `usr...` id -- confirmed by the protocol doc's own warning. * Do NOT call this with your own user_id: the backend computes a self/other relationship context * for this generic path that errors out when both sides are the same uid (confirmed live 2026-09-09, * stable 400 MALFORMED_REQUEST). Use `getMyGroupProfile()` for your own group nickname instead -- * the backend added a dedicated `members/me/profile` route that sidesteps this entirely. */ export function getGroupMemberProfile(topic: string, userId: string) { return imGet(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}/profile`); } /** Dedicated self-profile route -- unlike getGroupMemberProfile(topic, myUserId), this one actually * works (see that function's own warning) and, as a bonus, returns the real optimistic-lock `version` * on every read, so callers no longer need to remember the last save's version between edits. */ export function getMyGroupProfile(topic: string) { return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`); } export function updateMyGroupNickname(topic: string, nickname: string, version: number) { return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, { nickname, version, }); } export function getGroupRoleLimits(topic: string) { return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`); } export function addGroupAdmins(topic: string, userIds: string[], version: number) { return imPost<{ succeeded: Array<{ user_id: string; role: GroupRole }>; failed?: Array<{ user_id: string; reason: string }>; admins: GroupMember[]; version: number; }>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version }); } export function removeGroupAdmin(topic: string, userId: string, version: number) { return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>( `/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`, { headers: { 'If-Match': String(version) } }, ); } /** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) -- * no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint * never returns a readable name, only the bare id -- callers needing one must cross-reference the * member list themselves, same as GroupFilesModal already has to for `uploader_id`). */ export interface GroupMute { user_id: string; muted_by?: string; muted_at: string; expires_at?: string | null; reason?: string; status?: string; version?: number; } export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) { // NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing // switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is // plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged. return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>( `/groups/${encodeURIComponent(topic)}/mutes`, { members, reason }, ); } export function getGroupMutes(topic: string, cursor = '') { return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`); } /** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own * `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page * function directly, silently truncating both the list and its own header count once a group has * enough muted members to span more than one page. */ export function getAllGroupMutes(topic: string) { return fetchAllPages((cursor) => getGroupMutes(topic, cursor)); } export function unmuteGroupMember(topic: string, userId: string) { return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`); } /** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via * `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */ export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) { return imPost<{ owner: string; previous_owner: string; version: number }>( `/groups/${encodeURIComponent(topic)}/owner-transfer`, { target_user_id: targetUserId, version }, { headers: { 'X-Action-Token': actionToken } }, ); } export function getGroupGovernance(topic: string) { return imGet(`/groups/${encodeURIComponent(topic)}/governance`); } export function updateGroupGovernance( topic: string, patch: Partial> & { version: number }, ) { return imPatch(`/groups/${encodeURIComponent(topic)}/governance`, patch); } /** * Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the * doc's own single-outcome worked example): `"joined"` when the group's own governance AND the * target's own privacy setting both don't require approval (added immediately, no further action * needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a * real GroupInvitation record is created instead, delivered to the target as a system notification -- * see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this * app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a * `reason`. */ export interface GroupInvitationResult { user_id: string; status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed'; invitation_id?: string; reason?: string; } export function inviteGroupMembers(topic: string, userIds: string[], message?: string) { return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, { user_ids: userIds, message, }); } /** * The target's own action on a `group.invitation.created` system notification (README/docs * :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource * directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group * immediately on success (no further approval step at this point -- any approval requirement was * already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status * instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself, * which this same endpoint resolves). */ export function acceptGroupInvitation(invitationId: string) { return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`); } /** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example * (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}` * object) or this file's own previous type -- the real response is a FLAT `user_id` string with no * display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's * example doesn't show. Reading `req.applicant.user_id` against this real shape would throw * (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by * live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */ export interface GroupJoinRequest { request_id: string; user_id: string; /** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for * a self-initiated request (search/QR). */ inviter_id: string; /** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */ message?: string; status: string; source: string; created_at: string; handled_at?: string; handled_by?: string; version: number; } /** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every * other function in this block is the admin's reviewing side). `source` and `qr_token` are only * relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain * "request to join" with no QR code involved would omit both, though this app doesn't have another * UI entry point that reaches a group's join-requests without already being a member (no public * group directory/search exists anywhere in this codebase or its design handoff). */ export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) { return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>( `/groups/${encodeURIComponent(topic)}/join-requests`, body, ); } export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') { const qs = new URLSearchParams({ status }); if (cursor) qs.set('cursor', cursor); return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`); } export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) { return imPost<{ request_id: string; status: string; version: number }>( `/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`, { version }, ); } /** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason` * key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and * every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */ export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) { return imPost<{ request_id: string; status: string; version: number }>( `/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`, { version }, ); } /** Confirmed live: `announcement` is always a present object, even with nothing published -- * every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for * emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */ export function getGroupSummary(topic: string) { return imGet<{ pending_join_request_count: number; announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null; permissions?: GroupPermissions; }>(`/groups/${encodeURIComponent(topic)}/summary`); } /** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not * the `published_by` object this file originally guessed -- resolve it against the group's member * list for a display name/role, same as any other bare user id in this API. When nothing has been * published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"", * author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check * `content` for emptiness, not object existence. * `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this * type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed * by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three). * Optional here because an old cached response (or a tenant not yet on the new deploy) may still * omit them. */ export interface GroupAnnouncement { announcement_id: string; topic: string; content: string; version: number; published_at: string; author: string; permissions?: { can_edit?: boolean }; read_count?: number; member_count?: number; read_summary?: { read_count: number; member_count: number }; } export function getLatestGroupAnnouncement(topic: string) { return imGet(`/groups/${encodeURIComponent(topic)}/announcements/latest`); } export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) { return imPut(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version }); } export function readGroupAnnouncement(topic: string, announcementId: string) { return imPost<{ announcement_id: string; read_at: string }>( `/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`, {}, ); } /** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT * `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type * previously declared -- undetected until now because this list only ever rendered its own * `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader` * object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a * delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */ export interface GroupFile { group_file_id: string; file_id: string; file_name: string; file_size: number; mime_type: string; uploader_id?: string; created_at: string; download_state?: string; } /** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by * the old app that the real server reads `q` and silently no-ops a `query` param instead. */ export function getGroupFiles(topic: string, q = '', type = '', cursor = '') { const qs = new URLSearchParams(); if (q) qs.set('q', q); if (type) qs.set('type', type); if (cursor) qs.set('cursor', cursor); const suffix = qs.toString(); return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`); } /** * The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other * field is server-side `omitempty` -- confirmed live by the old app, which always sends the full * set below. */ export function addGroupFile( topic: string, file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number }, ) { return imPost(`/groups/${encodeURIComponent(topic)}/files`, file); } export function deleteGroupFile(topic: string, groupFileId: string) { return imDelete(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`); }