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.
@@ -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, historyPage: historyPageMeta,
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
- getMessagePage(beforeId = null, maxBytes = CODEX_MESSAGE_PAGE_BYTES) {
266
- const requestedBeforeId = beforeId == null ? '' : String(beforeId);
267
- let end = this.messages.length;
268
- if (requestedBeforeId) {
269
- const beforeIndex = this.messages.findIndex(item => String(item.id || '') === requestedBeforeId);
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
- const groupStarts = [0];
279
- for (let i = 1; i < end; i++) {
280
- const item = this.messages[i];
281
- if (item.kind === 'turn-start' && (!item.threadId || item.threadId === this.threadId)) {
282
- groupStarts.push(i);
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
- groupStarts.push(end);
286
-
287
- let start = end;
288
- let selectedBytes = 2;
289
- for (let groupIndex = groupStarts.length - 2; groupIndex >= 0; groupIndex--) {
290
- const candidateStart = groupStarts[groupIndex];
291
- const candidate = this.messages.slice(candidateStart, end);
292
- const candidateBytes = Buffer.byteLength(JSON.stringify(candidate), 'utf8');
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
- const messages = this.messages.slice(start, end);
300
- return {
301
- messages,
302
- hasMore: start > 0,
303
- beforeId: messages[0]?.id || null,
304
- bytes: selectedBytes,
305
- maxBytes
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) { const next = this.createItem(item); this.messages.push(next); this.emitEvent({ type: 'message', message: next }); return next; }
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
- for (const turn of thread?.turns || []) {
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
- if (eventText) this.append({ kind: 'event', level: 'info', text: eventText });
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
 
@@ -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-history-before') {
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({ type: 'codex-history-page', page: codex.getMessagePage(payload.beforeId) }));
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
  });
@@ -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;