From 8a9c6d3a3293fd53735ff37590afeb9d19b83233 Mon Sep 17 00:00:00 2001 From: Shigma Date: Tue, 21 May 2024 18:40:22 +0800 Subject: [PATCH] feat(satori): support multi uploads, proxy urls --- adapters/discord/src/bot.ts | 2 +- adapters/kook/src/bot.ts | 2 +- adapters/satori/src/bot.ts | 4 ++- adapters/satori/src/ws.ts | 2 +- packages/core/src/bot.ts | 34 ++++++++++++++------- packages/core/src/index.ts | 6 ++-- packages/protocol/src/index.ts | 16 +++++----- packages/server/src/index.ts | 55 ++++++++++++++++++++++------------ 8 files changed, 78 insertions(+), 43 deletions(-) diff --git a/adapters/discord/src/bot.ts b/adapters/discord/src/bot.ts index 01b37b85..36d0c5e4 100644 --- a/adapters/discord/src/bot.ts +++ b/adapters/discord/src/bot.ts @@ -28,7 +28,7 @@ export class DiscordBot extends Bot extends Adapter.WsClient this.ctx.satori.upload(() => { return this.bots - .flatMap(bot => bot.resourceUrls) + .flatMap(bot => bot.proxyUrls) .filter(url => url.startsWith('upload://')) .map(url => url.replace('upload://', '')) }, async (path) => { diff --git a/packages/core/src/bot.ts b/packages/core/src/bot.ts index c6411da8..9ce5dad0 100644 --- a/packages/core/src/bot.ts +++ b/packages/core/src/bot.ts @@ -28,7 +28,7 @@ export abstract class Bot implements Login public hidden = false public platform: string public features: string[] - public resourceUrls: string[] + public proxyUrls: string[] public adapter?: Adapter public error?: Error public callbacks: Dict = {} @@ -51,7 +51,7 @@ export abstract class Bot implements Login self.platform = platform } - this.resourceUrls = [`upload://temp/${ctx.satori.uid}/`] + this.proxyUrls = [`upload://temp/${ctx.satori.uid}/`] this.features = Object.entries(Methods) .filter(([, value]) => this[value.name]) .map(([key]) => key) @@ -73,7 +73,7 @@ export abstract class Bot implements Login } registerUpload(path: string, callback: (path: string) => Promise) { - this.ctx.satori.upload(path, callback, this.resourceUrls) + this.ctx.satori.upload(path, callback, this.proxyUrls) } update(login: Login) { @@ -192,18 +192,32 @@ export abstract class Bot implements Login return this.sendMessage(id, content, null, options) } - async createUpload(data: ArrayBuffer, type: string | null, name?: string): Promise { - const result = { status: 200, data, type, name } - const id = Math.random().toString(36).slice(2) - this.ctx.satori._tempStore[id] = result + async createUpload(...uploads: Upload[]): Promise { + 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="${upload.filename}"`) + } + this.ctx.satori._tempStore[id] = { + status: 200, + data: upload.data, + headers, + } + ids.push(id) + } const timer = setTimeout(() => dispose(), 600000) const dispose = () => { _dispose() clearTimeout(timer) - delete this.ctx.satori._tempStore[id] + for (const id of ids) { + delete this.ctx.satori._tempStore[id] + } } const _dispose = this[Context.current].on('dispose', dispose) - return { url: `upload://temp/${this.ctx.satori.uid}/${id}` } + return ids.map(id => `upload://temp/${this.ctx.satori.uid}/${id}`) } async supports(name: string, session: Partial = {}) { @@ -217,7 +231,7 @@ export abstract class Bot implements Login } toJSON(): Login { - return clone(pick(this, ['platform', 'selfId', 'status', 'user', 'hidden', 'features', 'resourceUrls'])) + return clone(pick(this, ['platform', 'selfId', 'status', 'user', 'hidden', 'features', 'proxyUrls'])) } async getLogin() { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 60a4cefd..07b04b8c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -191,14 +191,14 @@ export class Satori extends Service { return this.ctx.set('component:' + name, render) } - upload(path: UploadRoute['path'], callback: UploadRoute['callback'], resourceUrls: UploadRoute['path'][] = []) { + upload(path: UploadRoute['path'], callback: UploadRoute['callback'], proxyUrls: UploadRoute['path'][] = []) { return this[Context.current].effect(() => { const route: UploadRoute = { path, callback } this._uploadRoutes.push(route) - resourceUrls.push(path) + proxyUrls.push(path) return () => { remove(this._uploadRoutes, route) - remove(resourceUrls, path) + remove(proxyUrls, path) } }) } diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 1cecb2c3..283468f3 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -5,6 +5,12 @@ export interface SendOptions { linkPreview?: boolean } +export interface Upload { + type: string + filename?: string + data: ArrayBuffer +} + export interface Response { status: number statusText?: string @@ -49,7 +55,7 @@ export const Methods: Dict = { 'reaction.clear': Method('clearReaction', ['channel_id', 'message_id', 'emoji']), 'reaction.list': Method('getReactionList', ['channel_id', 'message_id', 'emoji', 'next']), - 'upload.create': Method('createUpload', ['data', 'type'], true), + 'upload.create': Method('createUpload', [], true), 'guild.get': Method('getGuild', ['guild_id']), 'guild.list': Method('getGuildList', ['next']), @@ -111,7 +117,7 @@ export interface Methods { getReactionIter(channelId: string, messageId: string, emoji: string): AsyncIterable // upload - createUpload(data: ArrayBuffer, type: string | null, name?: string): Promise + createUpload(...uploads: Upload[]): Promise // user getLogin(): Promise @@ -225,7 +231,7 @@ export interface Login { hidden?: boolean status: Status features?: string[] - resourceUrls?: string[] + proxyUrls?: string[] } export const enum Status { @@ -236,10 +242,6 @@ export const enum Status { RECONNECT = 4, } -export interface Upload { - url: string -} - export interface Message { id?: string /** @deprecated */ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index e27a41ce..7b74cd8f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,4 +1,4 @@ -import { camelCase, Context, sanitize, Schema, Session, snakeCase, Time, Universal } from '@satorijs/core' +import { Binary, camelCase, Context, makeArray, sanitize, Schema, Session, snakeCase, Time, Universal } from '@satorijs/core' import {} from '@cordisjs/plugin-server' import WebSocket from 'ws' import { Readable } from 'node:stream' @@ -97,15 +97,20 @@ export function apply(ctx: Context, config: Config) { } if (method.name === 'createUpload') { - const [file] = Object.values(koa.request.files ?? {}).flat() - if (!file) { - koa.body = 'file not provided' - return koa.status = 400 - } - const data = await readFile(file.filepath) - const result = await bot.createUpload(data, file.mimetype, file.newFilename) - koa.body = result - return koa.status = 201 + const entries = Object.entries(koa.request.files ?? {}).map(([key, value]) => { + return [key, makeArray(value)[0]] as const + }) + const uploads = await Promise.all(entries.map>(async ([, file]) => { + const buffer = await readFile(file.filepath) + return { + data: Binary.fromSource(buffer), + type: file.mimetype, + filename: file.newFilename, + } + })) + const result = await bot.createUpload(...uploads) + koa.body = Object.fromEntries(entries.map(([key], index) => [key, result[index]])) + return koa.status = 200 } const json = koa.request.body @@ -136,16 +141,28 @@ export function apply(ctx: Context, config: Config) { koa.status = 200 }) - ctx.server.get(path + '/v1/upload/:name(.+)', async (koa) => { - const { status, statusText, data, headers } = await ctx.satori.download(koa.params.name) - koa.status = status - for (const [key, value] of headers || new Headers()) { - koa.set(key, value) - } - if (status >= 200 && status < 300) { - koa.body = data instanceof ReadableStream ? Readable.fromWeb(data) : data + ctx.server.get(path + '/v1/proxy/:url(.+)', async (koa) => { + const url = koa.params.url + koa.header['Access-Control-Allow-Origin'] = ctx.server.config.selfUrl || '*' + if (url.startsWith('upload://')) { + const { status, statusText, data, headers } = await ctx.satori.download(url.slice(9)) + koa.status = status + for (const [key, value] of headers || new Headers()) { + koa.set(key, value) + } + if (status >= 200 && status < 300) { + koa.body = data instanceof ReadableStream ? Readable.fromWeb(data) : data + } else { + koa.body = statusText + } } else { - koa.body = statusText + try { + koa.body = Readable.fromWeb(await ctx.http.get(koa.params.url, { responseType: 'stream' })) + } catch (error) { + if (!ctx.http.isError(error) || !error.response) throw error + koa.status = error.response.status + koa.body = error.response.data + } } })