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 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('是否记录未处理的 Webhook 事件 (Unknown events)'),
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 (e) {
72
- logger.error('Plugin failed to load:', e);
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
+ };
@@ -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: '获取信息失败: {0}',
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
- constructor(ctx: Context);
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
- constructor(ctx) {
130
+ // @ts-ignore
131
+ constructor(ctx, config) {
7
132
  super(ctx, 'githubsthFormatter');
8
- ctx.logger('githubsth').info('Formatter service initialized');
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 { repository, pusher, commits, compare, ref } = payload;
12
- if (!commits || commits.length === 0)
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 commitLines = commits.map((c) => {
15
- const shortHash = c.id?.substring(0, 7) || '???????';
16
- const message = c.message?.split('\n')[0] || 'No message';
17
- const author = c.author?.name || 'Unknown';
18
- return `[${shortHash}] ${message} - ${author}`;
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
- return (0, koishi_1.h)('message', [
21
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} 收到新的推送\n` }),
22
- (0, koishi_1.h)('text', { content: `提交者: ${pusher?.name || 'Unknown'}\n` }),
23
- (0, koishi_1.h)('text', { content: `分支: ${ref ? ref.replace('refs/heads/', '') : 'unknown'}\n` }),
24
- (0, koishi_1.h)('text', { content: `详情: ${compare || ''}\n` }),
25
- (0, koishi_1.h)('text', { content: commitLines })
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 { action, issue, repository, sender } = payload;
30
- return (0, koishi_1.h)('message', [
31
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} Issue ${action || 'updated'}\n` }),
32
- (0, koishi_1.h)('text', { content: `标题: #${issue?.number || '?'} ${issue?.title || 'No Title'}\n` }),
33
- (0, koishi_1.h)('text', { content: `发起人: ${sender?.login || 'Unknown'}\n` }),
34
- (0, koishi_1.h)('text', { content: `链接: ${issue?.html_url || ''}` })
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 { action, pull_request, repository, sender } = payload;
39
- return (0, koishi_1.h)('message', [
40
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} PR ${action || 'updated'}\n` }),
41
- (0, koishi_1.h)('text', { content: `标题: #${pull_request?.number || '?'} ${pull_request?.title || 'No Title'}\n` }),
42
- (0, koishi_1.h)('text', { content: `发起人: ${sender?.login || 'Unknown'}\n` }),
43
- (0, koishi_1.h)('text', { content: `状态: ${pull_request?.state || 'unknown'}\n` }),
44
- (0, koishi_1.h)('text', { content: `链接: ${pull_request?.html_url || ''}` })
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
- const { action, repository, sender } = payload;
49
- if (action !== 'created')
187
+ if (payload.action !== 'created')
50
188
  return null;
51
- return (0, koishi_1.h)('message', [
52
- (0, koishi_1.h)('text', { content: `[GitHub] ${sender?.login || 'Unknown'} Star 了仓库 ${repository?.full_name || 'Unknown Repo'} 🌟\n` }),
53
- (0, koishi_1.h)('text', { content: `当前 Star 数: ${repository?.stargazers_count !== undefined ? repository.stargazers_count : '?'}` })
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 { forkee, repository, sender } = payload;
58
- return (0, koishi_1.h)('message', [
59
- (0, koishi_1.h)('text', { content: `[GitHub] ${sender?.login || 'Unknown'} Fork 了仓库 ${repository?.full_name || 'Unknown Repo'}\n` }),
60
- (0, koishi_1.h)('text', { content: `新仓库: ${forkee?.full_name || 'Unknown'}` })
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
- const { action, release, repository } = payload;
65
- if (action !== 'published')
209
+ if (payload.action !== 'published')
66
210
  return null;
67
- return (0, koishi_1.h)('message', [
68
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} 发布了新版本 ${release?.tag_name || 'unknown'} 🎉\n` }),
69
- (0, koishi_1.h)('text', { content: `标题: ${release?.name || 'No Title'}\n` }),
70
- (0, koishi_1.h)('text', { content: `链接: ${release?.html_url || ''}` })
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 { action, discussion, repository, sender } = payload;
75
- return (0, koishi_1.h)('message', [
76
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} Discussion ${action || 'updated'}\n` }),
77
- (0, koishi_1.h)('text', { content: `标题: #${discussion?.number || '?'} ${discussion?.title || 'No Title'}\n` }),
78
- (0, koishi_1.h)('text', { content: `发起人: ${sender?.login || 'Unknown'}\n` }),
79
- (0, koishi_1.h)('text', { content: `链接: ${discussion?.html_url || ''}` })
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
- const { action, workflow_run, repository } = payload;
84
- if (action !== 'completed')
234
+ if (payload.action !== 'completed')
85
235
  return null;
86
- const statusIcon = workflow_run?.conclusion === 'success' ? '✅' : '❌';
87
- return (0, koishi_1.h)('message', [
88
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} 工作流运行完成 ${statusIcon}\n` }),
89
- (0, koishi_1.h)('text', { content: `工作流: ${workflow_run?.name || 'Unknown'}\n` }),
90
- (0, koishi_1.h)('text', { content: `结果: ${workflow_run?.conclusion || 'unknown'}\n` }),
91
- (0, koishi_1.h)('text', { content: `分支: ${workflow_run?.head_branch || 'unknown'}\n` }),
92
- (0, koishi_1.h)('text', { content: `链接: ${workflow_run?.html_url || ''}` })
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
- const { action, issue, comment, repository, sender } = payload;
97
- if (action !== 'created')
249
+ if (payload.action !== 'created')
98
250
  return null;
99
- const body = comment?.body || '';
100
- const shortBody = body.length > 100 ? body.substring(0, 100) + '...' : body;
101
- return (0, koishi_1.h)('message', [
102
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} Issue 收到新评论\n` }),
103
- (0, koishi_1.h)('text', { content: `标题: #${issue?.number || '?'} ${issue?.title || 'No Title'}\n` }),
104
- (0, koishi_1.h)('text', { content: `评论人: ${sender?.login || 'Unknown'}\n` }),
105
- (0, koishi_1.h)('text', { content: `内容: ${shortBody}\n` }),
106
- (0, koishi_1.h)('text', { content: `链接: ${comment?.html_url || ''}` })
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
- const { action, pull_request, review, repository, sender } = payload;
111
- if (action !== 'submitted')
265
+ if (payload.action !== 'submitted')
112
266
  return null;
113
- return (0, koishi_1.h)('message', [
114
- (0, koishi_1.h)('text', { content: `[GitHub] ${repository?.full_name || 'Unknown Repo'} PR 收到 Review\n` }),
115
- (0, koishi_1.h)('text', { content: `标题: #${pull_request?.number || '?'} ${pull_request?.title || 'No Title'}\n` }),
116
- (0, koishi_1.h)('text', { content: `Reviewer: ${sender?.login || 'Unknown'}\n` }),
117
- (0, koishi_1.h)('text', { content: `状态: ${review?.state || 'unknown'}\n` }),
118
- (0, koishi_1.h)('text', { content: `链接: ${review?.html_url || ''}` })
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
  }
@@ -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
- this.ctx.on('github/push', (payload) => this.handleEvent('push', payload));
15
- this.ctx.on('github/issues', (payload) => this.handleEvent('issues', payload));
16
- this.ctx.on('github/pull_request', (payload) => this.handleEvent('pull_request', payload));
17
- this.ctx.on('github/pull-request', (payload) => this.handleEvent('pull_request', payload));
18
- this.ctx.on('github/star', (payload) => this.handleEvent('star', payload));
19
- this.ctx.on('github/fork', (payload) => this.handleEvent('fork', payload));
20
- this.ctx.on('github/release', (payload) => this.handleEvent('release', payload));
21
- this.ctx.on('github/discussion', (payload) => this.handleEvent('discussion', payload));
22
- this.ctx.on('github/workflow_run', (payload) => this.handleEvent('workflow_run', payload));
23
- this.ctx.on('github/workflow-run', (payload) => this.handleEvent('workflow_run', payload));
24
- this.ctx.on('github/issue_comment', (payload) => this.handleEvent('issue_comment', payload));
25
- this.ctx.on('github/issue-comment', (payload) => this.handleEvent('issue_comment', payload));
26
- this.ctx.on('github/pull-request-review', (payload) => this.handleEvent('pull_request_review', payload));
27
- // Fallback: Listen to message-created for adapters that map webhooks to messages
28
- this.ctx.on('message-created', (session) => {
29
- if (session.platform !== 'github')
30
- return;
31
- // Try to find payload
32
- const payload = session.payload || session.extra || session.data;
33
- if (payload) {
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 || (realPayload.action === 'started'))
53
- eventType = 'star'; // star event usually has action 'created' but check payload structure
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
- realPayload.sender = payload.actor;
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.repository?.full_name;
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
- // Comprehensive patching for specific events to prevent formatter crashes
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 (e) {
150
- this.ctx.logger('githubsth').warn(`Failed to patch payload for ${event}:`, e);
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 dbRules = await this.ctx.database.get('github_subscription', {
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(r => ({ ...r, platform: r.platform })),
179
- ...configRules
141
+ ...dbRules.map((rule) => ({ ...rule, platform: rule.platform })),
142
+ ...configRules,
180
143
  ];
181
- const matchedRules = allRules.filter(rule => {
182
- // Event match
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 (e) {
242
- this.ctx.logger('githubsth').error(`Error formatting event ${event}:`, e);
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
- // Ensure author exists in commits
281
- if (payload.commits.length > 0) {
282
- payload.commits.forEach((c) => {
283
- if (!c.author)
284
- c.author = { name: 'Unknown' };
285
- if (!c.id)
286
- c.id = '0000000';
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 && payload.repository.stargazers_count === undefined) {
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
- async sendMessage(rule, message) {
363
- // Find suitable bots
364
- const bots = this.ctx.bots.filter(bot => {
365
- if (rule.platform)
366
- return bot.platform === rule.platform;
367
- return true; // If platform not specified, try all
368
- });
369
- if (bots.length === 0) {
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('notifier').debug(`No bot found for channel ${rule.channelId} (platform: ${rule.platform})`);
364
+ this.ctx.logger('githubsth').warn('Failed to cleanup dedup table:', error);
372
365
  }
373
- return;
374
366
  }
375
- let sent = false;
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 bot.sendMessage(rule.channelId, message);
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
- break; // Break on first success
378
+ return;
384
379
  }
385
- catch (e) {
380
+ catch (error) {
386
381
  if (this.config.debug) {
387
- this.ctx.logger('notifier').warn(`Bot ${bot.sid} failed to send message: ${e}`);
382
+ this.ctx.logger('notifier').warn(`Bot ${bot.sid} failed to send message with retries: ${error}`);
388
383
  }
389
384
  }
390
385
  }
391
- if (!sent) {
392
- this.ctx.logger('notifier').warn(`Failed to send message to ${rule.channelId}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-githubsth",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Github Subscriptions Notifications, push notifications for GitHub subscriptions For koishi",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",