Convert im_webapp from static mock prototypes to real backend integration

Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.

Backup checkpoint before reconciling with origin/dev's parallel rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 11:40:53 +08:00
co-authored by Claude Sonnet 5
parent ba8f48ee83
commit 251c9de87c
36 changed files with 52812 additions and 5199 deletions
+690
View File
@@ -0,0 +1,690 @@
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS protocol.
// Endpoint shapes verified directly against the deployed backend (im_team/im_server, server/im_http.go).
import { API_KEY } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
import LocalStorageUtil from './local-storage.js';
const DEFAULT_API_BASE = '/v1/im';
const DEFAULT_FILE_BASE = '/v0/file';
export class ImApiError extends Error {
constructor(message, reason, status, details) {
super(message);
this.reason = reason;
this.status = status;
this.details = details;
}
}
// docs/IM-前后端对接文档.md §12.4's full reason list. Every existing call site already does
// `catch(err => onToast(err.message || '...'))`, so translating known reasons at the one place the
// error is constructed fixes all of them at once, rather than needing a matching change at every
// individual catch block scattered across the app.
const REASON_MESSAGES = {
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
ACCOUNT_DISABLED: '账号已被禁用',
RATE_LIMITED: '操作过于频繁,请稍后再试',
VALIDATION_FAILED: '提交的内容不符合要求',
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
FORBIDDEN: '没有权限执行此操作',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截'
};
function requestId() {
return (typeof crypto != 'undefined' && crypto.randomUUID) ? crypto.randomUUID() :
'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);
}
// Tenant context persists in localStorage (not sessionStorage): it must survive browser restarts
// the same way the auth-token does, so a returning user's auto-login can still supply `hi.tenant`.
export function getTenantContext() {
return LocalStorageUtil.getObject('im-tenant');
}
export function currentTenantCode() {
const tenant = getTenantContext();
return (tenant && tenant.code) || 'default';
}
function apiBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.api) || DEFAULT_API_BASE).replace(/\/$/, '');
}
function fileBase() {
const tenant = getTenantContext();
return ((tenant && tenant.endpoints && tenant.endpoints.file) || DEFAULT_FILE_BASE).replace(/\/$/, '');
}
// tinode-web.jsx always sets this on login/reconnect (sessionStorage, tab-lifetime), independent of
// whether the user opted into "keep logged in" (which only controls the separate localStorage token
// used for surviving a browser restart). REST calls need a token for the current session regardless.
function authToken() {
return sessionStorage.getItem('im-session-token') || '';
}
function normalizeResponse(payload) {
return (payload && payload.code === 0 && payload.data !== undefined) ? payload.data : payload;
}
// Core request helper. `auth: false` skips the Authorization header (pre-login calls).
export async function imRequest(path, {method = 'GET', body, headers, auth = true, tenantCodeOverride} = {}) {
const finalHeaders = {
'Content-Type': 'application/json',
'X-MeetingIM-APIKey': API_KEY,
'X-IM-Schema': '1',
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
'X-Request-ID': requestId(),
...headers
};
if (auth) {
const token = authToken();
if (token) {
finalHeaders.Authorization = 'Bearer ' + token;
}
}
const response = await fetch(apiBase() + path, {
method,
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined
});
const text = await response.text();
let payload = {};
if (text) {
try {
payload = JSON.parse(text);
} catch (e) {
throw new ImApiError(response.ok ? '接口返回非 JSON 数据' : (response.statusText || '接口请求失败'),
'NON_JSON_RESPONSE', response.status, text.slice(0, 160));
}
}
if (!response.ok || (payload && payload.code && payload.code !== 0)) {
// A known reason always wins over the raw backend message -- gives every caller (they all just do
// `err.message || '...'`) the doc-mandated, consistently-worded text instead of whatever string
// that particular backend handler happened to write, and instead of duplicating this mapping at
// every individual call site. An unrecognized reason still falls back to the backend's own message
// (not silently replaced with a generic string) since this backend's messages have consistently
// been reasonable, specific, business-appropriate text in real testing, not raw internals -- only
// reasons this app doesn't otherwise know how to react to fall through to it.
const known = payload.reason && REASON_MESSAGES[payload.reason];
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason, response.status, payload.details);
}
return normalizeResponse(payload);
}
const idem = () => ({'Idempotency-Key': requestId()});
export const imGet = (path, opts) => imRequest(path, opts);
export const imPost = (path, body, opts = {}) => imRequest(path, {method: 'POST', body, headers: idem(), ...opts});
export const imPatch = (path, body, opts = {}) => imRequest(path, {method: 'PATCH', body, headers: idem(), ...opts});
export const imPut = (path, body, opts = {}) => imRequest(path, {method: 'PUT', body, headers: idem(), ...opts});
export const imDelete = (path, opts = {}) => imRequest(path, {method: 'DELETE', headers: idem(), ...opts});
function qs(params) {
const usp = new URLSearchParams();
Object.entries(params || {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
usp.set(k, v);
}
});
const s = usp.toString();
return s ? '?' + s : '';
}
// -- Public uploads (multipart; used before login and for standalone attachments not tied to a chat message). --
export async function uploadPublicFile(file, purpose, onProgress) {
const form = new FormData();
form.append('purpose', purpose);
form.append('file', file);
// Use XHR instead of fetch to get upload progress events.
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', apiBase() + '/public/uploads');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('X-IM-Schema', '1');
xhr.setRequestHeader('X-Tenant-Code', currentTenantCode());
xhr.setRequestHeader('X-Request-ID', requestId());
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
if (xhr.status >= 200 && xhr.status < 300 && payload.code === 0) {
resolve(normalizeResponse(payload));
} else {
reject(new ImApiError(payload.message || payload.reason || '上传失败', payload.reason, xhr.status, payload.details));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Native large-file upload (Ch.6 group avatars & group files). uploadPublicFile() above can't be reused --
// its `purpose` is server-whitelisted to exactly 'enterprise_license'/'tenant_entry_feedback'. The vendored
// SDK's own tinode.getLargeFileHelper().upload() talks to the same underlying native endpoint but gets two
// headers wrong for this backend (verified live against the real server, not just read from source):
// it sends 'X-Tinode-APIKey' (server only accepts 'X-MeetingIM-APIKey', same rename Phase 0 already applied
// everywhere else in this file) and 'X-Tinode-Auth' (server's getHttpAuth() doesn't recognize that header
// name at all, only 'X-MeetingIM-Auth' or plain 'Authorization'). The auth *value* must be the native Tinode
// session token (tinode.getAuthToken().token, scheme 'Token') -- the REST layer's own sessionStorage Bearer
// token is a separate namespace and fails this endpoint's base64 secret decode with a 400. Response is
// Tinode's native {ctrl:{code,text,params:{url,expires}}} envelope, not the {code:0,data} REST envelope --
// there's no separate file_id field; the id is the last path segment of `url`, kept verbatim (including its
// extension suffix) since getFileUrl() already round-trips that exact format unmodified.
export function uploadLargeFile(tinode, file, onProgress) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
if (onProgress) {
xhr.upload.onprogress = e => onProgress(e.lengthComputable ? e.loaded / e.total : 0);
}
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
const url = ctrl.params.url;
resolve({fileId: url.slice(url.lastIndexOf('/') + 1), url, expires: ctrl.params.expires});
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for sending chat attachments
// (messages-view.jsx). The vendored SDK's LargeFileHelper hardcodes the same two wrong headers
// uploadLargeFile() above works around ('X-Tinode-APIKey' instead of 'X-MeetingIM-APIKey', and
// 'X-Tinode-Auth' instead of 'Authorization') -- confirmed live: every image/video/file/voice message
// too large to send inband was silently failing to upload. Can't just call uploadLargeFile() at each
// call site instead, because messages-view.jsx also depends on LargeFileHelper's cancel-in-flight-send
// support (handleCancelUpload) and its urlPromise contract (Drafty.attachFile/insertImage/insertVideo/
// insertAudio expect the resolved value to be a bare URL string, or for video a [url, prevUrl] pair --
// not the {fileId,url,expires} object uploadLargeFile() returns). This mirrors just that subset of the
// real class's interface (upload() returning a bare-URL promise, onProgress, and cancel() aborting any
// still-in-flight XHRs via the same `this.xhr` array pattern the real LargeFileHelper.cancel() uses).
export function createChatUploader(tinode) {
const xhrs = [];
return {
onProgress: null,
upload(file) {
const self = this;
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
return Promise.reject(new ImApiError('尚未登录,无法上传文件', 'AUTH_REQUIRED', 401));
}
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('file', file);
form.set('id', tinode.getNextUniqueId());
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('POST', fileBase() + '/u/');
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.upload.onprogress = e => {
if (e.lengthComputable && self.onProgress) {
self.onProgress(e.loaded / e.total);
}
};
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch (e) { /* handled below */ }
const ctrl = payload.ctrl || {};
if (xhr.status >= 200 && xhr.status < 300 && ctrl.params && ctrl.params.url) {
resolve(ctrl.params.url);
} else {
reject(new ImApiError(ctrl.text || '上传失败', 'UPLOAD_FAILED', xhr.status));
}
};
xhr.onerror = () => reject(new ImApiError('网络错误,上传失败', 'NETWORK_ERROR', 0));
xhr.send(form);
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// Fixed-headers drop-in replacement for tinode.getLargeFileHelper(), for downloading a chat/favorite
// out-of-band attachment (content-library-view.jsx's DownloadPage). Same wrong-header bug as
// createChatUploader() above -- the vendored SDK's LargeFileHelper.download() also hardcodes
// 'X-Tinode-APIKey'/'X-Tinode-Auth', so every real (non-blob:/data:) download was silently failing.
// Mirrors the real class's download()/cancel() interface exactly (including the onProgress/onError
// callback-style signature DownloadPage already calls it with) so the call site needs no other change.
export function createFileDownloader(tinode) {
const xhrs = [];
return {
download(relativeUrl, filename, mimetype, onProgress, onError) {
const authToken = tinode && tinode.getAuthToken && tinode.getAuthToken();
if (!authToken) {
const err = new ImApiError('尚未登录,无法下载文件', 'AUTH_REQUIRED', 401);
if (onError) { onError(err); }
return Promise.reject(err);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrs.push(xhr);
xhr.open('GET', relativeUrl, true);
xhr.setRequestHeader('X-MeetingIM-APIKey', API_KEY);
xhr.setRequestHeader('Authorization', 'Token ' + authToken.token);
xhr.responseType = 'blob';
xhr.onprogress = e => { if (onProgress) { onProgress(e.loaded); } };
xhr.onload = () => {
if (xhr.status === 200) {
const link = document.createElement('a');
link.href = window.URL.createObjectURL(new Blob([xhr.response], {type: mimetype}));
link.style.display = 'none';
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
resolve();
} else {
const reader = new FileReader();
reader.onload = function() {
let err;
try {
const pkt = JSON.parse(this.result);
err = new ImApiError((pkt.ctrl && pkt.ctrl.text) || '下载失败', 'DOWNLOAD_FAILED', xhr.status);
} catch (e) {
err = new ImApiError('下载失败', 'DOWNLOAD_FAILED', xhr.status);
}
if (onError) { onError(err); }
reject(err);
};
reader.readAsText(xhr.response);
}
};
xhr.onerror = () => {
const err = new ImApiError('网络错误,下载失败', 'NETWORK_ERROR', 0);
if (onError) { onError(err); }
reject(err);
};
xhr.send();
});
},
cancel() {
xhrs.forEach(req => { if (req.readyState < 4) { req.abort(); } });
}
};
}
// -- 1.1 Tenant resolution & entry (pre-login, no Authorization header). --
export function resolveTenant(tenantCode) {
return imRequest('/tenants/resolve', {
method: 'POST', auth: false, tenantCodeOverride: tenantCode,
body: {tenant_code: tenantCode, platform: 'web', app_version: PACKAGE_VERSION || '0.25.3'}
});
}
export function submitTenantApplication(body) {
return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(), body});
}
export function submitTenantEntryFeedback(body) {
return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(), body});
}
// -- 1.4/1.5/1.6 Phone verification codes (register / reset_password / security scenes). --
export function requestVerificationCode(scene, phone) {
return imRequest('/auth/verification-codes', {method: 'POST', auth: false, headers: idem(), body: {scene, phone}});
}
export function verifyRegisterCode(challengeId, code) {
return imRequest('/auth/verification-codes/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function verifyPasswordResetCode(challengeId, code) {
return imRequest('/auth/password-reset/challenges/' + encodeURIComponent(challengeId) + '/verify',
{method: 'POST', auth: false, body: {code}});
}
export function confirmPasswordReset(resetToken, newPassword) {
return imRequest('/auth/password-reset/confirm',
{method: 'POST', auth: false, headers: idem(), body: {reset_token: resetToken, new_password: newPassword}});
}
// -- Bootstrap / home. --
export function getBootstrap() {
return imRequest('/bootstrap');
}
export function getMe() {
return imRequest('/me');
}
// -- Realname (1.8/1.9). --
export function getRealnameStatus() {
return imRequest('/security/realname');
}
export function submitRealname(body) {
return imPost('/security/realname/submissions', body);
}
// -- Security challenge (1.7) & independent password (1.11). --
export function createSecurityChallenge(scene) {
return imPost('/security/challenges', {scene, channel: 'sms'});
}
export function verifySecurityChallenge(challengeId, code) {
return imPost('/security/challenges/' + encodeURIComponent(challengeId) + '/verify', {code});
}
export function setIndependentPassword(password, purpose, actionToken) {
return imPost('/settings/independent-password', {password, purpose, action_token: actionToken});
}
// `action` defaults server-side to 'close_youth_mode' if omitted (server/im_http.go
// imIndependentPasswordVerifyRequest) -- pass 'set_independent_password' to mint a token usable
// with enableYouthMode instead, letting an already-set password unlock re-enabling without
// forcing another SMS round-trip through the identity-check flow.
export function verifyIndependentPassword(password, purpose, action) {
return imPost('/settings/independent-password/verify', {password, purpose, action});
}
// -- Friends / contacts (Ch.5). --
export const getFriends = (query = '', cursor = '', initial = '') => imGet('/friends' + qs({query, cursor, initial}));
export const getFriendRequests = (direction = 'inbound', status = 'all', cursor = '') =>
imGet('/friend-requests' + qs({direction, status, cursor}));
export const createFriendRequest = (targetUserId, message = '', source = 'search', sourceTopic = '') =>
imPost('/friend-requests', {target_user_id: targetUserId, message, source, source_topic: sourceTopic});
export const acceptFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
export const rejectFriendRequest = (requestId_, version) => imPost('/friend-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const deleteFriend = userId => imDelete('/friends/' + encodeURIComponent(userId));
export const updateFriendRemark = (userId, remark) => imPatch('/friends/' + encodeURIComponent(userId), {remark});
export const searchUsers = (query, type = 'account', cursor = '') => imGet('/users/search' + qs({q: query, type, cursor}));
export const getUserProfile = identifier => imGet('/users/' + encodeURIComponent(identifier) + '/profile');
export const createMyQRCode = (expiresInSeconds = 86400) => imPost('/me/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const createUserQRCode = (identifier, expiresInSeconds = 86400) =>
imPost('/users/' + encodeURIComponent(identifier) + '/qrcodes', {purpose: 'add_friend', expires_in_seconds: expiresInSeconds});
export const resolveQRCode = (token, purpose = 'add_friend') => imPost('/qrcodes/resolve', {token, purpose});
export const getBlocks = (cursor = '') => imGet('/blocks' + qs({cursor}));
export const blockUser = userId => imPut('/blocks/' + encodeURIComponent(userId), {});
export const unblockUser = userId => imDelete('/blocks/' + encodeURIComponent(userId));
export const batchUnblockUsers = userIds => imPost('/blocks/batch-delete', {user_ids: userIds});
// -- Conversations (Ch.2/3). --
export const searchConversations = (query = '', cursor = '') => imGet('/search/conversations' + qs({q: query, cursor}));
export const getSystemNotifications = (cursor = '', limit = 20) => imGet('/system-notifications' + qs({cursor, limit}));
export const markSystemNotificationRead = id => imPost('/system-notifications/' + encodeURIComponent(id) + '/read', {read_at: new Date().toISOString()});
export const getConversationContext = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/context');
export const createP2PConversation = peerUserId => imPost('/conversations/p2p', {peer_user_id: peerUserId});
export const getConversationSettings = topic => imGet('/conversations/' + encodeURIComponent(topic) + '/settings');
export const updateConversationSettings = (topic, body) => imPatch('/conversations/' + encodeURIComponent(topic) + '/settings', body);
// -- Messages (Ch.4/7). --
export const recallMessage = (topic, seq, version = 1, reason = 'mistake') =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/recall', {version, reason});
export const reviseMessage = (topic, seq, content, version = 1) =>
imPost('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/revisions', {content, client_mid: requestId(), version});
export const getMessageReceipts = (topic, seq, cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/receipts' + qs({cursor}));
// type='all' searches every message type (text/image/video/voice/file/link) -- the backend's own
// default when the param is omitted/empty is "no filter", so defaulting to 'text' here would have
// silently restricted every unqualified call to text-only messages.
export const searchTopicMessages = (topic, {query = '', type = 'all', cursor = ''} = {}) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/search' + qs({q: query, type, cursor}));
export const getMessageContext = (topic, seq, before = 20, after = 20) =>
imGet('/topics/' + encodeURIComponent(topic) + '/messages/' + encodeURIComponent(seq) + '/context' + qs({before, after}));
export const getTopicMedia = (topic, type = 'image', cursor = '') =>
imGet('/topics/' + encodeURIComponent(topic) + '/media' + qs({type, cursor}));
export const getForwardTargets = (query = '', cursor = '') => imGet('/forward-targets' + qs({query, cursor}));
export const forwardMessages = (mode, sources, targetTopics, comment = '') =>
imPost('/messages/forward', {mode, sources, target_topics: targetTopics, comment});
export const getForwardBundle = (bundleId, cursor = '') => imGet('/forward-bundles/' + encodeURIComponent(bundleId) + qs({cursor}));
export const favoriteMessages = messages => imPost('/favorites/batch', {messages: messages.map(m => ({...m, seq: String(m.seq)}))});
export const getFavorites = ({type = 'all', query = '', cursor = ''} = {}) => imGet('/favorites' + qs({type, q: query, cursor}));
export const getFavorite = favoriteId => imGet('/favorites/' + encodeURIComponent(favoriteId));
export const deleteFavorite = favoriteId => imDelete('/favorites/' + encodeURIComponent(favoriteId));
export const batchDeleteFavorites = favoriteIds => imPost('/favorites/batch-delete', {favorite_ids: favoriteIds});
export const forwardFavorites = (favoriteIds, targetTopics, comment = '') =>
imPost('/favorites/forward', {favorite_ids: favoriteIds, target_topics: targetTopics, comment});
export const checkURL = (url, source = 'favorite') => imPost('/security/urls/check', {url, source});
export const confirmRiskChallenge = (riskChallengeId, decision = 'continue') =>
imPost('/security/risk-challenges/' + encodeURIComponent(riskChallengeId) + '/confirm', {decision});
export const getMentionableGroupMembers = (topic, query = '', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' + qs({mentionable: true, query, cursor}));
export async function fetchFileState(fileId, range = '') {
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// GET /v0/file/s/{id} is the native Tinode file-serving endpoint, not the /v1/im REST API --
// it's auth-gated by the vendored SDK's auth-handler registry, which is keyed by the literal
// scheme word in the header and only recognizes 'token' (used by uploadLargeFile above), never
// 'bearer'. Sending 'Bearer' here (as imRequest correctly does for actual /v1/im/* calls) gets a
// flat 400 "malformed" from every single call -- confirmed live against the real backend: same
// token value, only the scheme word differs. authToken() itself already holds the right value
// (tinode-web.jsx's handleLoginSuccessful stores tinode.getAuthToken().token verbatim into
// sessionStorage['im-session-token']), so no plumbing change is needed beyond the scheme word.
if (token) {
headers.Authorization = 'Token ' + token;
}
if (range) {
headers.Range = range;
}
const response = await fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers});
return {
ok: response.ok, status: response.status,
contentType: response.headers.get('content-type') || '',
contentLength: response.headers.get('content-length') || '',
acceptRanges: response.headers.get('accept-ranges') || '',
etag: response.headers.get('etag') || ''
};
}
export function publicFileBase() {
return fileBase();
}
// File serving (GET /v0/file/s/{id}) is auth-gated -- the upload endpoints never return a directly
// servable URL (publicUploadPayload only has file_id/purpose/mime_type/size/expires_at), so a plain
// <img src> can't be pointed at it (browsers don't attach custom headers to img/video/audio requests).
// This fetches the bytes with the right headers once per file_id and hands back a blob: object URL,
// cached so avatars/attachments repeated across a list (friends, search results, ...) aren't refetched.
const fileUrlCache = new Map();
export function getFileUrl(fileId) {
if (!fileId) {
return Promise.resolve(null);
}
if (fileUrlCache.has(fileId)) {
return fileUrlCache.get(fileId);
}
const headers = {
'X-MeetingIM-APIKey': API_KEY, 'X-IM-Schema': '1',
'X-Tenant-Code': currentTenantCode(), 'X-Request-ID': requestId()
};
const token = authToken();
// Same native-endpoint scheme requirement as fetchFileState() above -- 'Token', not 'Bearer'.
if (token) {
headers.Authorization = 'Token ' + token;
}
const promise = fetch(fileBase() + '/s/' + encodeURIComponent(fileId), {headers}).then(res => {
if (!res.ok) {
throw new ImApiError('文件加载失败', 'FILE_FETCH_FAILED', res.status);
}
return res.blob();
}).then(blob => URL.createObjectURL(blob)).catch(err => {
fileUrlCache.delete(fileId);
throw err;
});
fileUrlCache.set(fileId, promise);
return promise;
}
// -- Groups (Ch.6). --
export const getGroups = ({category = 'joined', query = '', cursor = ''} = {}) => imGet('/groups' + qs({category, query, cursor}));
export const createGroup = ({name, description = '', memberUserIds = [], avatarFileId = ''}) =>
imPost('/groups', {name, description, member_user_ids: memberUserIds, avatar_file_id: avatarFileId});
export const getGroup = topic => imGet('/groups/' + encodeURIComponent(topic));
export const updateGroup = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic), body);
export const getGroupSettings = topic => imGet('/groups/' + encodeURIComponent(topic) + '/settings');
export const updateGroupMemberSettings = (topic, {pinned, mute_notifications}) =>
imPatch('/groups/' + encodeURIComponent(topic) + '/member-settings', {pinned, mute_notifications});
export const updateMyGroupProfile = (topic, nickname, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/members/me/profile', {nickname, version});
export const leaveGroup = topic => imPost('/groups/' + encodeURIComponent(topic) + '/leave', {});
export const getGroupMembers = (topic, {query = '', cursor = '', role = '', eligibleRole = '', mentionable, muteEligible} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/members' +
qs({query, cursor, role, eligible_role: eligibleRole, mentionable: mentionable ? 'true' : '', mute_eligible: muteEligible ? 'true' : ''}));
export const createGroupQRCode = (topic, expiresInSeconds = 86400) =>
imPost('/groups/' + encodeURIComponent(topic) + '/qrcodes', {expires_in_seconds: expiresInSeconds});
export const inviteGroupMembers = (topic, userIds, message = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/invitations', {user_ids: userIds, message});
export const acceptGroupInvitation = invitationId => imPost('/group-invitations/' + encodeURIComponent(invitationId) + '/accept', {});
export const createGroupJoinRequest = (topic, {message = '', source = 'qr', qrToken = ''} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests', {message, source, qr_token: qrToken});
export const getGroupJoinRequests = (topic, status = 'pending', cursor = '') =>
imGet('/groups/' + encodeURIComponent(topic) + '/join-requests' + qs({status, cursor}));
export const acceptGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/accept', version ? {version} : {});
// Server struct (imGroupJoinRequestActionRequest) only has `Version` -- no reason field exists, and the
// business layer hardcodes an empty reject-reason even internally, so there's nothing to send here. Every
// JSON body goes through decodeIMJSONBody's DisallowUnknownFields(), so including an extra `reason` key
// (as an earlier draft of this function did) makes every single call 400 regardless of its value.
export const rejectGroupJoinRequest = (topic, requestId_, version) =>
imPost('/groups/' + encodeURIComponent(topic) + '/join-requests/' + encodeURIComponent(requestId_) + '/reject', version ? {version} : {});
export const getGroupSummary = topic => imGet('/groups/' + encodeURIComponent(topic) + '/summary');
export const getLatestGroupAnnouncement = topic => imGet('/groups/' + encodeURIComponent(topic) + '/announcements/latest');
export const updateCurrentGroupAnnouncement = (topic, content, version) =>
imPut('/groups/' + encodeURIComponent(topic) + '/announcements/current', {content, version});
export const getGroupAnnouncement = (topic, announcementId) =>
imGet('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId));
export const readGroupAnnouncement = (topic, announcementId) =>
imPost('/groups/' + encodeURIComponent(topic) + '/announcements/' + encodeURIComponent(announcementId) + '/read', {});
export const getGroupGovernance = topic => imGet('/groups/' + encodeURIComponent(topic) + '/governance');
export const updateGroupGovernance = (topic, body) => imPatch('/groups/' + encodeURIComponent(topic) + '/governance', body);
export const getGroupRoleLimits = topic => imGet('/groups/' + encodeURIComponent(topic) + '/role-limits');
export const addGroupAdmins = (topic, userIds, version) => imPost('/groups/' + encodeURIComponent(topic) + '/admins/batch', {user_ids: userIds, version});
// Must merge into idem()'s headers, not replace them (imDelete/imPost's `{headers: idem(), ...opts}`
// lets a same-key `opts.headers` clobber the whole object) -- the same Idempotency-Key-goes-missing
// bug this codebase already found and fixed once in disableYouthMode.
export const deleteGroupAdmin = (topic, userId, version) =>
imDelete('/groups/' + encodeURIComponent(topic) + '/admins/' + encodeURIComponent(userId), version ? {headers: {...idem(), 'If-Match': String(version)}} : {});
export const muteGroupMembers = (topic, members, reason = '') => imPost('/groups/' + encodeURIComponent(topic) + '/mutes', {members, reason});
export const getGroupMutes = (topic, cursor = '') => imGet('/groups/' + encodeURIComponent(topic) + '/mutes' + qs({cursor}));
export const unmuteGroupMember = (topic, userId) => imDelete('/groups/' + encodeURIComponent(topic) + '/mutes/' + encodeURIComponent(userId));
export const transferGroupOwner = (topic, targetUserId, version, actionToken = '') =>
imPost('/groups/' + encodeURIComponent(topic) + '/owner-transfer', {target_user_id: targetUserId, version},
actionToken ? {headers: {...idem(), 'X-Action-Token': actionToken}} : {});
// Real backend reads the search text from `q`, not `query` (confirmed against ListFiles's real
// query-param handling, same bug class as getOrganizationMembers) -- the search box in Group Files
// was silently a no-op.
export const getGroupFiles = (topic, {query = '', type = '', cursor = ''} = {}) =>
imGet('/groups/' + encodeURIComponent(topic) + '/files' + qs({q: query, type, cursor}));
// All fields besides file_id are `omitempty` server-side, so omitting them (as an earlier draft of this
// function did) never errors -- it just registers a file with a blank name and 0 size in the files list.
export const addGroupFile = (topic, {fileId, fileName = '', mimeType = '', fileType = '', fileSize = 0, sourceTopic = '', sourceSeq} = {}) =>
imPost('/groups/' + encodeURIComponent(topic) + '/files', {
file_id: fileId, file_name: fileName, mime_type: mimeType, file_type: fileType, file_size: fileSize,
source_topic: sourceTopic, source_seq: sourceSeq
});
export const deleteGroupFile = (topic, groupFileId) => imDelete('/groups/' + encodeURIComponent(topic) + '/files/' + encodeURIComponent(groupFileId));
export const getGroupMemberProfile = (topic, userId) => imGet('/groups/' + encodeURIComponent(topic) + '/members/' + encodeURIComponent(userId) + '/profile');
// -- Organization / workbench (Ch.9). --
export const getWorkbench = () => imGet('/workbench');
export const getOrganizationDepartments = (parentId = 'root', cursor = '') => imGet('/organization/departments' + qs({parent_id: parentId, cursor}));
export const getOrganizationDepartmentMembers = (departmentId, cursor = '') =>
imGet('/organization/departments/' + encodeURIComponent(departmentId) + '/members' + qs({cursor}));
// Real backend only reads `q` (server/im_http.go), never `query` -- confirmed against
// server/im_http_test.go's own request fixtures. This function used to send `query`, silently making
// every caller's search box a no-op (server ignores the unrecognized param and returns the unfiltered
// list every time).
export const getOrganizationMembers = ({query = '', departmentId = '', cursor = ''} = {}) =>
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor}));
export const searchOrganization = (query = '', types = 'department,user', cursor = '') =>
imGet('/organization/search' + qs({q: query, types, cursor}));
export const getMeetings = (cursor = '', limit = 20) => imGet('/meetings' + qs({limit, cursor}));
export const createMeeting = body => imPost('/meetings', body);
export const getMeeting = meetingId => imGet('/meetings/' + encodeURIComponent(meetingId));
export const updateMeeting = (meetingId, body) => imPatch('/meetings/' + encodeURIComponent(meetingId), body);
export const cancelMeeting = (meetingId, version, reason = '时间冲突') => imPost('/meetings/' + encodeURIComponent(meetingId) + '/cancel', {version, reason});
export const sendMeetingReminders = meetingId => imPost('/meetings/' + encodeURIComponent(meetingId) + '/reminders', {});
export const respondMeeting = (meetingId, response, version) => imPut('/meetings/' + encodeURIComponent(meetingId) + '/my-response', {response, version});
// Note: the server only reads scope/from/to/status/cursor -- 'date' and 'department_id' are
// silently ignored (confirmed against server/im_http.go), so a single-day filter must be
// expressed as from==to, and there is no way to scope this list to one department.
export const getDailyReports = ({scope = 'mine', from = '', to = '', status = '', cursor = ''} = {}) =>
imGet('/reports/daily' + qs({scope, from, to, status, cursor}));
export const createDailyReport = reportDate => imPost('/reports/daily', {report_date: reportDate});
export const getDailyReport = reportId => imGet('/reports/daily/' + encodeURIComponent(reportId));
export const updateDailyDraft = (reportId, content, version, attachments = []) =>
imPut('/reports/daily/' + encodeURIComponent(reportId) + '/draft', {content, attachments, version});
export const submitDailyReport = (reportId, version) => imPost('/reports/daily/' + encodeURIComponent(reportId) + '/submit', {version});
// department_id is also ignored here -- statistics are always tenant-wide.
export const getDailyStatistics = date => imGet('/reports/daily/statistics' + qs({date}));
export const sendDailyReminders = (date, userIds = []) => imPost('/reports/daily/reminders', {date, user_ids: userIds});
export const getMonthlyReports = ({scope = 'mine', fromMonth = '', toMonth = '', status = '', cursor = ''} = {}) =>
imGet('/reports/monthly' + qs({scope, from_month: fromMonth, to_month: toMonth, status, cursor}));
export const createMonthlyReport = month => imPost('/reports/monthly', {month});
export const getMonthlyReport = reportId => imGet('/reports/monthly/' + encodeURIComponent(reportId));
export const updateMonthlyDraft = (reportId, workSummary, nextPlan, version, attachments = []) =>
imPut('/reports/monthly/' + encodeURIComponent(reportId) + '/draft', {work_summary: workSummary, next_plan: nextPlan, attachments, version});
export const submitMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/submit', {version});
export const reopenMonthlyReport = (reportId, version, reason = '补充内容') =>
imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version, reason});
// department_id is ignored here too -- statistics are always tenant-wide.
export const getMonthlyStatistics = month => imGet('/reports/monthly/statistics' + qs({month}));
export const getExecutiveMailboxConfig = () => imGet('/executive-mailbox/config');
export const submitExecutiveMailboxMessage = body => imPost('/executive-mailbox/submissions', body);
// -- Personal settings (Ch.10). --
export const getMeProfile = () => imGet('/me/profile');
export const updateMeProfile = body => imPatch('/me/profile', body);
export const getMePrivacy = () => imGet('/me/privacy');
export const updateMePrivacy = body => imPatch('/me/privacy', body);
export const getYouthMode = () => imGet('/settings/youth-mode');
export const enableYouthMode = (policyVersion, actionToken) => imPost('/settings/youth-mode/enable', {policy_version: policyVersion, action_token: actionToken});
// imPost spreads `opts` last, so passing `headers` directly would silently replace (not merge
// with) the Idempotency-Key header from idem() -- confirmed live: the server actually enforces
// it ("Idempotency-Key is required", 400), unlike the earlier research pass's assumption that a
// missing key here was harmless. Merge explicitly instead of letting the spread clobber it.
export const disableYouthMode = actionToken => imPost('/settings/youth-mode/disable', {}, {headers: {...idem(), 'X-Action-Token': actionToken}});
export const getLatestAppRelease = ({platform = 'android', currentVersion = '1.0.0', channel = 'stable'} = {}) =>
imGet('/app/releases/latest' + qs({platform, current_version: currentVersion, channel}));
export const updateDevicePushToken = (deviceId, body) => imPut('/devices/' + encodeURIComponent(deviceId) + '/push-token', body);
// -- Reports & feedback (Ch.11). --
export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/reasons' + qs({target_type: targetType}));
export const submitAbuseReport = body => imPost('/reports/abuse', body);
export const getFeedbackConfig = () => imGet('/feedback/config');
export const submitFeedback = body => imPost('/feedback', body);