diff --git a/packages/core/package.json b/packages/core/package.json index d24a28ead5..0e7c271f08 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,8 +33,9 @@ }, "dependencies": { "@koishijs/utils": "^5.4.5", - "cordis": "^1.6.0", + "@satorijs/core": "^1.0.0", + "cordis": "^2.0.3", "fastest-levenshtein": "^1.0.12", "minato": "^1.2.1" } -} \ No newline at end of file +} diff --git a/packages/core/src/bot.ts b/packages/core/src/bot.ts new file mode 100644 index 0000000000..557437ce58 --- /dev/null +++ b/packages/core/src/bot.ts @@ -0,0 +1,40 @@ +import { Dict, makeArray, sleep } from '@koishijs/utils' +import { Context } from './context' +import { Session } from './session' +import * as satori from '@satorijs/core' + +declare module '@satorijs/core' { + interface Bot { + getGuildMemberMap(guildId: string): Promise> + broadcast(channels: (string | [string, string])[], content: string, delay?: number): Promise + } +} + +export { satori } +export { Adapter } from '@satorijs/core' + +export const Bot = satori.Bot +export type Bot = satori.Bot + +Bot.prototype.session = function session(this: Bot, payload) { + return new Session(this, payload) +} + +Bot.prototype.getGuildMemberMap = async function getGuildMemberMap(this: Bot, guildId) { + const list = await this.getGuildMemberList(guildId) + return Object.fromEntries(list.map(info => [info.userId, info.nickname || info.username])) +} + +Bot.prototype.broadcast = async function broadcast(this: Bot, channels, content, delay = this.ctx.options.delay.broadcast) { + const messageIds: string[] = [] + for (let index = 0; index < channels.length; index++) { + if (index && delay) await sleep(delay) + try { + const [channelId, guildId] = makeArray(channels[index]) + messageIds.push(...await this.sendMessage(channelId, content, guildId)) + } catch (error) { + this.ctx.logger('bot').warn(error) + } + } + return messageIds +} diff --git a/packages/core/src/command/command.ts b/packages/core/src/command/command.ts index 1b156044fe..1ce88e049d 100644 --- a/packages/core/src/command/command.ts +++ b/packages/core/src/command/command.ts @@ -1,10 +1,9 @@ -import { coerce, Dict, Logger, remove, Schema } from '@koishijs/utils' -import { Awaitable } from 'cosmokit' -import { Context, Disposable } from 'cordis' +import { Awaitable, coerce, Dict, Logger, remove, Schema } from '@koishijs/utils' +import { Context, Disposable } from '../context' import { Argv } from './parser' -import { Next } from '../protocol' +import { Next } from '../internal' import { Channel, User } from '../database' -import { Computed, FieldCollector, Session } from '../protocol/session' +import { Computed, FieldCollector, Session } from '../session' const logger = new Logger('command') diff --git a/packages/core/src/command/index.ts b/packages/core/src/command/index.ts index aec84957bb..b1ae454cae 100644 --- a/packages/core/src/command/index.ts +++ b/packages/core/src/command/index.ts @@ -1,11 +1,11 @@ -import { Context } from 'cordis' -import { Awaitable } from 'cosmokit' +import { Awaitable, defineProperty } from '@koishijs/utils' +import { Context } from '../context' import { Command } from './command' import { Argv } from './parser' import runtime from './runtime' import validate from './validate' import { Channel, User } from '../database' -import { Session } from '../protocol' +import { Session } from '../session' export * from './command' export * from './runtime' @@ -16,7 +16,7 @@ interface CommandMap extends Map { resolve(key: string): Command } -declare module 'cordis' { +declare module '../context' { interface Context extends Commander.Mixin { $commander: Commander } @@ -43,13 +43,14 @@ export namespace Commander { export class Commander { static readonly key = '$commander' + static readonly methods = ['command'] _commandList: Command[] = [] _commands = new Map() as CommandMap _shortcuts: Command.Shortcut[] = [] constructor(private ctx: Context, private config: Commander.Config = {}) { - this[Context.current] = ctx + defineProperty(this, Context.current, ctx) ctx.plugin(runtime) ctx.plugin(validate) } @@ -127,7 +128,4 @@ export class Commander { } } -Context.service(Commander.key, { - constructor: Commander, - methods: ['command'], -}) +Context.service(Commander.key, Commander) diff --git a/packages/core/src/command/parser.ts b/packages/core/src/command/parser.ts index 9fa43755dc..700f19b355 100644 --- a/packages/core/src/command/parser.ts +++ b/packages/core/src/command/parser.ts @@ -1,9 +1,9 @@ import { camelCase, Dict, escapeRegExp, paramCase, segment, Time } from '@koishijs/utils' import { Command } from './command' -import { Context } from 'cordis' +import { Context } from '../context' import { Channel, User } from '../database' -import { Session } from '../protocol/session' -import { Next } from '../protocol' +import { Session } from '../session' +import { Next } from '../internal' export interface Token { rest?: string diff --git a/packages/core/src/command/runtime.ts b/packages/core/src/command/runtime.ts index 4f97da546a..2e9286cf93 100644 --- a/packages/core/src/command/runtime.ts +++ b/packages/core/src/command/runtime.ts @@ -1,7 +1,7 @@ import { defineProperty, valueMap } from '@koishijs/utils' import { Argv } from './parser' -import { Context } from 'cordis' -import { Session } from '../protocol/session' +import { Context } from '../context' +import { Session } from '../session' export default function runtime(ctx: Context) { ctx.before('parse', (content, session) => { diff --git a/packages/core/src/command/validate.ts b/packages/core/src/command/validate.ts index 51d87b7c04..07ba4d26ec 100644 --- a/packages/core/src/command/validate.ts +++ b/packages/core/src/command/validate.ts @@ -1,5 +1,5 @@ import { Argv } from './parser' -import { Context } from 'cordis' +import { Context } from '../context' export default function validate(ctx: Context) { // add user fields diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts new file mode 100644 index 0000000000..44af2922fb --- /dev/null +++ b/packages/core/src/context.ts @@ -0,0 +1,112 @@ +import { Awaitable, defineProperty, Schema, Time } from '@koishijs/utils' +import * as satori from '@satorijs/core' +import * as cordis from 'cordis' +import { Computed, Session } from './session' + +export type Plugin = cordis.Plugin + +export namespace Plugin { + export type Function = cordis.Plugin.Function + export type Constructor = cordis.Plugin.Constructor + export type Object = cordis.Plugin.Object +} + +export type State = cordis.State +export type Fork = cordis.Fork +export type Runtime = cordis.Runtime + +export const Service = cordis.Service + +export { Disposable } from 'cordis' + +export interface Events extends satori.Events { + 'appellation'(name: string, session: Session): string +} + +export interface Context { + [Context.events]: Events + [Context.session]: Session +} + +export class Context extends satori.Context { + get app() { + return this.root + } +} + +export namespace Context { + export interface Config extends satori.Context.Config, Config.Basic, Config.Features, Config.Advanced {} + + export const Config = Schema.intersect([]) as Config.Static + + export namespace Config { + export interface Basic { + locale?: string + prefix?: Computed + nickname?: string | string[] + autoAssign?: Computed> + autoAuthorize?: Computed> + } + + export interface Features { + delay?: DelayConfig + } + + export interface DelayConfig { + character?: number + message?: number + cancel?: number + broadcast?: number + prompt?: number + } + + export interface Advanced { + maxListeners?: number + prettyErrors?: boolean + } + + export interface Static extends Schema { + Basic: Schema + Features: Schema + Advanced: Schema + } + } + + defineProperty(Context.Config, 'Basic', Schema.object({ + locale: Schema.string().default('zh').description('默认使用的语言。'), + prefix: Schema.union([ + Schema.array(String), + Schema.transform(String, (prefix) => [prefix]), + ] as const).default(['']).description('指令前缀字符,可以是字符串或字符串数组。将用于指令前缀的匹配。'), + nickname: Schema.union([ + Schema.array(String), + Schema.transform(String, (nickname) => [nickname]), + ] as const).description('机器人的昵称,可以是字符串或字符串数组。将用于指令前缀的匹配。'), + autoAssign: Schema.union([Boolean, Function]).default(true).description('当获取不到频道数据时,是否使用接受者作为代理者。'), + autoAuthorize: Schema.union([Schema.natural(), Function]).default(1).description('当获取不到用户数据时默认使用的权限等级。'), + }).description('基础设置')) + + defineProperty(Context.Config, 'Features', Schema.object({ + delay: Schema.object({ + character: Schema.natural().role('ms').default(0).description('调用 `session.sendQueued()` 时消息间发送的最小延迟,按前一条消息的字数计算。'), + message: Schema.natural().role('ms').default(0.1 * Time.second).description('调用 `session.sendQueued()` 时消息间发送的最小延迟,按固定值计算。'), + cancel: Schema.natural().role('ms').default(0).description('调用 `session.cancelQueued()` 时默认的延迟。'), + broadcast: Schema.natural().role('ms').default(0.5 * Time.second).description('调用 `bot.broadcast()` 时默认的延迟。'), + prompt: Schema.natural().role('ms').default(Time.minute).description('调用 `session.prompt()` 时默认的等待时间。'), + }), + }).description('消息设置')) + + defineProperty(Context.Config, 'Advanced', Schema.object({ + prettyErrors: Schema.boolean().default(true).description('启用报错优化模式。在此模式下 Koishi 会对程序抛出的异常进行整理,过滤掉框架内部的调用记录,输出更易读的提示信息。'), + maxListeners: Schema.natural().default(64).description('每种监听器的最大数量。如果超过这个数量,Koishi 会认定为发生了内存泄漏,将产生一个警告。'), + }).description('高级设置')) + + Context.Config.list.push(Context.Config.Basic, Context.Config.Features, Context.Config.Advanced) +} + +// for backward compatibility +export { Context as App } + +export function defineConfig(config: Context.Config) { + return config +} diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index 41f5efbefc..ae0c8c4b32 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -1,9 +1,9 @@ import * as utils from '@koishijs/utils' -import { Dict, MaybeArray } from '@koishijs/utils' +import { defineProperty, Dict, MaybeArray } from '@koishijs/utils' import { Database, Driver, Result, Update } from 'minato' -import { Context, Plugin } from 'cordis' +import { Context, Plugin } from './context' -declare module 'cordis' { +declare module './context' { interface Events { 'model'(name: keyof Tables): void } @@ -67,9 +67,11 @@ export namespace DatabaseService { } export class DatabaseService extends Database { + static readonly methods = ['getSelfIds', 'broadcast'] + constructor(protected app: Context) { super() - this[Context.current] = app + defineProperty(this, Context.current, app) this.extend('user', { // TODO v5: change to number @@ -180,10 +182,7 @@ DatabaseService.prototype.extend = function extend(this: DatabaseService, name, } Context.service('database') -Context.service('model', { - constructor: DatabaseService, - methods: ['getSelfIds', 'broadcast'], -}) +Context.service('model', DatabaseService) export const defineDriver = (constructor: Driver.Constructor, schema?: utils.Schema, prepare?: Plugin.Function): Plugin.Object => ({ name: constructor.name, diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts index 3672b4f2e4..db99deeca1 100644 --- a/packages/core/src/i18n.ts +++ b/packages/core/src/i18n.ts @@ -1,10 +1,10 @@ import { Dict, isNullable, Logger, Random, Time } from '@koishijs/utils' -import { Context } from 'cordis' +import { Context } from './context' const logger = new Logger('i18n') const kTemplate = Symbol('template') -declare module 'cordis' { +declare module './context' { interface Context { i18n: I18n } @@ -171,6 +171,4 @@ export class I18n { } } -Context.service('i18n', { - constructor: I18n, -}) +Context.service('i18n', I18n) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa338ac8cd..5dbd1fba25 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,101 +1,13 @@ -import { defineProperty, Schema, Time } from '@koishijs/utils' -import { Awaitable } from 'cosmokit' -import { App } from 'cordis' -import { Computed } from './protocol' - export * from '@koishijs/utils' -export * from 'cordis' export * from 'minato' -export * from './i18n' -export * from './database' export * from './selector' -export * from './protocol' +export * from './bot' +export * from './context' +export * from './database' +export * from './i18n' +export * from './internal' +export * from './session' export * from './command' const version: string = require('../package.json').version export { version } - -/** @deprecated for backward compatibility */ -export interface EventMap {} - -declare module 'cordis' { - interface Events extends EventMap {} - - namespace App { - export interface Config extends Config.Basic, Config.Features, Config.Advanced {} - - export const Config: Config.Static - - export namespace Config { - export interface Basic { - locale?: string - prefix?: Computed - nickname?: string | string[] - autoAssign?: Computed> - autoAuthorize?: Computed> - } - - export interface Features { - delay?: DelayConfig - } - - export interface Advanced { - maxListeners?: number - prettyErrors?: boolean - } - - export interface Static extends Schema { - Basic: Schema - Features: Schema - Advanced: Schema - } - } - } -} - -export interface DelayConfig { - character?: number - message?: number - cancel?: number - broadcast?: number - prompt?: number -} - -export interface AppConfig extends App.Config.Basic, App.Config.Features, App.Config.Advanced {} - -defineProperty(App, 'Config', Schema.intersect([])) - -defineProperty(App.Config, 'Basic', Schema.object({ - locale: Schema.string().default('zh').description('默认使用的语言。'), - prefix: Schema.union([ - Schema.array(String), - Schema.transform(String, (prefix) => [prefix]), - ] as const).default(['']).description('指令前缀字符,可以是字符串或字符串数组。将用于指令前缀的匹配。'), - nickname: Schema.union([ - Schema.array(String), - Schema.transform(String, (nickname) => [nickname]), - ] as const).description('机器人的昵称,可以是字符串或字符串数组。将用于指令前缀的匹配。'), - autoAssign: Schema.union([Boolean, Function]).default(true).description('当获取不到频道数据时,是否使用接受者作为代理者。'), - autoAuthorize: Schema.union([Schema.natural(), Function]).default(1).description('当获取不到用户数据时默认使用的权限等级。'), -}).description('基础设置')) - -defineProperty(App.Config, 'Features', Schema.object({ - delay: Schema.object({ - character: Schema.natural().role('ms').default(0).description('调用 `session.sendQueued()` 时消息间发送的最小延迟,按前一条消息的字数计算。'), - message: Schema.natural().role('ms').default(0.1 * Time.second).description('调用 `session.sendQueued()` 时消息间发送的最小延迟,按固定值计算。'), - cancel: Schema.natural().role('ms').default(0).description('调用 `session.cancelQueued()` 时默认的延迟。'), - broadcast: Schema.natural().role('ms').default(0.5 * Time.second).description('调用 `bot.broadcast()` 时默认的延迟。'), - prompt: Schema.natural().role('ms').default(Time.minute).description('调用 `session.prompt()` 时默认的等待时间。'), - }), -}).description('消息设置')) - -defineProperty(App.Config, 'Advanced', Schema.object({ - prettyErrors: Schema.boolean().default(true).description('启用报错优化模式。在此模式下 Koishi 会对程序抛出的异常进行整理,过滤掉框架内部的调用记录,输出更易读的提示信息。'), - maxListeners: Schema.natural().default(64).description('每种监听器的最大数量。如果超过这个数量,Koishi 会认定为发生了内存泄漏,将产生一个警告。'), -}).description('高级设置')) - -App.Config.list.push(App.Config.Basic, App.Config.Features, App.Config.Advanced) - -export function defineConfig(config: App.Config) { - return config -} diff --git a/packages/core/src/protocol/index.ts b/packages/core/src/internal.ts similarity index 94% rename from packages/core/src/protocol/index.ts rename to packages/core/src/internal.ts index 30571f8bc7..ce74ae1e87 100644 --- a/packages/core/src/protocol/index.ts +++ b/packages/core/src/internal.ts @@ -1,15 +1,9 @@ - -import { coerce, defineProperty, Dict, escapeRegExp, makeArray } from '@koishijs/utils' -import { Awaitable } from 'cosmokit' +import { Awaitable, coerce, defineProperty, Dict, escapeRegExp, makeArray } from '@koishijs/utils' import { Computed, Session } from './session' -import { Channel, User } from '../database' -import { Context } from 'cordis' - -export * from './adapter' -export * from './bot' -export * from './session' +import { Channel, User } from './database' +import { Context } from './context' -declare module 'cordis' { +declare module './context' { interface Context extends Internal.Mixin { $internal: Internal } @@ -55,6 +49,8 @@ export namespace Internal { } export class Internal { + static readonly methods = ['middleware'] + _hooks: [Context, Middleware][] = [] _nameRE: RegExp _sessions: Dict = Object.create(null) @@ -62,7 +58,7 @@ export class Internal { _channelCache = new SharedCache>() constructor(private ctx: Context, private config: Internal.Config) { - this[Context.current] = ctx + defineProperty(this, Context.current, ctx) this.prepare() // bind built-in event listeners @@ -173,7 +169,7 @@ export class Internal { // execute middlewares let index = 0, midStack = '', lastCall = '' - const { prettyErrors } = this.ctx.app.options + const { prettyErrors } = this.ctx.options const next: Next = async (callback) => { if (prettyErrors) { lastCall = new Error().stack.split('\n', 3)[2] @@ -227,10 +223,7 @@ export class Internal { } } -Context.service('$internal', { - constructor: Internal, - methods: ['middleware'], -}) +Context.service('$internal', Internal) export namespace SharedCache { export interface Entry { diff --git a/packages/core/src/protocol/adapter.ts b/packages/core/src/protocol/adapter.ts deleted file mode 100644 index 629b522e69..0000000000 --- a/packages/core/src/protocol/adapter.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Dict, Logger, paramCase, remove, Schema } from '@koishijs/utils' -import { Awaitable } from 'cosmokit' -import { Context, Plugin } from 'cordis' -import { Session } from './session' -import { Bot } from './bot' - -declare module 'cordis' { - interface Context { - bots: Adapter.BotList - } -} - -export abstract class Adapter { - public bots: Bot[] = [] - public platform: string - - protected abstract start(): Awaitable - protected abstract stop(): Awaitable - - constructor(public ctx: Context, public config: T) { - ctx.on('ready', () => this.start()) - ctx.on('dispose', () => this.stop()) - } - - connect(bot: Bot): Awaitable {} - disconnect(bot: Bot): Awaitable {} - - dispatch(session: Session) { - if (!this.ctx.lifecycle.isActive) return - const events: string[] = [session.type] - if (session.subtype) { - events.unshift(events[0] + '/' + session.subtype) - if (session.subsubtype) { - events.unshift(events[0] + '/' + session.subsubtype) - } - } - for (const event of events) { - this.ctx.emit(session, paramCase(event), session) - } - } -} - -const logger = new Logger('app') - -export namespace Adapter { - export interface Constructor { - new (ctx: Context, options?: S): Adapter - [redirect]?(bot: any): string - schema?: Schema - } - - export const redirect = Symbol('koishi.adapter.redirect') - export const library: Dict = {} - export const configMap: Dict = {} - - export type BotConfig = R & { bots?: R[] } - export type PluginConfig = S & BotConfig - - export function join(platform: string, protocol: string) { - return protocol ? `${platform}.${protocol}` : platform - } - - type CreatePluginRestParams = [Constructor] | [Dict, ((bot: any) => string)?] - - export function define( - platform: string, - bot: Bot.Constructor, - adapter: Constructor, - ): Plugin.Object> - - export function define( - platform: string, - bot: Bot.Constructor, - adapters: Record>, - redirect?: (config: T) => K, - ): Plugin.Object> - - export function define(platform: string, constructor: Bot.Constructor, ...args: CreatePluginRestParams) { - const name = platform + '-adapter' - platform = platform.toLowerCase() - Bot.library[platform] = constructor - - let BotConfig: Schema - if (typeof args[0] === 'function') { - library[platform] = args[0] - BotConfig = args[0].schema - } else { - library[platform] = { [redirect]: args[1] } as Constructor - BotConfig = library[platform].schema = Schema.union([]).description('机器人要使用的协议。') - const FlatConfig = Schema.object({ protocol: Schema.string() }) - function flatten(schema: Schema) { - if (schema.type === 'union' || schema.type === 'intersect') { - schema.list.forEach(flatten) - } else if (schema.type === 'object') { - for (const key in schema.dict) { - FlatConfig.dict[key] = new Schema(schema.dict[key]) - FlatConfig.dict[key].meta = { ...schema.dict[key].meta, required: false } - } - } else { - throw new Error('cannot flatten bot schema') - } - } - - for (const protocol in args[0]) { - library[join(platform, protocol)] = args[0][protocol] - flatten(args[0][protocol].schema) - BotConfig.list.push(Schema.intersect([ - Schema.object({ - protocol: Schema.const(protocol).required(), - }), - args[0][protocol].schema, - ]).description(protocol)) - } - BotConfig.list.push(Schema.transform(FlatConfig, (value) => { - if (value.protocol) throw new Error(`unknown protocol "${value.protocol}"`) - value.protocol = args[1](value) as never - logger.debug('infer type as %s', value.protocol) - return value - })) - } - - const Config = Schema.intersect([ - constructor.schema, - Schema.union([ - Schema.object({ bots: Schema.array(BotConfig).required().hidden() }), - Schema.transform(BotConfig, config => ({ bots: [config] })), - ]), - ]) - - function apply(ctx: Context, config: PluginConfig = {}) { - ctx.emit('adapter', platform) - configMap[platform] = config - - for (const options of config.bots) { - ctx.bots.create(platform, options, constructor) - } - } - - return { name, Config, apply } - } - - export class BotList extends Array { - adapters: Dict = {} - - constructor(private app: Context) { - super() - this[Context.current] = app - } - - protected get caller() { - return this[Context.current] - } - - get(sid: string) { - return this.find(bot => bot.sid === sid) - } - - create(platform: string, options: any, constructor?: new (adapter: Adapter, config: any) => T): T { - constructor ||= Bot.library[platform] as any - const adapter = this.resolve(platform, options) - const bot = new constructor(adapter, options) - adapter.bots.push(bot) - this.push(bot) - this.caller.emit('bot-added', bot) - this.caller.on('dispose', () => { - this.remove(bot.id) - }) - return bot - } - - remove(id: string) { - const index = this.findIndex(bot => bot.id === id) - if (index < 0) return - const [bot] = this.splice(index, 1) - const exist = remove(bot.adapter.bots, bot) - this.caller.emit('bot-removed', bot) - return exist - } - - private resolve(platform: string, config: Bot.BaseConfig): Adapter { - const type = join(platform, config.protocol) - if (this.adapters[type]) return this.adapters[type] - - const constructor = Adapter.library[type] - if (!constructor) { - throw new Error(`unsupported protocol "${type}"`) - } - - if (constructor[redirect]) { - config.protocol = constructor[redirect](config) - return this.resolve(platform, config) - } - - const adapter = new constructor(this.caller, configMap[platform]) - adapter.platform = platform - this.caller.on('dispose', () => { - delete this.adapters[type] - }) - return this.adapters[type] = adapter - } - } -} - -Context.service('bots', { - constructor: Adapter.BotList, -}) diff --git a/packages/core/src/protocol/bot.ts b/packages/core/src/protocol/bot.ts deleted file mode 100644 index 3d62c2f2f7..0000000000 --- a/packages/core/src/protocol/bot.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { Dict, Logger, makeArray, Random, Schema, sleep } from '@koishijs/utils' -import { Awaitable } from 'cosmokit' -import { Adapter } from './adapter' -import { App, Context } from 'cordis' -import { Session } from './session' - -declare module 'cordis' { - interface Events { - 'adapter'(name: string): void - 'bot-added'(bot: Bot): void - 'bot-removed'(bot: Bot): void - 'bot-status-updated'(bot: Bot): void - 'bot-connect'(bot: Bot): Awaitable - 'bot-disconnect'(bot: Bot): Awaitable - } -} - -const logger = new Logger('bot') - -export interface Bot extends Bot.BaseConfig, Bot.Methods, Bot.UserBase {} - -export abstract class Bot { - public app: App - public ctx: Context - public platform: string - public hidden?: boolean - public internal?: any - public selfId?: string - public logger: Logger - public id = Random.id() - - private _status: Bot.Status - - error?: Error - - constructor(public adapter: Adapter, public config: T) { - this.ctx = adapter.ctx - this.app = this.ctx.app - this.platform = config.platform || adapter.platform - this.logger = new Logger(adapter.platform) - this._status = 'offline' - this.extendModel() - - adapter.ctx.on('ready', () => this.start()) - adapter.ctx.on('dispose', () => this.stop()) - } - - private extendModel() { - if (this.platform in this.ctx.model.tables.user.fields) return - this.ctx.model.extend('user', { - [this.platform]: { type: 'string', length: 63 }, - }, { - unique: [this.platform as never], - }) - } - - get status() { - return this._status - } - - set status(value) { - this._status = value - if (this.ctx.bots.includes(this)) { - this.ctx.emit('bot-status-updated', this) - } - } - - resolve() { - this.status = 'online' - logger.success('logged in to %s as %c (%s)', this.platform, this.username, this.selfId) - } - - reject(error: Error) { - this.error = error - this.status = 'offline' - logger.error(error) - } - - async start() { - if (this.config.disabled) return - if (['connect', 'reconnect', 'online'].includes(this.status)) return - this.status = 'connect' - try { - await this.ctx.parallel('bot-connect', this) - await this.adapter.connect(this) - } catch (error) { - this.reject(error) - } - } - - async stop() { - if (['disconnect', 'offline'].includes(this.status)) return - this.status = 'disconnect' - try { - await this.ctx.parallel('bot-disconnect', this) - await this.adapter.disconnect(this) - } catch (error) { - this.logger.warn(error) - } - this.status = 'offline' - } - - get sid() { - return `${this.platform}:${this.selfId}` - } - - /** @deprecated using `bot.session()` instead */ - createSession(session: Partial) { - return new Session(this, { - ...session, - type: 'send', - selfId: this.selfId, - platform: this.platform, - timestamp: Date.now(), - author: { - userId: this.selfId, - username: this.username, - avatar: this.avatar, - discriminator: this.discriminator, - isBot: true, - }, - }) - } - - async session(data: Partial) { - const session = this.createSession(data) - if (await this.ctx.serial(session, 'before-send', session)) return - return session - } - - async getGuildMemberMap(guildId: string) { - const list = await this.getGuildMemberList(guildId) - return Object.fromEntries(list.map(info => [info.userId, info.nickname || info.username])) - } - - async broadcast(channels: (string | [string, string])[], content: string, delay = this.ctx.app.options.delay.broadcast) { - const messageIds: string[] = [] - for (let index = 0; index < channels.length; index++) { - if (index && delay) await sleep(delay) - try { - const [channelId, guildId] = makeArray(channels[index]) - messageIds.push(...await this.sendMessage(channelId, content, guildId)) - } catch (error) { - this.ctx.logger('bot').warn(error) - } - } - return messageIds - } -} - -export namespace Bot { - export const library: Dict = {} - - export interface BaseConfig { - disabled?: boolean - protocol?: string - platform?: string - } - - export interface Constructor { - new (adapter: Adapter, config: S): Bot - schema?: Schema - } - - export type Status = 'offline' | 'online' | 'connect' | 'disconnect' | 'reconnect' - - export interface Methods { - // message - sendMessage(channelId: string, content: string, guildId?: string): Promise - sendPrivateMessage(userId: string, content: string): Promise - getMessage(channelId: string, messageId: string): Promise - editMessage(channelId: string, messageId: string, content: string): Promise - deleteMessage(channelId: string, messageId: string): Promise - - // user - getSelf(): Promise - getUser(userId: string): Promise - getFriendList(): Promise - deleteFriend(userId: string): Promise - - // guild - getGuild(guildId: string): Promise - getGuildList(): Promise - - // guild member - getGuildMember(guildId: string, userId: string): Promise - getGuildMemberList(guildId: string): Promise - kickGuildMember(guildId: string, userId: string, permanent?: boolean): Promise - muteGuildMember(guildId: string, userId: string, duration: number, reason?: string): Promise - - // channel - getChannel(channelId: string, guildId?: string): Promise - getChannelList(guildId: string): Promise - muteChannel(channelId: string, guildId?: string, enable?: boolean): Promise - - // request - handleFriendRequest(messageId: string, approve: boolean, comment?: string): Promise - handleGuildRequest(messageId: string, approve: boolean, comment?: string): Promise - handleGuildMemberRequest(messageId: string, approve: boolean, comment?: string): Promise - getChannelMessageHistory(channelId: string, before?: string): Promise - } - - export interface Channel { - channelId: string - channelName?: string - } - - export interface Guild { - guildId: string - guildName?: string - } - - export interface UserBase { - username?: string - nickname?: string - avatar?: string - discriminator?: string - isBot?: boolean - } - - export interface User extends UserBase { - userId: string - } - - export interface GuildMember extends User { - roles?: string[] - } - - export interface Author extends GuildMember { - anonymous?: string - } - - export interface Role { - id: string - } - - export interface MessageBase { - messageId?: string - channelId?: string - guildId?: string - userId?: string - content?: string - timestamp?: number - author?: Author - quote?: Message - } - - export interface Message extends MessageBase { - subtype?: 'private' | 'group' - } -} diff --git a/packages/core/src/selector.ts b/packages/core/src/selector.ts index cec1ff23ac..8f5db21ad6 100644 --- a/packages/core/src/selector.ts +++ b/packages/core/src/selector.ts @@ -1,44 +1,37 @@ -import { Logger, Promisify, remove } from '@koishijs/utils' -import { Context, Events } from 'cordis' -import { Session } from './protocol/session' - -declare module 'cordis' { - interface Context extends SelectorService.Mixin { - selector: SelectorService - } - - namespace Context { - interface Meta { - filter: Filter - } - } -} +import { defineProperty, Logger, Promisify, remove } from '@koishijs/utils' +import { GetEvents, Parameters, ReturnType, ThisType } from 'cordis' +import { Context, Events } from './context' +import { Session } from './session' export type Filter = (session: Session) => boolean -export namespace SelectorService { - export interface Mixin { +/* eslint-disable max-len */ +declare module './context' { + interface Context { + filter: Filter + selector: SelectorService logger(name: string): Logger - any(): Context - never(): Context - union(arg: Filter | Context): Context - intersect(arg: Filter | Context): Context - exclude(arg: Filter | Context): Context - user(...values: string[]): Context - self(...values: string[]): Context - guild(...values: string[]): Context - channel(...values: string[]): Context - platform(...values: string[]): Context - private(...values: string[]): Context - waterfall(name: K, ...args: Parameters): Promisify> - waterfall(thisArg: ThisParameterType, name: K, ...args: Parameters): Promisify> - chain(name: K, ...args: Parameters): ReturnType - chain(thisArg: ThisParameterType, name: K, ...args: Parameters): ReturnType + any(): this + never(): this + union(arg: Filter | Context): this + intersect(arg: Filter | Context): this + exclude(arg: Filter | Context): this + user(...values: string[]): this + self(...values: string[]): this + guild(...values: string[]): this + channel(...values: string[]): this + platform(...values: string[]): this + private(...values: string[]): this + waterfall>(name: K, ...args: Parameters[K]>): Promisify[K]>> + waterfall>(thisArg: ThisType[K]>, name: K, ...args: Parameters[K]>): Promisify[K]>> + chain>(name: K, ...args: Parameters[K]>): ReturnType[K]> + chain>(thisArg: ThisType[K]>, name: K, ...args: Parameters[K]>): ReturnType[K]> before(name: K, listener: BeforeEventMap[K], append?: boolean): () => boolean setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): () => boolean setInterval(callback: (...args: any[]) => void, ms: number, ...args: any[]): () => boolean } } +/* eslint-enable max-len */ type OmitSubstring = S extends `${infer L}${T}${infer R}` ? `${L}${R}` : never type BeforeEventName = OmitSubstring @@ -51,9 +44,15 @@ function property(ctx: Context, key: K, ...values: Sess }) } -export class SelectorService { - constructor(private app: Context) { - this[Context.current] = app +export class SelectorService { + static readonly methods = [ + 'any', 'never', 'union', 'intersect', 'exclude', 'select', + 'user', 'self', 'guild', 'channel', 'platform', 'private', + 'chain', 'waterfall', 'before', 'logger', 'setTimeout', 'setInterval', + ] + + constructor(private app: C) { + defineProperty(this, Context.current, app) app.filter = () => true @@ -63,14 +62,14 @@ export class SelectorService { app.on('internal/runtime', (runtime) => { if (!runtime.uid) return - runtime.context.filter = (session) => { - return runtime.children.some(p => p.context.filter(session)) + runtime.ctx.filter = (session) => { + return runtime.children.some(p => p.ctx.filter(session)) } }) } protected get caller() { - return this[Context.current] + return this[Context.current] as Context } any() { @@ -81,19 +80,19 @@ export class SelectorService { return this.caller.extend({ filter: () => false }) } - union(arg: Filter | Context) { + union(arg: Filter | C) { const caller = this.caller const filter = typeof arg === 'function' ? arg : arg.filter return this.caller.extend({ filter: s => caller.filter(s) || filter(s) }) } - intersect(arg: Filter | Context) { + intersect(arg: Filter | C) { const caller = this.caller const filter = typeof arg === 'function' ? arg : arg.filter return this.caller.extend({ filter: s => caller.filter(s) && filter(s) }) } - exclude(arg: Filter | Context) { + exclude(arg: Filter | C) { const caller = this.caller const filter = typeof arg === 'function' ? arg : arg.filter return this.caller.extend({ filter: s => caller.filter(s) && !filter(s) }) @@ -176,11 +175,4 @@ export class SelectorService { } } -Context.service('selector', { - constructor: SelectorService, - methods: [ - 'any', 'never', 'union', 'intersect', 'exclude', 'select', - 'user', 'self', 'guild', 'channel', 'platform', 'private', - 'chain', 'waterfall', 'before', 'logger', 'setTimeout', 'setInterval', - ], -}) +Context.service('selector', SelectorService) diff --git a/packages/core/src/protocol/session.ts b/packages/core/src/session.ts similarity index 77% rename from packages/core/src/protocol/session.ts rename to packages/core/src/session.ts index 1b399a8045..b0b1eea244 100644 --- a/packages/core/src/protocol/session.ts +++ b/packages/core/src/session.ts @@ -1,72 +1,12 @@ -import { Channel, Tables, User } from '../database' -import { Argv, Command } from '../command' -import { Awaitable } from 'cosmokit' -import { defineProperty, isNullable, Logger, makeArray, observe, Promisify, Random, segment } from '@koishijs/utils' -import { Middleware, Next } from '.' -import { App, Context } from 'cordis' -import { Bot } from './bot' - -type Genres = 'friend' | 'channel' | 'guild' | 'guild-member' | 'guild-role' | 'guild-file' | 'guild-emoji' -type Actions = 'added' | 'deleted' | 'updated' -type SessionEventCallback = (session: Session) => void - -declare module 'cordis' { - interface Events extends Record<`${Genres}-${Actions}`, SessionEventCallback> { - 'message': SessionEventCallback - 'message-deleted': SessionEventCallback - 'message-updated': SessionEventCallback - 'reaction-added': SessionEventCallback - 'reaction-deleted': SessionEventCallback - 'reaction-deleted/one': SessionEventCallback - 'reaction-deleted/all': SessionEventCallback - 'reaction-deleted/emoji': SessionEventCallback - 'send': SessionEventCallback - 'friend-request': SessionEventCallback - 'guild-request': SessionEventCallback - 'guild-member-request': SessionEventCallback - 'guild-member/role': SessionEventCallback - 'guild-member/ban': SessionEventCallback - 'guild-member/nickname': SessionEventCallback - 'notice/poke': SessionEventCallback - 'notice/lucky-king': SessionEventCallback - 'notice/honor': SessionEventCallback - 'notice/honor/talkative': SessionEventCallback - 'notice/honor/performer': SessionEventCallback - 'notice/honor/emotion': SessionEventCallback - - // session events - 'appellation'(name: string, session: Session): string - 'before-send'(session: Session): Awaitable - } -} +import { defineProperty, isNullable, Logger, makeArray, observe, Promisify, segment } from '@koishijs/utils' +import * as satori from '@satorijs/core' +import { Argv, Command } from './command' +import { Context } from './context' +import { Channel, Tables, User } from './database' +import { Middleware, Next } from './internal' const logger = new Logger('session') -export interface Session extends Session.Payload {} - -export namespace Session { - export interface Payload { - platform?: string - selfId: string - type?: string - subtype?: string - messageId?: string - channelId?: string - guildId?: string - userId?: string - content?: string - timestamp?: number - author?: Bot.Author - quote?: Bot.Message - channelName?: string - guildName?: string - operatorId?: string - targetId?: string - duration?: number - file?: FileInfo - } -} - export interface Parsed { content: string prefix: string @@ -82,71 +22,32 @@ interface Task { reject(reason: any): void } -export class Session { - type?: string - subtype?: string - subsubtype?: string - scope?: string - - bot: Bot - app: App - - selfId: string - operatorId?: string - targetId?: string - duration?: number - file?: FileInfo - - id: string - platform?: string - argv?: Argv - user?: User.Observed - channel?: Channel.Observed - guild?: Channel.Observed - parsed?: Parsed +export namespace Session { + export interface Payload extends satori.Session.Payload {} +} + +export class Session extends satori.Session { + public argv?: Argv + public user?: User.Observed + public channel?: Channel.Observed + public guild?: Channel.Observed + public parsed?: Parsed + public scope?: string private _promise: Promise private _queuedTasks: Task[] private _queuedTimeout: NodeJS.Timeout - constructor(bot: Bot, session: Partial) { - Object.assign(this, session) - this.platform = bot.platform - defineProperty(this, 'app', bot.ctx.app) - defineProperty(this, 'bot', bot) + constructor(bot: satori.Bot, payload: Partial) { + super(bot, payload) + defineProperty(this, 'scope', null) defineProperty(this, 'user', null) defineProperty(this, 'channel', null) - defineProperty(this, 'id', Random.id()) + defineProperty(this, 'guild', null) defineProperty(this, '_queuedTasks', []) defineProperty(this, '_queuedTimeout', null) } - [Context.filter](ctx: Context) { - return ctx.filter(this) - } - - get uid() { - return `${this.platform}:${this.userId}` - } - - get gid() { - return `${this.platform}:${this.guildId}` - } - - get cid() { - return `${this.platform}:${this.channelId}` - } - - get sid() { - return `${this.platform}:${this.selfId}` - } - - toJSON(): Session.Payload { - return Object.fromEntries(Object.entries(this).filter(([key]) => { - return !key.startsWith('_') && !key.startsWith('$') - })) as any - } - private async _preprocess() { let node: segment.Parsed let content = this.content.trim() @@ -166,7 +67,7 @@ export class Session(argv: Argv, collectors: FieldColl } return fields } - -export interface FileInfo { - id: string - name: string - size: number - busid: number -} diff --git a/packages/koishi/package.json b/packages/koishi/package.json index 195c81db54..4cba98240f 100644 --- a/packages/koishi/package.json +++ b/packages/koishi/package.json @@ -3,7 +3,7 @@ "description": "Cross-Platform Chatbot Framework Made with Love", "version": "4.7.6", "main": "lib/index.js", - "typings": "lib/node.d.ts", + "typings": "lib/index.d.ts", "engines": { "node": ">=12.0.0" }, @@ -34,21 +34,10 @@ "@types/parseurl": "^1.3.1" }, "dependencies": { - "@koa/router": "^10.1.1", "@koishijs/core": "^4.7.6", "@koishijs/utils": "^5.4.5", - "@types/koa": "*", - "@types/koa__router": "*", - "@types/ws": "^8.5.3", - "axios": "^0.24.0", + "@satorijs/env-node": "^1.0.0", "file-type": "^16.5.3", - "koa": "^2.13.4", - "koa-bodyparser": "^4.3.0", - "ns-require": "^1.1.4", - "parseurl": "^1.3.3", - "path-to-regexp": "^6.2.1", - "portfinder": "^1.0.28", - "proxy-agent": "^5.0.0", - "ws": "^8.6.0" + "ns-require": "^1.1.4" } -} \ No newline at end of file +} diff --git a/packages/koishi/src/adapter.ts b/packages/koishi/src/adapter.ts deleted file mode 100644 index c632ec350d..0000000000 --- a/packages/koishi/src/adapter.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Adapter, Bot, Context, Schema } from '@koishijs/core' -import { defineProperty, Logger, Time } from '@koishijs/utils' -import { Awaitable } from 'cosmokit' -import WebSocket from 'ws' - -declare module '@koishijs/core' { - interface Bot { - socket?: WebSocket - } - - namespace Adapter { - export namespace WebSocketClient { - export interface Config { - retryLazy?: number - retryTimes?: number - retryInterval?: number - } - - export const Config: Schema - } - - export abstract class WebSocketClient extends Adapter { - protected abstract prepare(bot: Bot): Awaitable - protected abstract accept(bot: Bot): void - - public config: T - public isListening: boolean - - constructor(ctx: Context, config: T) - connect(bot: Bot): void - disconnect(bot: Bot): void - start(): void - stop(): void - } - } -} - -const logger = new Logger('adapter') - -abstract class WebSocketClient extends Adapter { - protected abstract prepare(bot: Bot): Awaitable - protected abstract accept(bot: Bot): void - - public config: T - public isListening = false - - static Config: Schema = Schema.object({ - retryTimes: Schema.natural().description('初次连接时的最大重试次数,仅用于 ws 协议。').default(6), - retryInterval: Schema.natural().role('ms').description('初次连接时的重试时间间隔,仅用于 ws 协议。').default(5 * Time.second), - retryLazy: Schema.natural().role('ms').description('连接关闭后的重试时间间隔,仅用于 ws 协议。').default(Time.minute), - }).description('连接设置') - - constructor(ctx: Context, config: T) { - super(ctx, { - retryLazy: Time.minute, - retryInterval: 5 * Time.second, - retryTimes: 6, - ...config, - }) - } - - connect(bot: Bot) { - let _retryCount = 0 - const { retryTimes, retryInterval, retryLazy } = this.config - - const reconnect = async (initial = false) => { - logger.debug('websocket client opening') - const socket = await this.prepare(bot) - const url = socket.url.replace(/\?.+/, '') - - socket.on('error', error => logger.debug(error)) - - socket.on('close', (code, reason) => { - bot.socket = null - logger.debug(`websocket closed with ${code}`) - if (!this.isListening || bot.config.disabled) return bot.status = 'offline' - - // remove query args to protect privacy - const message = reason.toString() || `failed to connect to ${url}, code: ${code}` - let timeout = retryInterval - if (_retryCount >= retryTimes) { - if (initial) { - return bot.reject(new Error(message)) - } else { - timeout = retryLazy - } - } - - _retryCount++ - bot.status = 'reconnect' - logger.warn(`${message}, will retry in ${Time.format(timeout)}...`) - setTimeout(() => { - if (this.isListening && !bot.config.disabled) reconnect() - }, timeout) - }) - - socket.on('open', () => { - _retryCount = 0 - bot.socket = socket - logger.info('connect to server: %c', url) - this.accept(bot) - }) - } - - reconnect(true) - } - - disconnect(bot: Bot) { - bot.socket?.close() - } - - start() { - this.isListening = true - } - - stop() { - this.isListening = false - logger.debug('websocket client closing') - for (const bot of this.bots) { - bot.socket?.close() - } - } -} - -defineProperty(Adapter, 'WebSocketClient', WebSocketClient) diff --git a/packages/koishi/src/assets.ts b/packages/koishi/src/assets.ts index 591cdab7ff..76fb4a5b11 100644 --- a/packages/koishi/src/assets.ts +++ b/packages/koishi/src/assets.ts @@ -1,11 +1,11 @@ -import { App, Context, Schema, Service } from '@koishijs/core' +import { Context, Schema, Service } from '@koishijs/core' import { defineProperty, segment } from '@koishijs/utils' import { createHash } from 'crypto' import { basename } from 'path' import FileType from 'file-type' declare module '@koishijs/core' { - namespace App { + namespace Context { interface Config { assets?: Config.Assets } @@ -22,7 +22,7 @@ declare module '@koishijs/core' { } } -defineProperty(App.Config, 'Assets', Schema.object({ +defineProperty(Context.Config, 'Assets', Schema.object({ whitelist: Schema.array(Schema.string().required().role('url')).description('不处理的白名单 URL 列表。'), }).description('资源设置')) @@ -35,14 +35,14 @@ export abstract class Assets extends Service { abstract upload(url: string, file: string): Promise abstract stats(): Promise - constructor(ctx: Context) { + constructor(protected ctx: Context) { super(ctx, 'assets') } public async transform(content: string) { return await segment.transformAsync(content, Object.fromEntries(this.types.map((type) => { return [type, async (data) => { - if (this.ctx.app.options.assets.whitelist.some(prefix => data.url.startsWith(prefix))) { + if (this.ctx.options.assets.whitelist.some(prefix => data.url.startsWith(prefix))) { return segment(type, data) } else { return segment(type, { url: await this.upload(data.url, data.file) }) diff --git a/packages/koishi/src/index.ts b/packages/koishi/src/index.ts index 02411f9668..9125a24fe8 100644 --- a/packages/koishi/src/index.ts +++ b/packages/koishi/src/index.ts @@ -1,15 +1,11 @@ -import { App, Context, Schema } from '@koishijs/core' +import { Context, Schema } from '@koishijs/core' import { Cache } from './cache' import { Assets } from './assets' -import { Quester } from './quester' -import { Router } from './router' -export * from './adapter' +export { Quester, Router } from '@satorijs/env-node' export * from './assets' export * from './cache' export * from './patch' -export * from './quester' -export * from './router' export * from '@koishijs/core' export * from '@koishijs/utils' @@ -18,22 +14,12 @@ declare module '@koishijs/core' { interface Context { assets: Assets cache: Cache - http: Quester - router: Router } } -App.Config.list.unshift(App.Config.Network) -App.Config.list.push(Schema.object({ - request: Quester.Config, - assets: App.Config.Assets, +Context.Config.list.push(Schema.object({ + assets: Context.Config.Assets, })) Context.service('assets') Context.service('cache') -Context.service('http', { - constructor: Quester, -}) -Context.service('router', { - constructor: Router, -}) diff --git a/packages/koishi/src/patch.ts b/packages/koishi/src/patch.ts index 165bca99b9..956e53a2fe 100644 --- a/packages/koishi/src/patch.ts +++ b/packages/koishi/src/patch.ts @@ -1,10 +1,8 @@ -import { App, Context } from '@koishijs/core' -import { trimSlash } from '@koishijs/utils' -import { getPortPromise } from 'portfinder' +import { Context } from '@koishijs/core' import ns from 'ns-require' declare module '@koishijs/core' { - interface App { + interface Context { baseDir: string } @@ -21,9 +19,7 @@ export class Patch { } } -Context.service('$patch', { - constructor: Patch, -}) +Context.service('$patch', Patch) export const scope = ns({ namespace: 'koishi', @@ -38,27 +34,3 @@ Context.prototype.plugin = function (this: Context, entry: any, config?: any) { } return plugin.call(this, entry, config) } - -const start = App.prototype.start -App.prototype.start = async function (this: App, ...args) { - if (this.options.selfUrl) { - this.options.selfUrl = trimSlash(this.options.selfUrl) - } - - if (this.options.port) { - this.options.port = await getPortPromise({ - port: this.options.port, - stopPort: this.options.maxPort || this.options.port, - }) - const { host, port } = this.options - await new Promise(resolve => this._httpServer.listen(port, host, resolve)) - this.logger('app').info('server listening at %c', `http://${host}:${port}`) - this.on('dispose', () => { - this.logger('app').info('http server closing') - this._wsServer?.close() - this._httpServer?.close() - }) - } - - return start.call(this, ...args) -} diff --git a/packages/koishi/src/quester.ts b/packages/koishi/src/quester.ts deleted file mode 100644 index 6a61bac2e7..0000000000 --- a/packages/koishi/src/quester.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { App, Context, Schema } from '@koishijs/core' -import { Dict } from '@koishijs/utils' -import { Agent, ClientRequestArgs } from 'http' -import WebSocket from 'ws' -import ProxyAgent from 'proxy-agent' -import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios' - -declare module '@koishijs/core' { - namespace App { - interface Config { - request?: Quester.Config - } - } - - interface Adapter { - http?: Quester - } -} - -export interface Quester { - (method: Method, url: string, config?: AxiosRequestConfig): Promise - axios(url: string, config?: AxiosRequestConfig): Promise> - extend(config: Quester.Config): Quester - config: Quester.Config - head(url: string, config?: AxiosRequestConfig): Promise> - get(url: string, config?: AxiosRequestConfig): Promise - delete(url: string, config?: AxiosRequestConfig): Promise - post(url: string, data?: any, config?: AxiosRequestConfig): Promise - put(url: string, data?: any, config?: AxiosRequestConfig): Promise - patch(url: string, data?: any, config?: AxiosRequestConfig): Promise - ws(url: string, options?: ClientRequestArgs): WebSocket -} - -export class Quester { - constructor(ctx: Context, config: App.Config) { - return Quester.create(config.request) - } -} - -export namespace Quester { - export interface Config { - headers?: Dict - endpoint?: string - timeout?: number - proxyAgent?: string - } - - export const Config = createSchema() - - export function createSchema(config: Config = {}): Schema { - return Schema.object({ - endpoint: Schema.string().role('url').description('API 请求的终结点。').default(config.endpoint), - proxyAgent: Schema.string().role('url').description('使用的代理服务器地址。').default(config.proxyAgent), - headers: Schema.dict(String).description('要附加的额外请求头。').default(config.headers || {}), - timeout: Schema.natural().role('ms').description('等待连接建立的最长时间。').default(config.timeout), - }).description('请求设置') - } - - const agents: Dict = {} - - function getAgent(url: string) { - return agents[url] ||= new ProxyAgent(url) - } - - export function create(config: Quester.Config = {}) { - const { endpoint = '' } = config - - const options: AxiosRequestConfig = { - timeout: config.timeout, - headers: config.headers, - } - - if (config.proxyAgent) { - options.httpAgent = getAgent(config.proxyAgent) - options.httpsAgent = getAgent(config.proxyAgent) - } - - const request = async (url: string, config: AxiosRequestConfig = {}) => axios({ - ...options, - ...config, - url: endpoint + url, - headers: { - ...options.headers, - ...config.headers, - }, - }) - - const http = (async (method, url, config) => { - const response = await request(url, { ...config, method }) - return response.data - }) as Quester - - http.config = config - http.axios = request as any - http.extend = (newConfig) => create({ ...config, ...newConfig }) - - http.get = (url, config) => http('GET', url, config) - http.delete = (url, config) => http('DELETE', url, config) - http.post = (url, data, config) => http('POST', url, { ...config, data }) - http.put = (url, data, config) => http('PUT', url, { ...config, data }) - http.patch = (url, data, config) => http('PATCH', url, { ...config, data }) - http.head = async (url, config) => { - const response = await request(url, { ...config, method: 'HEAD' }) - return response.headers - } - - http.ws = (url, options = {}) => { - return new WebSocket(url, { - agent: config.proxyAgent && getAgent(config.proxyAgent), - handshakeTimeout: config.timeout, - ...options, - headers: { - ...config.headers, - ...options.headers, - }, - }) - } - - return http - } -} diff --git a/packages/koishi/src/router.ts b/packages/koishi/src/router.ts deleted file mode 100644 index 31ec2105cb..0000000000 --- a/packages/koishi/src/router.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { App, Context, Schema } from '@koishijs/core' -import { defineProperty, MaybeArray, remove } from '@koishijs/utils' -import { createServer, IncomingMessage, Server } from 'http' -import { pathToRegexp } from 'path-to-regexp' -import parseUrl from 'parseurl' -import WebSocket from 'ws' -import KoaRouter from '@koa/router' -import Koa from 'koa' - -declare module 'koa' { - // koa-bodyparser - interface Request { - body?: any - rawBody?: string - } -} - -declare module '@koishijs/core' { - interface App { - _httpServer?: Server - _wsServer?: WebSocket.Server - } - - namespace App { - interface Config extends Config.Network {} - - namespace Config { - interface Static { - Network: Schema - } - - interface Network { - host?: string - port?: number - maxPort?: number - selfUrl?: string - } - } - } -} - -defineProperty(App.Config, 'Network', Schema.object({ - host: Schema.string().default('localhost').description('要监听的 IP 地址。如果将此设置为 `0.0.0.0` 将监听所有地址,包括局域网和公网地址。'), - port: Schema.natural().max(65535).description('要监听的端口。'), - maxPort: Schema.natural().max(65535).description('允许监听的最大端口号。'), - selfUrl: Schema.string().role('url').description('应用暴露在公网的地址。部分插件 (例如 github 和 telegram) 需要用到。'), -}).description('网络设置')) - -type WebSocketCallback = (socket: WebSocket, request: IncomingMessage) => void - -export class WebSocketLayer { - clients = new Set() - regexp: RegExp - - constructor(private router: Router, path: MaybeArray, public callback?: WebSocketCallback) { - this.regexp = pathToRegexp(path) - } - - accept(socket: WebSocket, request: IncomingMessage) { - if (!this.regexp.test(parseUrl(request).pathname)) return - this.clients.add(socket) - socket.on('close', () => { - this.clients.delete(socket) - }) - this.callback?.(socket, request) - return true - } - - close() { - remove(this.router.wsStack, this) - for (const socket of this.clients) { - socket.close() - } - } -} - -export class Router extends KoaRouter { - wsStack: WebSocketLayer[] = [] - - constructor(ctx: Context) { - super() - - // create server - const koa = new Koa() - koa.use(require('koa-bodyparser')()) - koa.use(this.routes()) - koa.use(this.allowedMethods()) - - ctx.app._httpServer = createServer(koa.callback()) - ctx.app._wsServer = new WebSocket.Server({ - server: ctx.app._httpServer, - }) - - ctx.app._wsServer.on('connection', (socket, request) => { - for (const manager of this.wsStack) { - if (manager.accept(socket, request)) return - } - socket.close() - }) - } - - /** - * hack into router methods to make sure that koa middlewares are disposable - */ - register(...args: Parameters) { - const layer = super.register(...args) - const context: Context = this[Context.current] - context?.state.disposables.push(() => { - remove(this.stack, layer) - }) - return layer - } - - ws(path: MaybeArray, callback?: WebSocketCallback) { - const layer = new WebSocketLayer(this, path, callback) - this.wsStack.push(layer) - const context: Context = this[Context.current] - context?.state.disposables.push(() => layer.close()) - return layer - } -}