-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.ts
257 lines (243 loc) · 9.88 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
import { Context, Schema, h, version as kVersion, pick } from 'koishi'
import { } from 'koishi-plugin-puppeteer'
import { } from '@koishijs/cache'
import { } from '@koishijs/plugin-notifier'
import type { Page } from 'puppeteer-core'
import { readFileSync } from 'fs'
import { ruler, parser, appendElements, templater, linerElements } from './helper'
import { ImageRule, RuleType, RuleComputed, PageWorker, CacheModel, CacheRule } from './types'
import { CacheService, FREQUENCY_THRESHOLD, cacheKeyHash } from './cache'
const { version: pVersion } = require('../package.json')
const css = readFileSync(require.resolve('./default.css'), 'utf8')
export const name = 'imagify'
export interface Config {
quality: number
regroupement: boolean
pagepool: number
advanced: boolean
rules?: ImageRule[][]
cache: {
enable: boolean
databased: boolean
driver: CacheModel
threshold: number
rule: CacheRule[]
}
templates: string[]
maxLineCount?: number
maxLength?: number
background: string
blur: number
style: string
}
export const Config: Schema<Config> = Schema.intersect([
Schema.object({
quality: Schema.number().min(20).default(80).max(100).description('生成的图片质量').experimental(),
regroupement: Schema.boolean().default(false).description('并发渲染(这会显著提高内存占用)'),
cache: Schema.intersect([
Schema.object({
enable: Schema.boolean().default(false).description('启用缓存').experimental(),
}),
Schema.union([
Schema.intersect([
Schema.object({
enable: Schema.const(true).required(),
driver: Schema.union([
Schema.const(CacheModel.NATIVE).description('由 imagify 自行管理').experimental(),
Schema.const(CacheModel.CACHE).description('由 Cache 服务管理(需要 Cache 服务)'),
]).default(CacheModel.CACHE).description('缓存存储方式,推荐使用 cache 服务'),
rule: Schema.array(Schema.object({})).role('table').description('缓存命中规则,点击右侧「添加行」添加规则。').hidden(),
}),
Schema.union([
Schema.object({
driver: Schema.const(CacheModel.NATIVE).required(),
databased: Schema.boolean().default(true).description('使用数据库代替本地文件(需要 database 服务)').disabled(),
threshold: Schema.number().min(1).default(FREQUENCY_THRESHOLD).description('缓存阈值,当缓存命中次数超过该值时,缓存将被提升为高频缓存'),
}),
Schema.object({}),
]),
]),
Schema.object({}),
]),
]),
}),
Schema.union([
Schema.object({
regroupement: Schema.const(true).required(),
pagepool: Schema.number().min(1).default(5).max(128).description('初始化页面池数量'),
}),
Schema.object({})
]),
Schema.object({
advanced: Schema.boolean().default(false).description('是否启用高级模式')
}),
Schema.union([
Schema.object({
// @ts-ignore
advanced: Schema.const(false),
maxLineCount: Schema.number().min(1).default(20).description('当文本行数超过该值时转为图片'),
maxLength: Schema.number().min(1).default(648).description('当返回的文本字数超过该值时转为图片'),
}),
Schema.object({
advanced: Schema.const(true).required(),
rules: Schema.array(Schema.array(Schema.object({
type: Schema.union([
Schema.const(RuleType.PLATFORM).description('平台名'),
Schema.const(RuleType.USER).description('用户ID'),
Schema.const(RuleType.GROUP).description('群组ID'),
Schema.const(RuleType.CHANNEL).description('频道ID'),
Schema.const(RuleType.BOT).description('机器人ID'),
Schema.const(RuleType.COMMAND).description('命令名'),
Schema.const(RuleType.CONTENT).description('内容文本'),
Schema.const(RuleType.LENGTH).description('内容字数'),
]).description('类型'),
computed: Schema.union([
Schema.const(RuleComputed.REGEXP).description('正则'),
Schema.const(RuleComputed.EQUAL).description('等于'),
Schema.const(RuleComputed.NOT_EQUAL).description('不等于'),
Schema.const(RuleComputed.CONTAIN).description('包含'),
Schema.const(RuleComputed.NOT_CONTAIN).description('不包含'),
Schema.const(RuleComputed.MATH).description('数学(高级)'),
]).description('计算'),
righthand: Schema.string().description('匹配'),
})).role('table').description('AND 规则,点击右侧「添加行」添加 OR 规则。')).description('规则列表,点击右侧「添加项目」添加 AND 规则。详见<a href="https://imagify.koishi.chat/rule">文档</a>'),
templates: Schema.array(Schema.string().role('textarea')).description('自定义模板,点击右侧「添加行」添加模板。').disabled(),
}).description('高级设置'),
]),
Schema.intersect([
Schema.object({
background: Schema.string().role('link').description('背景图片地址,以 http(s):// 开头'),
blur: Schema.number().min(1).max(50).default(10).description('文本卡片模糊程度'),
customize: Schema.boolean().default(false).description('自定义样式'),
}).description('样式设置'),
Schema.union([
Schema.object({
customize: Schema.const(true).required(),
style: Schema.string().role('textarea').default(css).description('直接编辑样式, class 见<a href="https://imagify.koishi.chat/style">文档</a>'),
}),
Schema.object({}),
])
]),
]) as Schema<Config>
export const inject = {
required: ['puppeteer'],
optional: ['database', 'cache']
}
export function apply(ctx: Context, config: Config) {
const logger = ctx.logger('imagify')
let cacheService: CacheService
let pagepool: PageWorker<Page>[] = []
let page: Page
let template: string
// load fs of NATIVE cache model
// if (config.cache.enable && config.cache.driver === CacheModel.NATIVE)
// ctx.plugin(FsPlugin)
if (config?.cache?.enable) {
cacheService = new CacheService(ctx, config)
}
async function createPage(template) {
const page = await ctx.puppeteer.page()
await page.setContent(templater(template, {
style: config.style || css,
background: config.background,
blur: config.blur,
element: '',
kVersion,
pVersion
}))
return page
}
async function getWorker() {
return new Promise<PageWorker<Page>>((resolve) => {
function check() {
const available = pagepool.find(p => !p.busy)
if (available) {
available.busy = true
resolve(available)
} else {
setTimeout(check, 100)
}
}
check()
});
}
ctx.on('ready', async () => {
if (config?.cache?.enable) {
// clean residue cache
if (config?.cache?.driver === CacheModel.CACHE) await ctx.cache.clear('imagify')
else if (config?.cache?.driver === CacheModel.NATIVE) await cacheService.dispose()
}
template ??= readFileSync(require.resolve('./template.thtml'), 'utf8')
// preload pages
if (config.regroupement)
for (let i = 0; i < config.pagepool; i++)
pagepool.push({
busy: false,
page: await createPage(template)
})
})
ctx.on('dispose', async () => {
for (const page of pagepool) {
page.busy = false
await page.page.close()
}
if (config?.cache)
if (config?.cache?.driver === CacheModel.CACHE) await ctx.cache.clear('imagify')
else if (config?.cache?.driver === CacheModel.NATIVE) await cacheService.dispose()
})
ctx.before('send', async (session, options) => {
// console.time('imagifycost')
session.argv = (options?.session as (typeof session))?.argv || {}
const rule = ruler(session)
const verdict = config.advanced
? config.rules.every(rule)
: session.elements.filter(e => e.type.includes(session.platform)).length === 0
? h('', session.elements).toString(true).length > config.maxLength || session.elements.filter(e => linerElements.includes(e.type)).length > config.maxLineCount
: false
let cached = false
// imagify of non platform elements
if (verdict) {
let img: Buffer
const hashKey = cacheKeyHash(session.content)
if (config?.cache?.enable) {
const cacheItem = await cacheService.load(hashKey)
if (cacheItem) {
img = Buffer.from(cacheItem, 'base64')
cached = true
}
}
if (!cached) {
const screenShotPage = async (page: Page) => {
const { width, height } = await page.evaluate((elementString) => {
document.body.style.margin = '0'
document.querySelector('.text-card').innerHTML = elementString
// fix screenshot size of body element
const rect = document.body.getBoundingClientRect()
return {
width: rect.width,
height: rect.height
}
}, (await parser(session.elements, session)).join(''))
return await page.screenshot({
clip: { x: 0, y: 0, width, height },
quality: config.quality,
type: 'jpeg'
})
}
if (config.regroupement) {
const worker = await getWorker()
try {
img = await screenShotPage(worker.page)
worker.busy = false
} catch (error) {
worker.busy = false
logger.error(error)
}
} else img = await screenShotPage(page ??= await createPage(template))
if (config?.cache?.enable) await cacheService.save(hashKey, Buffer.from(img).toString('base64'))
}
// console.timeEnd('imagifycost')
session.elements = [h.image(img, 'image/jpeg'), ...session.elements.filter(e => appendElements.includes(e.type))]
}
}, true)
}