Files
im_webapp/web/src/lib/api/groups.ts
T
Your NameandClaude Sonnet 5 4961c0ae6e fix(groups,system): remove-member action + stale invite button
Adds removeGroupMember (DELETE /groups/{topic}/members/{user_id}) --
confirmed live the backend now has this route (server/im_http.go's
serveIMGroupMember + canRemoveIMGroupMember), which an earlier pass
recorded as a genuine protocol gap; it isn't anymore. Wired into
GroupMembersModal with a per-row "移出" button gated by the same rule
the backend enforces (owner or admin removes a plain member, only the
owner removes an admin, nobody removes the owner or themselves), with
a confirm dialog before the real DELETE call. Verified live end-to-end
against a real owned group: member count 4 -> 3, list updates
correctly (the side panel's own separate "群成员 N" summary count
still trailed behind after this -- confirmed via a direct GET that the
server's own group payload hadn't recalculated member_count yet, a
backend read-consistency lag unrelated to this fix, not chased
further).

Also fixes the reported "已处理的群邀请消息,即使群解散了,接受邀请
按钮还是一直可以点" bug: the button's visibility used to be derived
purely from the notification's static action payload every render, so
neither a successful accept nor a definitively-failed one (expired,
already handled, group gone) ever stopped it from looking freshly
clickable again. Tracks per-invitation resolved state (accepted/
failed) once either outcome happens and swaps the button for a static
resolved label. Doesn't survive a page reload for invitations resolved
in an earlier visit (the notification record itself carries no
resolved flag to read back) -- a real, acknowledged limit, not
silently hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:08:07 +08:00

503 lines
24 KiB
TypeScript

// 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<GroupProfile>('/groups', body);
}
export function getGroup(topic: string) {
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
}
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
return imPatch<GroupProfile>(`/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<GroupSettings>(`/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<GroupMemberSettings>(`/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<GroupMemberProfile>(`/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<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
}
export function updateGroupGovernance(
topic: string,
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
) {
return imPatch<GroupGovernance>(`/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<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
}
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
return imPut<GroupAnnouncement>(`/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<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
}
export function deleteGroupFile(topic: string, groupFileId: string) {
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
}