feat: 实现 Go 与 JS/TS SDK 连接收发与其余接口
This commit is contained in:
@@ -0,0 +1,780 @@
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import {
|
||||
APIError,
|
||||
AuthReason,
|
||||
Body,
|
||||
ClientOptions,
|
||||
ConnectionEvent,
|
||||
ConnectionState,
|
||||
Credential,
|
||||
GroupEvent,
|
||||
GroupMemberIn,
|
||||
HandshakeLimits,
|
||||
Message,
|
||||
PresenceEvent,
|
||||
RecallResult,
|
||||
Receipt,
|
||||
RegisterOptions,
|
||||
RegisterResult,
|
||||
ReconnectBackoff,
|
||||
RevokedEvent,
|
||||
SendOptions,
|
||||
SendResult,
|
||||
Target,
|
||||
Transport,
|
||||
marshalJSON,
|
||||
registerURLFromConnect,
|
||||
} from "./types.js";
|
||||
import { MqttTransport } from "./mqtt.js";
|
||||
|
||||
type RespFrame = {
|
||||
ok: boolean;
|
||||
data?: unknown;
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
type SendItem = {
|
||||
frame: Record<string, unknown>;
|
||||
payload: string;
|
||||
id: string;
|
||||
result: { resolve: (v: SendResult) => void; reject: (e: unknown) => void };
|
||||
inflight: boolean;
|
||||
};
|
||||
|
||||
type DedupState = "delivered" | "acked";
|
||||
|
||||
export class Client {
|
||||
private opts: Required<
|
||||
Pick<
|
||||
ClientOptions,
|
||||
| "manualAck"
|
||||
| "allowTcp"
|
||||
| "connectTimeoutMs"
|
||||
| "clientLabel"
|
||||
| "sendQueueSize"
|
||||
| "maxInflight"
|
||||
| "dedupCapacity"
|
||||
>
|
||||
> &
|
||||
ClientOptions = {
|
||||
manualAck: false,
|
||||
allowTcp: false,
|
||||
connectTimeoutMs: 30_000,
|
||||
clientLabel: "js-sdk/0.1",
|
||||
sendQueueSize: 1000,
|
||||
maxInflight: 100,
|
||||
dedupCapacity: 10000,
|
||||
};
|
||||
|
||||
private transport?: Transport;
|
||||
private backoff = new ReconnectBackoff();
|
||||
private endpointId = "";
|
||||
private ridSeq = 0;
|
||||
private pending = new Map<string, { resolve: (r: RespFrame) => void; reject: (e: unknown) => void }>();
|
||||
private sendQ: SendItem[] = [];
|
||||
private inflight = 0;
|
||||
private handshook = false;
|
||||
private stopReconnect = false;
|
||||
private closed = false;
|
||||
private state: ConnectionState = "offline";
|
||||
private limits: HandshakeLimits = {
|
||||
server_time_ms: 0,
|
||||
server_version: "",
|
||||
max_body_bytes: 262144,
|
||||
max_meta_bytes: 4096,
|
||||
max_frame_bytes: 786432,
|
||||
max_ttl_seconds: 2592000,
|
||||
max_schedule_seconds: 31536000,
|
||||
ack_timeout_seconds: 300,
|
||||
};
|
||||
private clockSkew = 0;
|
||||
private dedup = new Map<string, DedupState>();
|
||||
private dedupOrd: string[] = [];
|
||||
private receiptSeen = new Set<string>();
|
||||
private cbChain: Promise<void> = Promise.resolve();
|
||||
|
||||
private onSession?: (token: string) => void;
|
||||
private onMessage?: (msg: Message) => void | Promise<void>;
|
||||
private onReceipt?: (r: Receipt) => void;
|
||||
private onRevoked?: (e: RevokedEvent) => void;
|
||||
private onPresence?: (e: PresenceEvent) => void;
|
||||
private onGroupEvent?: (e: GroupEvent) => void;
|
||||
private onConnection?: (e: ConnectionEvent) => void;
|
||||
|
||||
onSessionHandler(h: (token: string) => void): void {
|
||||
this.onSession = h;
|
||||
}
|
||||
onMessageHandler(h: (msg: Message) => void | Promise<void>): void {
|
||||
this.onMessage = h;
|
||||
}
|
||||
onReceiptHandler(h: (r: Receipt) => void): void {
|
||||
this.onReceipt = h;
|
||||
}
|
||||
onRevokedHandler(h: (e: RevokedEvent) => void): void {
|
||||
this.onRevoked = h;
|
||||
}
|
||||
onPresenceHandler(h: (e: PresenceEvent) => void): void {
|
||||
this.onPresence = h;
|
||||
}
|
||||
onGroupEventHandler(h: (e: GroupEvent) => void): void {
|
||||
this.onGroupEvent = h;
|
||||
}
|
||||
onConnectionHandler(h: (e: ConnectionEvent) => void): void {
|
||||
this.onConnection = h;
|
||||
}
|
||||
|
||||
/** 别名,贴近文档 onSession(handler) */
|
||||
onSessionCb(h: (token: string) => void): void {
|
||||
this.onSessionHandler(h);
|
||||
}
|
||||
|
||||
clockSkewMs(): number {
|
||||
return this.clockSkew;
|
||||
}
|
||||
|
||||
getLimits(): HandshakeLimits {
|
||||
return { ...this.limits };
|
||||
}
|
||||
|
||||
async connect(
|
||||
url: string,
|
||||
endpointId: string,
|
||||
credential: Credential,
|
||||
options: ClientOptions = {},
|
||||
): Promise<void> {
|
||||
if (this.closed) throw new APIError("closed", "已关闭");
|
||||
if (this.transport) throw new APIError("bad_request", "已在连接中");
|
||||
this.opts = {
|
||||
...this.opts,
|
||||
...options,
|
||||
connectTimeoutMs: options.connectTimeoutMs ?? 30_000,
|
||||
clientLabel: options.clientLabel ?? "js-sdk/0.1",
|
||||
sendQueueSize: options.sendQueueSize ?? 1000,
|
||||
maxInflight: options.maxInflight ?? 100,
|
||||
dedupCapacity: options.dedupCapacity ?? 10000,
|
||||
manualAck: options.manualAck ?? false,
|
||||
allowTcp: options.allowTcp ?? false,
|
||||
};
|
||||
this.endpointId = endpointId;
|
||||
this.stopReconnect = false;
|
||||
this.handshook = false;
|
||||
this.backoff = new ReconnectBackoff();
|
||||
const pass = credential.sessionToken ?? credential.password ?? "";
|
||||
const tr = options.transport ?? new MqttTransport();
|
||||
this.transport = tr;
|
||||
tr.setCredential(pass);
|
||||
this.setState("connecting");
|
||||
|
||||
await tr.start({
|
||||
url,
|
||||
endpointId,
|
||||
connectTimeoutMs: this.opts.connectTimeoutMs!,
|
||||
allowTcp: !!this.opts.allowTcp,
|
||||
backoff: this.backoff,
|
||||
onDown: (p) => this.handleDown(p),
|
||||
onOffline: () => {
|
||||
this.handshook = false;
|
||||
if (!this.stopReconnect && !this.closed) this.setState("reconnecting");
|
||||
},
|
||||
onAuthFailed: (r) => this.failAuth(r),
|
||||
onKicked: () => this.failKicked(),
|
||||
mqttReady: () => this.doHello(),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + this.opts.connectTimeoutMs!;
|
||||
while (Date.now() < deadline) {
|
||||
if (this.handshook) return;
|
||||
if (this.stopReconnect || this.state === "auth_failed" || this.state === "kicked") {
|
||||
throw new APIError("auth_failed", this.state);
|
||||
}
|
||||
await sleep(20);
|
||||
}
|
||||
await this.close();
|
||||
throw new APIError("not_connected", "连接超时");
|
||||
}
|
||||
|
||||
private failAuth(reason: AuthReason): void {
|
||||
this.stopReconnect = true;
|
||||
this.handshook = false;
|
||||
this.setState("auth_failed", reason);
|
||||
this.failQueued(new APIError(reason, "认证失败,停止重连"));
|
||||
void this.transport?.stop();
|
||||
}
|
||||
|
||||
private failKicked(): void {
|
||||
this.stopReconnect = true;
|
||||
this.handshook = false;
|
||||
this.setState("kicked", "0x8E");
|
||||
this.failQueued(new APIError("kicked", "被顶号,停止重连"));
|
||||
void this.transport?.stop();
|
||||
}
|
||||
|
||||
private setState(state: ConnectionState, reason?: string): void {
|
||||
this.state = state;
|
||||
this.enqueueCb(() => this.onConnection?.({ state, reason }));
|
||||
}
|
||||
|
||||
private enqueueCb(fn: () => void | Promise<void>): void {
|
||||
this.cbChain = this.cbChain.then(async () => {
|
||||
try {
|
||||
await fn();
|
||||
} catch {
|
||||
/* 回调错误不打断串行链 */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private nextRid(): string {
|
||||
this.ridSeq += 1;
|
||||
return String(this.ridSeq);
|
||||
}
|
||||
|
||||
private async doHello(): Promise<void> {
|
||||
const sentAt = Date.now();
|
||||
const req: Record<string, unknown> = {
|
||||
v: 1,
|
||||
type: "hello",
|
||||
rid: this.nextRid(),
|
||||
client: this.opts.clientLabel,
|
||||
};
|
||||
if (this.opts.maxReceiveBytes && this.opts.maxReceiveBytes > 0) {
|
||||
req.max_receive_bytes = this.opts.maxReceiveBytes;
|
||||
}
|
||||
const data = (await this.request(req, true)) as Record<string, unknown>;
|
||||
const recvAt = Date.now();
|
||||
const serverTime = Number(data.server_time_ms ?? 0);
|
||||
this.clockSkew = serverTime - Math.floor((sentAt + recvAt) / 2);
|
||||
this.limits = {
|
||||
server_time_ms: serverTime,
|
||||
server_version: String(data.server_version ?? ""),
|
||||
max_body_bytes: Number(data.max_body_bytes ?? 262144),
|
||||
max_meta_bytes: Number(data.max_meta_bytes ?? 4096),
|
||||
max_frame_bytes: Number(data.max_frame_bytes ?? 786432),
|
||||
max_ttl_seconds: Number(data.max_ttl_seconds ?? 2592000),
|
||||
max_schedule_seconds: Number(data.max_schedule_seconds ?? 31536000),
|
||||
ack_timeout_seconds: Number(data.ack_timeout_seconds ?? 300),
|
||||
};
|
||||
this.handshook = true;
|
||||
this.setState("online");
|
||||
const token = data.session_token ? String(data.session_token) : "";
|
||||
if (token) {
|
||||
this.transport?.setCredential(token);
|
||||
this.enqueueCb(() => this.onSession?.(token));
|
||||
}
|
||||
void this.drainSendQueue();
|
||||
}
|
||||
|
||||
private handleDown(payload: Uint8Array): void {
|
||||
let head: { type?: string; rid?: string };
|
||||
try {
|
||||
head = JSON.parse(new TextDecoder().decode(payload));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const text = new TextDecoder().decode(payload);
|
||||
switch (head.type) {
|
||||
case "resp": {
|
||||
const rf = JSON.parse(text) as RespFrame & { rid: string };
|
||||
const p = this.pending.get(rf.rid ?? head.rid!);
|
||||
if (p) {
|
||||
this.pending.delete(rf.rid ?? head.rid!);
|
||||
p.resolve(rf);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "msg":
|
||||
void this.handleMsg(JSON.parse(text));
|
||||
break;
|
||||
case "receipt":
|
||||
void this.handleReceipt(JSON.parse(text));
|
||||
break;
|
||||
case "revoked":
|
||||
this.handleRevoked(JSON.parse(text));
|
||||
break;
|
||||
case "presence": {
|
||||
const p = JSON.parse(text) as PresenceEvent;
|
||||
this.enqueueCb(() => this.onPresence?.(p));
|
||||
break;
|
||||
}
|
||||
case "group_event": {
|
||||
const g = JSON.parse(text) as GroupEvent;
|
||||
this.enqueueCb(() => this.onGroupEvent?.(g));
|
||||
break;
|
||||
}
|
||||
case "fatal": {
|
||||
const f = JSON.parse(text) as { reason?: string };
|
||||
this.stopReconnect = true;
|
||||
this.setState("auth_failed", f.reason);
|
||||
this.failQueued(new APIError("fatal", f.reason ?? ""));
|
||||
void this.transport?.stop();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMsg(m: {
|
||||
id: string;
|
||||
from: string;
|
||||
to: Target;
|
||||
body: Body;
|
||||
meta?: Record<string, unknown>;
|
||||
send_at_ms: number;
|
||||
}): Promise<void> {
|
||||
const key = `${m.from}\0${m.id}`;
|
||||
const ent = this.dedup.get(key);
|
||||
if (ent === "acked") {
|
||||
await this.sendAckFrame(m.from, m.id);
|
||||
return;
|
||||
}
|
||||
if (ent === "delivered") return;
|
||||
this.rememberDedup(key, "delivered");
|
||||
|
||||
const msg: Message = {
|
||||
id: m.id,
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
body: m.body,
|
||||
meta: m.meta,
|
||||
send_at_ms: m.send_at_ms,
|
||||
};
|
||||
|
||||
let cbErr: unknown;
|
||||
await new Promise<void>((resolve) => {
|
||||
this.enqueueCb(async () => {
|
||||
try {
|
||||
await this.onMessage?.(msg);
|
||||
} catch (e) {
|
||||
cbErr = e;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (this.opts.manualAck) return;
|
||||
if (cbErr) {
|
||||
this.dedup.delete(key);
|
||||
return;
|
||||
}
|
||||
await this.sendAckFrame(m.from, m.id);
|
||||
this.dedup.set(key, "acked");
|
||||
}
|
||||
|
||||
private rememberDedup(key: string, st: DedupState): void {
|
||||
if (!this.dedup.has(key)) {
|
||||
this.dedupOrd.push(key);
|
||||
while (this.dedupOrd.length > this.opts.dedupCapacity!) {
|
||||
const old = this.dedupOrd.shift()!;
|
||||
this.dedup.delete(old);
|
||||
}
|
||||
}
|
||||
this.dedup.set(key, st);
|
||||
}
|
||||
|
||||
async ack(msg: Message): Promise<void> {
|
||||
await this.sendAckFrame(msg.from, msg.id);
|
||||
this.rememberDedup(`${msg.from}\0${msg.id}`, "acked");
|
||||
}
|
||||
|
||||
private async sendAckFrame(from: string, id: string): Promise<void> {
|
||||
const data = await this.request(
|
||||
{ v: 1, type: "ack", rid: this.nextRid(), from, id },
|
||||
true,
|
||||
);
|
||||
if (data && typeof data === "object" && "result" in (data as object)) {
|
||||
const result = String((data as { result: string }).result);
|
||||
if (result && result !== "accepted") {
|
||||
this.enqueueCb(() => this.onRevoked?.({ id, from, reason: result }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleReceipt(r: Receipt & { receipt_id: string }): Promise<void> {
|
||||
if (this.receiptSeen.has(r.receipt_id)) {
|
||||
await this.sendReceiptAck(r.receipt_id);
|
||||
return;
|
||||
}
|
||||
this.receiptSeen.add(r.receipt_id);
|
||||
this.enqueueCb(() => this.onReceipt?.(r));
|
||||
await this.sendReceiptAck(r.receipt_id);
|
||||
}
|
||||
|
||||
private async sendReceiptAck(receiptId: string): Promise<void> {
|
||||
await this.request({ v: 1, type: "receipt_ack", rid: this.nextRid(), receipt_id: receiptId }, true);
|
||||
}
|
||||
|
||||
private handleRevoked(r: RevokedEvent): void {
|
||||
const key = `${r.from}\0${r.id}`;
|
||||
const ent = this.dedup.get(key);
|
||||
if (!ent || ent === "acked") return;
|
||||
this.dedup.delete(key);
|
||||
this.enqueueCb(() => this.onRevoked?.(r));
|
||||
}
|
||||
|
||||
private request(frame: Record<string, unknown>, allowUnready: boolean): Promise<unknown> {
|
||||
if (this.closed) return Promise.reject(new APIError("closed"));
|
||||
if (!allowUnready && !this.handshook) return Promise.reject(new APIError("not_connected", "未握手"));
|
||||
const tr = this.transport;
|
||||
if (!tr) return Promise.reject(new APIError("not_connected"));
|
||||
const rid = String(frame.rid ?? this.nextRid());
|
||||
frame.rid = rid;
|
||||
const payload = marshalJSON(frame);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(rid, {
|
||||
resolve: (rf) => {
|
||||
if (!rf.ok) {
|
||||
reject(new APIError(rf.error?.code ?? "bad_request", rf.error?.message ?? ""));
|
||||
return;
|
||||
}
|
||||
resolve(rf.data);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
void tr.publishUp(payload).catch((e) => {
|
||||
this.pending.delete(rid);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async send(to: Target, body: Body, opt: SendOptions = {}): Promise<SendResult> {
|
||||
const enc = body.enc || "utf8";
|
||||
const b: Body = {
|
||||
enc,
|
||||
data: body.data,
|
||||
content_type:
|
||||
opt.contentType ||
|
||||
body.content_type ||
|
||||
(enc === "base64" ? "application/octet-stream" : "text/plain; charset=utf-8"),
|
||||
};
|
||||
const n = bodyDecodedLen(b);
|
||||
const maxBody = this.limits.max_body_bytes || 262144;
|
||||
if (n > maxBody) throw new APIError("body_too_large", "正文超限");
|
||||
|
||||
if (opt.sendAt && opt.delayMs != null) throw new APIError("bad_request", "sendAt 与 delay 互斥");
|
||||
|
||||
const id = opt.id || uuidv7();
|
||||
const frame: Record<string, unknown> = {
|
||||
v: 1,
|
||||
type: "send",
|
||||
rid: this.nextRid(),
|
||||
id,
|
||||
to,
|
||||
body: b,
|
||||
};
|
||||
if (opt.meta) frame.meta = opt.meta;
|
||||
if (opt.talkPassword) frame.talk_password = opt.talkPassword;
|
||||
if (opt.receipt != null) frame.receipt = opt.receipt;
|
||||
if (opt.keep) {
|
||||
const off: Record<string, unknown> = { keep: true };
|
||||
if (opt.ttl != null) off.ttl_seconds = opt.ttl;
|
||||
frame.offline = off;
|
||||
}
|
||||
if (opt.sendAt) {
|
||||
frame.send_at_ms = opt.sendAt.getTime() + this.clockSkew;
|
||||
} else if (opt.delayMs != null) {
|
||||
frame.delay_ms = opt.delayMs;
|
||||
}
|
||||
|
||||
const payload = marshalJSON(frame);
|
||||
const maxFrame = this.limits.max_frame_bytes || 786432;
|
||||
if (this.handshook && payload.length > maxFrame) {
|
||||
throw new APIError("frame_too_large", "整帧超限");
|
||||
}
|
||||
if (this.closed || this.stopReconnect) throw new APIError("closed");
|
||||
if (this.sendQ.length >= this.opts.sendQueueSize!) {
|
||||
throw new APIError("queue_full", "发送队列已满");
|
||||
}
|
||||
|
||||
return new Promise<SendResult>((resolve, reject) => {
|
||||
this.sendQ.push({ frame, payload, id, result: { resolve, reject }, inflight: false });
|
||||
void this.drainSendQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async drainSendQueue(): Promise<void> {
|
||||
while (true) {
|
||||
if (!this.handshook || !this.transport) return;
|
||||
const next = this.sendQ.find((x) => !x.inflight);
|
||||
if (!next || this.inflight >= this.opts.maxInflight!) return;
|
||||
next.inflight = true;
|
||||
this.inflight++;
|
||||
void this.dispatchSend(next);
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchSend(item: SendItem): Promise<void> {
|
||||
const rid = String(item.frame.rid);
|
||||
const tr = this.transport!;
|
||||
try {
|
||||
const data = await new Promise<unknown>((resolve, reject) => {
|
||||
this.pending.set(rid, {
|
||||
resolve: (rf) => {
|
||||
if (!rf.ok) {
|
||||
reject(new APIError(rf.error?.code ?? "bad_request", rf.error?.message ?? ""));
|
||||
return;
|
||||
}
|
||||
resolve(rf.data);
|
||||
},
|
||||
reject,
|
||||
});
|
||||
void tr.publishUp(item.payload).catch((e) => {
|
||||
this.pending.delete(rid);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
const sd = (data ?? {}) as SendResult;
|
||||
this.finishSend(item, { id: sd.id || item.id, send_at_ms: sd.send_at_ms, state: sd.state });
|
||||
} catch (e) {
|
||||
if (e instanceof APIError && e.code === "rate_limited") {
|
||||
item.inflight = false;
|
||||
this.inflight = Math.max(0, this.inflight - 1);
|
||||
this.pending.delete(rid);
|
||||
setTimeout(() => void this.drainSendQueue(), 1000);
|
||||
return;
|
||||
}
|
||||
// 网络错误:保留队列
|
||||
if (!(e instanceof APIError)) {
|
||||
item.inflight = false;
|
||||
this.inflight = Math.max(0, this.inflight - 1);
|
||||
return;
|
||||
}
|
||||
this.finishSendErr(item, e);
|
||||
}
|
||||
}
|
||||
|
||||
private finishSend(item: SendItem, res: SendResult): void {
|
||||
this.sendQ = this.sendQ.filter((x) => x !== item);
|
||||
if (item.inflight) {
|
||||
this.inflight = Math.max(0, this.inflight - 1);
|
||||
item.inflight = false;
|
||||
}
|
||||
item.result.resolve(res);
|
||||
void this.drainSendQueue();
|
||||
}
|
||||
|
||||
private finishSendErr(item: SendItem, err: unknown): void {
|
||||
this.sendQ = this.sendQ.filter((x) => x !== item);
|
||||
if (item.inflight) {
|
||||
this.inflight = Math.max(0, this.inflight - 1);
|
||||
item.inflight = false;
|
||||
}
|
||||
item.result.reject(err);
|
||||
void this.drainSendQueue();
|
||||
}
|
||||
|
||||
private failQueued(err: unknown): void {
|
||||
for (const it of this.sendQ) it.result.reject(err);
|
||||
this.sendQ = [];
|
||||
this.inflight = 0;
|
||||
}
|
||||
|
||||
async recall(id: string): Promise<RecallResult> {
|
||||
return (await this.request({ v: 1, type: "recall", rid: this.nextRid(), id }, false)) as RecallResult;
|
||||
}
|
||||
|
||||
async status(id: string, cursor = "", limit = 0): Promise<unknown> {
|
||||
const req: Record<string, unknown> = { v: 1, type: "status", rid: this.nextRid(), id };
|
||||
if (cursor) req.cursor = cursor;
|
||||
if (limit) req.limit = limit;
|
||||
return this.request(req, false);
|
||||
}
|
||||
|
||||
async unlock(endpointId: string, talkPassword: string): Promise<void> {
|
||||
await this.request(
|
||||
{ v: 1, type: "unlock", rid: this.nextRid(), endpoint_id: endpointId, talk_password: talkPassword },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async presence(ids: string[]): Promise<unknown> {
|
||||
return this.request({ v: 1, type: "presence.get", rid: this.nextRid(), ids }, false);
|
||||
}
|
||||
|
||||
async directory(cursor = "", query = "", limit = 0): Promise<unknown> {
|
||||
const req: Record<string, unknown> = { v: 1, type: "directory.list", rid: this.nextRid() };
|
||||
if (cursor) req.cursor = cursor;
|
||||
if (query) req.query = query;
|
||||
if (limit) req.limit = limit;
|
||||
return this.request(req, false);
|
||||
}
|
||||
|
||||
async watchPresence(ids: string[] | "all"): Promise<void> {
|
||||
const req: Record<string, unknown> = { v: 1, type: "presence.watch", rid: this.nextRid() };
|
||||
if (ids === "all") req.all = true;
|
||||
else req.ids = ids;
|
||||
await this.request(req, false);
|
||||
}
|
||||
|
||||
async getSelf(): Promise<unknown> {
|
||||
return this.request({ v: 1, type: "self.get", rid: this.nextRid() }, false);
|
||||
}
|
||||
|
||||
async updateSelf(name?: string, defaultDelayMs?: number): Promise<void> {
|
||||
const req: Record<string, unknown> = { v: 1, type: "self.update", rid: this.nextRid() };
|
||||
if (name != null) req.name = name;
|
||||
if (defaultDelayMs != null) req.default_delay_ms = defaultDelayMs;
|
||||
await this.request(req, false);
|
||||
}
|
||||
|
||||
async setTalkPassword(talkPassword: string): Promise<void> {
|
||||
await this.request(
|
||||
{ v: 1, type: "self.talk_password", rid: this.nextRid(), talk_password: talkPassword },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async changeLoginPassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||
const data = (await this.request(
|
||||
{
|
||||
v: 1,
|
||||
type: "self.login_password",
|
||||
rid: this.nextRid(),
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
},
|
||||
false,
|
||||
)) as { session_token?: string };
|
||||
if (data?.session_token) {
|
||||
this.transport?.setCredential(data.session_token);
|
||||
this.enqueueCb(() => this.onSession?.(data.session_token!));
|
||||
}
|
||||
}
|
||||
|
||||
async createGroup(id: string, name: string, members: GroupMemberIn[]): Promise<unknown> {
|
||||
return this.request(
|
||||
{
|
||||
v: 1,
|
||||
type: "group.create",
|
||||
rid: this.nextRid(),
|
||||
id,
|
||||
name,
|
||||
members: members.map((m) => ({ id: m.id, talk_password: m.talkPassword ?? "" })),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async addGroupMembers(groupId: string, members: GroupMemberIn[]): Promise<unknown> {
|
||||
return this.request(
|
||||
{
|
||||
v: 1,
|
||||
type: "group.add",
|
||||
rid: this.nextRid(),
|
||||
group_id: groupId,
|
||||
members: members.map((m) => ({ id: m.id, talk_password: m.talkPassword ?? "" })),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async removeGroupMember(groupId: string, endpointId: string): Promise<void> {
|
||||
await this.request(
|
||||
{ v: 1, type: "group.remove", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async leaveGroup(groupId: string): Promise<void> {
|
||||
await this.request({ v: 1, type: "group.leave", rid: this.nextRid(), group_id: groupId }, false);
|
||||
}
|
||||
|
||||
async transferGroup(groupId: string, endpointId: string): Promise<void> {
|
||||
await this.request(
|
||||
{ v: 1, type: "group.transfer", rid: this.nextRid(), group_id: groupId, endpoint_id: endpointId },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async renameGroup(groupId: string, name: string): Promise<void> {
|
||||
await this.request(
|
||||
{ v: 1, type: "group.rename", rid: this.nextRid(), group_id: groupId, name },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async dissolveGroup(groupId: string): Promise<void> {
|
||||
await this.request({ v: 1, type: "group.dissolve", rid: this.nextRid(), group_id: groupId }, false);
|
||||
}
|
||||
|
||||
async listGroups(cursor = "", limit = 0): Promise<unknown> {
|
||||
const req: Record<string, unknown> = { v: 1, type: "group.list", rid: this.nextRid() };
|
||||
if (cursor) req.cursor = cursor;
|
||||
if (limit) req.limit = limit;
|
||||
return this.request(req, false);
|
||||
}
|
||||
|
||||
async getGroup(groupId: string, cursor = "", limit = 0): Promise<unknown> {
|
||||
const req: Record<string, unknown> = {
|
||||
v: 1,
|
||||
type: "group.get",
|
||||
rid: this.nextRid(),
|
||||
group_id: groupId,
|
||||
};
|
||||
if (cursor) req.cursor = cursor;
|
||||
if (limit) req.limit = limit;
|
||||
return this.request(req, false);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await this.request({ v: 1, type: "self.logout", rid: this.nextRid() }, false);
|
||||
} finally {
|
||||
this.stopReconnect = true;
|
||||
await this.transport?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.stopReconnect = true;
|
||||
this.failQueued(new APIError("closed", "已关闭"));
|
||||
this.setState("offline");
|
||||
await this.transport?.stop();
|
||||
this.transport = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function bodyDecodedLen(b: Body): number {
|
||||
if (b.enc === "base64") {
|
||||
const bin = atob(b.data);
|
||||
return bin.length;
|
||||
}
|
||||
return new TextEncoder().encode(b.data).length;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export async function register(
|
||||
connectOrRegisterURL: string,
|
||||
registrationCode: string,
|
||||
opt: RegisterOptions = {},
|
||||
): Promise<RegisterResult> {
|
||||
let regURL = connectOrRegisterURL;
|
||||
try {
|
||||
regURL = registerURLFromConnect(connectOrRegisterURL);
|
||||
} catch {
|
||||
/* 已是注册 URL */
|
||||
}
|
||||
const resp = await fetch(regURL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: marshalJSON({
|
||||
registration_code: registrationCode,
|
||||
id: opt.id ?? "",
|
||||
login_password: opt.loginPassword ?? "",
|
||||
name: opt.name ?? "",
|
||||
talk_password: opt.talkPassword ?? "",
|
||||
}),
|
||||
});
|
||||
const wrap = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
data?: { id: string; login_password?: string };
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
if (!wrap.ok) {
|
||||
throw new APIError(wrap.error?.code ?? "bad_request", wrap.error?.message ?? "注册失败");
|
||||
}
|
||||
return { id: wrap.data!.id, loginPassword: wrap.data!.login_password };
|
||||
}
|
||||
Reference in New Issue
Block a user