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:
co-authored by
Claude Sonnet 5
parent
ba8f48ee83
commit
251c9de87c
@@ -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);
|
||||
Reference in New Issue
Block a user