UNPKG

@satorijs/core

Version:

Core components of Satorijs

297 lines (256 loc) 8.73 kB
import { clone, Dict, isNonNullable, pick } from 'cosmokit' import { Context, Logger, Service } from 'cordis' import h from '@satorijs/element' import { Adapter } from './adapter' import { MessageEncoder } from './message' import { defineAccessor, Session } from './session' import { ExtractParams, InternalRequest, InternalRouter } from './internal' import { Event, List, Login, Methods, Response, SendOptions, Status, Upload, User } from '@satorijs/protocol' /* eslint-enable @typescript-eslint/no-unused-vars */ const eventAliases = [ ['message-created', 'message'], ['guild-removed', 'guild-deleted'], ['guild-member-removed', 'guild-member-deleted'], ] export interface Bot extends Methods { userId: string selfId: string internal: any } export abstract class Bot<C extends Context = Context, T = any> { static reusable = true static MessageEncoder?: new (bot: Bot, channelId: string, referrer?: any, options?: SendOptions) => MessageEncoder public [Service.tracker] = { associate: 'bot', property: 'ctx', } public sn: number public user?: User public platform?: string public features: string[] public hidden = false public adapter!: Adapter<C, this> public error: any public callbacks: Dict<Function> = {} public logger!: Logger public _internalRouter: InternalRouter<C> // Same as `this.ctx`, but with a more specific type. protected context: Context protected _status: Status = Status.OFFLINE constructor(public ctx: C, public config: T, public adapterName: string) { 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) }) } getInternalUrl(path: string, init?: ConstructorParameters<typeof URLSearchParams>[0], slash?: boolean) { let search = new URLSearchParams(init).toString() if (search) search = '?' + search return `internal${slash ? '/' : ':'}${this.platform}/${this.selfId}${path}${search}` } defineInternalRoute<P extends string>(path: P, callback: (request: InternalRequest<C, ExtractParams<P>>) => Promise<Response>) { return this._internalRouter.define(path, callback) } update(login: Login) { // make sure `status` is the last property to be assigned // so that `login-updated` event can be dispatched after all properties are updated 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() } private dispatchLoginEvent(type: string) { 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 = undefined } offline(error?: 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: any) { 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: Partial<Event> = {}): C[typeof Context.session] { return new Session(this, event) } dispatch(session: C[typeof Context.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 as any, session) } } async createMessage(channelId: string, content: h.Fragment, referrer?: any, options?: SendOptions) { const { MessageEncoder } = this.constructor as typeof Bot return new MessageEncoder!(this, channelId, referrer, options).send(content) } async sendMessage(channelId: string, content: h.Fragment, referrer?: any, options?: SendOptions) { const messages = await this.createMessage(channelId, content, referrer, options) return messages.map(message => message.id).filter(isNonNullable) } async sendPrivateMessage(userId: string, content: h.Fragment, guildId?: string, options?: SendOptions) { const { id } = await this.createDirectChannel(userId, guildId ?? options?.session?.guildId) return this.sendMessage(id, content, null, options) } async createUpload(...uploads: Upload[]): Promise<string[]> { const ids: string[] = [] 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(), 600000) const dispose = () => { _dispose() clearTimeout(timer) for (const id of ids) { delete this.ctx.satori._tempStore[id] } } const _dispose = this.ctx.on('dispose', dispose) return ids.map(id => this.getInternalUrl(`/_tmp/${id}`)) } async supports(name: string, session: Partial<C[typeof Context.session]> = {}) { return !!this[Methods[name]?.name] } async checkPermission(name: string, session: Partial<C[typeof Context.session]>) { if (name.startsWith('bot.')) { return this.supports(name.slice(4), session) } } toJSON(): Login { return clone({ ...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 } } const iterableMethods = [ 'getMessage', 'getReaction', 'getFriend', 'getGuild', 'getGuildMember', 'getGuildRole', 'getChannel', ] for (const name of iterableMethods) { Bot.prototype[name + 'Iter'] = function (this: Bot, ...args: any[]) { let list: List<any> if (!this[name + 'List']) throw new Error(`not implemented: ${name}List`) const getList = async () => { list = await this[name + 'List'](...args, list?.next) // `bot.getMessageList()` returns messages in ascending order if (name === 'getMessage') list.data.reverse() } return { async next() { if (list?.data.length) return { done: false, value: list.data.shift() } if (list && !list?.next) return { done: true, value: undefined } await getList() return this.next() }, [Symbol.asyncIterator]() { return this }, } } } defineAccessor(Bot.prototype, 'selfId', ['user', 'id']) defineAccessor(Bot.prototype, 'userId', ['user', 'id'])