kokkoro-plugin-og 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Yuki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1 +1,31 @@
1
1
  # kokkoro-plugin-og
2
+
3
+ 解析消息中的网页链接,并发送页面声明的 Open Graph 预览图片。
4
+
5
+ ## 安装
6
+
7
+ ```shell
8
+ bun add kokkoro-plugin-og
9
+ ```
10
+
11
+ 使用 `/og <url>` 主动解析网页。单独发送一个 HTTP 或 HTTPS 链接时,快捷方式也会自动解析该链接,链接两侧可以留有空白。
12
+
13
+ 要让普通群消息触发自动预览,需要开启「获取群内全部消息」权限。
14
+
15
+ 完整使用说明见 [Open Graph 预览图](https://kokkoro.js.org/plugin/og)。
16
+
17
+ ## API
18
+
19
+ 其他插件可以从 `service` 入口导入 `fetchImageUrl()`,获取网页声明的 Open Graph 预览图片地址:
20
+
21
+ ```typescript
22
+ import { fetchImageUrl } from 'kokkoro-plugin-og/service';
23
+
24
+ const imageUrl = await fetchImageUrl('https://ogp.me/');
25
+ ```
26
+
27
+ 参数、解析规则和错误行为见 [插件 API 文档](https://kokkoro.js.org/plugin/og#api)。
28
+
29
+ ## 配置
30
+
31
+ `OG_TIMEOUT` 设置请求超时时间,单位为毫秒。`OG_MAX_HTML_BYTES` 设置 HTML 内容上限,单位为字节。默认值和配置示例见 [插件配置](https://kokkoro.js.org/plugin/og#configuration)。
package/package.json CHANGED
@@ -1,15 +1,35 @@
1
1
  {
2
2
  "name": "kokkoro-plugin-og",
3
- "version": "0.0.0",
4
- "exports": "./src/index.ts",
3
+ "version": "1.0.0",
4
+ "description": "Open Graph 图片预览,解析网页链接并发送 og:image 声明的图片。",
5
+ "keywords": [
6
+ "bot",
7
+ "kokkoro",
8
+ "og",
9
+ "open graph",
10
+ "qq"
11
+ ],
12
+ "bugs": "https://github.com/kokkorojs/kokkoro/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/kokkorojs/kokkoro.git",
16
+ "directory": "plugins/og"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Yuki <admin@yuki.sh>",
5
20
  "type": "module",
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./service": "./src/service.ts"
24
+ },
6
25
  "files": [
7
26
  "src"
8
27
  ],
9
- "devDependencies": {
10
- "@types/bun": "latest"
28
+ "dependencies": {
29
+ "entities": "^7.0.1"
11
30
  },
12
31
  "peerDependencies": {
13
- "typescript": "^7"
32
+ "@kokkoro/core": "^3.1.4",
33
+ "typescript": "^6.0.3"
14
34
  }
15
35
  }
package/src/index.ts CHANGED
@@ -1 +1,35 @@
1
- console.log('Ciallo~(∠·ω< )⌒★');
1
+ import { type Bot, useCommand, useLogger } from '@kokkoro/core';
2
+
3
+ import { fetchImageUrl } from './service';
4
+
5
+ const logger = useLogger();
6
+
7
+ export default (bot: Bot) => {
8
+ useCommand('/og <url>', async context => {
9
+ const { url } = context.args;
10
+
11
+ try {
12
+ logger.debug('发送网页请求', { method: 'GET', url });
13
+
14
+ const imageUrl = await fetchImageUrl(url);
15
+
16
+ if (!imageUrl) {
17
+ throw new Error('未能获取 Open Graph 预览图片');
18
+ }
19
+ logger.debug('已解析 Open Graph 预览图片地址', { url, imageUrl });
20
+
21
+ const payload = { msg_id: context.id };
22
+
23
+ if ('group_openid' in context) {
24
+ await bot.sendGroupImage(context.group_openid, imageUrl, payload);
25
+ } else {
26
+ await bot.sendUserImage(context.author.user_openid, imageUrl, payload);
27
+ }
28
+ logger.info('已发送 Open Graph 预览图片', { url, imageUrl });
29
+ } catch (error) {
30
+ if (context.trigger === 'command') {
31
+ throw error;
32
+ }
33
+ }
34
+ }).shortcut(/^(?<url>https?:\/\/\S+)\s*$/iu);
35
+ };
package/src/service.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { decodeHTMLAttribute } from 'entities/decode';
2
+
3
+ import { formatBytes, parseUrl } from './util';
4
+
5
+ const { OG_TIMEOUT: TIMEOUT = 10000, OG_MAX_HTML_BYTES: MAX_HTML_BYTES = 1024 * 1024 } = import.meta.env;
6
+ const maxHtmlBytes = Number(MAX_HTML_BYTES);
7
+
8
+ if (!Number.isSafeInteger(maxHtmlBytes) || maxHtmlBytes <= 0) {
9
+ throw new RangeError('OG_MAX_HTML_BYTES 必须是正的安全整数');
10
+ }
11
+
12
+ /** 请求网页并返回首个有效的 Open Graph 图片地址,没有预览图时返回 `undefined`,请求失败时抛错。 */
13
+ export async function fetchImageUrl(url: string): Promise<string | undefined> {
14
+ const pageUrl = parseUrl(url);
15
+
16
+ if (!pageUrl) {
17
+ throw new Error('链接格式无效');
18
+ }
19
+ const response = await fetch(pageUrl, {
20
+ headers: { accept: 'text/html, application/xhtml+xml' },
21
+ signal: AbortSignal.timeout(Number(TIMEOUT)),
22
+ });
23
+
24
+ if (!response.ok) {
25
+ await response.body?.cancel();
26
+ throw new Error(`网页请求失败,状态码 ${response.status}`);
27
+ }
28
+ const contentType = response.headers.get('content-type')?.trim() ?? '';
29
+
30
+ if (!/^(?:text\/html|application\/xhtml\+xml)(?:\s*;|$)/i.test(contentType) || !response.body) {
31
+ await response.body?.cancel();
32
+ return undefined;
33
+ }
34
+ let byteLength = 0;
35
+ const body = response.body.pipeThrough(
36
+ new TransformStream<Uint8Array, Uint8Array>({
37
+ transform(chunk, controller) {
38
+ byteLength += chunk.byteLength;
39
+
40
+ if (byteLength > maxHtmlBytes) {
41
+ throw new RangeError(`网页内容超过 ${formatBytes(maxHtmlBytes)}`);
42
+ }
43
+ controller.enqueue(chunk);
44
+ },
45
+ }),
46
+ );
47
+ const base = new URL(response.url);
48
+ let imageUrl: URL | undefined;
49
+ const rewriter = new HTMLRewriter().on(
50
+ 'meta[property="og:image" i], meta[property="og:image:url" i], meta[property="og:image:secure_url" i]',
51
+ {
52
+ element(element) {
53
+ imageUrl ??= parseUrl(decodeHTMLAttribute(element.getAttribute('content') ?? ''), base);
54
+ },
55
+ },
56
+ );
57
+
58
+ await rewriter.transform(new Response(body, { headers: { 'content-type': contentType } })).arrayBuffer();
59
+ return imageUrl?.href;
60
+ }
package/src/util.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** 将字节数转换为二进制单位字符串,最多保留两位小数。 */
2
+ export function formatBytes(bytes: number): string {
3
+ let value = bytes;
4
+ let unit = 'B';
5
+
6
+ for (const next of ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']) {
7
+ if (value < 1024) {
8
+ break;
9
+ }
10
+ value /= 1024;
11
+ unit = next;
12
+ }
13
+ return `${Number(value.toFixed(2))} ${unit}`;
14
+ }
15
+
16
+ /** 解析不含用户凭据的 HTTP 或 HTTPS 地址,空值和无效地址返回 `undefined`。 */
17
+ export function parseUrl(source: string, base?: URL): URL | undefined {
18
+ if (!source.trim()) {
19
+ return undefined;
20
+ }
21
+ const url = URL.parse(source, base?.href);
22
+
23
+ if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
24
+ return undefined;
25
+ }
26
+ return url;
27
+ }