koishi-plugin-githubsth 1.0.3 → 1.0.5-alpha.0
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/commands/index.js +4 -9
- package/lib/commands/render.d.ts +3 -0
- package/lib/commands/render.js +55 -0
- package/lib/config.d.ts +8 -0
- package/lib/config.js +16 -0
- package/lib/database.js +1 -0
- package/lib/index.js +1 -1
- package/lib/services/formatter.d.ts +11 -11
- package/lib/services/formatter.js +1 -5
- package/lib/services/notifier.d.ts +22 -2
- package/lib/services/notifier.js +248 -53
- package/package.json +1 -1
package/lib/commands/index.js
CHANGED
|
@@ -37,17 +37,12 @@ exports.apply = apply;
|
|
|
37
37
|
const repo = __importStar(require("./repo"));
|
|
38
38
|
const admin = __importStar(require("./admin"));
|
|
39
39
|
const subscribe = __importStar(require("./subscribe"));
|
|
40
|
+
const render = __importStar(require("./render"));
|
|
40
41
|
function apply(ctx, config) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
ctx.command('githubsth', 'GitHub 推送通知')
|
|
44
|
-
.action(({ session }) => {
|
|
45
|
-
session?.execute('help githubsth');
|
|
46
|
-
});
|
|
42
|
+
ctx.command('githubsth', 'GitHub 订阅通知')
|
|
43
|
+
.action(({ session }) => session?.execute('help githubsth'));
|
|
47
44
|
ctx.plugin(repo, config);
|
|
48
|
-
console.log('githubsth.repo loaded');
|
|
49
45
|
ctx.plugin(admin, config);
|
|
50
|
-
console.log('githubsth.admin loaded');
|
|
51
46
|
ctx.plugin(subscribe, config);
|
|
52
|
-
|
|
47
|
+
ctx.plugin(render, config);
|
|
53
48
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.apply = apply;
|
|
4
|
+
const modes = ['text', 'image', 'auto'];
|
|
5
|
+
const themes = ['compact', 'card', 'terminal'];
|
|
6
|
+
const events = ['push', 'issues', 'issue_comment', 'pull_request', 'pull_request_review', 'star', 'fork', 'release', 'discussion', 'workflow_run'];
|
|
7
|
+
function apply(ctx, config) {
|
|
8
|
+
ctx.command('githubsth.render', '通知渲染设置(文本/图片)', { authority: 3 })
|
|
9
|
+
.alias('gh.render');
|
|
10
|
+
ctx.command('githubsth.render.status', '查看渲染状态', { authority: 3 })
|
|
11
|
+
.action(async () => {
|
|
12
|
+
const status = ctx.githubsthNotifier.getRenderStatus();
|
|
13
|
+
return [
|
|
14
|
+
`mode: ${status.mode} (configured: ${status.configuredMode})`,
|
|
15
|
+
`fallback: ${status.fallback}`,
|
|
16
|
+
`theme: ${status.theme}`,
|
|
17
|
+
`width: ${status.width}`,
|
|
18
|
+
`timeout: ${status.timeoutMs}ms`,
|
|
19
|
+
`puppeteer: ${status.hasPuppeteer ? 'ready' : 'missing'}`,
|
|
20
|
+
].join('\n');
|
|
21
|
+
});
|
|
22
|
+
ctx.command('githubsth.render.mode <mode:string>', '切换渲染模式(text/image/auto)', { authority: 3 })
|
|
23
|
+
.action(async (_, mode) => {
|
|
24
|
+
if (!mode || !modes.includes(mode)) {
|
|
25
|
+
return `无效模式。可选:${modes.join(', ')}`;
|
|
26
|
+
}
|
|
27
|
+
ctx.githubsthNotifier.setRenderMode(mode);
|
|
28
|
+
return `已切换运行时渲染模式为 ${mode}(重启后恢复配置值 ${config.renderMode})。`;
|
|
29
|
+
});
|
|
30
|
+
ctx.command('githubsth.render.theme <theme:string>', '切换图片主题(compact/card/terminal)', { authority: 3 })
|
|
31
|
+
.action(async (_, theme) => {
|
|
32
|
+
if (!theme || !themes.includes(theme)) {
|
|
33
|
+
return `无效主题。可选:${themes.join(', ')}`;
|
|
34
|
+
}
|
|
35
|
+
config.renderTheme = theme;
|
|
36
|
+
return `已切换图片主题为 ${theme}(当前进程生效)。`;
|
|
37
|
+
});
|
|
38
|
+
ctx.command('githubsth.render.width <width:number>', '设置图片宽度', { authority: 3 })
|
|
39
|
+
.action(async (_, width) => {
|
|
40
|
+
if (!width || Number.isNaN(width))
|
|
41
|
+
return '请提供有效的数字宽度。';
|
|
42
|
+
const normalized = Math.max(480, Math.min(1600, Math.floor(width)));
|
|
43
|
+
config.renderWidth = normalized;
|
|
44
|
+
return `已设置图片宽度为 ${normalized}px(当前进程生效)。`;
|
|
45
|
+
});
|
|
46
|
+
ctx.command('githubsth.render.preview [event:string]', '预览通知渲染', { authority: 3 })
|
|
47
|
+
.action(async ({ session }, event) => {
|
|
48
|
+
const selected = event && events.includes(event) ? event : 'issue_comment';
|
|
49
|
+
if (event && !events.includes(event)) {
|
|
50
|
+
await session?.send(`未知事件 ${event},已改用默认事件 issue_comment。`);
|
|
51
|
+
}
|
|
52
|
+
const preview = await ctx.githubsthNotifier.renderPreview(selected);
|
|
53
|
+
return preview || '预览失败:请检查 puppeteer 或渲染配置。';
|
|
54
|
+
});
|
|
55
|
+
}
|
package/lib/config.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ export interface Rule {
|
|
|
5
5
|
platform?: string;
|
|
6
6
|
events: string[];
|
|
7
7
|
}
|
|
8
|
+
export type RenderMode = 'text' | 'image' | 'auto';
|
|
9
|
+
export type RenderFallback = 'text' | 'drop';
|
|
10
|
+
export type RenderTheme = 'compact' | 'card' | 'terminal';
|
|
8
11
|
export interface Config {
|
|
9
12
|
defaultOwner?: string;
|
|
10
13
|
defaultRepo?: string;
|
|
@@ -16,6 +19,11 @@ export interface Config {
|
|
|
16
19
|
sendRetryCount: number;
|
|
17
20
|
sendRetryBaseDelayMs: number;
|
|
18
21
|
formatterLocale: 'zh-CN' | 'en-US';
|
|
22
|
+
renderMode: RenderMode;
|
|
23
|
+
renderFallback: RenderFallback;
|
|
24
|
+
renderTheme: RenderTheme;
|
|
25
|
+
renderWidth: number;
|
|
26
|
+
renderTimeoutMs: number;
|
|
19
27
|
rules?: Rule[];
|
|
20
28
|
}
|
|
21
29
|
export declare const Config: Schema<Config>;
|
package/lib/config.js
CHANGED
|
@@ -15,6 +15,22 @@ exports.Config = koishi_1.Schema.object({
|
|
|
15
15
|
koishi_1.Schema.const('zh-CN').description('中文'),
|
|
16
16
|
koishi_1.Schema.const('en-US').description('English'),
|
|
17
17
|
]).default('zh-CN').description('通知文本语言。'),
|
|
18
|
+
renderMode: koishi_1.Schema.union([
|
|
19
|
+
koishi_1.Schema.const('auto').description('自动:优先图片,失败回退'),
|
|
20
|
+
koishi_1.Schema.const('image').description('仅图片'),
|
|
21
|
+
koishi_1.Schema.const('text').description('仅文本'),
|
|
22
|
+
]).default('auto').description('通知渲染模式。'),
|
|
23
|
+
renderFallback: koishi_1.Schema.union([
|
|
24
|
+
koishi_1.Schema.const('text').description('图片失败回退文本'),
|
|
25
|
+
koishi_1.Schema.const('drop').description('图片失败则丢弃'),
|
|
26
|
+
]).default('text').description('图片渲染失败时的回退策略。'),
|
|
27
|
+
renderTheme: koishi_1.Schema.union([
|
|
28
|
+
koishi_1.Schema.const('compact').description('紧凑样式'),
|
|
29
|
+
koishi_1.Schema.const('card').description('卡片样式'),
|
|
30
|
+
koishi_1.Schema.const('terminal').description('终端样式'),
|
|
31
|
+
]).default('compact').description('图片通知主题。'),
|
|
32
|
+
renderWidth: koishi_1.Schema.number().min(480).max(1600).default(840).description('图片宽度(像素)。'),
|
|
33
|
+
renderTimeoutMs: koishi_1.Schema.number().min(1000).max(60000).default(12000).description('单次图片渲染超时(毫秒)。'),
|
|
18
34
|
defaultEvents: koishi_1.Schema.array(koishi_1.Schema.string())
|
|
19
35
|
.default(['push', 'issues', 'issue_comment', 'pull_request', 'pull_request_review', 'release', 'star', 'fork'])
|
|
20
36
|
.description('未显式指定时使用的默认订阅事件列表。'),
|
package/lib/database.js
CHANGED
package/lib/index.js
CHANGED
|
@@ -50,7 +50,7 @@ const formatter_1 = require("./services/formatter");
|
|
|
50
50
|
exports.name = 'githubsth';
|
|
51
51
|
exports.inject = {
|
|
52
52
|
required: ['database'],
|
|
53
|
-
optional: ['github'],
|
|
53
|
+
optional: ['github', 'puppeteer'],
|
|
54
54
|
};
|
|
55
55
|
__exportStar(require("./config"), exports);
|
|
56
56
|
function apply(ctx, config) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Context, Service
|
|
1
|
+
import { Context, Service } from 'koishi';
|
|
2
2
|
import type { Config } from '../config';
|
|
3
3
|
declare module 'koishi' {
|
|
4
4
|
interface Context {
|
|
@@ -8,16 +8,16 @@ declare module 'koishi' {
|
|
|
8
8
|
export declare class Formatter extends Service {
|
|
9
9
|
private readonly locale;
|
|
10
10
|
constructor(ctx: Context, config?: Partial<Config>);
|
|
11
|
-
formatPush(payload: any):
|
|
12
|
-
formatIssue(payload: any):
|
|
13
|
-
formatPullRequest(payload: any):
|
|
14
|
-
formatStar(payload: any):
|
|
15
|
-
formatFork(payload: any):
|
|
16
|
-
formatRelease(payload: any):
|
|
17
|
-
formatDiscussion(payload: any):
|
|
18
|
-
formatWorkflowRun(payload: any):
|
|
19
|
-
formatIssueComment(payload: any):
|
|
20
|
-
formatPullRequestReview(payload: any):
|
|
11
|
+
formatPush(payload: any): string | null;
|
|
12
|
+
formatIssue(payload: any): string;
|
|
13
|
+
formatPullRequest(payload: any): string;
|
|
14
|
+
formatStar(payload: any): string | null;
|
|
15
|
+
formatFork(payload: any): string;
|
|
16
|
+
formatRelease(payload: any): string | null;
|
|
17
|
+
formatDiscussion(payload: any): string;
|
|
18
|
+
formatWorkflowRun(payload: any): string | null;
|
|
19
|
+
formatIssueComment(payload: any): string | null;
|
|
20
|
+
formatPullRequestReview(payload: any): string | null;
|
|
21
21
|
private summarizeCommentBody;
|
|
22
22
|
private mapAction;
|
|
23
23
|
private t;
|
|
@@ -10,7 +10,6 @@ const I18N = {
|
|
|
10
10
|
unknownState: '未知状态',
|
|
11
11
|
unknownBranch: '未知分支',
|
|
12
12
|
unknownResult: '未知结果',
|
|
13
|
-
noDescription: '无描述',
|
|
14
13
|
emptyComment: '(空评论)',
|
|
15
14
|
hiddenMachinePayload: '检测到自动化系统签名/编码载荷,已省略原始内容。',
|
|
16
15
|
pushTitle: '🚀 [代码推送]',
|
|
@@ -71,7 +70,6 @@ const I18N = {
|
|
|
71
70
|
unknownState: 'Unknown state',
|
|
72
71
|
unknownBranch: 'Unknown branch',
|
|
73
72
|
unknownResult: 'Unknown result',
|
|
74
|
-
noDescription: 'No description',
|
|
75
73
|
emptyComment: '(empty comment)',
|
|
76
74
|
hiddenMachinePayload: 'Detected automated signature/encoded payload. Raw content hidden.',
|
|
77
75
|
pushTitle: '🚀 [Push Event]',
|
|
@@ -280,11 +278,9 @@ class Formatter extends koishi_1.Service {
|
|
|
280
278
|
const content = String(raw || '').replace(/\r/g, '').trim();
|
|
281
279
|
if (!content)
|
|
282
280
|
return this.t('emptyComment');
|
|
283
|
-
// Typical Vercel/GitHub app machine signature payload: [vc]: #<token>:<payload>
|
|
284
281
|
if (/^\[vc\]:\s*#[A-Za-z0-9+/=_:-]+/i.test(content)) {
|
|
285
282
|
return this.t('hiddenMachinePayload');
|
|
286
283
|
}
|
|
287
|
-
// Very long high-entropy lines are usually bot payloads/signatures.
|
|
288
284
|
const oneLine = content.replace(/\n+/g, ' ');
|
|
289
285
|
const looksEncoded = oneLine.length > 120 && !/\s/.test(oneLine.slice(0, 80));
|
|
290
286
|
if (looksEncoded)
|
|
@@ -301,7 +297,7 @@ class Formatter extends koishi_1.Service {
|
|
|
301
297
|
return template.replace(/\{(\d+)\}/g, (_, idx) => String(params[Number(idx)] ?? ''));
|
|
302
298
|
}
|
|
303
299
|
render(lines) {
|
|
304
|
-
return
|
|
300
|
+
return `${lines.join('\n')}\n`;
|
|
305
301
|
}
|
|
306
302
|
}
|
|
307
303
|
exports.Formatter = Formatter;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Context, Service } from 'koishi';
|
|
2
|
-
import { Config } from '../config';
|
|
1
|
+
import { Context, Service, h } from 'koishi';
|
|
2
|
+
import { Config, RenderMode } from '../config';
|
|
3
3
|
declare module 'koishi' {
|
|
4
4
|
interface Context {
|
|
5
5
|
githubsthNotifier: Notifier;
|
|
@@ -11,9 +11,28 @@ export declare class Notifier extends Service {
|
|
|
11
11
|
private readonly recentEventKeys;
|
|
12
12
|
private readonly memoryDedupWindowMs;
|
|
13
13
|
private dedupWriteCounter;
|
|
14
|
+
private runtimeRenderMode;
|
|
14
15
|
constructor(ctx: Context, config: Config);
|
|
16
|
+
setRenderMode(mode: RenderMode): void;
|
|
17
|
+
getRenderMode(): RenderMode;
|
|
18
|
+
getRenderStatus(): {
|
|
19
|
+
mode: RenderMode;
|
|
20
|
+
configuredMode: RenderMode;
|
|
21
|
+
fallback: import("../config").RenderFallback;
|
|
22
|
+
theme: import("../config").RenderTheme;
|
|
23
|
+
width: number;
|
|
24
|
+
timeoutMs: number;
|
|
25
|
+
hasPuppeteer: boolean;
|
|
26
|
+
};
|
|
27
|
+
renderPreview(event?: string): Promise<string | h | null>;
|
|
15
28
|
private registerListeners;
|
|
16
29
|
private handleEvent;
|
|
30
|
+
private formatByEvent;
|
|
31
|
+
private prepareOutboundMessage;
|
|
32
|
+
private renderTextAsImage;
|
|
33
|
+
private normalizeRenderedImage;
|
|
34
|
+
private buildImageHtml;
|
|
35
|
+
private escapeHtml;
|
|
17
36
|
private extractRepoName;
|
|
18
37
|
private patchPayloadForEvent;
|
|
19
38
|
private buildEventDedupKey;
|
|
@@ -22,4 +41,5 @@ export declare class Notifier extends Service {
|
|
|
22
41
|
private sendMessage;
|
|
23
42
|
private sendWithRetry;
|
|
24
43
|
private sleep;
|
|
44
|
+
private getPreviewPayload;
|
|
25
45
|
}
|
package/lib/services/notifier.js
CHANGED
|
@@ -10,9 +10,34 @@ class Notifier extends koishi_1.Service {
|
|
|
10
10
|
this.recentEventKeys = new Map();
|
|
11
11
|
this.memoryDedupWindowMs = 5000;
|
|
12
12
|
this.dedupWriteCounter = 0;
|
|
13
|
+
this.runtimeRenderMode = null;
|
|
13
14
|
this.ctx.logger('githubsth').info('Notifier service initialized');
|
|
14
15
|
this.registerListeners();
|
|
15
16
|
}
|
|
17
|
+
setRenderMode(mode) {
|
|
18
|
+
this.runtimeRenderMode = mode;
|
|
19
|
+
}
|
|
20
|
+
getRenderMode() {
|
|
21
|
+
return this.runtimeRenderMode || this.config.renderMode;
|
|
22
|
+
}
|
|
23
|
+
getRenderStatus() {
|
|
24
|
+
const puppeteer = this.ctx.puppeteer;
|
|
25
|
+
return {
|
|
26
|
+
mode: this.getRenderMode(),
|
|
27
|
+
configuredMode: this.config.renderMode,
|
|
28
|
+
fallback: this.config.renderFallback,
|
|
29
|
+
theme: this.config.renderTheme,
|
|
30
|
+
width: this.config.renderWidth,
|
|
31
|
+
timeoutMs: this.config.renderTimeoutMs,
|
|
32
|
+
hasPuppeteer: Boolean(puppeteer?.render),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async renderPreview(event = 'issue_comment') {
|
|
36
|
+
const payload = this.getPreviewPayload(event);
|
|
37
|
+
const text = this.formatByEvent(event, payload) || this.formatByEvent('issue_comment', this.getPreviewPayload('issue_comment')) || 'Preview unavailable.';
|
|
38
|
+
const preview = await this.prepareOutboundMessage(text);
|
|
39
|
+
return preview?.message || null;
|
|
40
|
+
}
|
|
16
41
|
registerListeners() {
|
|
17
42
|
const bind = (name, event) => {
|
|
18
43
|
this.ctx.on(name, (payload) => this.handleEvent(event, payload));
|
|
@@ -76,9 +101,6 @@ class Notifier extends koishi_1.Service {
|
|
|
76
101
|
}
|
|
77
102
|
}
|
|
78
103
|
async handleEvent(event, payload) {
|
|
79
|
-
if (this.config.debug) {
|
|
80
|
-
this.ctx.logger('githubsth').info(`Received event: ${event}`);
|
|
81
|
-
}
|
|
82
104
|
const realPayload = payload.payload || payload;
|
|
83
105
|
if (payload.actor && !realPayload.sender) {
|
|
84
106
|
const actorLogin = payload.actor.login || payload.actor.name || 'GitHub';
|
|
@@ -128,10 +150,6 @@ class Notifier extends koishi_1.Service {
|
|
|
128
150
|
this.ctx.logger('githubsth').warn('Cannot query rules: repoName is missing');
|
|
129
151
|
return;
|
|
130
152
|
}
|
|
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
|
-
}
|
|
135
153
|
const repoNames = [repoName];
|
|
136
154
|
if (repoName !== repoName.toLowerCase())
|
|
137
155
|
repoNames.push(repoName.toLowerCase());
|
|
@@ -144,50 +162,170 @@ class Notifier extends koishi_1.Service {
|
|
|
144
162
|
const matchedRules = allRules.filter((rule) => rule.events.includes('*') || rule.events.includes(event));
|
|
145
163
|
if (!matchedRules.length)
|
|
146
164
|
return;
|
|
147
|
-
|
|
165
|
+
// De-duplicate same delivery target to avoid double push caused by duplicated rules.
|
|
166
|
+
const uniqueRules = Array.from(new Map(matchedRules.map((rule) => [`${repoName}|${rule.channelId}`, rule])).values());
|
|
167
|
+
const textMessage = this.formatByEvent(event, realPayload);
|
|
168
|
+
if (!textMessage)
|
|
169
|
+
return;
|
|
170
|
+
const outbound = await this.prepareOutboundMessage(textMessage);
|
|
171
|
+
if (!outbound) {
|
|
172
|
+
if (this.config.debug)
|
|
173
|
+
this.ctx.logger('githubsth').warn(`Drop message because render failed and fallback=drop (${event}, ${repoName})`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
for (const rule of uniqueRules) {
|
|
177
|
+
await this.sendMessage(rule, outbound);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
formatByEvent(event, payload) {
|
|
181
|
+
switch (event) {
|
|
182
|
+
case 'push':
|
|
183
|
+
return this.ctx.githubsthFormatter.formatPush(payload);
|
|
184
|
+
case 'issues':
|
|
185
|
+
return this.ctx.githubsthFormatter.formatIssue(payload);
|
|
186
|
+
case 'pull_request':
|
|
187
|
+
return this.ctx.githubsthFormatter.formatPullRequest(payload);
|
|
188
|
+
case 'star':
|
|
189
|
+
return this.ctx.githubsthFormatter.formatStar(payload);
|
|
190
|
+
case 'fork':
|
|
191
|
+
return this.ctx.githubsthFormatter.formatFork(payload);
|
|
192
|
+
case 'release':
|
|
193
|
+
return this.ctx.githubsthFormatter.formatRelease(payload);
|
|
194
|
+
case 'discussion':
|
|
195
|
+
return this.ctx.githubsthFormatter.formatDiscussion(payload);
|
|
196
|
+
case 'workflow_run':
|
|
197
|
+
return this.ctx.githubsthFormatter.formatWorkflowRun(payload);
|
|
198
|
+
case 'issue_comment':
|
|
199
|
+
return this.ctx.githubsthFormatter.formatIssueComment(payload);
|
|
200
|
+
case 'pull_request_review':
|
|
201
|
+
return this.ctx.githubsthFormatter.formatPullRequestReview(payload);
|
|
202
|
+
default:
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async prepareOutboundMessage(textMessage) {
|
|
207
|
+
const mode = this.getRenderMode();
|
|
208
|
+
if (mode === 'text') {
|
|
209
|
+
return { message: textMessage, text: textMessage, isImage: false };
|
|
210
|
+
}
|
|
211
|
+
const imageMessage = await this.renderTextAsImage(textMessage);
|
|
212
|
+
if (imageMessage) {
|
|
213
|
+
return { message: imageMessage, text: textMessage, isImage: true };
|
|
214
|
+
}
|
|
215
|
+
if (mode === 'image' && this.config.renderFallback === 'drop')
|
|
216
|
+
return null;
|
|
217
|
+
return { message: textMessage, text: textMessage, isImage: false };
|
|
218
|
+
}
|
|
219
|
+
async renderTextAsImage(textMessage) {
|
|
220
|
+
const puppeteer = this.ctx.puppeteer;
|
|
221
|
+
if (!puppeteer || typeof puppeteer.render !== 'function')
|
|
222
|
+
return null;
|
|
148
223
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
case 'star':
|
|
160
|
-
message = this.ctx.githubsthFormatter.formatStar(realPayload);
|
|
161
|
-
break;
|
|
162
|
-
case 'fork':
|
|
163
|
-
message = this.ctx.githubsthFormatter.formatFork(realPayload);
|
|
164
|
-
break;
|
|
165
|
-
case 'release':
|
|
166
|
-
message = this.ctx.githubsthFormatter.formatRelease(realPayload);
|
|
167
|
-
break;
|
|
168
|
-
case 'discussion':
|
|
169
|
-
message = this.ctx.githubsthFormatter.formatDiscussion(realPayload);
|
|
170
|
-
break;
|
|
171
|
-
case 'workflow_run':
|
|
172
|
-
message = this.ctx.githubsthFormatter.formatWorkflowRun(realPayload);
|
|
173
|
-
break;
|
|
174
|
-
case 'issue_comment':
|
|
175
|
-
message = this.ctx.githubsthFormatter.formatIssueComment(realPayload);
|
|
176
|
-
break;
|
|
177
|
-
case 'pull_request_review':
|
|
178
|
-
message = this.ctx.githubsthFormatter.formatPullRequestReview(realPayload);
|
|
179
|
-
break;
|
|
180
|
-
}
|
|
224
|
+
const html = this.buildImageHtml(textMessage);
|
|
225
|
+
const task = puppeteer.render(html);
|
|
226
|
+
const timeout = this.config.renderTimeoutMs || 12000;
|
|
227
|
+
const rendered = await Promise.race([
|
|
228
|
+
task,
|
|
229
|
+
new Promise((resolve) => setTimeout(() => resolve(null), timeout)),
|
|
230
|
+
]);
|
|
231
|
+
if (!rendered)
|
|
232
|
+
return null;
|
|
233
|
+
return this.normalizeRenderedImage(rendered);
|
|
181
234
|
}
|
|
182
235
|
catch (error) {
|
|
183
|
-
this.ctx.logger('githubsth').
|
|
184
|
-
return;
|
|
236
|
+
this.ctx.logger('githubsth').warn('Image render failed:', error);
|
|
237
|
+
return null;
|
|
185
238
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
239
|
+
}
|
|
240
|
+
normalizeRenderedImage(rendered) {
|
|
241
|
+
if (!rendered)
|
|
242
|
+
return null;
|
|
243
|
+
if (typeof rendered === 'string') {
|
|
244
|
+
const trimmed = rendered.trim();
|
|
245
|
+
if (trimmed.startsWith('<img'))
|
|
246
|
+
return trimmed;
|
|
247
|
+
if (trimmed.startsWith('data:image/'))
|
|
248
|
+
return koishi_1.h.image(trimmed);
|
|
249
|
+
return null;
|
|
190
250
|
}
|
|
251
|
+
if (Buffer.isBuffer(rendered))
|
|
252
|
+
return koishi_1.h.image(rendered, 'image/png');
|
|
253
|
+
if (rendered instanceof Uint8Array)
|
|
254
|
+
return koishi_1.h.image(Buffer.from(rendered), 'image/png');
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
buildImageHtml(textMessage) {
|
|
258
|
+
const escaped = this.escapeHtml(textMessage).replace(/\n/g, '<br/>');
|
|
259
|
+
const width = this.config.renderWidth || 840;
|
|
260
|
+
let bg = 'linear-gradient(135deg, #1f2937, #111827)';
|
|
261
|
+
let card = 'rgba(17, 24, 39, 0.92)';
|
|
262
|
+
let accent = '#22d3ee';
|
|
263
|
+
let font = "'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif";
|
|
264
|
+
if (this.config.renderTheme === 'card') {
|
|
265
|
+
bg = 'linear-gradient(140deg, #0f172a, #1e293b)';
|
|
266
|
+
card = 'rgba(15, 23, 42, 0.9)';
|
|
267
|
+
accent = '#60a5fa';
|
|
268
|
+
}
|
|
269
|
+
else if (this.config.renderTheme === 'terminal') {
|
|
270
|
+
bg = '#0b1020';
|
|
271
|
+
card = '#0b1220';
|
|
272
|
+
accent = '#34d399';
|
|
273
|
+
font = "'Consolas', 'Courier New', monospace";
|
|
274
|
+
}
|
|
275
|
+
return `<!doctype html>
|
|
276
|
+
<html>
|
|
277
|
+
<head>
|
|
278
|
+
<meta charset="utf-8" />
|
|
279
|
+
<style>
|
|
280
|
+
* { box-sizing: border-box; }
|
|
281
|
+
body {
|
|
282
|
+
margin: 0;
|
|
283
|
+
width: ${width}px;
|
|
284
|
+
background: ${bg};
|
|
285
|
+
color: #e5e7eb;
|
|
286
|
+
font-family: ${font};
|
|
287
|
+
}
|
|
288
|
+
.wrap {
|
|
289
|
+
padding: 20px;
|
|
290
|
+
}
|
|
291
|
+
.card {
|
|
292
|
+
border-radius: 14px;
|
|
293
|
+
border: 1px solid rgba(148, 163, 184, 0.28);
|
|
294
|
+
background: ${card};
|
|
295
|
+
overflow: hidden;
|
|
296
|
+
}
|
|
297
|
+
.head {
|
|
298
|
+
padding: 12px 16px;
|
|
299
|
+
border-bottom: 1px solid rgba(148, 163, 184, 0.24);
|
|
300
|
+
font-weight: 700;
|
|
301
|
+
color: ${accent};
|
|
302
|
+
letter-spacing: .3px;
|
|
303
|
+
}
|
|
304
|
+
.content {
|
|
305
|
+
padding: 14px 16px;
|
|
306
|
+
line-height: 1.55;
|
|
307
|
+
word-break: break-word;
|
|
308
|
+
font-size: 14px;
|
|
309
|
+
}
|
|
310
|
+
</style>
|
|
311
|
+
</head>
|
|
312
|
+
<body>
|
|
313
|
+
<div class="wrap">
|
|
314
|
+
<div class="card">
|
|
315
|
+
<div class="head">GitHub Notification</div>
|
|
316
|
+
<div class="content">${escaped}</div>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</body>
|
|
320
|
+
</html>`;
|
|
321
|
+
}
|
|
322
|
+
escapeHtml(input) {
|
|
323
|
+
return String(input)
|
|
324
|
+
.replace(/&/g, '&')
|
|
325
|
+
.replace(/</g, '<')
|
|
326
|
+
.replace(/>/g, '>')
|
|
327
|
+
.replace(/"/g, '"')
|
|
328
|
+
.replace(/'/g, ''');
|
|
191
329
|
}
|
|
192
330
|
extractRepoName(payload, realPayload, event) {
|
|
193
331
|
let repoName = realPayload.repository?.full_name;
|
|
@@ -329,7 +467,6 @@ class Notifier extends koishi_1.Service {
|
|
|
329
467
|
if (recent && now - recent <= this.memoryDedupWindowMs)
|
|
330
468
|
return false;
|
|
331
469
|
this.recentEventKeys.set(dedupKey, now);
|
|
332
|
-
// durable dedup
|
|
333
470
|
const exists = await this.ctx.database.get('github_event_dedup', { dedupKey });
|
|
334
471
|
if (exists.length > 0)
|
|
335
472
|
return false;
|
|
@@ -365,22 +502,31 @@ class Notifier extends koishi_1.Service {
|
|
|
365
502
|
}
|
|
366
503
|
}
|
|
367
504
|
}
|
|
368
|
-
async sendMessage(rule,
|
|
505
|
+
async sendMessage(rule, outbound) {
|
|
369
506
|
const bots = this.ctx.bots.filter((bot) => !rule.platform || bot.platform === rule.platform);
|
|
370
507
|
if (!bots.length)
|
|
371
508
|
return;
|
|
372
509
|
for (const bot of bots) {
|
|
373
510
|
try {
|
|
374
|
-
await this.sendWithRetry(bot, rule.channelId, message);
|
|
375
|
-
if (this.config.debug)
|
|
511
|
+
await this.sendWithRetry(bot, rule.channelId, outbound.message);
|
|
512
|
+
if (this.config.debug)
|
|
376
513
|
this.ctx.logger('notifier').info(`Sent message to ${rule.channelId} via ${bot.platform}:${bot.selfId}`);
|
|
377
|
-
}
|
|
378
514
|
return;
|
|
379
515
|
}
|
|
380
516
|
catch (error) {
|
|
381
|
-
if (this.config.
|
|
382
|
-
|
|
517
|
+
if (outbound.isImage && this.config.renderFallback === 'text') {
|
|
518
|
+
try {
|
|
519
|
+
await this.sendWithRetry(bot, rule.channelId, outbound.text);
|
|
520
|
+
this.ctx.logger('notifier').warn(`Image failed on ${bot.platform}:${bot.selfId}, fallback to text succeeded.`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
catch (fallbackError) {
|
|
524
|
+
if (this.config.debug)
|
|
525
|
+
this.ctx.logger('notifier').warn(`Fallback text send failed: ${fallbackError}`);
|
|
526
|
+
}
|
|
383
527
|
}
|
|
528
|
+
if (this.config.debug)
|
|
529
|
+
this.ctx.logger('notifier').warn(`Bot ${bot.sid} failed to send message with retries: ${error}`);
|
|
384
530
|
}
|
|
385
531
|
}
|
|
386
532
|
this.ctx.logger('notifier').warn(`Failed to send message to ${rule.channelId}`);
|
|
@@ -407,6 +553,55 @@ class Notifier extends koishi_1.Service {
|
|
|
407
553
|
sleep(ms) {
|
|
408
554
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
409
555
|
}
|
|
556
|
+
getPreviewPayload(event) {
|
|
557
|
+
const baseRepo = { full_name: 'acmuhan/JackalClientDocs', stargazers_count: 128 };
|
|
558
|
+
const baseUser = { login: 'acmuhan' };
|
|
559
|
+
const payload = {
|
|
560
|
+
action: 'created',
|
|
561
|
+
repository: baseRepo,
|
|
562
|
+
sender: baseUser,
|
|
563
|
+
issue: {
|
|
564
|
+
number: 29,
|
|
565
|
+
title: 'Delete demobot',
|
|
566
|
+
html_url: 'https://github.com/acmuhan/JackalClientDocs/issues/29',
|
|
567
|
+
pull_request: { html_url: 'https://github.com/acmuhan/JackalClientDocs/pull/29' },
|
|
568
|
+
},
|
|
569
|
+
comment: {
|
|
570
|
+
body: '[vc]: #gOsUN...=eyJpc01vbm9yZXBvIjp0cnVl...'
|
|
571
|
+
},
|
|
572
|
+
pull_request: {
|
|
573
|
+
number: 29,
|
|
574
|
+
title: 'Delete demobot',
|
|
575
|
+
state: 'open',
|
|
576
|
+
html_url: 'https://github.com/acmuhan/JackalClientDocs/pull/29',
|
|
577
|
+
},
|
|
578
|
+
workflow_run: {
|
|
579
|
+
name: 'Deploy',
|
|
580
|
+
conclusion: 'success',
|
|
581
|
+
head_branch: 'main',
|
|
582
|
+
html_url: 'https://github.com/acmuhan/JackalClientDocs/actions/runs/1',
|
|
583
|
+
},
|
|
584
|
+
commits: [
|
|
585
|
+
{ id: 'ea5eaddca38f25ce013ee50d70addb49c8d28844', message: 'Delete demobot', author: { name: 'MuHan' } },
|
|
586
|
+
],
|
|
587
|
+
ref: 'refs/heads/main',
|
|
588
|
+
compare: 'https://github.com/acmuhan/JackalClientDocs/compare/old...new',
|
|
589
|
+
pusher: { name: 'acmuhan' },
|
|
590
|
+
release: { tag_name: 'v1.2.0', name: 'Spring Patch', html_url: 'https://github.com/acmuhan/JackalClientDocs/releases/tag/v1.2.0' },
|
|
591
|
+
forkee: { full_name: 'acmuhan/JackalClientDocs-fork' },
|
|
592
|
+
discussion: { number: 7, title: 'Roadmap', html_url: 'https://github.com/acmuhan/JackalClientDocs/discussions/7' },
|
|
593
|
+
review: { state: 'approved', html_url: 'https://github.com/acmuhan/JackalClientDocs/pull/29#pullrequestreview-1' },
|
|
594
|
+
};
|
|
595
|
+
if (event === 'star')
|
|
596
|
+
payload.action = 'created';
|
|
597
|
+
if (event === 'workflow_run')
|
|
598
|
+
payload.action = 'completed';
|
|
599
|
+
if (event === 'pull_request_review')
|
|
600
|
+
payload.action = 'submitted';
|
|
601
|
+
if (event === 'release')
|
|
602
|
+
payload.action = 'published';
|
|
603
|
+
return payload;
|
|
604
|
+
}
|
|
410
605
|
}
|
|
411
606
|
exports.Notifier = Notifier;
|
|
412
607
|
Notifier.inject = ['githubsthFormatter', 'database'];
|
package/package.json
CHANGED