koishi-plugin-githubsth 1.0.3-alpha.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.d.ts 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;
@@ -8,9 +8,18 @@ declare module 'koishi' {
8
8
  export declare class Notifier extends Service {
9
9
  config: Config;
10
10
  static inject: string[];
11
+ private readonly recentEventKeys;
12
+ private readonly memoryDedupWindowMs;
13
+ private dedupWriteCounter;
11
14
  constructor(ctx: Context, config: Config);
12
15
  private registerListeners;
13
16
  private handleEvent;
17
+ private extractRepoName;
14
18
  private patchPayloadForEvent;
19
+ private buildEventDedupKey;
20
+ private shouldProcessEvent;
21
+ private cleanupDedupTable;
15
22
  private sendMessage;
23
+ private sendWithRetry;
24
+ private sleep;
16
25
  }
@@ -7,42 +7,43 @@ class Notifier extends koishi_1.Service {
7
7
  constructor(ctx, config) {
8
8
  super(ctx, 'githubsthNotifier', true);
9
9
  this.config = config;
10
+ this.recentEventKeys = new Map();
11
+ this.memoryDedupWindowMs = 5000;
12
+ this.dedupWriteCounter = 0;
10
13
  this.ctx.logger('githubsth').info('Notifier service initialized');
11
14
  this.registerListeners();
12
15
  }
13
16
  registerListeners() {
14
- // adapter-github canonical event names
15
- this.ctx.on('github/issue', (payload) => this.handleEvent('issues', payload));
16
- this.ctx.on('github/issue-comment', (payload) => this.handleEvent('issue_comment', payload));
17
- this.ctx.on('github/pull-request', (payload) => this.handleEvent('pull_request', payload));
18
- this.ctx.on('github/workflow-run', (payload) => this.handleEvent('workflow_run', payload));
19
- // Backward compatibility aliases
20
- this.ctx.on('github/push', (payload) => this.handleEvent('push', payload));
21
- this.ctx.on('github/issues', (payload) => this.handleEvent('issues', payload));
22
- this.ctx.on('github/pull_request', (payload) => this.handleEvent('pull_request', payload));
23
- this.ctx.on('github/star', (payload) => this.handleEvent('star', payload));
24
- this.ctx.on('github/fork', (payload) => this.handleEvent('fork', payload));
25
- this.ctx.on('github/release', (payload) => this.handleEvent('release', payload));
26
- this.ctx.on('github/discussion', (payload) => this.handleEvent('discussion', payload));
27
- this.ctx.on('github/workflow_run', (payload) => this.handleEvent('workflow_run', payload));
28
- this.ctx.on('github/issue_comment', (payload) => this.handleEvent('issue_comment', payload));
29
- this.ctx.on('github/issue-comment', (payload) => this.handleEvent('issue_comment', payload));
30
- this.ctx.on('github/pull-request-review', (payload) => this.handleEvent('pull_request_review', payload));
31
- // Fallback: Listen to message-created for adapters that map webhooks to messages
32
- this.ctx.on('message-created', (session) => {
33
- if (session.platform !== 'github')
34
- return;
35
- // Try to find payload
36
- const payload = session.payload || session.extra || session.data;
37
- 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;
38
42
  if (this.config.debug) {
39
43
  this.ctx.logger('githubsth').info('Found payload in session, attempting to handle');
40
44
  }
41
- // Check if payload is wrapped (adapter-github structure)
42
45
  const realPayload = payload.payload || payload;
43
- // Infer event type
44
46
  let eventType = 'unknown';
45
- // Check inner payload first if it exists
46
47
  if (realPayload.issue && realPayload.comment)
47
48
  eventType = 'issue_comment';
48
49
  else if (realPayload.issue)
@@ -53,7 +54,7 @@ class Notifier extends koishi_1.Service {
53
54
  eventType = 'pull_request';
54
55
  else if (realPayload.commits)
55
56
  eventType = 'push';
56
- else if (realPayload.starred_at !== undefined || (realPayload.action === 'started'))
57
+ else if (realPayload.starred_at !== undefined || realPayload.action === 'started')
57
58
  eventType = 'star';
58
59
  else if (realPayload.forkee)
59
60
  eventType = 'fork';
@@ -66,13 +67,13 @@ class Notifier extends koishi_1.Service {
66
67
  else if (realPayload.repository && (realPayload.action === 'created' || realPayload.action === 'started'))
67
68
  eventType = 'star';
68
69
  if (eventType !== 'unknown') {
69
- this.handleEvent(eventType, payload);
70
+ void this.handleEvent(eventType, payload);
70
71
  }
71
72
  else if (this.config.logUnhandledEvents) {
72
73
  this.ctx.logger('githubsth').info(`Unhandled payload structure. Keys: ${Object.keys(realPayload).join(', ')}`);
73
74
  }
74
- }
75
- });
75
+ });
76
+ }
76
77
  }
