glad-web 1.0.46 → 2.0.1
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/README.md +4 -192
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/bin/glad.cjs +56 -0
- package/package.json +19 -61
- package/README.zh-CN.md +0 -198
- package/assets/logo.svg +0 -43
- package/bin/cli.js +0 -65
- package/lib/ai-tools/demo/enhanced-demo.js +0 -625
- package/lib/ai-tools/demo/index.js +0 -24
- package/lib/ai-tools/demo/responses.js +0 -88
- package/lib/ai-tools/detector.js +0 -76
- package/lib/ai-tools/registry.js +0 -300
- package/lib/claude/cli-usage.js +0 -95
- package/lib/claude/config.js +0 -82
- package/lib/claude/structured-session.js +0 -884
- package/lib/claude/transcript-repository.js +0 -216
- package/lib/codex/image-store.js +0 -174
- package/lib/codex/structured-session.js +0 -1590
- package/lib/commands/config.js +0 -78
- package/lib/commands/tools.js +0 -128
- package/lib/commands/web.js +0 -605
- package/lib/config/constants.js +0 -17
- package/lib/config/manager.js +0 -108
- package/lib/git/service.js +0 -83
- package/lib/notifications/message-formatter.js +0 -94
- package/lib/notifications/notification-service.js +0 -143
- package/lib/notifications/serverchan-client.js +0 -58
- package/lib/notifications/serverchan-settings-store.js +0 -115
- package/lib/schedule/job-runner.js +0 -162
- package/lib/schedule/job-store.js +0 -167
- package/lib/schedule/key-sequences.js +0 -49
- package/lib/schedule/scheduler-service.js +0 -39
- package/lib/server/routes/notifications.js +0 -52
- package/lib/server/routes/providers.js +0 -114
- package/lib/server/routes/schedules.js +0 -54
- package/lib/server/routes/skillhub.js +0 -104
- package/lib/server/routes/usage.js +0 -23
- package/lib/server/routes/workspace.js +0 -77
- package/lib/session/buffer.js +0 -102
- package/lib/session/file-attachment-store.js +0 -168
- package/lib/session/pty-manager.js +0 -255
- package/lib/session/rendered-history.js +0 -225
- package/lib/session/session-manager.js +0 -1032
- package/lib/session/text-history.js +0 -274
- package/lib/skillhub/client.js +0 -121
- package/lib/skillhub/settings-store.js +0 -168
- package/lib/skillhub/skill-installer.js +0 -320
- package/lib/usage/ccusage-runner.js +0 -128
- package/lib/usage/source-catalog.js +0 -26
- package/lib/usage/usage-service.js +0 -226
- package/lib/utils/logger.js +0 -74
- package/lib/utils/pid.js +0 -67
- package/lib/utils/validation.js +0 -53
- package/lib/web/bootstrap.js +0 -34
- package/lib/web/claude.js +0 -1150
- package/lib/web/codex.js +0 -1045
- package/lib/web/composer.js +0 -493
- package/lib/web/core.js +0 -385
- package/lib/web/git.js +0 -535
- package/lib/web/gitgraph.js +0 -293
- package/lib/web/index.html +0 -547
- package/lib/web/layout.js +0 -69
- package/lib/web/notifications.js +0 -164
- package/lib/web/schedules.js +0 -245
- package/lib/web/session.js +0 -361
- package/lib/web/shell.js +0 -74
- package/lib/web/skillhub.js +0 -197
- package/lib/web/styles.css +0 -932
- package/lib/web/terminal-scroll.js +0 -81
- package/lib/web/theme.js +0 -60
- package/lib/web/timed-inputs.js +0 -216
- package/lib/web/usage.js +0 -323
- package/lib/workspace/service.js +0 -77
- package/scripts/check-syntax.js +0 -26
package/lib/config/manager.js
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
const Conf = require('conf');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
|
|
5
|
-
// Config schema - only user preferences, no environment config
|
|
6
|
-
const schema = {
|
|
7
|
-
defaultAI: {
|
|
8
|
-
type: 'string',
|
|
9
|
-
default: ''
|
|
10
|
-
},
|
|
11
|
-
serverChan: {
|
|
12
|
-
type: 'object',
|
|
13
|
-
properties: {
|
|
14
|
-
sendKey: {
|
|
15
|
-
type: 'string',
|
|
16
|
-
default: ''
|
|
17
|
-
},
|
|
18
|
-
clientType: {
|
|
19
|
-
type: 'string',
|
|
20
|
-
enum: ['wechat', 'pushdeer'],
|
|
21
|
-
default: 'wechat'
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
default: {
|
|
25
|
-
sendKey: '',
|
|
26
|
-
clientType: 'wechat'
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
skillHub: {
|
|
30
|
-
type: 'object',
|
|
31
|
-
properties: {
|
|
32
|
-
baseUrl: { type: 'string', default: '' },
|
|
33
|
-
token: {
|
|
34
|
-
type: 'object',
|
|
35
|
-
properties: {
|
|
36
|
-
ciphertext: { type: 'string', default: '' },
|
|
37
|
-
iv: { type: 'string', default: '' },
|
|
38
|
-
authTag: { type: 'string', default: '' }
|
|
39
|
-
},
|
|
40
|
-
default: { ciphertext: '', iv: '', authTag: '' }
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
default: {
|
|
44
|
-
baseUrl: '',
|
|
45
|
-
token: { ciphertext: '', iv: '', authTag: '' }
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
version: {
|
|
49
|
-
type: 'string',
|
|
50
|
-
default: '1.0.0'
|
|
51
|
-
},
|
|
52
|
-
lastUpdated: {
|
|
53
|
-
type: 'string',
|
|
54
|
-
default: ''
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
// Create config instance
|
|
59
|
-
const config = new Conf({
|
|
60
|
-
projectName: 'glad',
|
|
61
|
-
cwd: path.join(os.homedir(), '.glad'),
|
|
62
|
-
configName: 'config',
|
|
63
|
-
schema
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// Get config value
|
|
67
|
-
function getConfig(key) {
|
|
68
|
-
if (key) {
|
|
69
|
-
return config.get(key);
|
|
70
|
-
}
|
|
71
|
-
return config.store;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Set config value
|
|
75
|
-
function setConfig(key, value) {
|
|
76
|
-
config.set(key, value);
|
|
77
|
-
config.set('lastUpdated', new Date().toISOString());
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Get default AI tool
|
|
81
|
-
function getDefaultAI() {
|
|
82
|
-
const value = config.get('defaultAI');
|
|
83
|
-
return value || null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Set default AI tool
|
|
87
|
-
function setDefaultAI(tool) {
|
|
88
|
-
setConfig('defaultAI', tool);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Get config file path
|
|
92
|
-
function getConfigPath() {
|
|
93
|
-
return config.path;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Reset config to defaults
|
|
97
|
-
function resetConfig() {
|
|
98
|
-
config.clear();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
module.exports = {
|
|
102
|
-
getConfig,
|
|
103
|
-
setConfig,
|
|
104
|
-
getDefaultAI,
|
|
105
|
-
setDefaultAI,
|
|
106
|
-
getConfigPath,
|
|
107
|
-
resetConfig
|
|
108
|
-
};
|
package/lib/git/service.js
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
const { execFile } = require('child_process');
|
|
2
|
-
|
|
3
|
-
function execFilePromise(file, args, cwd) {
|
|
4
|
-
return new Promise((resolve) => {
|
|
5
|
-
execFile(file, args, { cwd, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
6
|
-
resolve({ success: !error, error: error?.message, stdout, stderr });
|
|
7
|
-
});
|
|
8
|
-
});
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function parseGitStatusZ(stdout) {
|
|
12
|
-
if (!stdout) return [];
|
|
13
|
-
|
|
14
|
-
const entries = [];
|
|
15
|
-
const records = stdout.split('\0').filter(Boolean);
|
|
16
|
-
|
|
17
|
-
for (let i = 0; i < records.length; i++) {
|
|
18
|
-
const record = records[i];
|
|
19
|
-
if (record.length < 3) continue;
|
|
20
|
-
|
|
21
|
-
const status = record.substring(0, 2);
|
|
22
|
-
const path = record.substring(3);
|
|
23
|
-
const entry = { path, status };
|
|
24
|
-
|
|
25
|
-
if ((status.includes('R') || status.includes('C')) && i + 1 < records.length) {
|
|
26
|
-
entry.originalPath = records[++i];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
entries.push(entry);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return entries;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
class GitService {
|
|
36
|
-
async show(cwd, hash) {
|
|
37
|
-
return execFilePromise('git', ['show', '--format=fuller', '--stat', '-p', hash], cwd);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async log(cwd, maxCount = 100) {
|
|
41
|
-
const count = Number.parseInt(maxCount, 10) || 100;
|
|
42
|
-
const result = await execFilePromise(
|
|
43
|
-
'git',
|
|
44
|
-
['log', '--all', '--date-order', `--max-count=${count}`, '--pretty=format:%h|%p|%d|%s|%an|%ar'],
|
|
45
|
-
cwd
|
|
46
|
-
);
|
|
47
|
-
if (!result.success) return result;
|
|
48
|
-
|
|
49
|
-
const commits = result.stdout.split('\n').filter(Boolean).map(line => {
|
|
50
|
-
const [hash, parents, refs, subject, author, time] = line.split('|');
|
|
51
|
-
return { hash, parents: parents ? parents.split(' ') : [], refs: refs ? refs.trim() : '', subject, author, time };
|
|
52
|
-
});
|
|
53
|
-
return { ...result, commits };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async status(cwd) {
|
|
57
|
-
const result = await execFilePromise('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], cwd);
|
|
58
|
-
if (!result.success) return result;
|
|
59
|
-
return { ...result, files: parseGitStatusZ(result.stdout) };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
diffNumstat(cwd, isStaged = false) {
|
|
63
|
-
const args = isStaged ? ['diff', '--cached', '--numstat'] : ['diff', '--numstat'];
|
|
64
|
-
return execFilePromise('git', args, cwd);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
diffFile(cwd, filePath, isStaged = false) {
|
|
68
|
-
const args = isStaged
|
|
69
|
-
? ['diff', '--cached', '--no-ext-diff', '--', filePath]
|
|
70
|
-
: ['diff', '--no-ext-diff', '--', filePath];
|
|
71
|
-
return execFilePromise('git', args, cwd);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
async nameRev(cwd, hash) {
|
|
75
|
-
return execFilePromise('git', ['name-rev', '--name-only', '--exclude=tags/*', hash], cwd);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
module.exports = {
|
|
80
|
-
GitService,
|
|
81
|
-
execFilePromise,
|
|
82
|
-
parseGitStatusZ
|
|
83
|
-
};
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
const path = require('path');
|
|
2
|
-
|
|
3
|
-
const TITLE_LABELS = {
|
|
4
|
-
approval: '待审批',
|
|
5
|
-
completed: '已完成',
|
|
6
|
-
failed: '执行失败',
|
|
7
|
-
disconnected: '连接中断',
|
|
8
|
-
test: '通知测试'
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
function compactText(value, maxLength = 20) {
|
|
12
|
-
const text = String(value || '').replace(/[\r\n]+/g, ' ').trim();
|
|
13
|
-
if (text.length <= maxLength) return text;
|
|
14
|
-
return `${text.slice(0, Math.max(1, maxLength - 1))}…`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function formatLocalDateTime(value) {
|
|
18
|
-
const date = new Date(Number(value) || Date.now());
|
|
19
|
-
const pad = number => String(number).padStart(2, '0');
|
|
20
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
21
|
-
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function formatDuration(durationMs) {
|
|
25
|
-
const totalSeconds = Math.max(0, Math.round(Number(durationMs) / 1000));
|
|
26
|
-
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return '';
|
|
27
|
-
if (totalSeconds < 60) return `${totalSeconds}秒`;
|
|
28
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
29
|
-
const seconds = totalSeconds % 60;
|
|
30
|
-
return seconds ? `${minutes}分${seconds}秒` : `${minutes}分`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function escapeMarkdown(value) {
|
|
34
|
-
return String(value || '').replace(/([\\`*_{}\[\]()#+\-.!|>])/g, '\\$1');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function providerName(provider, session) {
|
|
38
|
-
if (provider === 'claude') return 'Claude';
|
|
39
|
-
if (provider === 'codex') return 'Codex';
|
|
40
|
-
return session?.tool?.displayName || 'AI';
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function sessionDirectory(session) {
|
|
44
|
-
return session?.workingDir || session?.workingDirectory || session?.ptyManager?.workingDir || process.cwd();
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function formatNotification({ kind, provider, session, durationMs = null }, clientType = 'wechat') {
|
|
48
|
-
const name = String(session?.name || providerName(provider, session));
|
|
49
|
-
const directory = sessionDirectory(session);
|
|
50
|
-
const type = providerName(provider, session);
|
|
51
|
-
const title = `${TITLE_LABELS[kind] || '通知'}|${compactText(name)}`;
|
|
52
|
-
const details = [
|
|
53
|
-
['类型', type],
|
|
54
|
-
['会话', name],
|
|
55
|
-
['创建', formatLocalDateTime(session?.startTime)],
|
|
56
|
-
['目录', directory]
|
|
57
|
-
];
|
|
58
|
-
const duration = formatDuration(durationMs);
|
|
59
|
-
if (duration) details.push(['本轮耗时', duration]);
|
|
60
|
-
|
|
61
|
-
if (clientType === 'pushdeer') {
|
|
62
|
-
return {
|
|
63
|
-
title,
|
|
64
|
-
description: details
|
|
65
|
-
.map(([label, value]) => label === '目录'
|
|
66
|
-
? `**${label}:** \`${escapeMarkdown(value)}\``
|
|
67
|
-
: `**${label}:** ${escapeMarkdown(value)}`)
|
|
68
|
-
.join('\n\n')
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
title,
|
|
74
|
-
description: details.map(([label, value]) => `${label}:${value}`).join('\n\n')
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function createTestSession(session = null) {
|
|
79
|
-
if (session) return session;
|
|
80
|
-
return {
|
|
81
|
-
name: 'Glad 测试会话',
|
|
82
|
-
startTime: Date.now(),
|
|
83
|
-
workingDir: path.resolve(process.cwd()),
|
|
84
|
-
tool: { displayName: 'Glad' }
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
module.exports = {
|
|
89
|
-
formatNotification,
|
|
90
|
-
createTestSession,
|
|
91
|
-
compactText,
|
|
92
|
-
formatLocalDateTime,
|
|
93
|
-
formatDuration
|
|
94
|
-
};
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
const { formatNotification, createTestSession } = require('./message-formatter');
|
|
2
|
-
|
|
3
|
-
class NotificationService {
|
|
4
|
-
constructor({ sessionManager, settingsStore, channel, logger = console }) {
|
|
5
|
-
this.sessionManager = sessionManager;
|
|
6
|
-
this.settingsStore = settingsStore;
|
|
7
|
-
this.channel = channel;
|
|
8
|
-
this.logger = logger;
|
|
9
|
-
this.seenEvents = new Map();
|
|
10
|
-
this.handlers = {
|
|
11
|
-
claude: payload => this.handleProviderEvent('claude', payload),
|
|
12
|
-
codex: payload => this.handleProviderEvent('codex', payload),
|
|
13
|
-
exit: payload => this.clearSession(payload.sessionId)
|
|
14
|
-
};
|
|
15
|
-
sessionManager.on('claude-event', this.handlers.claude);
|
|
16
|
-
sessionManager.on('codex-event', this.handlers.codex);
|
|
17
|
-
sessionManager.on('exit', this.handlers.exit);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
stop() {
|
|
21
|
-
this.sessionManager.off('claude-event', this.handlers.claude);
|
|
22
|
-
this.sessionManager.off('codex-event', this.handlers.codex);
|
|
23
|
-
this.sessionManager.off('exit', this.handlers.exit);
|
|
24
|
-
this.seenEvents.clear();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
getSessionState(sessionId) {
|
|
28
|
-
const session = this.sessionManager.get(sessionId);
|
|
29
|
-
if (!session) return null;
|
|
30
|
-
return {
|
|
31
|
-
enabled: Boolean(session.serverChanNotificationEnabled),
|
|
32
|
-
configured: this.settingsStore.getPublic().configured
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async setSessionEnabled(sessionId, enabled) {
|
|
37
|
-
const session = this.sessionManager.get(sessionId);
|
|
38
|
-
if (!session) {
|
|
39
|
-
const error = new Error('Session not found');
|
|
40
|
-
error.statusCode = 404;
|
|
41
|
-
throw error;
|
|
42
|
-
}
|
|
43
|
-
if (enabled && !this.settingsStore.getPublic().configured) {
|
|
44
|
-
const error = new Error('请先配置 Server酱');
|
|
45
|
-
error.statusCode = 409;
|
|
46
|
-
error.code = 'SERVERCHAN_NOT_CONFIGURED';
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
session.serverChanNotificationEnabled = Boolean(enabled);
|
|
50
|
-
if (enabled && session.pendingPermissions?.size > 0) {
|
|
51
|
-
await this.sendEvent({
|
|
52
|
-
kind: 'approval',
|
|
53
|
-
provider: session.kind === 'claude-structured' ? 'claude' : 'codex',
|
|
54
|
-
session
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
return this.getSessionState(sessionId);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
disableAllSessions() {
|
|
61
|
-
for (const session of this.sessionManager.sessions.values()) {
|
|
62
|
-
session.serverChanNotificationEnabled = false;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async sendTest(input = {}) {
|
|
67
|
-
const settings = this.settingsStore.resolve(input);
|
|
68
|
-
const session = createTestSession(
|
|
69
|
-
input.sessionId ? this.sessionManager.get(input.sessionId) : null
|
|
70
|
-
);
|
|
71
|
-
const provider = session?.kind === 'claude-structured'
|
|
72
|
-
? 'claude'
|
|
73
|
-
: session?.kind === 'codex-structured' ? 'codex' : null;
|
|
74
|
-
const message = formatNotification({ kind: 'test', provider, session }, settings.clientType);
|
|
75
|
-
await this.channel.send({ ...settings, ...message });
|
|
76
|
-
return { success: true };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
handleProviderEvent(provider, payload = {}) {
|
|
80
|
-
const { session, event, sessionId } = payload;
|
|
81
|
-
if (!session || !event || !session.serverChanNotificationEnabled) return;
|
|
82
|
-
|
|
83
|
-
if (event.type === 'permission-request') {
|
|
84
|
-
const requestId = event.request?.id || event.request?.toolUseId || 'pending';
|
|
85
|
-
if (!this.markSeen(sessionId, `approval:${requestId}`)) return;
|
|
86
|
-
this.sendEvent({ kind: 'approval', provider, session });
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (event.type === 'runtime-disconnected') {
|
|
91
|
-
if (!event.activeTurn || !this.markSeen(sessionId, `disconnected:${event.turnId || 'active'}`)) return;
|
|
92
|
-
this.sendEvent({ kind: 'disconnected', provider, session });
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (event.type === 'turn-failed') {
|
|
97
|
-
if (!this.markSeen(sessionId, `failed:${event.turnId || event.createdAt || 'start'}`)) return;
|
|
98
|
-
this.sendEvent({ kind: 'failed', provider, session, durationMs: event.durationMs });
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const message = event.type === 'message' ? event.message : null;
|
|
103
|
-
if (!message || message.kind !== 'turn-end') return;
|
|
104
|
-
if (provider === 'codex' && message.threadId && session.threadId && message.threadId !== session.threadId) return;
|
|
105
|
-
|
|
106
|
-
const status = String(message.turnStatus || message.status || 'completed');
|
|
107
|
-
if (status === 'cancelled' || status === 'interrupted') return;
|
|
108
|
-
const turnId = message.turnId || message.id;
|
|
109
|
-
if (!this.markSeen(sessionId, `turn:${turnId}`)) return;
|
|
110
|
-
this.sendEvent({
|
|
111
|
-
kind: status === 'failed' ? 'failed' : 'completed',
|
|
112
|
-
provider,
|
|
113
|
-
session,
|
|
114
|
-
durationMs: message.durationMs
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
markSeen(sessionId, key) {
|
|
119
|
-
const seen = this.seenEvents.get(sessionId) || new Set();
|
|
120
|
-
if (seen.has(key)) return false;
|
|
121
|
-
seen.add(key);
|
|
122
|
-
if (seen.size > 100) seen.delete(seen.values().next().value);
|
|
123
|
-
this.seenEvents.set(sessionId, seen);
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
clearSession(sessionId) {
|
|
128
|
-
this.seenEvents.delete(sessionId);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async sendEvent(event) {
|
|
132
|
-
try {
|
|
133
|
-
const settings = this.settingsStore.get();
|
|
134
|
-
if (!settings.sendKey) return;
|
|
135
|
-
const message = formatNotification(event, settings.clientType);
|
|
136
|
-
await this.channel.send({ ...settings, ...message });
|
|
137
|
-
} catch (error) {
|
|
138
|
-
this.logger.error?.(`[serverchan] notification failed: ${error.message}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
module.exports = NotificationService;
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
const API_BASE = 'https://sctapi.ftqq.com';
|
|
2
|
-
|
|
3
|
-
function sanitizeServerChanError(payload, fallback) {
|
|
4
|
-
const message = payload && typeof payload === 'object'
|
|
5
|
-
? payload.message || payload.error || payload.data?.error
|
|
6
|
-
: '';
|
|
7
|
-
const text = String(message || fallback || 'Server酱发送失败').trim();
|
|
8
|
-
return text.slice(0, 300);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
class ServerChanClient {
|
|
12
|
-
constructor({ fetchImpl = globalThis.fetch, timeoutMs = 10_000 } = {}) {
|
|
13
|
-
this.fetchImpl = fetchImpl;
|
|
14
|
-
this.timeoutMs = timeoutMs;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async send({ sendKey, title, description = '' }) {
|
|
18
|
-
const controller = new AbortController();
|
|
19
|
-
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
20
|
-
try {
|
|
21
|
-
const response = await this.fetchImpl(`${API_BASE}/${encodeURIComponent(sendKey)}.send`, {
|
|
22
|
-
method: 'POST',
|
|
23
|
-
headers: {
|
|
24
|
-
'content-type': 'application/x-www-form-urlencoded'
|
|
25
|
-
},
|
|
26
|
-
body: new URLSearchParams({
|
|
27
|
-
title: String(title || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 64),
|
|
28
|
-
desp: String(description || '')
|
|
29
|
-
}),
|
|
30
|
-
signal: controller.signal
|
|
31
|
-
});
|
|
32
|
-
const text = await response.text();
|
|
33
|
-
let payload = null;
|
|
34
|
-
try {
|
|
35
|
-
payload = text ? JSON.parse(text) : null;
|
|
36
|
-
} catch (_) {
|
|
37
|
-
payload = null;
|
|
38
|
-
}
|
|
39
|
-
if (!response.ok || (payload && Number(payload.code) !== 0)) {
|
|
40
|
-
const error = new Error(sanitizeServerChanError(payload, `HTTP ${response.status}`));
|
|
41
|
-
error.statusCode = 502;
|
|
42
|
-
throw error;
|
|
43
|
-
}
|
|
44
|
-
return payload || { code: 0 };
|
|
45
|
-
} catch (error) {
|
|
46
|
-
if (error && error.name === 'AbortError') {
|
|
47
|
-
const timeoutError = new Error('Server酱请求超时');
|
|
48
|
-
timeoutError.statusCode = 504;
|
|
49
|
-
throw timeoutError;
|
|
50
|
-
}
|
|
51
|
-
throw error;
|
|
52
|
-
} finally {
|
|
53
|
-
clearTimeout(timeout);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
module.exports = ServerChanClient;
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const {
|
|
3
|
-
getConfig,
|
|
4
|
-
setConfig,
|
|
5
|
-
getConfigPath
|
|
6
|
-
} = require('../config/manager');
|
|
7
|
-
|
|
8
|
-
const CLIENT_TYPES = new Set(['wechat', 'pushdeer']);
|
|
9
|
-
|
|
10
|
-
function normalizeClientType(value) {
|
|
11
|
-
const clientType = String(value || 'wechat').trim().toLowerCase();
|
|
12
|
-
if (!CLIENT_TYPES.has(clientType)) {
|
|
13
|
-
const error = new Error('接收客户端必须是微信或 PushDeer');
|
|
14
|
-
error.statusCode = 400;
|
|
15
|
-
throw error;
|
|
16
|
-
}
|
|
17
|
-
return clientType;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function normalizeSendKey(value) {
|
|
21
|
-
const sendKey = String(value || '').trim();
|
|
22
|
-
if (!sendKey || sendKey.length < 8 || sendKey.length > 512 || /\s/.test(sendKey)) {
|
|
23
|
-
const error = new Error('请输入有效的 Server酱 SendKey');
|
|
24
|
-
error.statusCode = 400;
|
|
25
|
-
throw error;
|
|
26
|
-
}
|
|
27
|
-
return sendKey;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function maskSendKey(sendKey) {
|
|
31
|
-
if (!sendKey) return '';
|
|
32
|
-
return `${sendKey.slice(0, Math.min(3, sendKey.length))}${'•'.repeat(10)}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
class ServerChanSettingsStore {
|
|
36
|
-
constructor({
|
|
37
|
-
readConfig = getConfig,
|
|
38
|
-
writeConfig = setConfig,
|
|
39
|
-
configPath = getConfigPath,
|
|
40
|
-
chmod = fs.chmodSync
|
|
41
|
-
} = {}) {
|
|
42
|
-
this.readConfig = readConfig;
|
|
43
|
-
this.writeConfig = writeConfig;
|
|
44
|
-
this.configPath = configPath;
|
|
45
|
-
this.chmod = chmod;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get() {
|
|
49
|
-
const stored = this.readConfig('serverChan') || {};
|
|
50
|
-
return {
|
|
51
|
-
sendKey: String(stored.sendKey || '').trim(),
|
|
52
|
-
clientType: CLIENT_TYPES.has(stored.clientType) ? stored.clientType : 'wechat'
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
getPublic() {
|
|
57
|
-
const settings = this.get();
|
|
58
|
-
return {
|
|
59
|
-
configured: Boolean(settings.sendKey),
|
|
60
|
-
maskedKey: maskSendKey(settings.sendKey),
|
|
61
|
-
clientType: settings.clientType
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
save(input = {}) {
|
|
66
|
-
const existing = this.get();
|
|
67
|
-
const sendKey = input.sendKey == null || String(input.sendKey).trim() === ''
|
|
68
|
-
? existing.sendKey
|
|
69
|
-
: normalizeSendKey(input.sendKey);
|
|
70
|
-
if (!sendKey) {
|
|
71
|
-
const error = new Error('请先填写 Server酱 SendKey');
|
|
72
|
-
error.statusCode = 400;
|
|
73
|
-
throw error;
|
|
74
|
-
}
|
|
75
|
-
const settings = {
|
|
76
|
-
sendKey,
|
|
77
|
-
clientType: normalizeClientType(input.clientType ?? existing.clientType)
|
|
78
|
-
};
|
|
79
|
-
this.writeConfig('serverChan', settings);
|
|
80
|
-
this.restrictConfigFile();
|
|
81
|
-
return this.getPublic();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
resolve(input = {}) {
|
|
85
|
-
const existing = this.get();
|
|
86
|
-
return {
|
|
87
|
-
sendKey: input.sendKey == null || String(input.sendKey).trim() === ''
|
|
88
|
-
? normalizeSendKey(existing.sendKey)
|
|
89
|
-
: normalizeSendKey(input.sendKey),
|
|
90
|
-
clientType: normalizeClientType(input.clientType ?? existing.clientType)
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
clear() {
|
|
95
|
-
this.writeConfig('serverChan', { sendKey: '', clientType: 'wechat' });
|
|
96
|
-
this.restrictConfigFile();
|
|
97
|
-
return this.getPublic();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
restrictConfigFile() {
|
|
101
|
-
try {
|
|
102
|
-
const configPath = typeof this.configPath === 'function' ? this.configPath() : this.configPath;
|
|
103
|
-
if (configPath && fs.existsSync(configPath)) this.chmod(configPath, 0o600);
|
|
104
|
-
} catch (_) {
|
|
105
|
-
// Best effort: configuration remains usable on filesystems without chmod support.
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
module.exports = {
|
|
111
|
-
ServerChanSettingsStore,
|
|
112
|
-
normalizeClientType,
|
|
113
|
-
normalizeSendKey,
|
|
114
|
-
maskSendKey
|
|
115
|
-
};
|