glad-web 1.0.35 → 1.0.37
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/codex/structured-session.js +57 -49
- package/lib/commands/web.js +23 -2
- package/lib/config/manager.js +18 -0
- package/lib/notifications/message-formatter.js +94 -0
- package/lib/notifications/notification-service.js +143 -0
- package/lib/notifications/serverchan-client.js +58 -0
- package/lib/notifications/serverchan-settings-store.js +115 -0
- package/lib/server/routes/notifications.js +52 -0
- package/lib/session/session-manager.js +1 -0
- package/lib/web/codex.js +130 -43
- package/lib/web/core.js +6 -5
- package/lib/web/index.html +60 -6
- package/lib/web/notifications.js +162 -0
- package/lib/web/schedules.js +1 -1
- package/lib/web/session.js +8 -8
- package/lib/web/styles.css +33 -1
- package/package.json +1 -1
|
@@ -4,8 +4,6 @@ const readline = require('readline');
|
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const PTYManager = require('../session/pty-manager');
|
|
6
6
|
|
|
7
|
-
const CODEX_MESSAGE_PAGE_BYTES = 200 * 1024;
|
|
8
|
-
|
|
9
7
|
const PERMISSION_MODES = new Set(['untrusted', 'on-request', 'never']);
|
|
10
8
|
const SANDBOX_MODES = new Set(['read-only', 'workspace-write', 'danger-full-access']);
|
|
11
9
|
const FALLBACK_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'ultra'];
|
|
@@ -199,6 +197,7 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
199
197
|
this.status = 'idle';
|
|
200
198
|
this.presentation = 'structured';
|
|
201
199
|
this.messages = [];
|
|
200
|
+
this.replayingHistory = false;
|
|
202
201
|
this.pendingPermissions = new Map();
|
|
203
202
|
this.completedPermissions = [];
|
|
204
203
|
this.threadId = options.resume || null;
|
|
@@ -252,58 +251,51 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
252
251
|
}
|
|
253
252
|
|
|
254
253
|
snapshot() {
|
|
255
|
-
const historyPage = this.getMessagePage();
|
|
256
|
-
const { messages, ...historyPageMeta } = historyPage;
|
|
257
254
|
return { id: this.id, name: this.name, tool: this.tool.displayName, toolKey: this.tool.key,
|
|
258
|
-
status: this.status, state: this.getControlState(), messages
|
|
255
|
+
status: this.status, state: this.getControlState(), messages: this.messages.map(item => this.toPublicMessage(item)),
|
|
259
256
|
pendingPermissions: [
|
|
260
257
|
...this.completedPermissions,
|
|
261
258
|
...Array.from(this.pendingPermissions.values()).map(item => item.public)
|
|
262
259
|
] };
|
|
263
260
|
}
|
|
264
261
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (beforeIndex < 0) {
|
|
271
|
-
return { messages: [], hasMore: false, beforeId: null, bytes: 2, maxBytes };
|
|
272
|
-
}
|
|
273
|
-
end = beforeIndex;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (end <= 0) return { messages: [], hasMore: false, beforeId: null, bytes: 2, maxBytes };
|
|
262
|
+
toPublicMessage(item) {
|
|
263
|
+
if (!item || typeof item !== 'object') return item;
|
|
264
|
+
const message = { ...item };
|
|
265
|
+
const isSubagent = Boolean(message.threadId && this.threadId && message.threadId !== this.threadId);
|
|
266
|
+
let hasDetail = false;
|
|
277
267
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
268
|
+
if (message.kind === 'tool') {
|
|
269
|
+
for (const field of ['result', 'input', 'changes', 'error', 'agentsStates']) {
|
|
270
|
+
const value = message[field];
|
|
271
|
+
if (value != null && value !== '' && (!Array.isArray(value) || value.length)) hasDetail = true;
|
|
272
|
+
delete message[field];
|
|
283
273
|
}
|
|
274
|
+
if (item.error) message.hasError = true;
|
|
284
275
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (start < end && candidateBytes > maxBytes) break;
|
|
294
|
-
start = candidateStart;
|
|
295
|
-
selectedBytes = candidateBytes;
|
|
296
|
-
if (candidateBytes >= maxBytes) break;
|
|
276
|
+
if (isSubagent && ['user', 'assistant', 'reasoning', 'event'].includes(message.kind)) {
|
|
277
|
+
if (message.text) hasDetail = true;
|
|
278
|
+
delete message.text;
|
|
279
|
+
delete message.skills;
|
|
280
|
+
}
|
|
281
|
+
if (message.kind === 'reasoning') {
|
|
282
|
+
if (message.text) hasDetail = true;
|
|
283
|
+
delete message.text;
|
|
297
284
|
}
|
|
285
|
+
message.hasDetail = hasDetail;
|
|
286
|
+
message.detailRevision = Number(item.updatedAt || item.createdAt || 0);
|
|
287
|
+
return message;
|
|
288
|
+
}
|
|
298
289
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
};
|
|
290
|
+
getMessageDetails({ ids = [], threadId = null } = {}) {
|
|
291
|
+
const requestedIds = new Set((Array.isArray(ids) ? ids : [])
|
|
292
|
+
.map(value => String(value || '')).filter(Boolean));
|
|
293
|
+
const requestedThreadId = threadId == null ? '' : String(threadId);
|
|
294
|
+
const messages = this.messages.filter(item => {
|
|
295
|
+
if (requestedThreadId && String(item.threadId || '') === requestedThreadId) return true;
|
|
296
|
+
return requestedIds.has(String(item.id || ''));
|
|
297
|
+
}).map(item => ({ ...item, detailLoaded: true }));
|
|
298
|
+
return { messages, threadId: requestedThreadId || null };
|
|
307
299
|
}
|
|
308
300
|
|
|
309
301
|
getControlState() {
|
|
@@ -340,12 +332,17 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
340
332
|
isRunning() { return this.running && (this.presentation !== 'terminal' || Boolean(this.terminalSession)); }
|
|
341
333
|
|
|
342
334
|
createItem(item) { return { id: crypto.randomUUID(), createdAt: Date.now(), ...item }; }
|
|
343
|
-
append(item) {
|
|
335
|
+
append(item) {
|
|
336
|
+
const next = this.createItem(item);
|
|
337
|
+
this.messages.push(next);
|
|
338
|
+
if (!this.replayingHistory) this.emitEvent({ type: 'message', message: this.toPublicMessage(next) });
|
|
339
|
+
return next;
|
|
340
|
+
}
|
|
344
341
|
patch(id, patch) {
|
|
345
342
|
const item = this.messages.find(message => message.id === id);
|
|
346
343
|
if (!item) return null;
|
|
347
344
|
Object.assign(item, { updatedAt: Date.now() }, patch);
|
|
348
|
-
this.emitEvent({ type: 'message-updated', message: item });
|
|
345
|
+
if (!this.replayingHistory) this.emitEvent({ type: 'message-updated', message: this.toPublicMessage(item) });
|
|
349
346
|
return item;
|
|
350
347
|
}
|
|
351
348
|
emitEvent(event) { this.emit('event', event); }
|
|
@@ -379,8 +376,14 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
379
376
|
for (const request of this.pendingRequests.values()) request.reject(new Error(`Codex app-server exited (${code})`));
|
|
380
377
|
this.pendingRequests.clear();
|
|
381
378
|
if (this.running && this.presentation === 'structured') {
|
|
379
|
+
const activeTurn = Boolean(this.currentTurnId || this.status === 'running' || this.status === 'waiting_approval');
|
|
382
380
|
this.compacting = false;
|
|
383
381
|
this.append({ kind: 'event', level: 'error', text: 'Codex app-server exited.' });
|
|
382
|
+
this.emitEvent({
|
|
383
|
+
type: 'runtime-disconnected',
|
|
384
|
+
activeTurn,
|
|
385
|
+
turnId: this.currentTurnId || null
|
|
386
|
+
});
|
|
384
387
|
this.setStatus('idle');
|
|
385
388
|
}
|
|
386
389
|
}
|
|
@@ -979,10 +982,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
979
982
|
this.currentTurnId = started?.turn?.id || started?.turnId || this.currentTurnId;
|
|
980
983
|
return true;
|
|
981
984
|
} catch (error) {
|
|
985
|
+
const failedAt = Date.now();
|
|
982
986
|
this.currentTurnId = null;
|
|
983
987
|
this.currentTurnStartedAt = null;
|
|
984
988
|
this.setStatus('idle');
|
|
985
989
|
this.append({ kind: 'event', level: 'error', text: `Unable to send message: ${error.message}` });
|
|
990
|
+
this.emitEvent({ type: 'turn-failed', createdAt: failedAt });
|
|
986
991
|
throw error;
|
|
987
992
|
}
|
|
988
993
|
}
|
|
@@ -1112,10 +1117,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1112
1117
|
if (!options.preserveEffort) this.effort = thread?.reasoningEffort || thread?.reasoning_effort || this.effort;
|
|
1113
1118
|
this.tokenUsage = thread?.tokenUsage || thread?.token_usage || this.tokenUsage;
|
|
1114
1119
|
this.messages = [];
|
|
1120
|
+
this.replayingHistory = true;
|
|
1115
1121
|
this.completedPermissions = [];
|
|
1116
1122
|
this.turnContexts.clear();
|
|
1117
1123
|
this.providerItemContexts.clear();
|
|
1118
|
-
|
|
1124
|
+
try {
|
|
1125
|
+
for (const turn of thread?.turns || []) {
|
|
1119
1126
|
const status = turn.status === 'failed' ? 'failed' : turn.status === 'interrupted' ? 'cancelled' : 'completed';
|
|
1120
1127
|
const startedAt = Number(turn.startedAt || turn.createdAt || 0);
|
|
1121
1128
|
const completedAt = Number(turn.completedAt || turn.updatedAt || 0);
|
|
@@ -1134,11 +1141,12 @@ class CodexStructuredSession extends EventEmitter {
|
|
|
1134
1141
|
|| (startedAtMs && completedAtMs && completedAtMs >= startedAtMs ? completedAtMs - startedAtMs : null);
|
|
1135
1142
|
this.append({ kind: 'turn-end', turnId: turn.id, status, durationMs,
|
|
1136
1143
|
...(completedAtMs ? { createdAt: completedAtMs } : {}) });
|
|
1144
|
+
}
|
|
1145
|
+
if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
|
|
1146
|
+
} finally {
|
|
1147
|
+
this.replayingHistory = false;
|
|
1137
1148
|
}
|
|
1138
|
-
|
|
1139
|
-
const historyPage = this.getMessagePage();
|
|
1140
|
-
const { messages, ...historyPageMeta } = historyPage;
|
|
1141
|
-
this.emitEvent({ type: 'history-reset', messages, historyPage: historyPageMeta });
|
|
1149
|
+
this.emitEvent({ type: 'history-reset', messages: this.messages.map(item => this.toPublicMessage(item)) });
|
|
1142
1150
|
this.emitEvent({ type: 'state', state: this.getControlState() });
|
|
1143
1151
|
}
|
|
1144
1152
|
|
package/lib/commands/web.js
CHANGED
|
@@ -46,6 +46,10 @@ const { getClaudeRuntimeConfig } = require('../claude/config');
|
|
|
46
46
|
const registerScheduleRoutes = require('../server/routes/schedules');
|
|
47
47
|
const registerWorkspaceRoutes = require('../server/routes/workspace');
|
|
48
48
|
const registerProviderRoutes = require('../server/routes/providers');
|
|
49
|
+
const registerNotificationRoutes = require('../server/routes/notifications');
|
|
50
|
+
const { ServerChanSettingsStore } = require('../notifications/serverchan-settings-store');
|
|
51
|
+
const ServerChanClient = require('../notifications/serverchan-client');
|
|
52
|
+
const NotificationService = require('../notifications/notification-service');
|
|
49
53
|
|
|
50
54
|
async function webCommand(options) {
|
|
51
55
|
const port = parseInt(options.port) || 3000;
|
|
@@ -90,6 +94,13 @@ async function webCommand(options) {
|
|
|
90
94
|
logger,
|
|
91
95
|
hasConnectedSessionClient
|
|
92
96
|
});
|
|
97
|
+
const serverChanSettings = new ServerChanSettingsStore();
|
|
98
|
+
const notificationService = new NotificationService({
|
|
99
|
+
sessionManager,
|
|
100
|
+
settingsStore: serverChanSettings,
|
|
101
|
+
channel: new ServerChanClient(),
|
|
102
|
+
logger
|
|
103
|
+
});
|
|
93
104
|
sessionManager.on('output', ({ sessionId, data }) => {
|
|
94
105
|
broadcastToSession(sessionId, { type: 'output', data });
|
|
95
106
|
});
|
|
@@ -128,6 +139,10 @@ async function webCommand(options) {
|
|
|
128
139
|
});
|
|
129
140
|
|
|
130
141
|
registerScheduleRoutes(app, { jobStore, jobRunner });
|
|
142
|
+
registerNotificationRoutes(app, {
|
|
143
|
+
settingsStore: serverChanSettings,
|
|
144
|
+
notificationService
|
|
145
|
+
});
|
|
131
146
|
|
|
132
147
|
// API: List all active sessions
|
|
133
148
|
app.get('/api/sessions', (req, res) => {
|
|
@@ -377,10 +392,14 @@ async function webCommand(options) {
|
|
|
377
392
|
if (payload.type === 'codex-compact') {
|
|
378
393
|
sessionManager.compactCodexContext(sessionId).catch(error => logger.error(`Codex compact error: ${error.message}`));
|
|
379
394
|
}
|
|
380
|
-
if (payload.type === 'codex-
|
|
395
|
+
if (payload.type === 'codex-detail-request') {
|
|
381
396
|
const codex = sessionManager.get(sessionId);
|
|
382
397
|
if (codex && codex.kind === 'codex-structured' && codex.presentation === 'structured') {
|
|
383
|
-
ws.send(JSON.stringify({
|
|
398
|
+
ws.send(JSON.stringify({
|
|
399
|
+
type: 'codex-detail-response',
|
|
400
|
+
requestId: payload.requestId || null,
|
|
401
|
+
detail: codex.getMessageDetails({ ids: payload.ids, threadId: payload.threadId })
|
|
402
|
+
}));
|
|
384
403
|
}
|
|
385
404
|
}
|
|
386
405
|
if (payload.type === 'codex-abort') {
|
|
@@ -453,6 +472,7 @@ async function webCommand(options) {
|
|
|
453
472
|
'gitgraph.js',
|
|
454
473
|
'styles.css',
|
|
455
474
|
'core.js',
|
|
475
|
+
'notifications.js',
|
|
456
476
|
'claude.js',
|
|
457
477
|
'schedules.js',
|
|
458
478
|
'shell.js',
|
|
@@ -514,6 +534,7 @@ async function webCommand(options) {
|
|
|
514
534
|
|
|
515
535
|
process.on('SIGINT', () => {
|
|
516
536
|
schedulerService.stop();
|
|
537
|
+
notificationService.stop();
|
|
517
538
|
sessionManager.killAll();
|
|
518
539
|
process.exit(0);
|
|
519
540
|
});
|
package/lib/config/manager.js
CHANGED
|
@@ -8,6 +8,24 @@ const schema = {
|
|
|
8
8
|
type: 'string',
|
|
9
9
|
default: ''
|
|
10
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
|
+
},
|
|
11
29
|
version: {
|
|
12
30
|
type: 'string',
|
|
13
31
|
default: '1.0.0'
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
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;
|
|
@@ -0,0 +1,58 @@
|
|
|
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;
|