后端问题清单"新增2"记录过:POST /reports/daily/reminders 要求调用方传入完整候选 user_ids 名单(服务端只做"从候选里筛出未交的人再提醒",不会自己算全公司名单), 但前端一直传空数组,必然命中服务端"user_ids为空"的400校验。 重新核实时发现这条其实不是后端缺口——GET /organization/members 不传 q/department_id 时就会返回调用者可见范围内的全部在职成员(分页),这个接口本来就在会议参会人选择器 里用着,只是"提醒未提交成员"这个按钮没有复用它。改为点击时先分页拉取全部组织成员 user_id,再传给提醒接口;组织架构为空时给出明确提示而不是让请求再次落入400。 已在浏览器验证请求格式(limit=100,与后端实际接受的参数一致)和空组织架构下的 兜底提示;当前测试租户组织架构数据本身为空,未能覆盖"真的有人被提醒"这条路径, 但接口契约已对照 im_be 源码逐字段核实。
837 lines
51 KiB
JavaScript
837 lines
51 KiB
JavaScript
// 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 = {
|
||
// Real per-account risk is low (this app always sends a hardcoded key, so hitting these means a
|
||
// deployment-wide config problem, not something an individual user did) -- but exactly this "the
|
||
// whole site is silently misconfigured" scenario has genuinely happened once already (a stale API
|
||
// key got redeployed to production), so it's worth a real message instead of raw English site-wide.
|
||
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||
AUTH_FAILED: '账号或密码错误',
|
||
TOKEN_EXPIRED: '登录已过期,请重新登录',
|
||
ACCOUNT_DISABLED: '账号已被禁用',
|
||
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||
VALIDATION_FAILED: '提交的内容不符合要求',
|
||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||
// 后端 server/im_http.go 里 30+ 处 403 用的都是 imReasonPermissionDenied="PERMISSION_DENIED",
|
||
// 从未发送过字面量 "FORBIDDEN"——原来的 FORBIDDEN 条目是死代码,全站 403 一直透传英文原文。
|
||
PERMISSION_DENIED: '没有权限执行此操作',
|
||
NOT_FOUND: '内容不存在或已被删除',
|
||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||
AUTH_REQUIRED: '请先登录',
|
||
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
|
||
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
|
||
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
|
||
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
|
||
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
|
||
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
|
||
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
|
||
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
|
||
VERIFICATION_RATE_LIMITED: details => details && details.retry_after_seconds
|
||
? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试`
|
||
: '操作过于频繁,请稍后再试',
|
||
ALREADY_GROUP_MEMBER: '对方已经是群成员',
|
||
DESCRIPTION_REQUIRED: '请填写简介内容',
|
||
DESCRIPTION_TOO_LONG: '简介内容过长',
|
||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||
INVALID_EMAIL: '邮箱格式不正确',
|
||
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
|
||
TOO_MANY_FILES: '文件数量超出限制',
|
||
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: '该操作已被安全策略拦截',
|
||
// CODE_INVALID is shared by two unrelated verify endpoints -- independent-password PIN check
|
||
// (青少年模式关闭/开启走的这条接口,对接文档 809 行) *and* the plain SMS verification-code
|
||
// verify endpoints (/auth/verification-codes/{id}/verify, /security/challenges/{id}/verify --
|
||
// confirmed live: entering a wrong SMS code on the "忘记独立密码?" identity-check screen also
|
||
// gets back reason=CODE_INVALID). This table only keys off the reason string with no per-call-site
|
||
// override, so a message written for just one of the two ("独立密码不正确") showed up verbatim on
|
||
// the *other* endpoint's screen, where there's no "独立密码" concept in view at all -- confusing,
|
||
// wrong-sounding copy for a real, correctly-rejected wrong code. Kept generic enough to be accurate
|
||
// for both rather than wrong in either.
|
||
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 known = translateReason(payload.reason, payload.details);
|
||
throw new ImApiError(known || payload.message || payload.reason || response.statusText || '请求失败',
|
||
payload.reason, response.status, payload.details);
|
||
}
|
||
return normalizeResponse(payload);
|
||
}
|
||
|
||
// Several batch endpoints (favorites/batch, favorites/forward, messages/forward, ...) always return
|
||
// HTTP 200 and bury the real per-item outcome in a nested `items[]`/`failed[]` array instead of
|
||
// throwing -- imRequest's translation above never runs for those, since it only fires on a non-2xx/
|
||
// non-zero-code response. Exported so call sites reading one of those per-item `reason` strings
|
||
// directly (e.g. send-message.jsx's sendFavorite) can reuse the exact same table instead of either
|
||
// duplicating it or -- as was actually happening -- showing the raw untranslated reason string.
|
||
export function translateReason(reason, details) {
|
||
const entry = reason && REASON_MESSAGES[reason];
|
||
return (typeof entry === 'function' ? entry(details) : entry) || null;
|
||
}
|
||
|
||
const idem = () => ({'Idempotency-Key': requestId()});
|
||
|
||
// Most list endpoints here cap out at 20-50 items per page (some as low as 30, e.g. group
|
||
// members against a 128-member cap) and round7 testing found several call sites only ever
|
||
// fetched the first page -- for things like "am I this group's owner/admin" or the admin
|
||
// candidate list, a real owner/admin sitting past page 1 went undetected entirely rather than
|
||
// just showing an incomplete list. Empty/repeated next_cursor means done; this app's list
|
||
// responses haven't been observed to send a separate has_more that disagrees with that.
|
||
export async function fetchAllPages(fetchPage, startCursor = '') {
|
||
let cursor = startCursor;
|
||
let items = [];
|
||
for (;;) {
|
||
const page = await fetchPage(cursor);
|
||
items = items.concat((page && page.items) || []);
|
||
const nextCursor = (page && page.next_cursor) || '';
|
||
if (!nextCursor || nextCursor === cursor) {
|
||
break;
|
||
}
|
||
cursor = nextCursor;
|
||
}
|
||
return items;
|
||
}
|
||
|
||
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.
|
||
// This upload talks to Tinode's native {ctrl} protocol (see the comment above), not the REST layer,
|
||
// so REASON_MESSAGES above never applies to it -- confirmed against server/datamodel.go's Err*
|
||
// constructors for the literal ctrl.text values a failure here can actually carry.
|
||
const UPLOAD_CTRL_TEXT_CN = {
|
||
'too large': '文件大小超出限制',
|
||
'malformed': '请求内容有误,请稍后重试',
|
||
'permission denied': '没有权限执行此操作',
|
||
'gone': '内容不存在或已被删除',
|
||
'not found': '内容不存在或已被删除'
|
||
};
|
||
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 {
|
||
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
|
||
reject(new ImApiError(known || 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 {
|
||
// Same native-{ctrl}-protocol translation as uploadLargeFile above -- REASON_MESSAGES
|
||
// never applies here, and a too-large photo/video/file picked from the attachment panel
|
||
// is a realistic, easy way for a real user to hit this.
|
||
const known = ctrl.text && UPLOAD_CTRL_TEXT_CN[ctrl.text.trim().toLowerCase()];
|
||
reject(new ImApiError(known || 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);
|
||
const text = pkt.ctrl && pkt.ctrl.text;
|
||
const known = text && UPLOAD_CTRL_TEXT_CN[text.trim().toLowerCase()];
|
||
err = new ImApiError(known || 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 = '', limit} = {}) => imGet('/favorites' + qs({type, q: query, cursor, limit}));
|
||
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();
|
||
}
|
||
|
||
// Just the URL string -- fileBase() (the tenant's real, possibly cross-origin file domain, e.g.
|
||
// "https://api.118im.com/v0/file") + the server-relative path a Drafty ref/upload response carries.
|
||
// No fetch. Split out from getFileUrl() below for DownloadPage (content-library-view.jsx): that
|
||
// caller needs a correctly-qualified URL to *display and hand to the download button*, not to
|
||
// eagerly pull the whole file into a blob the moment the page opens -- getFileUrl()'s eager fetch,
|
||
// used unmodified there, silently downloaded every out-of-band attachment in full before the user
|
||
// ever pressed "download", defeating the ready/downloading/done UI entirely.
|
||
export function resolveFileUrl(fileId) {
|
||
return fileId ? fileBase() + '/s/' + encodeURIComponent(fileId) : null;
|
||
}
|
||
|
||
// 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(resolveFileUrl(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 = '', limit = ''} = {}) =>
|
||
imGet('/organization/members' + qs({q: query, department_id: departmentId, cursor, limit}));
|
||
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});
|
||
// TenantDailyReportStats does honor department_id (reportMemberScopeWhere adds `m.department_id=?`
|
||
// when it's non-empty) -- confirmed against current im_be HEAD, this wasn't always true and an earlier
|
||
// comment here claimed the opposite.
|
||
export const getDailyStatistics = (date, departmentId = '') => imGet('/reports/daily/statistics' + qs({date, department_id: departmentId}));
|
||
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});
|
||
// Same department_id support as getDailyStatistics (TenantMonthlyReportStats shares reportMemberScopeWhere).
|
||
export const getMonthlyStatistics = (month, departmentId = '') => imGet('/reports/monthly/statistics' + qs({month, department_id: departmentId}));
|
||
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});
|