@satorijs/core
Version:
Core components of Satorijs
284 lines (247 loc) • 9.93 kB
text/typescript
import { Context, Logger, Service, z } from 'cordis'
import { Awaitable, defineProperty, Dict } from 'cosmokit'
import { Bot } from './bot'
import { ExtractParams, InternalRequest, InternalRouter, JsonForm } from './internal'
import { Session } from './session'
import { FileResponse, HTTP } from '@cordisjs/plugin-http'
import { Meta, Response, SendOptions } from '@satorijs/protocol'
import h from '@satorijs/element'
h.warn = new Logger('element').warn
// do not remove the `type` modifier
// because `esModuleInterop` is not respected by esbuild
export type { Fragment, Render } from '@satorijs/element'
export { h, h as Element, h as segment, HTTP, HTTP as Quester }
export * from 'cordis'
export * from 'cosmokit'
export * as Universal from '@satorijs/protocol'
export * from './bot'
export * from './adapter'
export * from './message'
export * from './internal'
export * from './session'
declare module 'cordis' {
export interface Context {
[Context.session]: Session<this>
satori: Satori<this>
bots: Bot<this>[] & Dict<Bot<this>>
component(name: string, component: Component<GetSession<this>>, options?: Component.Options): () => void
}
export namespace Context {
const session: unique symbol
}
interface Events<C> {
'satori/meta'(): void
'internal/session'(session: GetSession<C>): void
'interaction/command'(session: GetSession<C>): void
'interaction/button'(session: GetSession<C>): void
'message'(session: GetSession<C>): void
'message-created'(session: GetSession<C>): void
'message-deleted'(session: GetSession<C>): void
'message-updated'(session: GetSession<C>): void
'message-pinned'(session: GetSession<C>): void
'message-unpinned'(session: GetSession<C>): void
'guild-added'(session: GetSession<C>): void
'guild-removed'(session: GetSession<C>): void
'guild-updated'(session: GetSession<C>): void
'guild-member-added'(session: GetSession<C>): void
'guild-member-removed'(session: GetSession<C>): void
'guild-member-updated'(session: GetSession<C>): void
'guild-role-created'(session: GetSession<C>): void
'guild-role-deleted'(session: GetSession<C>): void
'guild-role-updated'(session: GetSession<C>): void
'reaction-added'(session: GetSession<C>): void
'reaction-removed'(session: GetSession<C>): void
'login-added'(session: GetSession<C>): void
'login-removed'(session: GetSession<C>): void
'login-updated'(session: GetSession<C>): void
'friend-request'(session: GetSession<C>): void
'guild-request'(session: GetSession<C>): void
'guild-member-request'(session: GetSession<C>): void
'before-send'(session: GetSession<C>, options: SendOptions): Awaitable<void | boolean>
'send'(session: GetSession<C>): void
/** @deprecated use `login-added` instead */
'bot-added'(client: Bot<C>): void
/** @deprecated use `login-removed` instead */
'bot-removed'(client: Bot<C>): void
/** @deprecated use `login-updated` instead */
'bot-status-updated'(client: Bot<C>): void
'bot-connect'(client: Bot<C>): Awaitable<void>
'bot-disconnect'(client: Bot<C>): Awaitable<void>
}
}
declare module '@cordisjs/plugin-http' {
namespace HTTP {
export function createConfig(this: typeof HTTP, endpoint?: string | boolean): z<Config>
}
}
HTTP.createConfig = function createConfig(this, endpoint) {
return z.object({
endpoint: z.string().role('link').description('要连接的服务器地址。')
.default(typeof endpoint === 'string' ? endpoint : undefined!)
.required(typeof endpoint === 'boolean' ? endpoint : false),
headers: z.dict(String).role('table').description('要附加的额外请求头。'),
...this.Config.dict,
}).description('请求设置')
}
export type Component<S extends Session = Session> = h.Render<Awaitable<h.Fragment>, S>
export namespace Component {
export interface Options {
session?: boolean
}
}
export type GetSession<C extends Context> = C[typeof Context.session]
class SatoriContext extends Context {
constructor(config?: any) {
super(config)
this.provide('satori', undefined, true)
this.plugin(Satori)
}
}
export { SatoriContext as Context }
class DisposableSet<T> {
private sn = 0
private map1 = new Map<number, T[]>()
private map2 = new Map<T, Set<number>>()
constructor(private ctx: Context) {
defineProperty(this, Service.tracker, {
property: 'ctx',
})
}
add(...values: T[]) {
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, new Set())
hasUpdate = true
}
this.map2.get(value)!.add(sn)
}
this.map1.set(sn, values)
if (hasUpdate) this.ctx.emit('satori/meta')
return () => {
let hasUpdate = 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)
hasUpdate = true
}
}
if (hasUpdate) this.ctx.emit('satori/meta')
}
})
}
[Symbol.iterator]() {
return new Set(([] as T[]).concat(...this.map1.values()))[Symbol.iterator]()
}
}
export class Satori<C extends Context = Context> extends Service<unknown, C> {
static [Service.provide] = 'satori'
static [Service.immediate] = true
public uid = Math.random().toString(36).slice(2)
public proxyUrls: DisposableSet<string> = new DisposableSet(this.ctx)
public _internalRouter: InternalRouter<C>
public _tempStore: Dict<Response> = Object.create(null)
public _loginSeq = 0
public _sessionSeq = 0
constructor(ctx: C) {
super(ctx)
ctx.mixin('satori', ['bots', 'component'])
defineProperty(this.bots, Service.tracker, {})
const self = this
;(ctx as Context).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 } as FileResponse
})
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
// FIXME: missing response body
return error.response
}
})
}
public bots = new Proxy([] as Bot<C>[], {
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(bot => bot.sid === prop)
if (bot < 0) return true
target.splice(bot, 1)
return true
},
}) as Bot<C>[] & Dict<Bot<C>>
component(name: string, component: Component<C[typeof Context.session]>, options: Component.Options = {}) {
const render: Component = 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(h.normalize(result))
}
return this.ctx.set('component:' + name, render)
}
defineInternalRoute<P extends string>(path: P, callback: (request: InternalRequest<C, ExtractParams<P>>) => Promise<Response>) {
return this._internalRouter.define(path, callback)
}
async handleInternalRoute(method: HTTP.Method, url: URL, headers = new Headers(), body?: any): Promise<Response> {
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): Meta {
return {
logins: meta ? undefined! : this.bots.map(bot => bot.toJSON()),
proxyUrls: [...this.proxyUrls],
}
}
}
export default Satori