77
78
  async handleEvent(event, payload) {
78
79
  if (this.config.debug) {
@@ -86,42 +87,7 @@ class Notifier extends koishi_1.Service {
86
87
  if (payload.repository && !realPayload.repository) {
87
88
  realPayload.repository = payload.repository;
88
89
  }
89
- let repoName = realPayload.repository?.full_name;
90
- if (!repoName && realPayload.issue?.repository_url) {
91
- const parts = realPayload.issue.repository_url.split('/');
92
- if (parts.length >= 2) {
93
- repoName = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
94
- }
95
- }
96
- if (!repoName && realPayload.pull_request?.base?.repo?.full_name) {
97
- repoName = realPayload.pull_request.base.repo.full_name;
98
- }
99
- // adapter-github eventData fields
100
- if (!repoName && typeof payload.repoKey === 'string' && payload.repoKey.includes('/')) {
101
- repoName = payload.repoKey;
102
- }
103
- if (!repoName && typeof payload.owner === 'string' && typeof payload.repo === 'string') {
104
- repoName = `${payload.owner}/${payload.repo}`;
105
- }
106
- if (!repoName && typeof payload.repo === 'string' && payload.repo.includes('/')) {
107
- repoName = payload.repo;
108
- }
109
- if (!repoName && event === 'star') {
110
- if (payload.repository?.full_name) {
111
- repoName = payload.repository.full_name;
112
- }
113
- }
114
- if (!repoName && payload.repository?.full_name) {
115
- repoName = payload.repository.full_name;
116
- }
117
- if (!repoName) {
118
- if (this.config.debug) {
119
- this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
120
- }
121
- else if (this.config.logUnhandledEvents) {
122
- this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
123
- }
124
- }
90
+ let repoName = this.extractRepoName(payload, realPayload, event);
125
91
  if (!realPayload.repository) {
126
92
  realPayload.repository = { full_name: repoName || 'Unknown/Repo' };
127
93
  }
@@ -145,59 +111,40 @@ class Notifier extends koishi_1.Service {
145
111
  realPayload.sender = { login: 'GitHub' };
146
112
  }
147
113
  }
114
+ if (!(await this.shouldProcessEvent(event, payload, realPayload, repoName))) {
115
+ if (this.config.debug) {
116
+ this.ctx.logger('githubsth').info(`Skip duplicated event: ${event} (${repoName || 'unknown'})`);
117
+ }
118
+ return;
119
+ }
148
120
  try {
149
121
  this.patchPayloadForEvent(event, realPayload, repoName || 'Unknown/Repo');
122
+ repoName = repoName || realPayload.repository?.full_name;
150
123
  }
151
- catch (e) {
152
- this.ctx.logger('githubsth').warn(`Failed to patch payload for ${event}:`, e);
153
- }
154
- if (this.config.debug) {
155
- this.ctx.logger('githubsth').info(`Processing event ${event} for ${repoName}`);
156
- this.ctx.logger('notifier').info(`Received event ${event} for ${repoName}`);
157
- this.ctx.logger('notifier').debug(JSON.stringify(realPayload, null, 2));
124
+ catch (error) {
125
+ this.ctx.logger('githubsth').warn(`Failed to patch payload for ${event}:`, error);
158
126
  }
159
127
  if (!repoName) {
160
128
  this.ctx.logger('githubsth').warn('Cannot query rules: repoName is missing');
161
129
  return;
162
130
  }
131
+ if (this.config.debug) {
132
+ this.ctx.logger('githubsth').info(`Processing event ${event} for ${repoName}`);
133
+ this.ctx.logger('notifier').debug(JSON.stringify(realPayload, null, 2));
134
+ }
163
135
  const repoNames = [repoName];
164
- if (repoName !== repoName.toLowerCase()) {
136
+ if (repoName !== repoName.toLowerCase())
165
137
  repoNames.push(repoName.toLowerCase());
166
- }
167
- const dbRules = await this.ctx.database.get('github_subscription', {
168
- repo: repoNames
169
- });
170
- const configRules = (this.config.rules || []).filter((r) => r.repo === repoName ||
171
- r.repo === repoName.toLowerCase() ||
172
- r.repo === '*');
138
+ const dbRules = await this.ctx.database.get('github_subscription', { repo: repoNames });
139
+ const configRules = (this.config.rules || []).filter((rule) => rule.repo === repoName || rule.repo === repoName.toLowerCase() || rule.repo === '*');
173
140
  const allRules = [
174
- ...dbRules.map(r => ({ ...r, platform: r.platform })),
175
- ...configRules
141
+ ...dbRules.map((rule) => ({ ...rule, platform: rule.platform })),
142
+ ...configRules,
176
143
  ];
177
- const matchedRules = allRules.filter(rule => {
178
- if (!rule.events.includes('*') && !rule.events.includes(event))
179
- return false;
180
- return true;
181
- });
182
- if (matchedRules.length === 0) {
183
- if (this.config.debug) {
184
- this.ctx.logger('githubsth').info(`No matching rules for ${repoName} (event: ${event})`);
185
- this.ctx.logger('notifier').debug(`No matching rules for ${repoName} (event: ${event})`);
186
- }
187
- else if (this.config.logUnhandledEvents) {
188
- this.ctx.logger('githubsth').warn(`No matching rules for ${repoName} (event: ${event})`);
189
- }
144
+ const matchedRules = allRules.filter((rule) => rule.events.includes('*') || rule.events.includes(event));
145
+ if (!matchedRules.length)
190
146
  return;
191
- }
192
- if (this.config.debug) {
193
- this.ctx.logger('githubsth').info(`Found ${matchedRules.length} matching rules for ${repoName}`);
194
- this.ctx.logger('notifier').debug(`Found ${matchedRules.length} matching rules for ${repoName}`);
195
- }
196
147
  let message = null;
197
- if (!this.ctx.githubsthFormatter) {
198
- this.ctx.logger('notifier').warn('Formatter service not available');
199
- return;
200
- }
201
148
  try {
202
149
  switch (event) {
203
150
  case 'push':
@@ -232,26 +179,39 @@ class Notifier extends koishi_1.Service {
232
179
  break;
233
180
  }
234
181
  }
235
- catch (e) {
236
- this.ctx.logger('githubsth').error(`Error formatting event ${event}:`, e);
237
- if (this.config.debug) {
238
- this.ctx.logger('notifier').error(`Error formatting event ${event}:`, e);
239
- }
182
+ catch (error) {
183
+ this.ctx.logger('githubsth').error(`Error formatting event ${event}:`, error);
240
184
  return;
241
185
  }
242
- if (!message) {
243
- if (this.config.debug) {
244
- this.ctx.logger('notifier').debug(`Formatter returned null for event ${event}`);
245
- }
186
+ if (!message)
246
187
  return;
247
- }
248
188
  for (const rule of matchedRules) {
249
- if (this.config.debug) {
250
- this.ctx.logger('notifier').debug(`Sending message to channel ${rule.channelId} (platform: ${rule.platform || 'any'})`);
251
- }
252
189
  await this.sendMessage(rule, message);
253
190
  }
254
191
  }
192
+ extractRepoName(payload, realPayload, event) {
193
+ let repoName = realPayload.repository?.full_name;
194
+ if (!repoName && realPayload.issue?.repository_url) {
195
+ const parts = String(realPayload.issue.repository_url).split('/');
196
+ if (parts.length >= 2)
197
+ repoName = `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
198
+ }
199
+ if (!repoName && realPayload.pull_request?.base?.repo?.full_name) {
200
+ repoName = realPayload.pull_request.base.repo.full_name;
201
+ }
202
+ if (!repoName && typeof payload.repoKey === 'string' && payload.repoKey.includes('/'))
203
+ repoName = payload.repoKey;
204
+ if (!repoName && typeof payload.owner === 'string' && typeof payload.repo === 'string')
205
+ repoName = `${payload.owner}/${payload.repo}`;
206
+ if (!repoName && typeof payload.repo === 'string' && payload.repo.includes('/'))
207
+ repoName = payload.repo;
208
+ if (!repoName && payload.repository?.full_name)
209
+ repoName = payload.repository.full_name;
210
+ if (!repoName && this.config.logUnhandledEvents) {
211
+ this.ctx.logger('githubsth').warn(`Missing repo info for event: ${event}. Keys: ${Object.keys(realPayload).join(', ')}`);
212
+ }
213
+ return repoName;
214
+ }
255
215
  patchPayloadForEvent(event, payload, repoName) {
256
216
  const defaultUser = { login: 'GitHub', id: 0, avatar_url: '' };
257
217
  if (!payload.sender)
@@ -269,15 +229,13 @@ class Notifier extends koishi_1.Service {
269
229
  payload.ref = 'refs/heads/unknown';
270
230
  if (!payload.compare)
271
231
  payload.compare = '';
272
- if (payload.commits.length > 0) {
273
- payload.commits.forEach((c) => {
274
- if (!c.author)
275
- c.author = { name: 'Unknown' };
276
- if (!c.id)
277
- c.id = '0000000';
278
- if (!c.message)
279
- c.message = 'No message';
280
- });
232
+ for (const commit of payload.commits) {
233
+ if (!commit.author)
234
+ commit.author = { name: 'Unknown' };
235
+ if (!commit.id)
236
+ commit.id = '0000000';
237
+ if (!commit.message)
238
+ commit.message = 'No message';
281
239
  }
282
240
  break;
283
241
  case 'issues':
@@ -297,13 +255,10 @@ class Notifier extends koishi_1.Service {
297
255
  payload.pull_request.user = payload.sender;
298
256
  break;
299
257
  case 'star':
300
- if (!payload.action)
258
+ if (!payload.action || payload.action === 'started')
301
259
  payload.action = 'created';
302
- if (payload.action === 'started')
303
- payload.action = 'created';
304
- if (payload.repository && payload.repository.stargazers_count === undefined) {
260
+ if (payload.repository?.stargazers_count === undefined)
305
261
  payload.repository.stargazers_count = '?';
306
- }
307
262
  break;
308
263
  case 'fork':
309
264
  if (!payload.forkee)
@@ -351,38 +306,107 @@ class Notifier extends koishi_1.Service {
351
306
  break;
352
307
  }
353
308
  }
354
- async sendMessage(rule, message) {
355
- const bots = this.ctx.bots.filter(bot => {
356
- if (rule.platform)
357
- return bot.platform === rule.platform;
358
- return true;
359
- });
360
- 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) {
361
363
  if (this.config.debug) {
362
- 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);
363
365
  }
364
- return;
365
366
  }
366
- 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;
367
372
  for (const bot of bots) {
368
373
  try {
369
- await bot.sendMessage(rule.channelId, message);
370
- sent = true;
374
+ await this.sendWithRetry(bot, rule.channelId, message);
371
375
  if (this.config.debug) {
372
376
  this.ctx.logger('notifier').info(`Sent message to ${rule.channelId} via ${bot.platform}:${bot.selfId}`);
373
377
  }
374
- break;
378
+ return;
375
379
  }
376
- catch (e) {
380
+ catch (error) {
377
381
  if (this.config.debug) {
378
- 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}`);
379
383
  }
380
384
  }
381
385
  }
382
- if (!sent) {
383
- 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
+ }
384
404
  }
405
+ throw lastError;
406
+ }
407
+ sleep(ms) {
408
+ return new Promise((resolve) => setTimeout(resolve, ms));
385
409
  }
386
410
  }
387
411
  exports.Notifier = Notifier;
388
- Notifier.inject = ['githubsthFormatter'];
412
+ Notifier.inject = ['githubsthFormatter', 'database'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-githubsth",
3
- "version": "1.0.3-alpha.1",
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",