UNPKG

@satorijs/core

Version:

Core components of Satorijs

906 lines (896 loc) 28.2 kB
var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/index.ts import { Context as Context4, Logger as Logger2, Service as Service4, z as z2 } from "cordis"; import { defineProperty as defineProperty2 } from "cosmokit"; // src/internal.ts import { Service } from "cordis"; import { remove, valueMap } from "cosmokit"; import { pathToRegexp } from "path-to-regexp"; var InternalRouter = class { constructor(ctx) { this.ctx = ctx; } static { __name(this, "InternalRouter"); } [Service.tracker] = { property: "ctx" }; routes = []; define(path, callback) { return this.ctx.effect(() => { const route = { ...pathToRegexp(path), callback }; this.routes.push(route); return () => remove(this.routes, route); }); } handle(bot, method, path, query, headers, body) { for (const route of this.routes) { const capture = route.regexp.exec(path); if (!capture) continue; const params = {}; route.keys.forEach(({ name }, index) => { params[name] = capture[index + 1]; }); return route.callback({ bot, method, params, query, body, headers: Object.fromEntries(headers.entries()) }); } } }; var JsonForm; ((JsonForm2) => { function load(data, path, form) { const value = form.get(path); if (value instanceof File) return value; if (!data || typeof data !== "object") return data; if (Array.isArray(data)) { return data.map((value2, index) => load(value2, `${path}.${index}`, form)); } return valueMap(data, (value2, key) => { return load(value2, `${path}.${key}`, form); }); } JsonForm2.load = load; __name(load, "load"); function dump(data, path, form) { if (!data || typeof data !== "object") return data; if (data instanceof Blob) { form.append(path, data); return null; } if (Array.isArray(data)) { return data.map((value, index) => dump(value, `${path}.${index}`, form)); } return valueMap(data, (value, key) => { return dump(value, `${path}.${key}`, form); }); } JsonForm2.dump = dump; __name(dump, "dump"); async function decode(body) { const type = body.headers.get("content-type"); if (type?.startsWith("multipart/form-data")) { const response = new globalThis.Response(body.body, { headers: body.headers }); const form = await response.formData(); const json = form.get("$"); return load(JSON.parse(json), "$", form); } else if (type?.startsWith("application/json")) { return JSON.parse(new TextDecoder().decode(body.body)); } else { throw new Error(`Unsupported content type: ${type}`); } } JsonForm2.decode = decode; __name(decode, "decode"); async function encode(data) { const form = new FormData(); const json = JSON.stringify(JsonForm2.dump(data, "$", form)); if ([...form.entries()].length) { form.append("$", json); const request = new Request("stub:", { method: "POST", body: form }); return { body: await request.arrayBuffer(), headers: request.headers }; } else { const body = new TextEncoder().encode(json).buffer; const headers = new Headers({ "content-type": "application/json" }); return { body, headers }; } } JsonForm2.encode = encode; __name(encode, "encode"); })(JsonForm || (JsonForm = {})); // src/index.ts import { HTTP } from "@cordisjs/plugin-http"; import h3 from "@satorijs/element"; export * from "cordis"; export * from "cosmokit"; import * as Universal from "@satorijs/protocol"; // src/bot.ts import { clone as clone2, isNonNullable, pick } from "cosmokit"; import { Service as Service3 } from "cordis"; // src/session.ts import { Channel, Resource } from "@satorijs/protocol"; import { clone, defineProperty, isNullable } from "cosmokit"; import { Context, Service as Service2 } from "cordis"; import h from "@satorijs/element"; var Session = class { static { __name(this, "Session"); } [Service2.tracker] = { associate: "session", property: "ctx" }; id; // for backward compatibility sn; bot; app; event; locales = []; constructor(bot, event) { event.selfId ??= bot.selfId; event.platform ??= bot.platform; event.timestamp ??= Date.now(); this.event = event; this.sn = this.id = ++bot.ctx.satori._sessionSeq; defineProperty(this, "bot", bot); defineProperty(this, "app", bot.ctx.root); defineProperty(this, Context.current, bot.ctx); return Context.associate(this, "session"); } /** @deprecated */ get data() { return this.event; } get isDirect() { return this.event.channel?.type === Channel.Type.DIRECT; } set isDirect(value) { (this.event.channel ??= {}).type = value ? Channel.Type.DIRECT : Channel.Type.TEXT; } get author() { return { ...this.event.user, ...this.event.member, userId: this.event.user?.id, username: this.event.user?.name, nickname: this.event.member?.name }; } get uid() { return `${this.platform}:${this.userId}`; } get gid() { return `${this.platform}:${this.guildId}`; } get cid() { return `${this.platform}:${this.channelId}`; } get fid() { return `${this.platform}:${this.channelId}:${this.userId}`; } get sid() { return `${this.platform}:${this.selfId}`; } get elements() { return this.event.message?.elements; } set elements(value) { this.event.message ??= {}; this.event.message.elements = value; } get content() { return this.event.message?.elements?.join(""); } set content(value) { this.event.message ??= {}; this.event.message.quote = void 0; this.event.message.elements = isNullable(value) ? value : h.parse(value); if (this.event.message.elements?.[0]?.type === "quote") { const el = this.event.message.elements.shift(); this.event.message.quote = Resource.decode(el); } } setInternal(type, data) { this.event._type = type; this.event._data = data; const internal = Object.create(this.bot.internal); defineProperty(this, type, Object.assign(internal, data)); } async transform(elements) { return await h.transformAsync(elements, async ({ type, attrs, children }, session) => { const render = type === "component" ? attrs.is : this.app.get("component:" + type); if (!render) return true; children = await render(attrs, children, session); return this.transform(h.toElementArray(children)); }, this); } toJSON() { const event = { ...clone(this.event), sn: this.sn, login: this.bot.toJSON(), ["id"]: this.sn // for backward compatibility }; if (event.message?.elements) { event.message.content = this.content; delete event.message.elements; if (event.message.quote) { event.message.content = Resource.encode("quote", event.message.quote) + event.message.content; } } return event; } }; function defineAccessor(prototype, name, keys) { Object.defineProperty(prototype, name, { get() { return keys.reduce((data, key) => data?.[key], this); }, set(value) { if (value === void 0) return; const _keys = keys.slice(); const last = _keys.pop(); const data = _keys.reduce((data2, key) => data2[key] ??= {}, this); data[last] = value; } }); } __name(defineAccessor, "defineAccessor"); defineAccessor(Session.prototype, "type", ["event", "type"]); defineAccessor(Session.prototype, "subtype", ["event", "subtype"]); defineAccessor(Session.prototype, "subsubtype", ["event", "subsubtype"]); defineAccessor(Session.prototype, "selfId", ["event", "selfId"]); defineAccessor(Session.prototype, "platform", ["event", "platform"]); defineAccessor(Session.prototype, "timestamp", ["event", "timestamp"]); defineAccessor(Session.prototype, "userId", ["event", "user", "id"]); defineAccessor(Session.prototype, "channelId", ["event", "channel", "id"]); defineAccessor(Session.prototype, "channelName", ["event", "channel", "name"]); defineAccessor(Session.prototype, "guildId", ["event", "guild", "id"]); defineAccessor(Session.prototype, "guildName", ["event", "guild", "name"]); defineAccessor(Session.prototype, "messageId", ["event", "message", "id"]); defineAccessor(Session.prototype, "operatorId", ["event", "operator", "id"]); defineAccessor(Session.prototype, "roleId", ["event", "role", "id"]); defineAccessor(Session.prototype, "quote", ["event", "message", "quote"]); defineAccessor(Session.prototype, "referrer", ["event", "referrer"]); // src/bot.ts import { Methods, Status } from "@satorijs/protocol"; var eventAliases = [ ["message-created", "message"], ["guild-removed", "guild-deleted"], ["guild-member-removed", "guild-member-deleted"] ]; var Bot = class { constructor(ctx, config, adapterName) { this.ctx = ctx; this.config = config; this.adapterName = adapterName; this.sn = ++ctx.satori._loginSeq; this.internal = null; this._internalRouter = new InternalRouter(ctx); this.context = ctx; ctx.bots.push(this); this.context.emit("bot-added", this); this.logger = ctx.logger(adapterName); this.platform = adapterName; this.features = Object.entries(Methods).filter(([, value]) => this[value.name]).map(([key]) => key); ctx.on("ready", async () => { await Promise.resolve(); this.dispatchLoginEvent("login-added"); return this.start(); }); ctx.on("dispose", () => this.dispose()); ctx.on("interaction/button", (session) => { const cb = this.callbacks[session.event.button.id]; if (cb) cb(session); }); } static { __name(this, "Bot"); } static reusable = true; static MessageEncoder; [Service3.tracker] = { associate: "bot", property: "ctx" }; sn; user; platform; features; hidden = false; adapter; error; callbacks = {}; logger; _internalRouter; // Same as `this.ctx`, but with a more specific type. context; _status = Status.OFFLINE; getInternalUrl(path, init, slash) { let search = new URLSearchParams(init).toString(); if (search) search = "?" + search; return `internal${slash ? "/" : ":"}${this.platform}/${this.selfId}${path}${search}`; } defineInternalRoute(path, callback) { return this._internalRouter.define(path, callback); } update(login) { const { sn, status, ...rest } = login; Object.assign(this, rest); this.status = status; } dispose() { const index = this.ctx.bots.findIndex((bot) => bot.sid === this.sid); if (index >= 0) { this.ctx.bots.splice(index, 1); this.context.emit("bot-removed", this); this.dispatchLoginEvent("login-removed"); } return this.stop(); } dispatchLoginEvent(type) { const session = this.session(); session.type = type; session.event.login = this.toJSON(); this.dispatch(session); } get status() { return this._status; } set status(value) { if (value === this._status) return; this._status = value; if (this.ctx.bots?.some((bot) => bot.sid === this.sid)) { this.context.emit("bot-status-updated", this); this.dispatchLoginEvent("login-updated"); } } get isActive() { return this._status !== Status.OFFLINE && this._status !== Status.DISCONNECT; } online() { this.status = Status.ONLINE; this.error = void 0; } offline(error) { this.status = Status.OFFLINE; this.error = error; } async start() { if (this.isActive) return; this.status = Status.CONNECT; try { await this.context.parallel("bot-connect", this); await this.adapter?.connect(this); } catch (error) { this.offline(error); } } async stop() { if (!this.isActive) return; this.status = Status.DISCONNECT; try { await this.context.parallel("bot-disconnect", this); await this.adapter?.disconnect(this); } catch (error) { this.context.emit(this.ctx, "internal/error", error); } finally { this.offline(); } } get sid() { return `${this.platform}:${this.selfId}`; } session(event = {}) { return new Session(this, event); } dispatch(session) { if (!this.ctx.lifecycle.isActive) return; let events = [session.type]; for (const aliases of eventAliases) { if (aliases.includes(session.type)) { events = aliases; session.type = aliases[0]; break; } } this.context.emit("internal/session", session); if (session.type === "internal") { this.context.emit(session.event._type, session.event._data, session.bot); return; } for (const event of events) { this.context.emit(session, event, session); } } async createMessage(channelId, content, referrer, options) { const { MessageEncoder: MessageEncoder2 } = this.constructor; return new MessageEncoder2(this, channelId, referrer, options).send(content); } async sendMessage(channelId, content, referrer, options) { const messages = await this.createMessage(channelId, content, referrer, options); return messages.map((message) => message.id).filter(isNonNullable); } async sendPrivateMessage(userId, content, guildId, options) { const { id } = await this.createDirectChannel(userId, guildId ?? options?.session?.guildId); return this.sendMessage(id, content, null, options); } async createUpload(...uploads) { const ids = []; for (const upload of uploads) { const id = Math.random().toString(36).slice(2); const headers = new Headers(); headers.set("content-type", upload.type); if (upload.filename) { headers.set("content-disposition", `attachment; filename*=UTF-8''${encodeURIComponent(upload.filename)}`); } this.ctx.satori._tempStore[id] = { status: 200, body: upload.data, headers }; ids.push(id); } const timer = setTimeout(() => dispose(), 6e5); const dispose = /* @__PURE__ */ __name(() => { _dispose(); clearTimeout(timer); for (const id of ids) { delete this.ctx.satori._tempStore[id]; } }, "dispose"); const _dispose = this.ctx.on("dispose", dispose); return ids.map((id) => this.getInternalUrl(`/_tmp/${id}`)); } async supports(name, session = {}) { return !!this[Methods[name]?.name]; } async checkPermission(name, session) { if (name.startsWith("bot.")) { return this.supports(name.slice(4), session); } } toJSON() { return clone2({ ...pick(this, ["sn", "user", "platform", "selfId", "status", "hidden", "features"]), adapter: this.adapterName }); } async getLogin() { return this.toJSON(); } /** @deprecated use `bot.getLogin()` instead */ async getSelf() { const { user } = await this.getLogin(); return user; } }; var iterableMethods = [ "getMessage", "getReaction", "getFriend", "getGuild", "getGuildMember", "getGuildRole", "getChannel" ]; for (const name of iterableMethods) { Bot.prototype[name + "Iter"] = function(...args) { let list; if (!this[name + "List"]) throw new Error(`not implemented: ${name}List`); const getList = /* @__PURE__ */ __name(async () => { list = await this[name + "List"](...args, list?.next); if (name === "getMessage") list.data.reverse(); }, "getList"); return { async next() { if (list?.data.length) return { done: false, value: list.data.shift() }; if (list && !list?.next) return { done: true, value: void 0 }; await getList(); return this.next(); }, [Symbol.asyncIterator]() { return this; } }; }; } defineAccessor(Bot.prototype, "selfId", ["user", "id"]); defineAccessor(Bot.prototype, "userId", ["user", "id"]); // src/adapter.ts import { remove as remove2, Time } from "cosmokit"; import { Status as Status2 } from "@satorijs/protocol"; import { z } from "cordis"; var Adapter = class { constructor(ctx) { this.ctx = ctx; } static { __name(this, "Adapter"); } static schema = false; bots = []; async connect(bot) { } async disconnect(bot) { } fork(ctx, bot) { bot.adapter = this; this.bots.push(bot); ctx.on("dispose", () => { remove2(this.bots, bot); }); } }; ((Adapter2) => { Adapter2.WsClientConfig = z.object({ retryTimes: z.natural().description("初次连接时的最大重试次数。").default(6), retryInterval: z.natural().role("ms").description("初次连接时的重试时间间隔。").default(5 * Time.second), retryLazy: z.natural().role("ms").description("连接关闭后的重试时间间隔。").default(Time.minute) }).description("连接设置"); class WsClientBase extends Adapter2 { constructor(ctx, config) { super(ctx); this.config = config; } static { __name(this, "WsClientBase"); } socket; connectionId = 0; async start() { let retryCount = 0; const connectionId = ++this.connectionId; const logger = this.ctx.logger("adapter"); const { retryTimes, retryInterval, retryLazy } = this.config; const reconnect = /* @__PURE__ */ __name((initial, message) => { if (!this.getActive() || connectionId !== this.connectionId) return; let timeout = retryInterval; if (retryCount >= retryTimes) { if (initial) { return this.setStatus(Status2.OFFLINE, new Error(message)); } else { timeout = retryLazy; } } retryCount++; this.setStatus(Status2.RECONNECT); logger.warn(`${message}, will retry in ${Time.format(timeout)}...`); setTimeout(() => { if (!this.getActive() || connectionId !== this.connectionId) return; connect(); }, timeout); }, "reconnect"); const connect = /* @__PURE__ */ __name(async (initial = false) => { logger.debug("websocket client opening"); let socket; try { socket = await this.prepare(); } catch (error) { reconnect(initial, error.toString() || `failed to prepare websocket`); return; } const url = socket.url.replace(/\?.+/, ""); socket.addEventListener("error", (event) => { if (event.message) logger.warn(event.message); }); socket.addEventListener("close", ({ code, reason }) => { if (this.socket === socket) this.socket = void 0; logger.debug(`websocket closed with ${code}`); reconnect(initial, reason.toString() || `failed to connect to ${url}, code: ${code}`); }); socket.addEventListener("open", () => { retryCount = 0; this.socket = socket; logger.info("connect to server: %c", url); this.accept(socket); }); }, "connect"); connect(true); } async stop() { this.socket?.close(); } } Adapter2.WsClientBase = WsClientBase; class WsClient extends WsClientBase { constructor(ctx, bot) { super(ctx, bot.config); this.bot = bot; bot.adapter = this; } static { __name(this, "WsClient"); } static reusable = true; getActive() { return this.bot.isActive; } setStatus(status, error) { this.bot.status = status; this.bot.error = error; } async connect(bot) { this.start(); } async disconnect(bot) { this.stop(); } } Adapter2.WsClient = WsClient; })(Adapter || (Adapter = {})); // src/message.ts import h2 from "@satorijs/element"; var AggregateError = class extends Error { constructor(errors, message = "") { super(message); this.errors = errors; } static { __name(this, "AggregateError"); } }; var MessageEncoder = class { constructor(bot, channelId, referrer, options = {}) { this.bot = bot; this.channelId = channelId; this.referrer = referrer; this.options = options; } static { __name(this, "MessageEncoder"); } errors = []; results = []; session; async prepare() { } async render(elements, flush) { for (const element of elements) { await this.visit(element); } if (flush) { await this.flush(); } } async send(content) { this.session = this.bot.session({ type: "send", channel: { id: this.channelId, ...this.options.session?.event.channel }, guild: this.options.session?.event.guild }); for (const key in this.options.session || {}) { if (key === "id" || key === "event") continue; this.session[key] = this.options.session[key]; } await this.prepare(); const session = this.options.session ?? this.session; this.session.elements = await session.transform(h2.normalize(content)); const btns = h2.select(this.session.elements, "button").filter((v) => v.attrs.type !== "link" && !v.attrs.id); for (const btn of btns) { const r = Math.random().toString(36).slice(2); btn.attrs.id ||= r; if (typeof btn.attrs.action === "function") this.bot.callbacks[btn.attrs.id] = btn.attrs.action; } if (await this.session.app.serial(this.session, "before-send", this.session, this.options)) return []; await this.render(this.session.elements); await this.flush(); if (this.errors.length) { throw new AggregateError(this.errors); } else { return this.results; } } }; // src/index.ts h3.warn = new Logger2("element").warn; HTTP.createConfig = /* @__PURE__ */ __name(function createConfig(endpoint) { return z2.object({ endpoint: z2.string().role("link").description("要连接的服务器地址。").default(typeof endpoint === "string" ? endpoint : void 0).required(typeof endpoint === "boolean" ? endpoint : false), headers: z2.dict(String).role("table").description("要附加的额外请求头。"), ...this.Config.dict }).description("请求设置"); }, "createConfig"); var SatoriContext = class extends Context4 { static { __name(this, "SatoriContext"); } constructor(config) { super(config); this.provide("satori", void 0, true); this.plugin(Satori); } }; var DisposableSet = class { constructor(ctx) { this.ctx = ctx; defineProperty2(this, Service4.tracker, { property: "ctx" }); } static { __name(this, "DisposableSet"); } sn = 0; map1 = /* @__PURE__ */ new Map(); map2 = /* @__PURE__ */ new Map(); add(...values) { const sn = ++this.sn; return this.ctx.effect(() => { let hasUpdate = false; for (const value of values) { if (!this.map2.has(value)) { this.map2.set(value, /* @__PURE__ */ new Set()); hasUpdate = true; } this.map2.get(value).add(sn); } this.map1.set(sn, values); if (hasUpdate) this.ctx.emit("satori/meta"); return () => { let hasUpdate2 = false; this.map1.delete(sn); for (const value of values) { this.map2.get(value).delete(sn); if (this.map2.get(value).size === 0) { this.map2.delete(value); hasUpdate2 = true; } } if (hasUpdate2) this.ctx.emit("satori/meta"); }; }); } [Symbol.iterator]() { return new Set([].concat(...this.map1.values()))[Symbol.iterator](); } }; var Satori = class extends Service4 { static { __name(this, "Satori"); } static [Service4.provide] = "satori"; static [Service4.immediate] = true; uid = Math.random().toString(36).slice(2); proxyUrls = new DisposableSet(this.ctx); _internalRouter; _tempStore = /* @__PURE__ */ Object.create(null); _loginSeq = 0; _sessionSeq = 0; constructor(ctx) { super(ctx); ctx.mixin("satori", ["bots", "component"]); defineProperty2(this.bots, Service4.tracker, {}); const self = this; ctx.on("http/file", async function(_url, options) { const url = new URL(_url); if (url.protocol !== "internal:") return; const { status, body, headers } = await self.handleInternalRoute("GET", url); if (status >= 400) throw new Error(`Failed to fetch ${_url}, status code: ${status}`); if (status >= 300) { const location = headers?.get("location"); return this.file(location, options); } const type = headers?.get("content-type"); const filename = headers?.get("content-disposition")?.split("filename=")[1]; return { data: body, filename, type, mime: type }; }); this._internalRouter = new InternalRouter(ctx); this.defineInternalRoute("/_tmp/:id", async ({ params, method }) => { if (method !== "GET") return { status: 405 }; return this._tempStore[params.id] ?? { status: 404 }; }); this.defineInternalRoute("/_api/:name", async ({ bot, headers, params, method, body }) => { if (method !== "POST") return { status: 405 }; const args = await JsonForm.decode({ body, headers: new Headers(headers) }); if (!args) return { status: 400 }; try { let root = bot.internal; for (const part of params.name.split(".")) { root = root[part]; } let result = root(...args); if (headers["satori-pagination"]) { if (!result?.[Symbol.for("satori.pagination")]) { return { status: 400, statusText: "This API does not support pagination" }; } result = await result[Symbol.for("satori.pagination")](); } else { result = await result; } return { ...await JsonForm.encode(result), status: 200 }; } catch (error) { if (!ctx.http.isError(error) || !error.response) throw error; return error.response; } }); } bots = new Proxy([], { get(target, prop) { if (prop in target || typeof prop === "symbol") { return Reflect.get(target, prop); } return target.find((bot) => bot.sid === prop); }, deleteProperty(target, prop) { if (prop in target || typeof prop === "symbol") { return Reflect.deleteProperty(target, prop); } const bot = target.findIndex((bot2) => bot2.sid === prop); if (bot < 0) return true; target.splice(bot, 1); return true; } }); component(name, component, options = {}) { const render = /* @__PURE__ */ __name(async (attrs, children, session) => { if (options.session && session.type === "send") { throw new Error("interactive components is not available outside sessions"); } const result = await component(attrs, children, session); return session.transform(h3.normalize(result)); }, "render"); return this.ctx.set("component:" + name, render); } defineInternalRoute(path, callback) { return this._internalRouter.define(path, callback); } async handleInternalRoute(method, url, headers = new Headers(), body) { const capture = /^([^/]+)\/([^/]+)(\/.+)$/.exec(url.pathname); if (!capture) return { status: 400 }; const [, platform, selfId, path] = capture; const bot = this.bots[`${platform}:${selfId}`]; if (!bot) return { status: 404 }; let response = await this._internalRouter.handle(bot, method, path, url.searchParams, headers, body); response ??= await bot._internalRouter.handle(bot, method, path, url.searchParams, headers, body); if (!response) return { status: 404 }; return response; } toJSON(meta = false) { return { logins: meta ? void 0 : this.bots.map((bot) => bot.toJSON()), proxyUrls: [...this.proxyUrls] }; } }; var index_default = Satori; export { Adapter, Bot, SatoriContext as Context, h3 as Element, HTTP, InternalRouter, JsonForm, MessageEncoder, MessageEncoder as Messenger, MessageEncoder as Modulator, HTTP as Quester, Satori, Session, Universal, index_default as default, defineAccessor, h3 as h, h3 as segment };