koishi-plugin-githubsth 1.0.3-alpha.1 → 1.0.3
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/config.d.ts +5 -0
- package/lib/config.js +14 -6
- package/lib/database.d.ts +8 -0
- package/lib/database.js +10 -0
- package/lib/index.js +5 -8
- package/lib/locales/en-US.d.ts +17 -0
- package/lib/locales/en-US.js +18 -0
- package/lib/locales/zh-CN.js +7 -7
- package/lib/services/formatter.d.ts +7 -1
- package/lib/services/formatter.js +261 -76
- package/lib/services/notifier.d.ts +9 -0
- package/lib/services/notifier.js +175 -151
- package/package.json +1 -1
package/lib/config.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface Config {
|
|
|
11
11
|
debug: boolean;
|
|
12
12
|
logUnhandledEvents: boolean;
|
|
13
13
|
defaultEvents: string[];
|
|
14
|
+
enableSessionFallback: boolean;
|
|
15
|
+
dedupRetentionHours: number;
|
|
16
|
+
sendRetryCount: number;
|
|
17
|
+
sendRetryBaseDelayMs: number;
|
|
18
|
+
formatterLocale: 'zh-CN' | 'en-US';
|
|
14
19
|
rules?: Rule[];
|
|
15
20
|
}
|
|
16
21
|
export declare const Config: Schema<Config>;
|
package/lib/config.js
CHANGED
|
@@ -3,17 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Config = void 0;
|
|
4
4
|
const koishi_1 = require("koishi");
|
|
5
5
|
exports.Config = koishi_1.Schema.object({
|
|
6
|
-
defaultOwner: koishi_1.Schema.string().description('
|
|
7
|
-
defaultRepo: koishi_1.Schema.string().description('
|
|
8
|
-
debug: koishi_1.Schema.boolean().default(false).description('
|
|
9
|
-
logUnhandledEvents: koishi_1.Schema.boolean().default(false).description('
|
|
6
|
+
defaultOwner: koishi_1.Schema.string().description('默认仓库 Owner。'),
|
|
7
|
+
defaultRepo: koishi_1.Schema.string().description('默认仓库名称。'),
|
|
8
|
+
debug: koishi_1.Schema.boolean().default(false).description('启用调试日志。'),
|
|
9
|
+
logUnhandledEvents: koishi_1.Schema.boolean().default(false).description('记录未处理的 webhook 事件。'),
|
|
10
|
+
enableSessionFallback: koishi_1.Schema.boolean().default(true).description('启用 message-created 回退事件解析。'),
|
|
11
|
+
dedupRetentionHours: koishi_1.Schema.number().min(1).max(720).default(72).description('事件幂等记录保留小时数。'),
|
|
12
|
+
sendRetryCount: koishi_1.Schema.number().min(0).max(10).default(2).description('消息发送失败重试次数。'),
|
|
13
|
+
sendRetryBaseDelayMs: koishi_1.Schema.number().min(100).max(30000).default(800).description('重试基础延迟(毫秒,指数退避)。'),
|
|
14
|
+
formatterLocale: koishi_1.Schema.union([
|
|
15
|
+
koishi_1.Schema.const('zh-CN').description('中文'),
|
|
16
|
+
koishi_1.Schema.const('en-US').description('English'),
|
|
17
|
+
]).default('zh-CN').description('通知文本语言。'),
|
|
10
18
|
defaultEvents: koishi_1.Schema.array(koishi_1.Schema.string())
|
|
11
19
|
.default(['push', 'issues', 'issue_comment', 'pull_request', 'pull_request_review', 'release', 'star', 'fork'])
|
|
12
|
-
.description('
|
|
20
|
+
.description('未显式指定时使用的默认订阅事件列表。'),
|
|
13
21
|
rules: koishi_1.Schema.array(koishi_1.Schema.object({
|
|
14
22
|
repo: koishi_1.Schema.string().required(),
|
|
15
23
|
channelId: koishi_1.Schema.string().required(),
|
|
16
24
|
platform: koishi_1.Schema.string(),
|
|
17
25
|
events: koishi_1.Schema.array(koishi_1.Schema.string()).default(['push', 'issues', 'pull_request', 'issue_comment', 'pull_request_review']),
|
|
18
|
-
})).hidden().description('
|
|
26
|
+
})).hidden().description('已废弃,仅保留兼容。建议改用数据库订阅管理。'),
|
|
19
27
|
});
|
package/lib/database.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ declare module 'koishi' {
|
|
|
3
3
|
interface Tables {
|
|
4
4
|
github_subscription: GithubSubscription;
|
|
5
5
|
github_trusted_repo: GithubTrustedRepo;
|
|
6
|
+
github_event_dedup: GithubEventDedup;
|
|
6
7
|
}
|
|
7
8
|
}
|
|
8
9
|
export interface GithubSubscription {
|
|
@@ -19,4 +20,11 @@ export interface GithubTrustedRepo {
|
|
|
19
20
|
addedBy: string;
|
|
20
21
|
addedAt: Date;
|
|
21
22
|
}
|
|
23
|
+
export interface GithubEventDedup {
|
|
24
|
+
id: number;
|
|
25
|
+
dedupKey: string;
|
|
26
|
+
event: string;
|
|
27
|
+
repo: string;
|
|
28
|
+
createdAt: Date;
|
|
29
|
+
}
|
|
22
30
|
export declare function apply(ctx: Context): void;
|
package/lib/database.js
CHANGED
|
@@ -21,4 +21,14 @@ function apply(ctx) {
|
|
|
21
21
|
autoInc: true,
|
|
22
22
|
unique: ['repo'],
|
|
23
23
|
});
|
|
24
|
+
ctx.model.extend('github_event_dedup', {
|
|
25
|
+
id: 'unsigned',
|
|
26
|
+
dedupKey: 'string',
|
|
27
|
+
event: 'string',
|
|
28
|
+
repo: 'string',
|
|
29
|
+
createdAt: 'timestamp',
|
|
30
|
+
}, {
|
|
31
|
+
autoInc: true,
|
|
32
|
+
unique: ['dedupKey'],
|
|
33
|
+
});
|
|
24
34
|
}
|
package/lib/index.js
CHANGED
|
@@ -44,6 +44,7 @@ exports.apply = apply;
|
|
|
44
44
|
const commands_1 = require("./commands");
|
|
45
45
|
const database = __importStar(require("./database"));
|
|
46
46
|
const zh_CN_1 = __importDefault(require("./locales/zh-CN"));
|
|
47
|
+
const en_US_1 = __importDefault(require("./locales/en-US"));
|
|
47
48
|
const notifier_1 = require("./services/notifier");
|
|
48
49
|
const formatter_1 = require("./services/formatter");
|
|
49
50
|
exports.name = 'githubsth';
|
|
@@ -55,20 +56,16 @@ __exportStar(require("./config"), exports);
|
|
|
55
56
|
function apply(ctx, config) {
|
|
56
57
|
const logger = ctx.logger('githubsth');
|
|
57
58
|
logger.info('Plugin loading...');
|
|
58
|
-
// 本地化
|
|
59
59
|
ctx.i18n.define('zh-CN', zh_CN_1.default);
|
|
60
|
-
|
|
60
|
+
ctx.i18n.define('en-US', en_US_1.default);
|
|
61
61
|
ctx.plugin(database);
|
|
62
|
-
|
|
63
|
-
ctx.plugin(formatter_1.Formatter);
|
|
62
|
+
ctx.plugin(formatter_1.Formatter, config);
|
|
64
63
|
ctx.plugin(notifier_1.Notifier, config);
|
|
65
|
-
// 注册命令
|
|
66
|
-
// admin and subscribe are already loaded in commands/index.ts, remove duplicate loading here
|
|
67
64
|
try {
|
|
68
65
|
ctx.plugin(commands_1.apply, config);
|
|
69
66
|
logger.info('Plugin loaded successfully');
|
|
70
67
|
}
|
|
71
|
-
catch (
|
|
72
|
-
logger.error('Plugin failed to load:',
|
|
68
|
+
catch (error) {
|
|
69
|
+
logger.error('Plugin failed to load:', error);
|
|
73
70
|
}
|
|
74
71
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare const _default: {
|
|
2
|
+
commands: {
|
|
3
|
+
githubsth: {
|
|
4
|
+
description: string;
|
|
5
|
+
};
|
|
6
|
+
'githubsth.repo': {
|
|
7
|
+
description: string;
|
|
8
|
+
messages: {
|
|
9
|
+
repo_info: string;
|
|
10
|
+
error: string;
|
|
11
|
+
specify_repo: string;
|
|
12
|
+
not_found: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export default _default;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = {
|
|
4
|
+
commands: {
|
|
5
|
+
githubsth: {
|
|
6
|
+
description: 'GitHub subscription notifier',
|
|
7
|
+
},
|
|
8
|
+
'githubsth.repo': {
|
|
9
|
+
description: 'Get repository info',
|
|
10
|
+
messages: {
|
|
11
|
+
repo_info: 'Repository: {0}/{1}\nDescription: {2}\nStars: {3}',
|
|
12
|
+
error: 'Failed to fetch repository info: {0}',
|
|
13
|
+
specify_repo: 'Please specify repository as owner/repo.',
|
|
14
|
+
not_found: 'Repository not found or access denied.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
package/lib/locales/zh-CN.js
CHANGED
|
@@ -3,16 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.default = {
|
|
4
4
|
commands: {
|
|
5
5
|
githubsth: {
|
|
6
|
-
description: 'GitHub
|
|
6
|
+
description: 'GitHub 订阅通知插件',
|
|
7
7
|
},
|
|
8
8
|
'githubsth.repo': {
|
|
9
9
|
description: '获取仓库信息',
|
|
10
10
|
messages: {
|
|
11
11
|
repo_info: '仓库: {0}/{1}\n描述: {2}\nStars: {3}',
|
|
12
|
-
error: '
|
|
13
|
-
specify_repo: '
|
|
14
|
-
not_found: '未找到仓库或无权限访问。'
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
}
|
|
12
|
+
error: '获取仓库信息失败: {0}',
|
|
13
|
+
specify_repo: '请指定仓库名称(owner/repo)。',
|
|
14
|
+
not_found: '未找到仓库或无权限访问。',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
18
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Context, Service, h } from 'koishi';
|
|
2
|
+
import type { Config } from '../config';
|
|
2
3
|
declare module 'koishi' {
|
|
3
4
|
interface Context {
|
|
4
5
|
githubsthFormatter: Formatter;
|
|
5
6
|
}
|
|
6
7
|
}
|
|
7
8
|
export declare class Formatter extends Service {
|
|
8
|
-
|
|
9
|
+
private readonly locale;
|
|
10
|
+
constructor(ctx: Context, config?: Partial<Config>);
|
|
9
11
|
formatPush(payload: any): h | null;
|
|
10
12
|
formatIssue(payload: any): h;
|
|
11
13
|
formatPullRequest(payload: any): h;
|
|
@@ -16,4 +18,8 @@ export declare class Formatter extends Service {
|
|
|
16
18
|
formatWorkflowRun(payload: any): h | null;
|
|
17
19
|
formatIssueComment(payload: any): h | null;
|
|
18
20
|
formatPullRequestReview(payload: any): h | null;
|
|
21
|
+
private summarizeCommentBody;
|
|
22
|
+
private mapAction;
|
|
23
|
+
private t;
|
|
24
|
+
private render;
|
|
19
25
|
}
|
|
@@ -2,121 +2,306 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Formatter = void 0;
|
|
4
4
|
const koishi_1 = require("koishi");
|
|
5
|
+
const I18N = {
|
|
6
|
+
'zh-CN': {
|
|
7
|
+
unknownRepo: '未知仓库',
|
|
8
|
+
unknownUser: '未知用户',
|
|
9
|
+
unknownTitle: '无标题',
|
|
10
|
+
unknownState: '未知状态',
|
|
11
|
+
unknownBranch: '未知分支',
|
|
12
|
+
unknownResult: '未知结果',
|
|
13
|
+
noDescription: '无描述',
|
|
14
|
+
emptyComment: '(空评论)',
|
|
15
|
+
hiddenMachinePayload: '检测到自动化系统签名/编码载荷,已省略原始内容。',
|
|
16
|
+
pushTitle: '🚀 [代码推送]',
|
|
17
|
+
pushBy: '推送者',
|
|
18
|
+
pushBranch: '分支',
|
|
19
|
+
pushCommits: '提交',
|
|
20
|
+
pushMore: '其余 {0} 条提交已省略',
|
|
21
|
+
pushCompare: '比较链接',
|
|
22
|
+
issueTitle: '🐞 [Issue 变更]',
|
|
23
|
+
issueCommentTitle: '💬 [Issue 评论]',
|
|
24
|
+
prCommentTitle: '💬 [PR 评论]',
|
|
25
|
+
issueLabel: '议题',
|
|
26
|
+
prTitle: '🔀 [PR 变更]',
|
|
27
|
+
prLabel: '拉取请求',
|
|
28
|
+
byUser: '发起人',
|
|
29
|
+
reviewer: '审查者',
|
|
30
|
+
commenter: '评论人',
|
|
31
|
+
content: '内容摘要',
|
|
32
|
+
status: '状态',
|
|
33
|
+
action: '动作',
|
|
34
|
+
link: '链接',
|
|
35
|
+
starTitle: '⭐ [收到 Star]',
|
|
36
|
+
starText: '{0} 为仓库点了 Star',
|
|
37
|
+
starCount: '当前 Star',
|
|
38
|
+
forkTitle: '🍴 [仓库 Fork]',
|
|
39
|
+
forkText: '{0} Fork 了仓库',
|
|
40
|
+
forkNewRepo: '新仓库',
|
|
41
|
+
releaseTitle: '📦 [版本发布]',
|
|
42
|
+
releaseTag: '版本号',
|
|
43
|
+
releaseName: '版本标题',
|
|
44
|
+
discussionTitle: '🧵 [Discussion 变更]',
|
|
45
|
+
workflowTitle: '⚙️ [工作流完成]',
|
|
46
|
+
workflowName: '工作流',
|
|
47
|
+
workflowResult: '结果',
|
|
48
|
+
workflowBranch: '分支',
|
|
49
|
+
reviewTitle: '✅ [PR Review]',
|
|
50
|
+
actionMap: {
|
|
51
|
+
opened: '已创建',
|
|
52
|
+
closed: '已关闭',
|
|
53
|
+
reopened: '已重新打开',
|
|
54
|
+
edited: '已编辑',
|
|
55
|
+
deleted: '已删除',
|
|
56
|
+
pinned: '已置顶',
|
|
57
|
+
unpinned: '已取消置顶',
|
|
58
|
+
submitted: '已提交',
|
|
59
|
+
created: '已创建',
|
|
60
|
+
synchronize: '已同步',
|
|
61
|
+
ready_for_review: '可审查',
|
|
62
|
+
published: '已发布',
|
|
63
|
+
completed: '已完成',
|
|
64
|
+
started: '已开始',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
'en-US': {
|
|
68
|
+
unknownRepo: 'Unknown repository',
|
|
69
|
+
unknownUser: 'Unknown user',
|
|
70
|
+
unknownTitle: 'Untitled',
|
|
71
|
+
unknownState: 'Unknown state',
|
|
72
|
+
unknownBranch: 'Unknown branch',
|
|
73
|
+
unknownResult: 'Unknown result',
|
|
74
|
+
noDescription: 'No description',
|
|
75
|
+
emptyComment: '(empty comment)',
|
|
76
|
+
hiddenMachinePayload: 'Detected automated signature/encoded payload. Raw content hidden.',
|
|
77
|
+
pushTitle: '🚀 [Push Event]',
|
|
78
|
+
pushBy: 'Pusher',
|
|
79
|
+
pushBranch: 'Branch',
|
|
80
|
+
pushCommits: 'Commits',
|
|
81
|
+
pushMore: '{0} more commits omitted',
|
|
82
|
+
pushCompare: 'Compare',
|
|
83
|
+
issueTitle: '🐞 [Issue Update]',
|
|
84
|
+
issueCommentTitle: '💬 [Issue Comment]',
|
|
85
|
+
prCommentTitle: '💬 [PR Comment]',
|
|
86
|
+
issueLabel: 'Issue',
|
|
87
|
+
prTitle: '🔀 [PR Update]',
|
|
88
|
+
prLabel: 'Pull Request',
|
|
89
|
+
byUser: 'By',
|
|
90
|
+
reviewer: 'Reviewer',
|
|
91
|
+
commenter: 'Commenter',
|
|
92
|
+
content: 'Summary',
|
|
93
|
+
status: 'Status',
|
|
94
|
+
action: 'Action',
|
|
95
|
+
link: 'Link',
|
|
96
|
+
starTitle: '⭐ [New Star]',
|
|
97
|
+
starText: '{0} starred the repository',
|
|
98
|
+
starCount: 'Stars',
|
|
99
|
+
forkTitle: '🍴 [Repository Forked]',
|
|
100
|
+
forkText: '{0} forked the repository',
|
|
101
|
+
forkNewRepo: 'New repository',
|
|
102
|
+
releaseTitle: '📦 [Release Published]',
|
|
103
|
+
releaseTag: 'Tag',
|
|
104
|
+
releaseName: 'Release name',
|
|
105
|
+
discussionTitle: '🧵 [Discussion Update]',
|
|
106
|
+
workflowTitle: '⚙️ [Workflow Completed]',
|
|
107
|
+
workflowName: 'Workflow',
|
|
108
|
+
workflowResult: 'Result',
|
|
109
|
+
workflowBranch: 'Branch',
|
|
110
|
+
reviewTitle: '✅ [PR Review]',
|
|
111
|
+
actionMap: {
|
|
112
|
+
opened: 'opened',
|
|
113
|
+
closed: 'closed',
|
|
114
|
+
reopened: 'reopened',
|
|
115
|
+
edited: 'edited',
|
|
116
|
+
deleted: 'deleted',
|
|
117
|
+
pinned: 'pinned',
|
|
118
|
+
unpinned: 'unpinned',
|
|
119
|
+
submitted: 'submitted',
|
|
120
|
+
created: 'created',
|
|
121
|
+
synchronize: 'synchronized',
|
|
122
|
+
ready_for_review: 'ready for review',
|
|
123
|
+
published: 'published',
|
|
124
|
+
completed: 'completed',
|
|
125
|
+
started: 'started',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
5
129
|
class Formatter extends koishi_1.Service {
|
|
6
|
-
|
|
130
|
+
// @ts-ignore
|
|
131
|
+
constructor(ctx, config) {
|
|
7
132
|
super(ctx, 'githubsthFormatter');
|
|
8
|
-
|
|
133
|
+
this.locale = config?.formatterLocale === 'en-US' ? 'en-US' : 'zh-CN';
|
|
134
|
+
ctx.logger('githubsth').info(`Formatter service initialized (${this.locale})`);
|
|
9
135
|
}
|
|
10
136
|
formatPush(payload) {
|
|
11
|
-
const
|
|
12
|
-
|
|
137
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
138
|
+
const pusher = payload.pusher?.name || payload.sender?.login || this.t('unknownUser');
|
|
139
|
+
const branch = payload.ref ? String(payload.ref).replace('refs/heads/', '') : this.t('unknownBranch');
|
|
140
|
+
const commits = Array.isArray(payload.commits) ? payload.commits : [];
|
|
141
|
+
if (commits.length === 0)
|
|
13
142
|
return null;
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const message =
|
|
17
|
-
const author =
|
|
18
|
-
return
|
|
143
|
+
const preview = commits.slice(0, 5).map((commit) => {
|
|
144
|
+
const hash = commit.id?.slice(0, 7) || '0000000';
|
|
145
|
+
const message = (commit.message || 'No message').split('\n')[0];
|
|
146
|
+
const author = commit.author?.name || this.t('unknownUser');
|
|
147
|
+
return `- [${hash}] ${message} — ${author}`;
|
|
19
148
|
}).join('\n');
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
149
|
+
const restCount = commits.length - 5;
|
|
150
|
+
const restLine = restCount > 0 ? `\n${this.t('pushMore', [restCount])}` : '';
|
|
151
|
+
const compareLine = payload.compare ? `\n${this.t('pushCompare')}: ${payload.compare}` : '';
|
|
152
|
+
return this.render([
|
|
153
|
+
`${this.t('pushTitle')} ${repository}`,
|
|
154
|
+
`${this.t('pushBy')}: ${pusher}`,
|
|
155
|
+
`${this.t('pushBranch')}: ${branch}`,
|
|
156
|
+
`${this.t('pushCommits')}:\n${preview}${restLine}${compareLine}`,
|
|
26
157
|
]);
|
|
27
158
|
}
|
|
28
159
|
formatIssue(payload) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
160
|
+
const issue = payload.issue || {};
|
|
161
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
162
|
+
const actor = payload.sender?.login || issue.user?.login || this.t('unknownUser');
|
|
163
|
+
const action = this.mapAction(payload.action);
|
|
164
|
+
return this.render([
|
|
165
|
+
`${this.t('issueTitle')} ${repository}`,
|
|
166
|
+
`${this.t('action')}: ${action}`,
|
|
167
|
+
`${this.t('issueLabel')}: #${issue.number ?? '?'} ${issue.title || this.t('unknownTitle')}`,
|
|
168
|
+
`${this.t('byUser')}: ${actor}`,
|
|
169
|
+
`${this.t('link')}: ${issue.html_url || ''}`,
|
|
35
170
|
]);
|
|
36
171
|
}
|
|
37
172
|
formatPullRequest(payload) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
173
|
+
const pr = payload.pull_request || {};
|
|
174
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
175
|
+
const actor = payload.sender?.login || pr.user?.login || this.t('unknownUser');
|
|
176
|
+
const action = this.mapAction(payload.action);
|
|
177
|
+
return this.render([
|
|
178
|
+
`${this.t('prTitle')} ${repository}`,
|
|
179
|
+
`${this.t('action')}: ${action}`,
|
|
180
|
+
`${this.t('prLabel')}: #${pr.number ?? '?'} ${pr.title || this.t('unknownTitle')}`,
|
|
181
|
+
`${this.t('byUser')}: ${actor}`,
|
|
182
|
+
`${this.t('status')}: ${pr.state || this.t('unknownState')}`,
|
|
183
|
+
`${this.t('link')}: ${pr.html_url || ''}`,
|
|
45
184
|
]);
|
|
46
185
|
}
|
|
47
186
|
formatStar(payload) {
|
|
48
|
-
|
|
49
|
-
if (action !== 'created')
|
|
187
|
+
if (payload.action !== 'created')
|
|
50
188
|
return null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
189
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
190
|
+
const actor = payload.sender?.login || this.t('unknownUser');
|
|
191
|
+
const starCount = payload.repository?.stargazers_count ?? '?';
|
|
192
|
+
return this.render([
|
|
193
|
+
`${this.t('starTitle')} ${repository}`,
|
|
194
|
+
this.t('starText', [actor]),
|
|
195
|
+
`${this.t('starCount')}: ${starCount}`,
|
|
54
196
|
]);
|
|
55
197
|
}
|
|
56
198
|
formatFork(payload) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
199
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
200
|
+
const actor = payload.sender?.login || this.t('unknownUser');
|
|
201
|
+
const forkee = payload.forkee?.full_name || this.t('unknownRepo');
|
|
202
|
+
return this.render([
|
|
203
|
+
`${this.t('forkTitle')} ${repository}`,
|
|
204
|
+
this.t('forkText', [actor]),
|
|
205
|
+
`${this.t('forkNewRepo')}: ${forkee}`,
|
|
61
206
|
]);
|
|
62
207
|
}
|
|
63
208
|
formatRelease(payload) {
|
|
64
|
-
|
|
65
|
-
if (action !== 'published')
|
|
209
|
+
if (payload.action !== 'published')
|
|
66
210
|
return null;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
211
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
212
|
+
const release = payload.release || {};
|
|
213
|
+
return this.render([
|
|
214
|
+
`${this.t('releaseTitle')} ${repository}`,
|
|
215
|
+
`${this.t('releaseTag')}: ${release.tag_name || 'unknown'}`,
|
|
216
|
+
`${this.t('releaseName')}: ${release.name || this.t('unknownTitle')}`,
|
|
217
|
+
`${this.t('link')}: ${release.html_url || ''}`,
|
|
71
218
|
]);
|
|
72
219
|
}
|
|
73
220
|
formatDiscussion(payload) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
221
|
+
const discussion = payload.discussion || {};
|
|
222
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
223
|
+
const actor = payload.sender?.login || discussion.user?.login || this.t('unknownUser');
|
|
224
|
+
const action = this.mapAction(payload.action);
|
|
225
|
+
return this.render([
|
|
226
|
+
`${this.t('discussionTitle')} ${repository}`,
|
|
227
|
+
`${this.t('action')}: ${action}`,
|
|
228
|
+
`Discussion: #${discussion.number ?? '?'} ${discussion.title || this.t('unknownTitle')}`,
|
|
229
|
+
`${this.t('byUser')}: ${actor}`,
|
|
230
|
+
`${this.t('link')}: ${discussion.html_url || ''}`,
|
|
80
231
|
]);
|
|
81
232
|
}
|
|
82
233
|
formatWorkflowRun(payload) {
|
|
83
|
-
|
|
84
|
-
if (action !== 'completed')
|
|
234
|
+
if (payload.action !== 'completed')
|
|
85
235
|
return null;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
236
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
237
|
+
const workflow = payload.workflow_run || {};
|
|
238
|
+
const result = workflow.conclusion || this.t('unknownResult');
|
|
239
|
+
const icon = result === 'success' ? '✅' : (result === 'failure' ? '❌' : '⚠️');
|
|
240
|
+
return this.render([
|
|
241
|
+
`${this.t('workflowTitle')} ${repository}`,
|
|
242
|
+
`${this.t('workflowName')}: ${workflow.name || 'Unknown'}`,
|
|
243
|
+
`${this.t('workflowResult')}: ${icon} ${result}`,
|
|
244
|
+
`${this.t('workflowBranch')}: ${workflow.head_branch || this.t('unknownBranch')}`,
|
|
245
|
+
`${this.t('link')}: ${workflow.html_url || ''}`,
|
|
93
246
|
]);
|
|
94
247
|
}
|
|
95
248
|
formatIssueComment(payload) {
|
|
96
|
-
|
|
97
|
-
if (action !== 'created')
|
|
249
|
+
if (payload.action !== 'created')
|
|
98
250
|
return null;
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
(
|
|
251
|
+
const issue = payload.issue || {};
|
|
252
|
+
const comment = payload.comment || {};
|
|
253
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
254
|
+
const actor = payload.sender?.login || comment.user?.login || this.t('unknownUser');
|
|
255
|
+
const isPrComment = Boolean(issue.pull_request);
|
|
256
|
+
return this.render([
|
|
257
|
+
`${isPrComment ? this.t('prCommentTitle') : this.t('issueCommentTitle')} ${repository}`,
|
|
258
|
+
`${isPrComment ? this.t('prLabel') : this.t('issueLabel')}: #${issue.number ?? '?'} ${issue.title || this.t('unknownTitle')}`,
|
|
259
|
+
`${this.t('commenter')}: ${actor}`,
|
|
260
|
+
`${this.t('content')}: ${this.summarizeCommentBody(comment.body || '')}`,
|
|
261
|
+
`${this.t('link')}: ${comment.html_url || issue.html_url || ''}`,
|
|
107
262
|
]);
|
|
108
263
|
}
|
|
109
264
|
formatPullRequestReview(payload) {
|
|
110
|
-
|
|
111
|
-
if (action !== 'submitted')
|
|
265
|
+
if (payload.action !== 'submitted')
|
|
112
266
|
return null;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
267
|
+
const pr = payload.pull_request || {};
|
|
268
|
+
const review = payload.review || {};
|
|
269
|
+
const repository = payload.repository?.full_name || this.t('unknownRepo');
|
|
270
|
+
const actor = payload.sender?.login || review.user?.login || this.t('unknownUser');
|
|
271
|
+
return this.render([
|
|
272
|
+
`${this.t('reviewTitle')} ${repository}`,
|
|
273
|
+
`${this.t('prLabel')}: #${pr.number ?? '?'} ${pr.title || this.t('unknownTitle')}`,
|
|
274
|
+
`${this.t('reviewer')}: ${actor}`,
|
|
275
|
+
`${this.t('status')}: ${review.state || this.t('unknownState')}`,
|
|
276
|
+
`${this.t('link')}: ${review.html_url || pr.html_url || ''}`,
|
|
119
277
|
]);
|
|
120
278
|
}
|
|
279
|
+
summarizeCommentBody(raw) {
|
|
280
|
+
const content = String(raw || '').replace(/\r/g, '').trim();
|
|
281
|
+
if (!content)
|
|
282
|
+
return this.t('emptyComment');
|
|
283
|
+
// Typical Vercel/GitHub app machine signature payload: [vc]: #<token>:<payload>
|
|
284
|
+
if (/^\[vc\]:\s*#[A-Za-z0-9+/=_:-]+/i.test(content)) {
|
|
285
|
+
return this.t('hiddenMachinePayload');
|
|
286
|
+
}
|
|
287
|
+
// Very long high-entropy lines are usually bot payloads/signatures.
|
|
288
|
+
const oneLine = content.replace(/\n+/g, ' ');
|
|
289
|
+
const looksEncoded = oneLine.length > 120 && !/\s/.test(oneLine.slice(0, 80));
|
|
290
|
+
if (looksEncoded)
|
|
291
|
+
return this.t('hiddenMachinePayload');
|
|
292
|
+
return oneLine.length > 180 ? `${oneLine.slice(0, 180)}...` : oneLine;
|
|
293
|
+
}
|
|
294
|
+
mapAction(action) {
|
|
295
|
+
const normalized = action || 'updated';
|
|
296
|
+
const mapped = I18N[this.locale].actionMap[normalized];
|
|
297
|
+
return mapped || normalized;
|
|
298
|
+
}
|
|
299
|
+
t(key, params = []) {
|
|
300
|
+
const template = I18N[this.locale][key];
|
|
301
|
+
return template.replace(/\{(\d+)\}/g, (_, idx) => String(params[Number(idx)] ?? ''));
|
|
302
|
+
}
|
|
303
|
+
render(lines) {
|
|
304
|
+
return (0, koishi_1.h)('message', [(0, koishi_1.h)('text', { content: `${lines.join('\n')}\n` })]);
|
|
305
|
+
}
|
|
121
306
|
}
|
|
122
307
|
exports.Formatter = Formatter;
|
|
@@ -8,9 +8,18 @@ declare module 'koishi' {
|
|
|
8
8
|
export declare class Notifier extends Service {
|
|
9
9
|
config: Config;
|
|
10
10
|
static inject: string[];
|
|
11
|
+
private readonly recentEventKeys;
|
|
12
|
+
private readonly memoryDedupWindowMs;
|
|
13
|
+
private dedupWriteCounter;
|
|
11
14
|
constructor(ctx: Context, config: Config);
|
|
12
15
|
private registerListeners;
|
|
13
16
|
private handleEvent;
|
|
17
|
+
private extractRepoName;
|
|
14
18
|
private patchPayloadForEvent;
|
|
19
|
+
private buildEventDedupKey;
|
|
20
|
+
private shouldProcessEvent;
|
|
21
|
+
private cleanupDedupTable;
|
|
15
22
|
private sendMessage;
|
|
23
|
+
private sendWithRetry;
|
|
24
|
+
private sleep;
|
|
16
25
|
}
|
package/lib/services/notifier.js
CHANGED
|
@@ -7,42 +7,43 @@ class Notifier extends koishi_1.Service {
|
|
|
7
7
|
constructor(ctx, config) {
|
|
8
8
|
super(ctx, 'githubsthNotifier', true);
|
|
9
9
|
this.config = config;
|
|
10
|
+
this.recentEventKeys = new Map();
|
|
11
|
+
this.memoryDedupWindowMs = 5000;
|
|
12
|
+
this.dedupWriteCounter = 0;
|
|
10
13
|
this.ctx.logger('githubsth').info('Notifier service initialized');
|
|
11
14
|
this.registerListeners();
|
|
12
15
|
}
|
|
13
16
|
registerListeners() {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
17
|
+
const bind = (name, event) => {
|
|
18
|
+
this.ctx.on(name, (payload) => this.handleEvent(event, payload));
|
|
19
|
+
};
|
|
20
|
+
bind('github/issue', 'issues');
|
|
21
|
+
bind('github/issue-comment', 'issue_comment');
|
|
22
|
+
bind('github/pull-request', 'pull_request');
|
|
23
|
+
bind('github/pull-request-review', 'pull_request_review');
|
|
24
|
+
bind('github/workflow-run', 'workflow_run');
|
|
25
|
+
bind('github/push', 'push');
|
|
26
|
+
bind('github/star', 'star');
|
|
27
|
+
bind('github/fork', 'fork');
|
|
28
|
+
bind('github/release', 'release');
|
|
29
|
+
bind('github/discussion', 'discussion');
|
|
30
|
+
// legacy aliases
|
|
31
|
+
bind('github/issues', 'issues');
|
|
32
|
+
bind('github/pull_request', 'pull_request');
|
|
33
|
+
bind('github/workflow_run', 'workflow_run');
|
|
34
|
+
bind('github/issue_comment', 'issue_comment');
|
|
35
|
+
if (this.config.enableSessionFallback !== false) {
|
|
36
|
+
this.ctx.on('message-created', (session) => {
|
|
37
|
+
if (session.platform !== 'github')
|
|
38
|
+
return;
|
|
39
|
+
const payload = session.payload || session.extra || session.data;
|
|
40
|
+
if (!payload)
|
|
41
|
+
return;
|
|
38
42
|
if (this.config.debug) {
|
|
39
43
|
this.ctx.logger('githubsth').info('Found payload in session, attempting to handle');
|
|
40
44
|
}
|
|
41
|
-
// Check if payload is wrapped (adapter-github structure)
|
|
42
45
|
const realPayload = payload.payload || payload;
|
|
43
|
-
// Infer event type
|
|
44
46
|
let eventType = 'unknown';
|
|
45
|
-
// Check inner payload first if it exists
|
|
46
47
|
if (realPayload.issue && realPayload.comment)
|
|
47
48
|
eventType = 'issue_comment';
|
|
48
49
|
else if (realPayload.issue)
|
|
@@ -53,7 +54,7 @@ class Notifier extends koishi_1.Service {
|
|
|
53
54
|
eventType = 'pull_request';
|
|
54
55
|
else if (realPayload.commits)
|
|
55
56
|
eventType = 'push';
|
|
56
|
-
else if (realPayload.starred_at !== undefined ||
|
|
57
|
+
else if (realPayload.starred_at !== undefined || realPayload.action === 'started')
|
|
57
58
|
eventType = 'star';
|
|
58
59
|
else if (realPayload.forkee)
|
|
59
60
|
eventType = 'fork';
|
|
@@ -66,13 +67,13 @@ class Notifier extends koishi_1.Service {
|
|
|
66
67
|
else if (realPayload.repository && (realPayload.action === 'created' || realPayload.action === 'started'))
|
|
67
68
|
eventType = 'star';
|
|
68
69
|
if (eventType !== 'unknown') {
|
|
69
|
-
this.handleEvent(eventType, payload);
|
|
70
|
+
void this.handleEvent(eventType, payload);
|
|
70
71
|
}
|
|
71
72
|
else if (this.config.logUnhandledEvents) {
|
|
72
73
|
this.ctx.logger('githubsth').info(`Unhandled payload structure. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
73
74
|
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
76
77
|
}
|
|
77
78
|
async handleEvent(event, payload) {
|
|
78
79
|
if (this.config.debug) {
|
|
@@ -86,42 +87,7 @@ class Notifier extends koishi_1.Service {
|
|
|
86
87
|
if (payload.repository && !realPayload.repository) {
|
|
87
88
|
realPayload.repository = payload.repository;
|
|
88
89
|
}
|
|
89
|
-
let repoName = realPayload
|
|
90
|
-
if (!repoName && realPayload.issue?.repository_url) {
|
|
91
|
-
const parts = realPayload.issue.repository_url.split('/');
|
|
92
|
-
if (parts.length >= 2) {
|
|
93
|
-
repoName = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (!repoName && realPayload.pull_request?.base?.repo?.full_name) {
|
|
97
|
-
repoName = realPayload.pull_request.base.repo.full_name;
|
|
98
|
-
}
|
|
99
|
-
// adapter-github eventData fields
|
|
100
|
-
if (!repoName && typeof payload.repoKey === 'string' && payload.repoKey.includes('/')) {
|
|
101
|
-
repoName = payload.repoKey;
|
|
102
|
-
}
|
|
103
|
-
if (!repoName && typeof payload.owner === 'string' && typeof payload.repo === 'string') {
|
|
104
|
-
repoName = `${payload.owner}/${payload.repo}`;
|
|
105
|
-
}
|
|
106
|
-
if (!repoName && typeof payload.repo === 'string' && payload.repo.includes('/')) {
|
|
107
|
-
repoName = payload.repo;
|
|
108
|
-
}
|
|
109
|
-
if (!repoName && event === 'star') {
|
|
110
|
-
if (payload.repository?.full_name) {
|
|
111
|
-
repoName = payload.repository.full_name;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (!repoName && payload.repository?.full_name) {
|
|
115
|
-
repoName = payload.repository.full_name;
|
|
116
|
-
}
|
|
117
|
-
if (!repoName) {
|
|
118
|
-
if (this.config.debug) {
|
|
119
|
-
this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
120
|
-
}
|
|
121
|
-
else if (this.config.logUnhandledEvents) {
|
|
122
|
-
this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
90
|
+
let repoName = this.extractRepoName(payload, realPayload, event);
|
|
125
91
|
if (!realPayload.repository) {
|
|
126
92
|
realPayload.repository = { full_name: repoName || 'Unknown/Repo' };
|
|
127
93
|
}
|
|
@@ -145,59 +111,40 @@ class Notifier extends koishi_1.Service {
|
|
|
145
111
|
realPayload.sender = { login: 'GitHub' };
|
|
146
112
|
}
|
|
147
113
|
}
|
|
114
|
+
if (!(await this.shouldProcessEvent(event, payload, realPayload, repoName))) {
|
|
115
|
+
if (this.config.debug) {
|
|
116
|
+
this.ctx.logger('githubsth').info(`Skip duplicated event: ${event} (${repoName || 'unknown'})`);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
148
120
|
try {
|
|
149
121
|
this.patchPayloadForEvent(event, realPayload, repoName || 'Unknown/Repo');
|
|
122
|
+
repoName = repoName || realPayload.repository?.full_name;
|
|
150
123
|
}
|
|
151
|
-
catch (
|
|
152
|
-
this.ctx.logger('githubsth').warn(`Failed to patch payload for ${event}:`,
|
|
153
|
-
}
|
|
154
|
-
if (this.config.debug) {
|
|
155
|
-
this.ctx.logger('githubsth').info(`Processing event ${event} for ${repoName}`);
|
|
156
|
-
this.ctx.logger('notifier').info(`Received event ${event} for ${repoName}`);
|
|
157
|
-
this.ctx.logger('notifier').debug(JSON.stringify(realPayload, null, 2));
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.ctx.logger('githubsth').warn(`Failed to patch payload for ${event}:`, error);
|
|
158
126
|
}
|
|
159
127
|
if (!repoName) {
|
|
160
128
|
this.ctx.logger('githubsth').warn('Cannot query rules: repoName is missing');
|
|
161
129
|
return;
|
|
162
130
|
}
|
|
131
|
+
if (this.config.debug) {
|
|
132
|
+
this.ctx.logger('githubsth').info(`Processing event ${event} for ${repoName}`);
|
|
133
|
+
this.ctx.logger('notifier').debug(JSON.stringify(realPayload, null, 2));
|
|
134
|
+
}
|
|
163
135
|
const repoNames = [repoName];
|
|
164
|
-
if (repoName !== repoName.toLowerCase())
|
|
136
|
+
if (repoName !== repoName.toLowerCase())
|
|
165
137
|
repoNames.push(repoName.toLowerCase());
|
|
166
|
-
}
|
|
167
|
-
const
|
|
168
|
-
repo: repoNames
|
|
169
|
-
});
|
|
170
|
-
const configRules = (this.config.rules || []).filter((r) => r.repo === repoName ||
|
|
171
|
-
r.repo === repoName.toLowerCase() ||
|
|
172
|
-
r.repo === '*');
|
|
138
|
+
const dbRules = await this.ctx.database.get('github_subscription', { repo: repoNames });
|
|
139
|
+
const configRules = (this.config.rules || []).filter((rule) => rule.repo === repoName || rule.repo === repoName.toLowerCase() || rule.repo === '*');
|
|
173
140
|
const allRules = [
|
|
174
|
-
...dbRules.map(
|
|
175
|
-
...configRules
|
|
141
|
+
...dbRules.map((rule) => ({ ...rule, platform: rule.platform })),
|
|
142
|
+
...configRules,
|
|
176
143
|
];
|
|
177
|
-
const matchedRules = allRules.filter(rule =>
|
|
178
|
-
|
|
179
|
-
return false;
|
|
180
|
-
return true;
|
|
181
|
-
});
|
|
182
|
-
if (matchedRules.length === 0) {
|
|
183
|
-
if (this.config.debug) {
|
|
184
|
-
this.ctx.logger('githubsth').info(`No matching rules for ${repoName} (event: ${event})`);
|
|
185
|
-
this.ctx.logger('notifier').debug(`No matching rules for ${repoName} (event: ${event})`);
|
|
186
|
-
}
|
|
187
|
-
else if (this.config.logUnhandledEvents) {
|
|
188
|
-
this.ctx.logger('githubsth').warn(`No matching rules for ${repoName} (event: ${event})`);
|
|
189
|
-
}
|
|
144
|
+
const matchedRules = allRules.filter((rule) => rule.events.includes('*') || rule.events.includes(event));
|
|
145
|
+
if (!matchedRules.length)
|
|
190
146
|
return;
|
|
191
|
-
}
|
|
192
|
-
if (this.config.debug) {
|
|
193
|
-
this.ctx.logger('githubsth').info(`Found ${matchedRules.length} matching rules for ${repoName}`);
|
|
194
|
-
this.ctx.logger('notifier').debug(`Found ${matchedRules.length} matching rules for ${repoName}`);
|
|
195
|
-
}
|
|
196
147
|
let message = null;
|
|
197
|
-
if (!this.ctx.githubsthFormatter) {
|
|
198
|
-
this.ctx.logger('notifier').warn('Formatter service not available');
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
148
|
try {
|
|
202
149
|
switch (event) {
|
|
203
150
|
case 'push':
|
|
@@ -232,26 +179,39 @@ class Notifier extends koishi_1.Service {
|
|
|
232
179
|
break;
|
|
233
180
|
}
|
|
234
181
|
}
|
|
235
|
-
catch (
|
|
236
|
-
this.ctx.logger('githubsth').error(`Error formatting event ${event}:`,
|
|
237
|
-
if (this.config.debug) {
|
|
238
|
-
this.ctx.logger('notifier').error(`Error formatting event ${event}:`, e);
|
|
239
|
-
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.ctx.logger('githubsth').error(`Error formatting event ${event}:`, error);
|
|
240
184
|
return;
|
|
241
185
|
}
|
|
242
|
-
if (!message)
|
|
243
|
-
if (this.config.debug) {
|
|
244
|
-
this.ctx.logger('notifier').debug(`Formatter returned null for event ${event}`);
|
|
245
|
-
}
|
|
186
|
+
if (!message)
|
|
246
187
|
return;
|
|
247
|
-
}
|
|
248
188
|
for (const rule of matchedRules) {
|
|
249
|
-
if (this.config.debug) {
|
|
250
|
-
this.ctx.logger('notifier').debug(`Sending message to channel ${rule.channelId} (platform: ${rule.platform || 'any'})`);
|
|
251
|
-
}
|
|
252
189
|
await this.sendMessage(rule, message);
|
|
253
190
|
}
|
|
254
191
|
}
|
|
192
|
+
extractRepoName(payload, realPayload, event) {
|
|
193
|
+
let repoName = realPayload.repository?.full_name;
|
|
194
|
+
if (!repoName && realPayload.issue?.repository_url) {
|
|
195
|
+
const parts = String(realPayload.issue.repository_url).split('/');
|
|
196
|
+
if (parts.length >= 2)
|
|
197
|
+
repoName = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
198
|
+
}
|
|
199
|
+
if (!repoName && realPayload.pull_request?.base?.repo?.full_name) {
|
|
200
|
+
repoName = realPayload.pull_request.base.repo.full_name;
|
|
201
|
+
}
|
|
202
|
+
if (!repoName && typeof payload.repoKey === 'string' && payload.repoKey.includes('/'))
|
|
203
|
+
repoName = payload.repoKey;
|
|
204
|
+
if (!repoName && typeof payload.owner === 'string' && typeof payload.repo === 'string')
|
|
205
|
+
repoName = `${payload.owner}/${payload.repo}`;
|
|
206
|
+
if (!repoName && typeof payload.repo === 'string' && payload.repo.includes('/'))
|
|
207
|
+
repoName = payload.repo;
|
|
208
|
+
if (!repoName && payload.repository?.full_name)
|
|
209
|
+
repoName = payload.repository.full_name;
|
|
210
|
+
if (!repoName && this.config.logUnhandledEvents) {
|
|
211
|
+
this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
return repoName;
|
|
214
|
+
}
|
|
255
215
|
patchPayloadForEvent(event, payload, repoName) {
|
|
256
216
|
const defaultUser = { login: 'GitHub', id: 0, avatar_url: '' };
|
|
257
217
|
if (!payload.sender)
|
|
@@ -269,15 +229,13 @@ class Notifier extends koishi_1.Service {
|
|
|
269
229
|
payload.ref = 'refs/heads/unknown';
|
|
270
230
|
if (!payload.compare)
|
|
271
231
|
payload.compare = '';
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
c.message = 'No message';
|
|
280
|
-
});
|
|
232
|
+
for (const commit of payload.commits) {
|
|
233
|
+
if (!commit.author)
|
|
234
|
+
commit.author = { name: 'Unknown' };
|
|
235
|
+
if (!commit.id)
|
|
236
|
+
commit.id = '0000000';
|
|
237
|
+
if (!commit.message)
|
|
238
|
+
commit.message = 'No message';
|
|
281
239
|
}
|
|
282
240
|
break;
|
|
283
241
|
case 'issues':
|
|
@@ -297,13 +255,10 @@ class Notifier extends koishi_1.Service {
|
|
|
297
255
|
payload.pull_request.user = payload.sender;
|
|
298
256
|
break;
|
|
299
257
|
case 'star':
|
|
300
|
-
if (!payload.action)
|
|
258
|
+
if (!payload.action || payload.action === 'started')
|
|
301
259
|
payload.action = 'created';
|
|
302
|
-
if (payload.
|
|
303
|
-
payload.action = 'created';
|
|
304
|
-
if (payload.repository && payload.repository.stargazers_count === undefined) {
|
|
260
|
+
if (payload.repository?.stargazers_count === undefined)
|
|
305
261
|
payload.repository.stargazers_count = '?';
|
|
306
|
-
}
|
|
307
262
|
break;
|
|
308
263
|
case 'fork':
|
|
309
264
|
if (!payload.forkee)
|
|
@@ -351,38 +306,107 @@ class Notifier extends koishi_1.Service {
|
|
|
351
306
|
break;
|
|
352
307
|
}
|
|
353
308
|
}
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
309
|
+
buildEventDedupKey(event, payload, realPayload, repoName) {
|
|
310
|
+
const keyRepo = repoName || payload.repoKey || `${payload.owner || ''}/${payload.repo || ''}` || realPayload.repository?.full_name || 'unknown/repo';
|
|
311
|
+
const action = realPayload.action || payload.action || '';
|
|
312
|
+
const commentId = realPayload.comment?.id || '';
|
|
313
|
+
const issueId = realPayload.issue?.id || realPayload.issue?.number || '';
|
|
314
|
+
const prId = realPayload.pull_request?.id || realPayload.pull_request?.number || '';
|
|
315
|
+
const releaseId = realPayload.release?.id || realPayload.release?.tag_name || '';
|
|
316
|
+
const workflowId = realPayload.workflow_run?.id || realPayload.workflow_run?.run_id || '';
|
|
317
|
+
const headCommit = realPayload.head_commit?.id || realPayload.after || realPayload.commits?.[0]?.id || '';
|
|
318
|
+
const explicitId = payload.id || realPayload.id || payload.timestamp || '';
|
|
319
|
+
return [event, keyRepo, action, commentId, issueId, prId, releaseId, workflowId, headCommit, explicitId].join('|');
|
|
320
|
+
}
|
|
321
|
+
async shouldProcessEvent(event, payload, realPayload, repoName) {
|
|
322
|
+
const now = Date.now();
|
|
323
|
+
for (const [key, timestamp] of this.recentEventKeys) {
|
|
324
|
+
if (now - timestamp > this.memoryDedupWindowMs)
|
|
325
|
+
this.recentEventKeys.delete(key);
|
|
326
|
+
}
|
|
327
|
+
const dedupKey = this.buildEventDedupKey(event, payload, realPayload, repoName);
|
|
328
|
+
const recent = this.recentEventKeys.get(dedupKey);
|
|
329
|
+
if (recent && now - recent <= this.memoryDedupWindowMs)
|
|
330
|
+
return false;
|
|
331
|
+
this.recentEventKeys.set(dedupKey, now);
|
|
332
|
+
// durable dedup
|
|
333
|
+
const exists = await this.ctx.database.get('github_event_dedup', { dedupKey });
|
|
334
|
+
if (exists.length > 0)
|
|
335
|
+
return false;
|
|
336
|
+
try {
|
|
337
|
+
await this.ctx.database.create('github_event_dedup', {
|
|
338
|
+
dedupKey,
|
|
339
|
+
event,
|
|
340
|
+
repo: repoName || realPayload.repository?.full_name || 'unknown/repo',
|
|
341
|
+
createdAt: new Date(),
|
|
342
|
+
});
|
|
343
|
+
this.dedupWriteCounter += 1;
|
|
344
|
+
if (this.dedupWriteCounter % 200 === 0) {
|
|
345
|
+
void this.cleanupDedupTable();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error?.code === 'SQLITE_CONSTRAINT')
|
|
350
|
+
return false;
|
|
351
|
+
this.ctx.logger('githubsth').warn('Failed to write dedup record, fallback to in-memory dedup only:', error);
|
|
352
|
+
}
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
async cleanupDedupTable() {
|
|
356
|
+
const cutoff = new Date(Date.now() - this.config.dedupRetentionHours * 60 * 60 * 1000);
|
|
357
|
+
try {
|
|
358
|
+
await this.ctx.database.remove('github_event_dedup', {
|
|
359
|
+
createdAt: { $lt: cutoff },
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
361
363
|
if (this.config.debug) {
|
|
362
|
-
this.ctx.logger('
|
|
364
|
+
this.ctx.logger('githubsth').warn('Failed to cleanup dedup table:', error);
|
|
363
365
|
}
|
|
364
|
-
return;
|
|
365
366
|
}
|
|
366
|
-
|
|
367
|
+
}
|
|
368
|
+
async sendMessage(rule, message) {
|
|
369
|
+
const bots = this.ctx.bots.filter((bot) => !rule.platform || bot.platform === rule.platform);
|
|
370
|
+
if (!bots.length)
|
|
371
|
+
return;
|
|
367
372
|
for (const bot of bots) {
|
|
368
373
|
try {
|
|
369
|
-
await
|
|
370
|
-
sent = true;
|
|
374
|
+
await this.sendWithRetry(bot, rule.channelId, message);
|
|
371
375
|
if (this.config.debug) {
|
|
372
376
|
this.ctx.logger('notifier').info(`Sent message to ${rule.channelId} via ${bot.platform}:${bot.selfId}`);
|
|
373
377
|
}
|
|
374
|
-
|
|
378
|
+
return;
|
|
375
379
|
}
|
|
376
|
-
catch (
|
|
380
|
+
catch (error) {
|
|
377
381
|
if (this.config.debug) {
|
|
378
|
-
this.ctx.logger('notifier').warn(`Bot ${bot.sid} failed to send message: ${
|
|
382
|
+
this.ctx.logger('notifier').warn(`Bot ${bot.sid} failed to send message with retries: ${error}`);
|
|
379
383
|
}
|
|
380
384
|
}
|
|
381
385
|
}
|
|
382
|
-
|
|
383
|
-
|
|
386
|
+
this.ctx.logger('notifier').warn(`Failed to send message to ${rule.channelId}`);
|
|
387
|
+
}
|
|
388
|
+
async sendWithRetry(bot, channelId, message) {
|
|
389
|
+
const retryCount = Math.max(0, this.config.sendRetryCount ?? 0);
|
|
390
|
+
const baseDelay = Math.max(100, this.config.sendRetryBaseDelayMs ?? 800);
|
|
391
|
+
let lastError;
|
|
392
|
+
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
393
|
+
try {
|
|
394
|
+
await bot.sendMessage(channelId, message);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
lastError = error;
|
|
399
|
+
if (attempt >= retryCount)
|
|
400
|
+
break;
|
|
401
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
402
|
+
await this.sleep(delay);
|
|
403
|
+
}
|
|
384
404
|
}
|
|
405
|
+
throw lastError;
|
|
406
|
+
}
|
|
407
|
+
sleep(ms) {
|
|
408
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
385
409
|
}
|
|
386
410
|
}
|
|
387
411
|
exports.Notifier = Notifier;
|
|
388
|
-
Notifier.inject = ['githubsthFormatter'];
|
|
412
|
+
Notifier.inject = ['githubsthFormatter', 'database'];
|
package/package.json
CHANGED