kokkoro-plugin-hitokoto 3.0.0 → 3.0.2

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/README.md CHANGED
@@ -1,27 +1,67 @@
1
1
  # kokkoro-plugin-hitokoto
2
2
 
3
- ヒトコト
3
+ Hitokoto 一言,随机获取动漫、小说、诗词等类型的语句。
4
4
 
5
5
  ## 安装
6
6
 
7
+ 在 Kokkoro 项目目录运行:
8
+
7
9
  ```shell
8
10
  bun add kokkoro-plugin-hitokoto
9
11
  ```
10
12
 
11
13
  ## 指令
12
14
 
13
- 发送「/一言」,插件默认会从一言接口随机返回一条动画或漫画语句。
15
+ ```text
16
+ /一言 [types]...
17
+ ```
18
+
19
+ `[types]...` 表示可选的类型参数,可以填写一个或多个中文类型名称。多个名称使用空格分隔。支持动画、漫画、游戏、文学、原创、来自网络、其他、影视、诗词、网易云、哲学和抖机灵。
20
+
21
+ 不填写类型时,插件随机返回一条语句。
22
+
23
+ ```text
24
+ /一言
25
+ /一言 动画 漫画 游戏
26
+ ```
14
27
 
15
28
  ## 快捷方式
16
29
 
17
- 也可以发送「来点骚话」触发,群聊需要开启「获取群内全部消息」权限。
30
+ 快捷方式匹配以下正则表达式:
31
+
32
+ ```regexp
33
+ /^来点(?<types>.+)?骚话$/
34
+ ```
35
+
36
+ 其中的 `types` 表示「来点」和「骚话」之间可选的类型名称。以下消息都能触发快捷方式:
37
+
38
+ ```text
39
+ 来点骚话
40
+ 来点诗词骚话
41
+ ```
42
+
43
+ 要让普通群消息触发快捷方式,需要在对应群聊中开启「获取群内全部消息」权限。未开启时,插件只能处理 @ 机器人的群消息。
44
+
45
+ ## API
46
+
47
+ 其他插件可以从 `service` 入口导入 `fetchSentence()` 使用,该函数返回一言接口的完整语句对象:
48
+
49
+ ```typescript
50
+ import { fetchSentence } from 'kokkoro-plugin-hitokoto/service';
51
+
52
+ const sentence = await fetchSentence('i');
53
+ ```
54
+
55
+ `fetchSentence()` 接收一言接口的类型代码或代码数组。`'i'` 表示诗词,`['a', 'b']` 表示动画和漫画。传入空数组时,函数不限制语句类型。
56
+
57
+ 请求失败时,`fetchSentence()` 会抛出 `Error`。一言错误响应使用上游的 `message`,其他请求错误使用 HTTP 状态码。包同时导出 `Sentence`、`SentenceType` 和 `ErrorResponse` 类型,以及 `isErrorResponse()` 类型守卫。
18
58
 
19
59
  ## 环境变量
20
60
 
21
- 如需修改语句类型,请在项目根目录创建 `.env` 文件,并通过 `HITOKOTO_TYPES` 设置一言接口的 `c` 参数。多个类型使用逗号分隔,默认值为 `a,b`。
61
+ 调用 `fetchSentence()` 时省略类型参数,函数会读取环境变量 `HITOKOTO_TYPES`。如需设置该变量,请在项目根目录创建 `.env` 文件。多个类型代码使用逗号分隔。未设置时,函数不限制语句类型。
22
62
 
23
63
  ```ini
24
- HITOKOTO_TYPES=c,d
64
+ HITOKOTO_TYPES=a,b,c
25
65
  ```
26
66
 
