// 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: '该操作已被安全策略拦截', // 独立密码校验的两个 reason(对接文档 809 行有写),青少年模式关闭/开启走的都是这条接口。 // 原来没收录,报错时直接透传后端英文原文——PIN 输错显示 "code invalid",连续错误锁定后 // 显示 "too many attempts",且不告诉用户要等多久能重试。 CODE_INVALID: '独立密码不正确', TOO_MANY_ATTEMPTS: details => details && details.retry_after_seconds ? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试` : '连续输错次数过多,请稍后重试' }; 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 entry = payload.reason && REASON_MESSAGES[payload.reason]; const known = typeof entry === 'function' ? entry(payload.details) : entry; 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'} }); } // 企业注册申请 / 入口反馈 / 意见反馈三个接口后端都定义了 client_context 且会入库 // (imTenantApplicationRequest / imTenantEntryFeedbackRequest / imFeedbackRequest, // server/im_http.go:255/263/367,落库点 :790/:840/:1478),需求文档和对接文档也都要求带上 // 设备与版本信息。前端一直没发。在这里统一注入,省得三处各写一遍、也不会再漏。 function clientContext() { return { platform: 'web', app_version: PACKAGE_VERSION || '', user_agent: (typeof navigator !== 'undefined' && navigator.userAgent) || '', language: (typeof navigator !== 'undefined' && navigator.language) || '', // 网络类型不是所有浏览器都有(Safari/Firefox 没有 navigator.connection),拿不到就不发这一项 network: (typeof navigator !== 'undefined' && navigator.connection && navigator.connection.effectiveType) || undefined }; } export function submitTenantApplication(body) { return imRequest('/tenant-applications', {method: 'POST', auth: false, headers: idem(), body: {client_context: clientContext(), ...body}}); } export function submitTenantEntryFeedback(body) { return imRequest('/tenant-entry-feedback', {method: 'POST', auth: false, headers: idem(), body: {client_context: clientContext(), ...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). -- // version 只能默认传 1:服务端做的是乐观锁校验(tenant_adapter.go:1963 要求 msg.Version 与传入值 // 完全相等,否则 ErrDuplicate),但**没有任何消息读取接口下发过 version 字段**——messagePayload / // messageSearchPayload / mediaMessagePayload / favoriteSnapshot 全都没有,冲突时的错误里也不回传 // 真实值。所以前端拿不到正确的版本号,被编辑过(version>1)的消息撤回必然失败。这是后端缺口, // 已记入《IM-后端问题清单.md》,不在前端绕。 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})); // 4.1 跨话题草稿:GET 对不存在的草稿返回 {content:'',entities:[],version:0} 而不是 404,PUT 用 // version 做乐观锁(对接文档描述的样子)。真实验证过用原生话题名(P2P 是对方 user_id,不是 // REST 层内部的 p2pXXX id)可以正常读写——这个接口内部会先查 Subs.Get 校验会话存在/有权限, // 跟 favorites 那条从不做话题归一化、直接 NOT_FOUND 的坏路径不是同一个问题。 export const getTopicDraft = topic => imGet('/topics/' + encodeURIComponent(topic) + '/draft'); export const updateTopicDraft = (topic, content, entities, version) => imPut('/topics/' + encodeURIComponent(topic) + '/draft', {content, entities, version}); 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 // 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}); // GET .../members/me/profile is the only endpoint that actually returns the saved nickname -- // confirmed live that GET .../members (getGroupMembers below) never includes group_nickname on any // item, so anything reading the nickname from that list (as this file's own callers used to) will // always see it as unset even right after a successful save. export const getMyGroupProfile = topic => imGet('/groups/' + encodeURIComponent(topic) + '/members/me/profile'); 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}); // server/im_http.go's serveIMMonthlyReportReopen decodes only {version} (DisallowUnknownFields // rejects anything else) -- an earlier draft of this function sent an extra `reason` field that // would 400 MALFORMED_REQUEST on every real call. Also: the store layer's UPDATE has // `WHERE ... AND author_id=?` keyed off the *caller's* uid, so this only ever lets a report's own // author reopen their own already-submitted report for further editing -- there is no // manager-reopens-someone-else's-report capability on the backend. export const reopenMonthlyReport = (reportId, version) => imPost('/reports/monthly/' + encodeURIComponent(reportId) + '/reopen', {version}); // 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', {client_context: clientContext(), ...body});