koishi-plugin-gitee-is 0.0.1

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/lib/index.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { Context, Schema } from 'koishi';
2
+ declare module 'koishi' {
3
+ interface Tables {
4
+ gitee_is_repo_binding: GiteeRepoBinding;
5
+ }
6
+ }
7
+ export interface GiteeRepoBinding {
8
+ id: number;
9
+ channelId: string;
10
+ owner: string;
11
+ repo: string;
12
+ lastCheckTime: Date;
13
+ isPrivate: boolean;
14
+ }
15
+ export interface Config {
16
+ giteeToken: string;
17
+ pollInterval: number;
18
+ debug: boolean;
19
+ maxIssuesPerMessage: number;
20
+ }
21
+ export declare const name = "gitee-is";
22
+ export declare const using: readonly ["database", "http"];
23
+ export declare const Config: Schema<Config>;
24
+ export declare function apply(ctx: Context, config: Config): void;
package/lib/index.js ADDED
@@ -0,0 +1,377 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
6
+ var __export = (target, all) => {
7
+ for (var name2 in all)
8
+ __defProp(target, name2, { get: all[name2], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ Config: () => Config,
24
+ apply: () => apply,
25
+ name: () => name,
26
+ using: () => using
27
+ });
28
+ module.exports = __toCommonJS(src_exports);
29
+ var import_koishi = require("koishi");
30
+ var import_date_fns = require("date-fns");
31
+ var name = "gitee-is";
32
+ var using = ["database", "http"];
33
+ var Config = import_koishi.Schema.object({
34
+ giteeToken: import_koishi.Schema.string().description("Gitee 私人访问令牌(https://gitee.com/profile/personal_access_tokens)").required(),
35
+ pollInterval: import_koishi.Schema.number().description("Issues 轮询间隔(秒)").default(60).min(30),
36
+ debug: import_koishi.Schema.boolean().description("是否开启调试日志").default(false),
37
+ maxIssuesPerMessage: import_koishi.Schema.number().description("单次推送最大 Issues 数量").default(5).min(1).max(10)
38
+ });
39
+ var GITEE_API_BASE = "https://gitee.com/api/v5";
40
+ var logger = new import_koishi.Logger("gitee-is");
41
+ function apply(ctx, config) {
42
+ if (config.debug) {
43
+ logger.level = import_koishi.Logger.DEBUG;
44
+ }
45
+ ctx.model.extend("gitee_is_repo_binding", {
46
+ id: {
47
+ type: "unsigned",
48
+ length: 4,
49
+ nullable: false
50
+ },
51
+ channelId: {
52
+ type: "string",
53
+ length: 128,
54
+ // 增加长度以容纳各种 ID 格式
55
+ nullable: false
56
+ },
57
+ owner: {
58
+ type: "string",
59
+ length: 64,
60
+ nullable: false
61
+ },
62
+ repo: {
63
+ type: "string",
64
+ length: 64,
65
+ nullable: false
66
+ },
67
+ lastCheckTime: {
68
+ type: "timestamp",
69
+ nullable: false,
70
+ initial: /* @__PURE__ */ new Date(0)
71
+ },
72
+ isPrivate: {
73
+ type: "boolean",
74
+ nullable: false,
75
+ initial: false
76
+ }
77
+ }, {
78
+ primary: "id",
79
+ autoInc: true,
80
+ unique: [["channelId", "owner", "repo"]]
81
+ });
82
+ function getChannelId(session) {
83
+ if (session.guildId) {
84
+ return {
85
+ channelId: `${session.platform}:${session.guildId}`,
86
+ isPrivate: false
87
+ };
88
+ } else {
89
+ return {
90
+ channelId: `${session.platform}:private:${session.userId}`,
91
+ isPrivate: true
92
+ };
93
+ }
94
+ }
95
+ __name(getChannelId, "getChannelId");
96
+ function parseChannelId(channelId) {
97
+ const parts = channelId.split(":");
98
+ if (parts.length === 3 && parts[1] === "private") {
99
+ return {
100
+ platform: parts[0],
101
+ targetId: parts[2],
102
+ isPrivate: true
103
+ };
104
+ } else if (parts.length >= 2) {
105
+ return {
106
+ platform: parts[0],
107
+ targetId: parts.slice(1).join(":"),
108
+ isPrivate: false
109
+ };
110
+ }
111
+ throw new Error(`Invalid channelId format: ${channelId}`);
112
+ }
113
+ __name(parseChannelId, "parseChannelId");
114
+ async function giteeRequest(endpoint, params) {
115
+ const url = `${GITEE_API_BASE}${endpoint}`;
116
+ try {
117
+ const data = await ctx.http.get(url, {
118
+ params: {
119
+ ...params,
120
+ access_token: config.giteeToken
121
+ },
122
+ timeout: 15e3
123
+ });
124
+ return data;
125
+ } catch (error) {
126
+ logger.error(`Gitee API 请求失败: ${endpoint}`, error);
127
+ throw error;
128
+ }
129
+ }
130
+ __name(giteeRequest, "giteeRequest");
131
+ const baseCommand = ctx.command("giteeis", "Gitee Issues 监控管理").usage("支持群聊和私聊使用,仓库格式为「所有者/仓库名」(如 koishijs/koishi) ");
132
+ baseCommand.subcommand(".add <repo:string>", "添加要监控的 Gitee 仓库").usage("添加要监控的 Gitee 仓库,格式:所有者/仓库名\n在群聊中使用则绑定到当前群,私聊中使用则绑定到当前私聊会话").example("giteeis.add koishijs/koishi").action(async ({ session }, repo) => {
133
+ if (!session) return "❌ 会话不存在";
134
+ const { channelId, isPrivate } = getChannelId(session);
135
+ const sceneType = isPrivate ? "私聊" : "群聊";
136
+ if (!repo || !repo.includes("/")) {
137
+ return "❌ 仓库格式错误!正确格式:所有者/仓库名(例如:koishijs/koishi)";
138
+ }
139
+ const [owner, repoName] = repo.split("/");
140
+ if (!owner || !repoName) {
141
+ return "❌ 仓库格式错误!所有者或仓库名不能为空。";
142
+ }
143
+ try {
144
+ await giteeRequest(`/repos/${owner}/${repoName}`);
145
+ } catch (error) {
146
+ const status = error?.response?.status;
147
+ if (status === 404) {
148
+ return `❌ 仓库 ${owner}/${repoName} 不存在!`;
149
+ } else if (status === 403) {
150
+ return `❌ 没有权限访问仓库 ${owner}/${repoName},请检查 Token 是否有权限访问该仓库。`;
151
+ }
152
+ return `❌ 验证仓库失败:${error.message || "未知错误"}`;
153
+ }
154
+ const existing = await ctx.database.get("gitee_is_repo_binding", {
155
+ channelId,
156
+ owner,
157
+ repo: repoName
158
+ });
159
+ if (existing.length > 0) {
160
+ return `ℹ️ 该${sceneType}已绑定仓库 ${owner}/${repoName},无需重复添加!`;
161
+ }
162
+ await ctx.database.create("gitee_is_repo_binding", {
163
+ channelId,
164
+ owner,
165
+ repo: repoName,
166
+ lastCheckTime: /* @__PURE__ */ new Date(),
167
+ isPrivate
168
+ });
169
+ return `✅ 成功在${sceneType}中绑定仓库 ${owner}/${repoName}!
170
+ 已开始监控其 Issues 动态,新 Issue 或更新将自动推送至此${sceneType}。`;
171
+ });
172
+ baseCommand.subcommand(".remove <repo:string>", "删除已绑定的 Gitee 仓库").usage("删除当前群聊或私聊中已绑定的 Gitee 仓库监控").example("giteeis.remove koishijs/koishi").action(async ({ session }, repo) => {
173
+ if (!session) return "❌ 会话不存在";
174
+ const { channelId, isPrivate } = getChannelId(session);
175
+ const sceneType = isPrivate ? "私聊" : "群聊";
176
+ if (!repo || !repo.includes("/")) {
177
+ return "❌ 仓库格式错误!正确格式:所有者/仓库名";
178
+ }
179
+ const [owner, repoName] = repo.split("/");
180
+ const result = await ctx.database.remove("gitee_is_repo_binding", {
181
+ channelId,
182
+ owner,
183
+ repo: repoName
184
+ });
185
+ const deletedCount = typeof result === "number" ? result : result.matchedCount || 0;
186
+ if (deletedCount > 0) {
187
+ return `✅ 成功删除${sceneType}中绑定的仓库 ${owner}/${repoName}!`;
188
+ } else {
189
+ return `ℹ️ 当前${sceneType}未绑定仓库 ${owner}/${repoName},无需删除!`;
190
+ }
191
+ });
192
+ baseCommand.subcommand(".list", "查看当前会话已绑定的所有 Gitee 仓库").usage("查看当前群聊或私聊中已绑定的所有 Gitee 仓库列表").action(async ({ session }) => {
193
+ if (!session) return "❌ 会话不存在";
194
+ const { channelId, isPrivate } = getChannelId(session);
195
+ const sceneType = isPrivate ? "私聊" : "群聊";
196
+ const bindings = await ctx.database.get("gitee_is_repo_binding", { channelId });
197
+ if (bindings.length === 0) {
198
+ return `ℹ️ 当前${sceneType}暂未绑定任何 Gitee 仓库!
199
+ 使用 giteeis.add <所有者/仓库名> 添加监控。`;
200
+ }
201
+ const repoList = bindings.map((b) => {
202
+ const lastCheck = (0, import_date_fns.format)(b.lastCheckTime, "yyyy-MM-dd HH:mm:ss");
203
+ return `• ${b.owner}/${b.repo} (上次检查: ${lastCheck})`;
204
+ }).join("\n");
205
+ return `📋 当前${sceneType}已绑定 ${bindings.length} 个 Gitee 仓库:
206
+ ${repoList}`;
207
+ });
208
+ baseCommand.subcommand(".issues <repo:string>", "查看指定仓库的所有 Open 状态 Issues").usage("查看指定仓库当前 Open 状态的 Issues 列表(无需绑定即可查询)").example("giteeis.issues koishijs/koishi").action(async ({ session }, repo) => {
209
+ if (!repo || !repo.includes("/")) {
210
+ return "❌ 仓库格式错误!正确格式:所有者/仓库名";
211
+ }
212
+ const [owner, repoName] = repo.split("/");
213
+ try {
214
+ const issues = await giteeRequest(`/repos/${owner}/${repoName}/issues`, {
215
+ state: "open",
216
+ per_page: 20,
217
+ sort: "created",
218
+ direction: "desc"
219
+ });
220
+ if (!Array.isArray(issues) || issues.length === 0) {
221
+ return `ℹ️ 仓库 ${owner}/${repoName} 暂无 Open 状态的 Issues!`;
222
+ }
223
+ const issuesList = issues.map((issue) => {
224
+ const number = issue?.number || "未知";
225
+ const title = issue?.title || "无标题";
226
+ const author = issue?.user?.login || "未知作者";
227
+ const createTime = issue?.created_at ? (0, import_date_fns.format)(new Date(issue.created_at), "yyyy-MM-dd HH:mm") : "未知时间";
228
+ const url = issue?.html_url || "无链接";
229
+ const labels = issue?.labels?.map((l) => l.name).join(", ") || "无标签";
230
+ return `【#${number}】${title}
231
+ 作者:${author} | 标签:${labels}
232
+ 创建时间:${createTime}
233
+ 链接:${url}`;
234
+ }).join("\n\n");
235
+ return `📄 仓库 ${owner}/${repoName} 的 Open Issues(共 ${issues.length} 条):
236
+
237
+ ${issuesList}`;
238
+ } catch (error) {
239
+ logger.error(`获取 Issues 失败: ${owner}/${repoName}`, error);
240
+ return `❌ 获取 Issues 失败!${error.message || "请检查仓库名是否正确或网络连接。"}`;
241
+ }
242
+ });
243
+ baseCommand.subcommand(".check", "手动触发一次 Issues 检查(调试用)").usage("立即执行一次 Issues 更新检查,不受轮询间隔限制").action(async ({ session }) => {
244
+ pollIssues().catch((err) => logger.error("手动检查失败", err));
245
+ return "🔍 已触发 Issues 检查,请留意后续消息推送。";
246
+ });
247
+ async function pollIssues() {
248
+ logger.debug("开始轮询检查 Gitee Issues 更新");
249
+ try {
250
+ const allBindings = await ctx.database.get("gitee_is_repo_binding", {});
251
+ if (allBindings.length === 0) {
252
+ logger.debug("暂无绑定的仓库,跳过本轮轮询");
253
+ return;
254
+ }
255
+ logger.debug(`开始检查 ${allBindings.length} 个绑定的仓库`);
256
+ for (const binding of allBindings) {
257
+ if (!binding?.channelId || !binding?.owner || !binding?.repo) {
258
+ logger.warn("无效的仓库绑定数据,跳过:", binding);
259
+ continue;
260
+ }
261
+ const { channelId, owner, repo, lastCheckTime, isPrivate } = binding;
262
+ try {
263
+ const { platform, targetId } = parseChannelId(channelId);
264
+ const issues = await giteeRequest(`/repos/${owner}/${repo}/issues`, {
265
+ state: "all",
266
+ sort: "updated",
267
+ direction: "desc",
268
+ per_page: 50,
269
+ since: lastCheckTime.toISOString()
270
+ });
271
+ if (!Array.isArray(issues) || issues.length === 0) {
272
+ logger.debug(`仓库 ${owner}/${repo} 无更新`);
273
+ continue;
274
+ }
275
+ const lastCheck = new Date(lastCheckTime).getTime();
276
+ const updatedIssues = issues.filter((issue) => {
277
+ const updateTime = new Date(issue?.updated_at).getTime();
278
+ return !isNaN(updateTime) && updateTime > lastCheck;
279
+ });
280
+ if (updatedIssues.length === 0) {
281
+ logger.debug(`仓库 ${owner}/${repo} 无有效更新`);
282
+ continue;
283
+ }
284
+ logger.info(`仓库 ${owner}/${repo} 发现 ${updatedIssues.length} 个更新的 Issues`);
285
+ const issuesToPush = updatedIssues.slice(0, config.maxIssuesPerMessage);
286
+ const remainingCount = updatedIssues.length - issuesToPush.length;
287
+ const pushMessages = issuesToPush.map((issue) => {
288
+ const issueUpdateTime = new Date(issue?.updated_at).getTime();
289
+ const issueCreateTime = new Date(issue?.created_at).getTime();
290
+ let type = "🔄 内容更新";
291
+ if (issueCreateTime > lastCheck) {
292
+ type = "🆕 新创建";
293
+ } else if (issue?.comments > 0) {
294
+ type = "💬 评论更新";
295
+ }
296
+ const number = issue?.number || "未知";
297
+ const title = issue?.title || "无标题";
298
+ const author = issue?.user?.login || "未知作者";
299
+ const updateTime = issue?.updated_at ? (0, import_date_fns.format)(new Date(issue.updated_at), "yyyy-MM-dd HH:mm") : "未知时间";
300
+ const state = issue?.state === "open" ? "🟢 Open" : "🔴 Closed";
301
+ const url = issue?.html_url || "无链接";
302
+ return `${type} Issue【#${number}】${title}
303
+ 状态:${state} | 仓库:${owner}/${repo}
304
+ 作者:${author} | 更新时间:${updateTime}
305
+ 链接:${url}`;
306
+ }).join("\n\n");
307
+ const message = remainingCount > 0 ? `${pushMessages}
308
+
309
+ ...还有 ${remainingCount} 条更新未显示` : pushMessages;
310
+ const bot = ctx.bots.find((b) => b.platform === platform);
311
+ if (!bot) {
312
+ logger.error(`未找到 ${platform} 平台的 Bot,无法推送到 ${channelId}`);
313
+ continue;
314
+ }
315
+ if (!bot.online) {
316
+ logger.warn(`Bot ${bot.selfId} (${platform}) 当前离线,跳过推送`);
317
+ continue;
318
+ }
319
+ if (isPrivate) {
320
+ await bot.sendPrivateMessage(targetId, message);
321
+ } else {
322
+ await bot.sendMessage(targetId, message);
323
+ }
324
+ logger.debug(`成功推送 ${issuesToPush.length} 条 Issues 更新到 ${channelId}`);
325
+ } catch (error) {
326
+ logger.error(`检查仓库 ${owner}/${repo} Issues 失败:`, error.message);
327
+ if (Math.random() < 0.2) {
328
+ try {
329
+ const { platform, targetId, isPrivate: isPrivate2 } = parseChannelId(channelId);
330
+ const bot = ctx.bots.find((b) => b.platform === platform && b.online);
331
+ if (bot) {
332
+ const errorMsg = `⚠️ 监控仓库 ${owner}/${repo} 时出错:${error.message?.substring(0, 100) || "未知错误"}`;
333
+ if (isPrivate2) {
334
+ await bot.sendPrivateMessage(targetId, errorMsg);
335
+ } else {
336
+ await bot.sendMessage(targetId, errorMsg);
337
+ }
338
+ }
339
+ } catch (err) {
340
+ logger.error("推送错误提示失败:", err);
341
+ }
342
+ }
343
+ continue;
344
+ } finally {
345
+ try {
346
+ await ctx.database.set(
347
+ "gitee_is_repo_binding",
348
+ { id: binding.id },
349
+ { lastCheckTime: /* @__PURE__ */ new Date() }
350
+ );
351
+ } catch (err) {
352
+ logger.error("更新最后检查时间失败:", err);
353
+ }
354
+ }
355
+ }
356
+ } catch (error) {
357
+ logger.error("轮询过程发生错误:", error.message);
358
+ }
359
+ }
360
+ __name(pollIssues, "pollIssues");
361
+ const pollTimer = ctx.setInterval(pollIssues, config.pollInterval * 1e3);
362
+ const initialTimer = ctx.setTimeout(() => {
363
+ pollIssues().catch((err) => logger.error("初始检查失败", err));
364
+ }, 5e3);
365
+ ctx.on("dispose", () => {
366
+ logger.info("Gitee Issues 插件已卸载,清理定时任务");
367
+ initialTimer();
368
+ });
369
+ }
370
+ __name(apply, "apply");
371
+ // Annotate the CommonJS export names for ESM import in node:
372
+ 0 && (module.exports = {
373
+ Config,
374
+ apply,
375
+ name,
376
+ using
377
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "koishi-plugin-gitee-is",
3
+ "description": "gitee-issues",
4
+ "version": "0.0.1",
5
+ "main": "lib/index.js",
6
+ "typings": "lib/index.d.ts",
7
+ "files": [
8
+ "lib",
9
+ "src"
10
+ ],
11
+ "license": "MIT",
12
+ "keywords": [
13
+ "gitee",
14
+ "koishi",
15
+ "plugin"
16
+ ],
17
+ "koishi": {
18
+ "description": {
19
+ "zh": "实时推送 * 非自己 * 的gitee issues的插件,支持自定义仓库和推送内容,包括评论等,适合需要监控gitee项目动态的用户使用。"
20
+ }
21
+ },
22
+ "peerDependencies": {
23
+ "koishi": "^4.18.7"
24
+ },
25
+ "dependencies": {
26
+ "date-fns": "^4.1.0"
27
+ }
28
+ }
package/readme.md ADDED
@@ -0,0 +1,5 @@
1
+ # koishi-plugin-gitee-is
2
+
3
+ [![npm](https://img.shields.io/npm/v/koishi-plugin-gitee-is?style=flat-square)](https://www.npmjs.com/package/koishi-plugin-gitee-is)
4
+
5
+ gitee-issues
package/src/index.ts ADDED
@@ -0,0 +1,495 @@
1
+ import { Context, Schema, Logger, Session } from 'koishi'
2
+ import { format } from 'date-fns'
3
+
4
+ // ====================== 1. 类型扩展(官方规范写法) ======================
5
+ declare module 'koishi' {
6
+ interface Tables {
7
+ gitee_is_repo_binding: GiteeRepoBinding
8
+ }
9
+ }
10
+
11
+ // ====================== 2. 核心类型定义 ======================
12
+ export interface GiteeRepoBinding {
13
+ id: number // 自增主键
14
+ channelId: string // 频道/会话ID(platform:guildId 或 platform:userId)
15
+ owner: string // Gitee 仓库所有者
16
+ repo: string // Gitee 仓库名称
17
+ lastCheckTime: Date // 最后检查时间
18
+ isPrivate: boolean // 是否为私聊绑定(true=私聊,false=群聊)
19
+ }
20
+
21
+ export interface Config {
22
+ giteeToken: string // Gitee 私人访问令牌
23
+ pollInterval: number // 轮询间隔(秒,默认 60,最小 30)
24
+ debug: boolean // 是否开启调试日志
25
+ maxIssuesPerMessage: number // 单次推送最大 Issues 数量
26
+ }
27
+
28
+ // ====================== 3. 插件元信息 & 配置校验 ======================
29
+ export const name = 'gitee-is'
30
+ export const using = ['database', 'http'] as const
31
+
32
+ export const Config: Schema<Config> = Schema.object({
33
+ giteeToken: Schema.string()
34
+ .description('Gitee 私人访问令牌(https://gitee.com/profile/personal_access_tokens)')
35
+ .required(),
36
+ pollInterval: Schema.number()
37
+ .description('Issues 轮询间隔(秒)')
38
+ .default(60)
39
+ .min(30),
40
+ debug: Schema.boolean()
41
+ .description('是否开启调试日志')
42
+ .default(false),
43
+ maxIssuesPerMessage: Schema.number()
44
+ .description('单次推送最大 Issues 数量')
45
+ .default(5)
46
+ .min(1)
47
+ .max(10),
48
+ })
49
+
50
+ // ====================== 4. 常量定义 ======================
51
+ const GITEE_API_BASE = 'https://gitee.com/api/v5'
52
+ const logger = new Logger('gitee-is')
53
+
54
+ // ====================== 5. 插件核心逻辑 ======================
55
+ export function apply(ctx: Context, config: Config) {
56
+ if (config.debug) {
57
+ logger.level = Logger.DEBUG
58
+ }
59
+
60
+ // --------------------- 5.2 数据库模型定义 ---------------------
61
+ ctx.model.extend('gitee_is_repo_binding', {
62
+ id: {
63
+ type: 'unsigned',
64
+ length: 4,
65
+ nullable: false,
66
+ },
67
+ channelId: {
68
+ type: 'string',
69
+ length: 128, // 增加长度以容纳各种 ID 格式
70
+ nullable: false,
71
+ },
72
+ owner: {
73
+ type: 'string',
74
+ length: 64,
75
+ nullable: false,
76
+ },
77
+ repo: {
78
+ type: 'string',
79
+ length: 64,
80
+ nullable: false,
81
+ },
82
+ lastCheckTime: {
83
+ type: 'timestamp',
84
+ nullable: false,
85
+ initial: new Date(0),
86
+ },
87
+ isPrivate: {
88
+ type: 'boolean',
89
+ nullable: false,
90
+ initial: false,
91
+ },
92
+ }, {
93
+ primary: 'id',
94
+ autoInc: true,
95
+ unique: [['channelId', 'owner', 'repo']],
96
+ })
97
+
98
+ // --------------------- 5.3 辅助函数:获取当前会话ID ---------------------
99
+ /**
100
+ * 获取当前会话的唯一标识
101
+ * 群聊: platform:guildId
102
+ * 私聊: platform:userId (private:userId)
103
+ */
104
+ function getChannelId(session: Session): { channelId: string; isPrivate: boolean } {
105
+ if (session.guildId) {
106
+ // 群聊场景
107
+ return {
108
+ channelId: `${session.platform}:${session.guildId}`,
109
+ isPrivate: false
110
+ }
111
+ } else {
112
+ // 私聊场景
113
+ return {
114
+ channelId: `${session.platform}:private:${session.userId}`,
115
+ isPrivate: true
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 解析 channelId 获取 platform 和实际 ID
122
+ */
123
+ function parseChannelId(channelId: string): { platform: string; targetId: string; isPrivate: boolean } {
124
+ const parts = channelId.split(':')
125
+
126
+ if (parts.length === 3 && parts[1] === 'private') {
127
+ // 私聊格式: platform:private:userId
128
+ return {
129
+ platform: parts[0],
130
+ targetId: parts[2],
131
+ isPrivate: true
132
+ }
133
+ } else if (parts.length >= 2) {
134
+ // 群聊格式: platform:guildId (guildId 可能包含冒号,取剩余部分)
135
+ return {
136
+ platform: parts[0],
137
+ targetId: parts.slice(1).join(':'),
138
+ isPrivate: false
139
+ }
140
+ }
141
+
142
+ throw new Error(`Invalid channelId format: ${channelId}`)
143
+ }
144
+
145
+ // --------------------- 5.4 Gitee API 请求封装 ---------------------
146
+ async function giteeRequest<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
147
+ const url = `${GITEE_API_BASE}${endpoint}`
148
+ try {
149
+ const data = await ctx.http.get(url, {
150
+ params: {
151
+ ...params,
152
+ access_token: config.giteeToken,
153
+ },
154
+ timeout: 15000,
155
+ })
156
+ return data as T
157
+ } catch (error) {
158
+ logger.error(`Gitee API 请求失败: ${endpoint}`, error)
159
+ throw error
160
+ }
161
+ }
162
+
163
+ // --------------------- 5.5 指令注册 ---------------------
164
+ const baseCommand = ctx.command('giteeis', 'Gitee Issues 监控管理')
165
+ .usage('支持群聊和私聊使用,仓库格式为「所有者/仓库名」(如 koishijs/koishi) ')
166
+
167
+ // 子指令 1:添加仓库
168
+ baseCommand.subcommand('.add <repo:string>', '添加要监控的 Gitee 仓库')
169
+ .usage('添加要监控的 Gitee 仓库,格式:所有者/仓库名\n在群聊中使用则绑定到当前群,私聊中使用则绑定到当前私聊会话')
170
+ .example('giteeis.add koishijs/koishi')
171
+ .action(async ({ session }, repo) => {
172
+ if (!session) return '❌ 会话不存在'
173
+
174
+ const { channelId, isPrivate } = getChannelId(session)
175
+ const sceneType = isPrivate ? '私聊' : '群聊'
176
+
177
+ if (!repo || !repo.includes('/')) {
178
+ return '❌ 仓库格式错误!正确格式:所有者/仓库名(例如:koishijs/koishi)'
179
+ }
180
+
181
+ const [owner, repoName] = repo.split('/')
182
+ if (!owner || !repoName) {
183
+ return '❌ 仓库格式错误!所有者或仓库名不能为空。'
184
+ }
185
+
186
+ // 验证仓库是否存在且可访问
187
+ try {
188
+ await giteeRequest(`/repos/${owner}/${repoName}`)
189
+ } catch (error: any) {
190
+ const status = error?.response?.status
191
+ if (status === 404) {
192
+ return `❌ 仓库 ${owner}/${repoName} 不存在!`
193
+ } else if (status === 403) {
194
+ return `❌ 没有权限访问仓库 ${owner}/${repoName},请检查 Token 是否有权限访问该仓库。`
195
+ }
196
+ return `❌ 验证仓库失败:${error.message || '未知错误'}`
197
+ }
198
+
199
+ // 检查是否已绑定
200
+ const existing = await ctx.database.get('gitee_is_repo_binding', {
201
+ channelId,
202
+ owner,
203
+ repo: repoName
204
+ })
205
+
206
+ if (existing.length > 0) {
207
+ return `ℹ️ 该${sceneType}已绑定仓库 ${owner}/${repoName},无需重复添加!`
208
+ }
209
+
210
+ // 创建绑定记录
211
+ await ctx.database.create('gitee_is_repo_binding', {
212
+ channelId,
213
+ owner,
214
+ repo: repoName,
215
+ lastCheckTime: new Date(),
216
+ isPrivate,
217
+ })
218
+
219
+ return `✅ 成功在${sceneType}中绑定仓库 ${owner}/${repoName}!\n已开始监控其 Issues 动态,新 Issue 或更新将自动推送至此${sceneType}。`
220
+ })
221
+
222
+ // 子指令 2:删除仓库
223
+ baseCommand.subcommand('.remove <repo:string>', '删除已绑定的 Gitee 仓库')
224
+ .usage('删除当前群聊或私聊中已绑定的 Gitee 仓库监控')
225
+ .example('giteeis.remove koishijs/koishi')
226
+ .action(async ({ session }, repo) => {
227
+ if (!session) return '❌ 会话不存在'
228
+
229
+ const { channelId, isPrivate } = getChannelId(session)
230
+ const sceneType = isPrivate ? '私聊' : '群聊'
231
+
232
+ if (!repo || !repo.includes('/')) {
233
+ return '❌ 仓库格式错误!正确格式:所有者/仓库名'
234
+ }
235
+
236
+ const [owner, repoName] = repo.split('/')
237
+
238
+ const result = await ctx.database.remove('gitee_is_repo_binding', {
239
+ channelId,
240
+ owner,
241
+ repo: repoName
242
+ })
243
+
244
+ // 兼容不同数据库驱动的返回值(number 或 WriteResult)
245
+ const deletedCount = typeof result === 'number' ? result : (result as any).matchedCount || 0
246
+
247
+ if (deletedCount > 0) {
248
+ return `✅ 成功删除${sceneType}中绑定的仓库 ${owner}/${repoName}!`
249
+ } else {
250
+ return `ℹ️ 当前${sceneType}未绑定仓库 ${owner}/${repoName},无需删除!`
251
+ }
252
+ })
253
+
254
+ // 子指令 3:查看当前绑定的仓库
255
+ baseCommand.subcommand('.list', '查看当前会话已绑定的所有 Gitee 仓库')
256
+ .usage('查看当前群聊或私聊中已绑定的所有 Gitee 仓库列表')
257
+ .action(async ({ session }) => {
258
+ if (!session) return '❌ 会话不存在'
259
+
260
+ const { channelId, isPrivate } = getChannelId(session)
261
+ const sceneType = isPrivate ? '私聊' : '群聊'
262
+
263
+ const bindings = await ctx.database.get('gitee_is_repo_binding', { channelId })
264
+
265
+ if (bindings.length === 0) {
266
+ return `ℹ️ 当前${sceneType}暂未绑定任何 Gitee 仓库!\n使用 giteeis.add <所有者/仓库名> 添加监控。`
267
+ }
268
+
269
+ const repoList = bindings.map(b => {
270
+ const lastCheck = format(b.lastCheckTime, 'yyyy-MM-dd HH:mm:ss')
271
+ return `• ${b.owner}/${b.repo} (上次检查: ${lastCheck})`
272
+ }).join('\n')
273
+
274
+ return `📋 当前${sceneType}已绑定 ${bindings.length} 个 Gitee 仓库:\n${repoList}`
275
+ })
276
+
277
+ // 子指令 4:查看仓库的 Open Issues
278
+ baseCommand.subcommand('.issues <repo:string>', '查看指定仓库的所有 Open 状态 Issues')
279
+ .usage('查看指定仓库当前 Open 状态的 Issues 列表(无需绑定即可查询)')
280
+ .example('giteeis.issues koishijs/koishi')
281
+ .action(async ({ session }, repo) => {
282
+ if (!repo || !repo.includes('/')) {
283
+ return '❌ 仓库格式错误!正确格式:所有者/仓库名'
284
+ }
285
+
286
+ const [owner, repoName] = repo.split('/')
287
+
288
+ try {
289
+ const issues = await giteeRequest<any[]>(`/repos/${owner}/${repoName}/issues`, {
290
+ state: 'open',
291
+ per_page: 20,
292
+ sort: 'created',
293
+ direction: 'desc',
294
+ })
295
+
296
+ if (!Array.isArray(issues) || issues.length === 0) {
297
+ return `ℹ️ 仓库 ${owner}/${repoName} 暂无 Open 状态的 Issues!`
298
+ }
299
+
300
+ const issuesList = issues.map((issue: any) => {
301
+ const number = issue?.number || '未知'
302
+ const title = issue?.title || '无标题'
303
+ const author = issue?.user?.login || '未知作者'
304
+ const createTime = issue?.created_at
305
+ ? format(new Date(issue.created_at), 'yyyy-MM-dd HH:mm')
306
+ : '未知时间'
307
+ const url = issue?.html_url || '无链接'
308
+ const labels = issue?.labels?.map((l: any) => l.name).join(', ') || '无标签'
309
+
310
+ return `【#${number}】${title}\n` +
311
+ `作者:${author} | 标签:${labels}\n` +
312
+ `创建时间:${createTime}\n` +
313
+ `链接:${url}`
314
+ }).join('\n\n')
315
+
316
+ return `📄 仓库 ${owner}/${repoName} 的 Open Issues(共 ${issues.length} 条):\n\n${issuesList}`
317
+ } catch (error: any) {
318
+ logger.error(`获取 Issues 失败: ${owner}/${repoName}`, error)
319
+ return `❌ 获取 Issues 失败!${error.message || '请检查仓库名是否正确或网络连接。'}`
320
+ }
321
+ })
322
+
323
+ // 子指令 5:手动触发检查
324
+ baseCommand.subcommand('.check', '手动触发一次 Issues 检查(调试用)')
325
+ .usage('立即执行一次 Issues 更新检查,不受轮询间隔限制')
326
+ .action(async ({ session }) => {
327
+ // 立即执行一次轮询
328
+ pollIssues().catch(err => logger.error('手动检查失败', err))
329
+ return '🔍 已触发 Issues 检查,请留意后续消息推送。'
330
+ })
331
+
332
+ // --------------------- 5.6 定时轮询逻辑 ---------------------
333
+ async function pollIssues() {
334
+ logger.debug('开始轮询检查 Gitee Issues 更新')
335
+
336
+ try {
337
+ const allBindings = await ctx.database.get('gitee_is_repo_binding', {})
338
+
339
+ if (allBindings.length === 0) {
340
+ logger.debug('暂无绑定的仓库,跳过本轮轮询')
341
+ return
342
+ }
343
+
344
+ logger.debug(`开始检查 ${allBindings.length} 个绑定的仓库`)
345
+
346
+ for (const binding of allBindings) {
347
+ if (!binding?.channelId || !binding?.owner || !binding?.repo) {
348
+ logger.warn('无效的仓库绑定数据,跳过:', binding)
349
+ continue
350
+ }
351
+
352
+ const { channelId, owner, repo, lastCheckTime, isPrivate } = binding
353
+
354
+ try {
355
+ // 解析 channelId
356
+ const { platform, targetId } = parseChannelId(channelId)
357
+
358
+ // 获取该仓库的 Issues
359
+ const issues = await giteeRequest<any[]>(`/repos/${owner}/${repo}/issues`, {
360
+ state: 'all',
361
+ sort: 'updated',
362
+ direction: 'desc',
363
+ per_page: 50,
364
+ since: lastCheckTime.toISOString(),
365
+ })
366
+
367
+ if (!Array.isArray(issues) || issues.length === 0) {
368
+ logger.debug(`仓库 ${owner}/${repo} 无更新`)
369
+ continue
370
+ }
371
+
372
+ // 过滤出在上次检查之后有更新的 Issues
373
+ const lastCheck = new Date(lastCheckTime).getTime()
374
+ const updatedIssues = issues.filter((issue: any) => {
375
+ const updateTime = new Date(issue?.updated_at).getTime()
376
+ return !isNaN(updateTime) && updateTime > lastCheck
377
+ })
378
+
379
+ if (updatedIssues.length === 0) {
380
+ logger.debug(`仓库 ${owner}/${repo} 无有效更新`)
381
+ continue
382
+ }
383
+
384
+ logger.info(`仓库 ${owner}/${repo} 发现 ${updatedIssues.length} 个更新的 Issues`)
385
+
386
+ // 限制单次推送数量
387
+ const issuesToPush = updatedIssues.slice(0, config.maxIssuesPerMessage)
388
+ const remainingCount = updatedIssues.length - issuesToPush.length
389
+
390
+ const pushMessages = issuesToPush.map((issue: any) => {
391
+ const issueUpdateTime = new Date(issue?.updated_at).getTime()
392
+ const issueCreateTime = new Date(issue?.created_at).getTime()
393
+
394
+ // 判断更新类型
395
+ let type = '🔄 内容更新'
396
+ if (issueCreateTime > lastCheck) {
397
+ type = '🆕 新创建'
398
+ } else if (issue?.comments > 0) {
399
+ type = '💬 评论更新'
400
+ }
401
+
402
+ const number = issue?.number || '未知'
403
+ const title = issue?.title || '无标题'
404
+ const author = issue?.user?.login || '未知作者'
405
+ const updateTime = issue?.updated_at
406
+ ? format(new Date(issue.updated_at), 'yyyy-MM-dd HH:mm')
407
+ : '未知时间'
408
+ const state = issue?.state === 'open' ? '🟢 Open' : '🔴 Closed'
409
+ const url = issue?.html_url || '无链接'
410
+
411
+ return `${type} Issue【#${number}】${title}\n` +
412
+ `状态:${state} | 仓库:${owner}/${repo}\n` +
413
+ `作者:${author} | 更新时间:${updateTime}\n` +
414
+ `链接:${url}`
415
+ }).join('\n\n')
416
+
417
+ const message = remainingCount > 0
418
+ ? `${pushMessages}\n\n...还有 ${remainingCount} 条更新未显示`
419
+ : pushMessages
420
+
421
+ // 获取对应平台的 Bot 并发送消息
422
+ const bot = ctx.bots.find(b => b.platform === platform)
423
+ if (!bot) {
424
+ logger.error(`未找到 ${platform} 平台的 Bot,无法推送到 ${channelId}`)
425
+ continue
426
+ }
427
+
428
+ if (!bot.online) {
429
+ logger.warn(`Bot ${bot.selfId} (${platform}) 当前离线,跳过推送`)
430
+ continue
431
+ }
432
+
433
+ // 根据私聊/群聊选择不同的发送方法
434
+ if (isPrivate) {
435
+ // 私聊:使用 sendPrivateMessage
436
+ await bot.sendPrivateMessage(targetId, message)
437
+ } else {
438
+ // 群聊:使用 sendMessage
439
+ await bot.sendMessage(targetId, message)
440
+ }
441
+
442
+ logger.debug(`成功推送 ${issuesToPush.length} 条 Issues 更新到 ${channelId}`)
443
+
444
+ } catch (error: any) {
445
+ logger.error(`检查仓库 ${owner}/${repo} Issues 失败:`, error.message)
446
+
447
+ // 错误提示(限流)
448
+ if (Math.random() < 0.2) {
449
+ try {
450
+ const { platform, targetId, isPrivate } = parseChannelId(channelId)
451
+ const bot = ctx.bots.find(b => b.platform === platform && b.online)
452
+ if (bot) {
453
+ const errorMsg = `⚠️ 监控仓库 ${owner}/${repo} 时出错:${error.message?.substring(0, 100) || '未知错误'}`
454
+ if (isPrivate) {
455
+ await bot.sendPrivateMessage(targetId, errorMsg)
456
+ } else {
457
+ await bot.sendMessage(targetId, errorMsg)
458
+ }
459
+ }
460
+ } catch (err) {
461
+ logger.error('推送错误提示失败:', err)
462
+ }
463
+ }
464
+ continue
465
+ } finally {
466
+ // 更新最后检查时间
467
+ try {
468
+ await ctx.database.set('gitee_is_repo_binding',
469
+ { id: binding.id },
470
+ { lastCheckTime: new Date() }
471
+ )
472
+ } catch (err) {
473
+ logger.error('更新最后检查时间失败:', err)
474
+ }
475
+ }
476
+ }
477
+ } catch (error: any) {
478
+ logger.error('轮询过程发生错误:', error.message)
479
+ }
480
+ }
481
+
482
+ // --------------------- 5.7 定时任务配置 ---------------------
483
+ const pollTimer = ctx.setInterval(pollIssues, config.pollInterval * 1000)
484
+
485
+ // 启动时延迟执行一次检查
486
+ const initialTimer = ctx.setTimeout(() => {
487
+ pollIssues().catch(err => logger.error('初始检查失败', err))
488
+ }, 5000)
489
+
490
+ // --------------------- 5.8 插件卸载清理 ---------------------
491
+ ctx.on('dispose', () => {
492
+ logger.info('Gitee Issues 插件已卸载,清理定时任务')
493
+ initialTimer()
494
+ })
495
+ }