koishi-plugin-githubsth 1.0.2 → 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 +10 -0
- package/lib/services/notifier.js +178 -162
- 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;
|
|
@@ -7,9 +7,19 @@ declare module 'koishi' {
|
|
|
7
7
|
}
|
|
8
8
|
export declare class Notifier extends Service {
|
|
9
9
|
config: Config;
|
|
10
|
+
static inject: string[];
|
|
11
|
+
private readonly recentEventKeys;
|
|
12
|
+
private readonly memoryDedupWindowMs;
|
|
13
|
+
private dedupWriteCounter;
|
|
10
14
|
constructor(ctx: Context, config: Config);
|
|
11
15
|
private registerListeners;
|
|
12
16
|
private handleEvent;
|
|
17
|
+
private extractRepoName;
|
|
13
18
|
private patchPayloadForEvent;
|
|
19
|
+
private buildEventDedupKey;
|
|
20
|
+
private shouldProcessEvent;
|
|
21
|
+
private cleanupDedupTable;
|
|
14
22
|
private sendMessage;
|
|
23
|
+
private sendWithRetry;
|
|
24
|
+
private sleep;
|
|
15
25
|
}
|
package/lib/services/notifier.js
CHANGED
|
@@ -7,38 +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
|
-
|
|
33
|
-
|
|
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;
|
|
34
42
|
if (this.config.debug) {
|
|
35
43
|
this.ctx.logger('githubsth').info('Found payload in session, attempting to handle');
|
|
36
44
|
}
|
|
37
|
-
// Check if payload is wrapped (adapter-github structure)
|
|
38
45
|
const realPayload = payload.payload || payload;
|
|
39
|
-
// Infer event type
|
|
40
46
|
let eventType = 'unknown';
|
|
41
|
-
// Check inner payload first if it exists
|
|
42
47
|
if (realPayload.issue && realPayload.comment)
|
|
43
48
|
eventType = 'issue_comment';
|
|
44
49
|
else if (realPayload.issue)
|
|
@@ -49,8 +54,8 @@ class Notifier extends koishi_1.Service {
|
|
|
49
54
|
eventType = 'pull_request';
|
|
50
55
|
else if (realPayload.commits)
|
|
51
56
|
eventType = 'push';
|
|
52
|
-
else if (realPayload.starred_at !== undefined ||
|
|
53
|
-
eventType = 'star';
|
|
57
|
+
else if (realPayload.starred_at !== undefined || realPayload.action === 'started')
|
|
58
|
+
eventType = 'star';
|
|
54
59
|
else if (realPayload.forkee)
|
|
55
60
|
eventType = 'fork';
|
|
56
61
|
else if (realPayload.release)
|
|
@@ -59,71 +64,36 @@ class Notifier extends koishi_1.Service {
|
|
|
59
64
|
eventType = 'discussion';
|
|
60
65
|
else if (realPayload.workflow_run)
|
|
61
66
|
eventType = 'workflow_run';
|
|
62
|
-
// Handle raw star event if it has repository info directly
|
|
63
67
|
else if (realPayload.repository && (realPayload.action === 'created' || realPayload.action === 'started'))
|
|
64
68
|
eventType = 'star';
|
|
65
69
|
if (eventType !== 'unknown') {
|
|
66
|
-
this.handleEvent(eventType, payload);
|
|
70
|
+
void this.handleEvent(eventType, payload);
|
|
67
71
|
}
|
|
68
72
|
else if (this.config.logUnhandledEvents) {
|
|
69
73
|
this.ctx.logger('githubsth').info(`Unhandled payload structure. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
70
74
|
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
async handleEvent(event, payload) {
|
|
75
79
|
if (this.config.debug) {
|
|
76
80
|
this.ctx.logger('githubsth').info(`Received event: ${event}`);
|
|
77
81
|
}
|
|
78
|
-
// Check if payload is nested in an 'event' object (common in some adapter versions)
|
|
79
|
-
// or if the event data is directly in payload
|
|
80
82
|
const realPayload = payload.payload || payload;
|
|
81
|
-
// Extract sender from wrapper if available (adapter-github often puts it in 'actor')
|
|
82
83
|
if (payload.actor && !realPayload.sender) {
|
|
83
|
-
|
|
84
|
+
const actorLogin = payload.actor.login || payload.actor.name || 'GitHub';
|
|
85
|
+
realPayload.sender = { ...payload.actor, login: actorLogin };
|
|
84
86
|
}
|
|
85
|
-
// Extract repository from wrapper if available
|
|
86
87
|
if (payload.repository && !realPayload.repository) {
|
|
87
88
|
realPayload.repository = payload.repository;
|
|
88
89
|
}
|
|
89
|
-
let repoName = realPayload
|
|
90
|
-
// Try to fallback if repoName is missing
|
|
91
|
-
if (!repoName && realPayload.issue?.repository_url) {
|
|
92
|
-
const parts = realPayload.issue.repository_url.split('/');
|
|
93
|
-
if (parts.length >= 2) {
|
|
94
|
-
repoName = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (!repoName && realPayload.pull_request?.base?.repo?.full_name) {
|
|
98
|
-
repoName = realPayload.pull_request.base.repo.full_name;
|
|
99
|
-
}
|
|
100
|
-
// Special handling for 'star' event (which might be 'watch' event with action 'started')
|
|
101
|
-
// The payload might be missing repository info in the main object but have it in the original session payload
|
|
102
|
-
if (!repoName && event === 'star') {
|
|
103
|
-
// Sometimes the repository info is at the root of the payload, not inside 'payload' property
|
|
104
|
-
if (payload.repository?.full_name) {
|
|
105
|
-
repoName = payload.repository.full_name;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (!repoName) {
|
|
109
|
-
if (this.config.debug) {
|
|
110
|
-
this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
111
|
-
}
|
|
112
|
-
else if (this.config.logUnhandledEvents) {
|
|
113
|
-
// Log at warning level if repo info is missing and logUnhandledEvents is on
|
|
114
|
-
this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
|
|
115
|
-
}
|
|
116
|
-
// Do not return here, let patching logic handle it with defaultRepo
|
|
117
|
-
}
|
|
118
|
-
// Patch realPayload with extracted repo info if missing
|
|
119
|
-
// This is crucial for formatter to work correctly as it expects repository object
|
|
90
|
+
let repoName = this.extractRepoName(payload, realPayload, event);
|
|
120
91
|
if (!realPayload.repository) {
|
|
121
92
|
realPayload.repository = { full_name: repoName || 'Unknown/Repo' };
|
|
122
93
|
}
|
|
123
94
|
else if (!realPayload.repository.full_name) {
|
|
124
95
|
realPayload.repository.full_name = repoName || 'Unknown/Repo';
|
|
125
96
|
}
|
|
126
|
-
// Patch realPayload with sender info if missing (e.g. issues event)
|
|
127
97
|
if (!realPayload.sender) {
|
|
128
98
|
if (realPayload.issue?.user) {
|
|
129
99
|
realPayload.sender = realPayload.issue.user;
|
|
@@ -138,72 +108,43 @@ class Notifier extends koishi_1.Service {
|
|
|
138
108
|
realPayload.sender = { login: realPayload.pusher.name || 'Pusher' };
|
|
139
109
|
}
|
|
140
110
|
else {
|
|
141
|
-
// Fallback sender
|
|
142
111
|
realPayload.sender = { login: 'GitHub' };
|
|
143
112
|
}
|
|
144
113
|
}
|
|
145
|
-
|
|
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
|
+
}
|
|
146
120
|
try {
|
|
147
121
|
this.patchPayloadForEvent(event, realPayload, repoName || 'Unknown/Repo');
|
|
122
|
+
repoName = repoName || realPayload.repository?.full_name;
|
|
148
123
|
}
|
|
149
|
-
catch (
|
|
150
|
-
this.ctx.logger('githubsth').warn(`Failed to patch payload for ${event}:`,
|
|
151
|
-
}
|
|
152
|
-
if (this.config.debug) {
|
|
153
|
-
this.ctx.logger('githubsth').info(`Processing event ${event} for ${repoName}`);
|
|
154
|
-
this.ctx.logger('notifier').info(`Received event ${event} for ${repoName}`);
|
|
155
|
-
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);
|
|
156
126
|
}
|
|
157
|
-
// Get rules from database
|
|
158
|
-
// Try to match both exact name and lowercase name to handle case sensitivity
|
|
159
|
-
// If repoName is missing (undefined), we can't query rules effectively by repo name
|
|
160
|
-
// But we might want to support global subscriptions or handle it gracefully
|
|
161
127
|
if (!repoName) {
|
|
162
128
|
this.ctx.logger('githubsth').warn('Cannot query rules: repoName is missing');
|
|
163
129
|
return;
|
|
164
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
|
+
}
|
|
165
135
|
const repoNames = [repoName];
|
|
166
|
-
if (repoName !== repoName.toLowerCase())
|
|
136
|
+
if (repoName !== repoName.toLowerCase())
|
|
167
137
|
repoNames.push(repoName.toLowerCase());
|
|
168
|
-
}
|
|
169
|
-
const
|
|
170
|
-
repo: repoNames
|
|
171
|
-
});
|
|
172
|
-
// Combine with config rules (if any, for backward compatibility or static rules)
|
|
173
|
-
// Also match config rules case-insensitively if needed
|
|
174
|
-
const configRules = (this.config.rules || []).filter((r) => r.repo === repoName ||
|
|
175
|
-
r.repo === repoName.toLowerCase() ||
|
|
176
|
-
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 === '*');
|
|
177
140
|
const allRules = [
|
|
178
|
-
...dbRules.map(
|
|
179
|
-
...configRules
|
|
141
|
+
...dbRules.map((rule) => ({ ...rule, platform: rule.platform })),
|
|
142
|
+
...configRules,
|
|
180
143
|
];
|
|
181
|
-
const matchedRules = allRules.filter(rule =>
|
|
182
|
-
|
|
183
|
-
if (!rule.events.includes('*') && !rule.events.includes(event))
|
|
184
|
-
return false;
|
|
185
|
-
return true;
|
|
186
|
-
});
|
|
187
|
-
if (matchedRules.length === 0) {
|
|
188
|
-
if (this.config.debug) {
|
|
189
|
-
this.ctx.logger('githubsth').info(`No matching rules for ${repoName} (event: ${event})`);
|
|
190
|
-
this.ctx.logger('notifier').debug(`No matching rules for ${repoName} (event: ${event})`);
|
|
191
|
-
}
|
|
192
|
-
else if (this.config.logUnhandledEvents) {
|
|
193
|
-
this.ctx.logger('githubsth').warn(`No matching rules for ${repoName} (event: ${event})`);
|
|
194
|
-
}
|
|
144
|
+
const matchedRules = allRules.filter((rule) => rule.events.includes('*') || rule.events.includes(event));
|
|
145
|
+
if (!matchedRules.length)
|
|
195
146
|
return;
|
|
196
|
-
}
|
|
197
|
-
if (this.config.debug) {
|
|
198
|
-
this.ctx.logger('githubsth').info(`Found ${matchedRules.length} matching rules for ${repoName}`);
|
|
199
|
-
this.ctx.logger('notifier').debug(`Found ${matchedRules.length} matching rules for ${repoName}`);
|
|
200
|
-
}
|
|
201
147
|
let message = null;
|
|
202
|
-
// Ensure formatter is loaded
|
|
203
|
-
if (!this.ctx.githubsthFormatter) {
|
|
204
|
-
this.ctx.logger('notifier').warn('Formatter service not available');
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
148
|
try {
|
|
208
149
|
switch (event) {
|
|
209
150
|
case 'push':
|
|
@@ -238,32 +179,43 @@ class Notifier extends koishi_1.Service {
|
|
|
238
179
|
break;
|
|
239
180
|
}
|
|
240
181
|
}
|
|
241
|
-
catch (
|
|
242
|
-
this.ctx.logger('githubsth').error(`Error formatting event ${event}:`,
|
|
243
|
-
if (this.config.debug) {
|
|
244
|
-
this.ctx.logger('notifier').error(`Error formatting event ${event}:`, e);
|
|
245
|
-
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.ctx.logger('githubsth').error(`Error formatting event ${event}:`, error);
|
|
246
184
|
return;
|
|
247
185
|
}
|
|
248
|
-
if (!message)
|
|
249
|
-
if (this.config.debug) {
|
|
250
|
-
this.ctx.logger('notifier').debug(`Formatter returned null for event ${event}`);
|
|
251
|
-
}
|
|
186
|
+
if (!message)
|
|
252
187
|
return;
|
|
253
|
-
}
|
|
254
188
|
for (const rule of matchedRules) {
|
|
255
|
-
if (this.config.debug) {
|
|
256
|
-
this.ctx.logger('notifier').debug(`Sending message to channel ${rule.channelId} (platform: ${rule.platform || 'any'})`);
|
|
257
|
-
}
|
|
258
189
|
await this.sendMessage(rule, message);
|
|
259
190
|
}
|
|
260
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
|
+
}
|
|
261
215
|
patchPayloadForEvent(event, payload, repoName) {
|
|
262
|
-
// Ensure sender exists (handled before, but good for type safety)
|
|
263
216
|
const defaultUser = { login: 'GitHub', id: 0, avatar_url: '' };
|
|
264
217
|
if (!payload.sender)
|
|
265
218
|
payload.sender = defaultUser;
|
|
266
|
-
// Ensure repository exists (handled before, but good for type safety)
|
|
267
219
|
const defaultRepo = { full_name: repoName, stargazers_count: 0, html_url: `https://github.com/${repoName}` };
|
|
268
220
|
if (!payload.repository)
|
|
269
221
|
payload.repository = defaultRepo;
|
|
@@ -277,16 +229,13 @@ class Notifier extends koishi_1.Service {
|
|
|
277
229
|
payload.ref = 'refs/heads/unknown';
|
|
278
230
|
if (!payload.compare)
|
|
279
231
|
payload.compare = '';
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!c.message)
|
|
288
|
-
c.message = 'No message';
|
|
289
|
-
});
|
|
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';
|
|
290
239
|
}
|
|
291
240
|
break;
|
|
292
241
|
case 'issues':
|
|
@@ -294,7 +243,6 @@ class Notifier extends koishi_1.Service {
|
|
|
294
243
|
payload.action = 'updated';
|
|
295
244
|
if (!payload.issue)
|
|
296
245
|
payload.issue = { number: 0, title: 'Unknown Issue', html_url: '', user: payload.sender };
|
|
297
|
-
// Ensure user exists in issue
|
|
298
246
|
if (!payload.issue.user)
|
|
299
247
|
payload.issue.user = payload.sender;
|
|
300
248
|
break;
|
|
@@ -307,11 +255,10 @@ class Notifier extends koishi_1.Service {
|
|
|
307
255
|
payload.pull_request.user = payload.sender;
|
|
308
256
|
break;
|
|
309
257
|
case 'star':
|
|
310
|
-
if (!payload.action)
|
|
258
|
+
if (!payload.action || payload.action === 'started')
|
|
311
259
|
payload.action = 'created';
|
|
312
|
-
if (payload.repository
|
|
260
|
+
if (payload.repository?.stargazers_count === undefined)
|
|
313
261
|
payload.repository.stargazers_count = '?';
|
|
314
|
-
}
|
|
315
262
|
break;
|
|
316
263
|
case 'fork':
|
|
317
264
|
if (!payload.forkee)
|
|
@@ -359,38 +306,107 @@ class Notifier extends koishi_1.Service {
|
|
|
359
306
|
break;
|
|
360
307
|
}
|
|
361
308
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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) {
|
|
370
363
|
if (this.config.debug) {
|
|
371
|
-
this.ctx.logger('
|
|
364
|
+
this.ctx.logger('githubsth').warn('Failed to cleanup dedup table:', error);
|
|
372
365
|
}
|
|
373
|
-
return;
|
|
374
366
|
}
|
|
375
|
-
|
|
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;
|
|
376
372
|
for (const bot of bots) {
|
|
377
373
|
try {
|
|
378
|
-
await
|
|
379
|
-
sent = true;
|
|
374
|
+
await this.sendWithRetry(bot, rule.channelId, message);
|
|
380
375
|
if (this.config.debug) {
|
|
381
376
|
this.ctx.logger('notifier').info(`Sent message to ${rule.channelId} via ${bot.platform}:${bot.selfId}`);
|
|
382
377
|
}
|
|
383
|
-
|
|
378
|
+
return;
|
|
384
379
|
}
|
|
385
|
-
catch (
|
|
380
|
+
catch (error) {
|
|
386
381
|
if (this.config.debug) {
|
|
387
|
-
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}`);
|
|
388
383
|
}
|
|
389
384
|
}
|
|
390
385
|
}
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
}
|
|
393
404
|
}
|
|
405
|
+
throw lastError;
|
|
406
|
+
}
|
|
407
|
+
sleep(ms) {
|
|
408
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
394
409
|
}
|
|
395
410
|
}
|
|
396
411
|
exports.Notifier = Notifier;
|
|
412
|
+
Notifier.inject = ['githubsthFormatter', 'database'];
|
package/package.json
CHANGED