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 +24 -0
- package/lib/index.js +377 -0
- package/package.json +28 -0
- package/readme.md +5 -0
- package/src/index.ts +495 -0
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
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
|
+
}
|