27
- 类型取值参阅[一言接口文档](https://developer.hitokoto.cn/sentence/#请求参数)。修改 `.env` 后需要重新启动项目。
67
+ 示例中的 `a`、`b` 和 `c` 分别表示动画、漫画和游戏。其他类型代码参阅 [一言接口文档](https://developer.hitokoto.cn/sentence/#请求参数)。修改 `.env` 后需要重新启动项目。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kokkoro-plugin-hitokoto",
3
- "version": "3.0.0",
4
- "description": "ヒトコト",
3
+ "version": "3.0.2",
4
+ "description": "Hitokoto 一言,随机获取动漫、小说、诗词等类型的语句。",
5
5
  "keywords": [
6
6
  "bot",
7
7
  "kokkoro",
@@ -17,12 +17,15 @@
17
17
  "license": "MIT",
18
18
  "author": "Yuki <admin@yuki.sh>",
19
19
  "type": "module",
20
- "exports": "./src/index.ts",
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./service": "./src/service.ts"
23
+ },
21
24
  "files": [
22
25
  "src"
23
26
  ],
24
27
  "peerDependencies": {
25
- "@kokkoro/core": "^3.1.3",
28
+ "@kokkoro/core": "^3.1.4",
26
29
  "typescript": "^6.0.3"
27
30
  }
28
31
  }
package/src/index.ts CHANGED
@@ -1,62 +1,25 @@
1
- import { useCommand } from '@kokkoro/core';
1
+ import { useCommand, useLogger } from '@kokkoro/core';
2
2
 
3
- type SentenceType =
4
- | 'a' // 动画
5
- | 'b' // 漫画
6
- | 'c' // 游戏
7
- | 'd' // 文学
8
- | 'e' // 原创
9
- | 'f' // 来自网络
10
- | 'g' // 其他
11
- | 'h' // 影视
12
- | 'i' // 诗词
13
- | 'j' // 网易云
14
- | 'k' // 哲学
15
- | 'l'; // 抖机灵
3
+ import { fetchSentence, HITOKOTO_API, resolveTypeCodes } from './service';
16
4
 
17
- interface Sentence {
18
- /** 一言标识 */
19
- readonly id: number;
20
- /** 一言正文。编码方式 unicode。使用 utf-8。 */
21
- readonly hitokoto: string;
22
- /** 类型。请参考第三节参数的表格 */
23
- readonly type: SentenceType;
24
- /** 一言的出处 */
25
- readonly from: string;
26
- /** 一言的作者 */
27
- readonly from_who: string | null;
28
- /** 添加者 */
29
- readonly creator: string;
30
- /** 添加者用户标识 */
31
- readonly creator_uid: number;
32
- /** 审核员标识 */
33
- readonly reviewer: number;
34
- /** 一言唯一标识,可以链接到 https://hitokoto.cn?uuid=[uuid] 查看这个一言的完整信息 */
35
- readonly uuid: string;
36
- /** 提交方式 */
37
- readonly commit_from: string;
38
- /** 添加时间 */
39
- readonly created_at: string;
40
- /** 句子长度 */
41
- readonly length: number;
42
- }
5
+ const logger = useLogger();
43
6
 
44
- const { HITOKOTO_TYPES = 'a,b' } = import.meta.env;
45
- const HITOKOTO_API = new URL('https://v1.hitokoto.cn');
7
+ export default () => {
8
+ useCommand('/一言 [types]...', async context => {
9
+ const payload = { c: resolveTypeCodes(context.args.types) };
46
10
 
47
- for (const type of HITOKOTO_TYPES.split(',')) {
48
- HITOKOTO_API.searchParams.append('c', type);
49
- }
11
+ logger.debug('发送 Hitokoto 请求', {
12
+ method: 'GET',
13
+ url: HITOKOTO_API,
14
+ payload,
15
+ });
50
16
 
51
- export default () => {
52
- useCommand('/一言', async () => {
53
- const response = await fetch(HITOKOTO_API);
17
+ const sentence = await fetchSentence(payload.c);
18
+ const { from, hitokoto, id, type } = sentence;
54
19
 
55
- if (!response.ok) {
56
- throw new Error(`接口请求失败,状态码 ${response.status}`);
57
- }
58
- const { hitokoto, from } = <Sentence>await response.json();
20
+ logger.debug('收到 Hitokoto 响应', sentence);
21
+ logger.info('已获取一言', { id, type });
59
22
 
60
23
  return `『${hitokoto}』——「${from}」`;
61
- }).shortcut('来点骚话');
24
+ }).shortcut(/^来点(?<types>.+)?骚话$/);
62
25
  };
package/src/service.ts ADDED
@@ -0,0 +1,184 @@
1
+ /** 一言 v1 语句接口的请求地址。 */
2
+ export const HITOKOTO_API = 'https://v1.hitokoto.cn';
3
+
4
+ /**
5
+ * 中文句子类型名称与一言接口 `c` 参数值的对应表。
6
+ *
7
+ * @see {@link https://developer.hitokoto.cn/sentence/#请求参数 | 一言语句接口请求参数}
8
+ */
9
+ export const TYPE_CODES = {
10
+ 动画: 'a',
11
+ 漫画: 'b',
12
+ 游戏: 'c',
13
+ 文学: 'd',
14
+ 原创: 'e',
15
+ 来自网络: 'f',
16
+ 其他: 'g',
17
+ 影视: 'h',
18
+ 诗词: 'i',
19
+ 网易云: 'j',
20
+ 哲学: 'k',
21
+ 抖机灵: 'l',
22
+ } as const;
23
+
24
+ /** {@link TYPE_CODES} 中的全部中文类型名称,以顿号分隔。 */
25
+ export const TYPE_NAMES = Object.keys(TYPE_CODES).join('、');
26
+
27
+ /** 一言接口 `c` 参数接受的句子类型代码。 */
28
+ export type SentenceType = (typeof TYPE_CODES)[keyof typeof TYPE_CODES];
29
+
30
+ /**
31
+ * 将中文句子类型名称转换为一言接口的类型代码。
32
+ *
33
+ * @remarks
34
+ * 返回的代码数组可以直接传给 {@link fetchSentence}。
35
+ *
36
+ * @param names - 要转换的类型名称数组。每个元素都必须是 {@link TYPE_CODES} 的键。
37
+ * @returns 与 `names` 顺序一致的类型代码数组。
38
+ * @throws `names` 包含不支持的类型名称时抛出 `Error`。
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * resolveTypeCodes(['动画', '诗词']);
43
+ * // => ['a', 'i']
44
+ * ```
45
+ *
46
+ * @see {@link https://developer.hitokoto.cn/sentence/#请求参数 | 一言语句接口请求参数}
47
+ */
48
+ export function resolveTypeCodes(names: string[]): SentenceType[] {
49
+ return names.map(name => {
50
+ const code: SentenceType = TYPE_CODES[<keyof typeof TYPE_CODES>name];
51
+
52
+ if (!code) {
53
+ throw new Error(`类型「${name}」不是有效值,支持的句子类型有:${TYPE_NAMES}`);
54
+ }
55
+ return code;
56
+ });
57
+ }
58
+
59
+ /** 一言 v1 语句接口成功响应的完整返回信息。 */
60
+ export interface Sentence {
61
+ /** 一言标识 */
62
+ readonly id: number;
63
+ /** 一言正文。编码方式 unicode。使用 utf-8。 */
64
+ readonly hitokoto: string;
65
+ /** 类型。请参考第三节参数的表格 */
66
+ readonly type: SentenceType;
67
+ /** 一言的出处 */
68
+ readonly from: string;
69
+ /** 一言的作者 */
70
+ readonly from_who: string | null;
71
+ /** 添加者 */
72
+ readonly creator: string;
73
+ /** 添加者用户标识 */
74
+ readonly creator_uid: number;
75
+ /** 审核员标识 */
76
+ readonly reviewer: number;
77
+ /** 一言唯一标识,可以链接到 https://hitokoto.cn?uuid=[uuid] 查看这个一言的完整信息 */
78
+ readonly uuid: string;
79
+ /** 提交方式 */
80
+ readonly commit_from: string;
81
+ /** 添加时间 */
82
+ readonly created_at: string;
83
+ /** 句子长度 */
84
+ readonly length: number;
85
+ }
86
+
87
+ /**
88
+ * 一言 v1 语句接口的错误响应体。
89
+ *
90
+ * @see {@link https://github.com/hitokoto-osc/hitokoto-api/blob/master/src/controllers/hitokoto/_utils.js#L119-L127 | 一言接口错误响应源码}
91
+ */
92
+ export interface ErrorResponse {
93
+ /** HTTP 状态码。 */
94
+ readonly status: number;
95
+ /** 错误信息。 */
96
+ readonly message: string;
97
+ /** 错误响应的数据,接口当前返回空数组。 */
98
+ readonly data: unknown[];
99
+ /** 生成响应时的 Unix 时间戳,单位为毫秒。 */
100
+ readonly ts: number;
101
+ }
102
+
103
+ /**
104
+ * 将单个句子类型代码规范化为代码数组。
105
+ *
106
+ * @remarks
107
+ * 未提供 `types` 时,函数读取以逗号分隔的 `HITOKOTO_TYPES`。环境变量未设置时返回空数组。
108
+ * 显式传入空数组会跳过环境变量,表示不限制句子类型。
109
+ *
110
+ * @param types - 单个句子类型代码或句子类型代码数组。
111
+ * @returns 用于生成一言接口 `c` 查询参数的类型代码数组。
112
+ */
113
+ export function resolveTypes(types: string | string[] = import.meta.env.HITOKOTO_TYPES?.split(',') ?? []): string[] {
114
+ return typeof types === 'string' ? [types] : types;
115
+ }
116
+
117
+ /**
118
+ * 判断一个值是否符合一言接口的错误响应结构。
119
+ *
120
+ * @remarks
121
+ * 该类型守卫只检查响应体字段,不读取或判断 HTTP 响应状态。
122
+ *
123
+ * @param value - 要检查的值,通常是解析后的非 2xx 响应体。
124
+ * @returns 如果 `value` 符合 {@link ErrorResponse} 结构则返回 `true`,否则返回 `false`。
125
+ * @see {@link https://github.com/hitokoto-osc/hitokoto-api/blob/master/src/controllers/hitokoto/_utils.js#L119-L127 | 一言接口错误响应源码}
126
+ */
127
+ export function isErrorResponse(value: unknown): value is ErrorResponse {
128
+ return (
129
+ typeof value === 'object' &&
130
+ value !== null &&
131
+ 'status' in value &&
132
+ typeof value.status === 'number' &&
133
+ 'message' in value &&
134
+ typeof value.message === 'string' &&
135
+ 'data' in value &&
136
+ Array.isArray(value.data) &&
137
+ 'ts' in value &&
138
+ typeof value.ts === 'number'
139
+ );
140
+ }
141
+
142
+ /**
143
+ * 请求一言 v1 语句接口,并返回随机语句的完整信息。
144
+ *
145
+ * @remarks
146
+ * `types` 接收接口类型代码,不接收中文类型名称。中文名称可以先通过 {@link resolveTypeCodes} 转换。
147
+ * 每个类型代码会作为独立的 `c` 查询参数发送,对应官网「可选择多个分类」的用法。
148
+ *
149
+ * 未提供 `types` 时,函数读取以逗号分隔的 `HITOKOTO_TYPES`。环境变量未设置时不限制句子类型。
150
+ * 显式传入空数组时,不会读取环境变量,也不会发送 `c` 查询参数。
151
+ *
152
+ * @param types - 单个句子类型代码或句子类型代码数组。
153
+ * @returns 一个 Promise,成功时返回完整的 {@link Sentence}。
154
+ * @throws 收到非 2xx 响应时抛出 `Error`。如果响应体符合 {@link ErrorResponse},错误信息使用其 `message`,
155
+ * 否则错误信息包含 HTTP 状态码。
156
+ * @throws 网络请求失败或成功响应无法解析为 JSON 时,传播底层错误。
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * const types = resolveTypeCodes(['动画', '诗词']);
161
+ * const sentence = await fetchSentence(types);
162
+ * console.log(sentence.hitokoto);
163
+ * ```
164
+ *
165
+ * @see {@link https://developer.hitokoto.cn/sentence/ | 一言语句接口}
166
+ */
167
+ export async function fetchSentence(types?: SentenceType | SentenceType[]): Promise<Sentence> {
168
+ const url = new URL(HITOKOTO_API);
169
+
170
+ for (const type of resolveTypes(types)) {
171
+ url.searchParams.append('c', type);
172
+ }
173
+ const response = await fetch(url);
174
+
175
+ if (response.ok) {
176
+ return <Sentence>await response.json();
177
+ }
178
+ const body = await response.json().catch(() => null);
179
+
180
+ if (isErrorResponse(body)) {
181
+ throw new Error(body.message);
182
+ }
183
+ throw new Error(`Hitokoto 请求失败,状态码 ${response.status}`);
184
+ }