@calltelemetry/openclaw-linear 0.4.1 → 0.5.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/README.md +263 -249
- package/index.ts +104 -1
- package/openclaw.plugin.json +4 -1
- package/package.json +5 -1
- package/prompts.yaml +61 -0
- package/src/active-session.ts +1 -1
- package/src/cli.ts +103 -0
- package/src/dispatch-service.ts +50 -2
- package/src/dispatch-state.ts +240 -8
- package/src/notify.ts +91 -0
- package/src/pipeline.ts +561 -406
- package/src/tier-assess.ts +1 -1
- package/src/webhook.ts +39 -30
package/src/dispatch-state.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dispatch-state.ts — File-backed persistent dispatch state.
|
|
2
|
+
* dispatch-state.ts — File-backed persistent dispatch state (v2).
|
|
3
3
|
*
|
|
4
4
|
* Tracks active and completed dispatches across gateway restarts.
|
|
5
5
|
* Uses file-level locking to prevent concurrent read-modify-write races.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* v2 additions:
|
|
8
|
+
* - Atomic compare-and-swap (CAS) transitions
|
|
9
|
+
* - Session-to-dispatch map for agent_end hook lookup
|
|
10
|
+
* - Monotonic attempt counter for stale-event rejection
|
|
11
|
+
* - "stuck" as terminal state with reason
|
|
12
|
+
* - No separate "rework" state — rework is "working" with attempt > 0
|
|
9
13
|
*/
|
|
10
14
|
import fs from "node:fs/promises";
|
|
11
15
|
import { existsSync, mkdirSync } from "node:fs";
|
|
@@ -18,6 +22,24 @@ import { homedir } from "node:os";
|
|
|
18
22
|
|
|
19
23
|
export type Tier = "junior" | "medior" | "senior";
|
|
20
24
|
|
|
25
|
+
export type DispatchStatus =
|
|
26
|
+
| "dispatched"
|
|
27
|
+
| "working"
|
|
28
|
+
| "auditing"
|
|
29
|
+
| "done"
|
|
30
|
+
| "failed"
|
|
31
|
+
| "stuck";
|
|
32
|
+
|
|
33
|
+
/** Valid CAS transitions: from → allowed next states */
|
|
34
|
+
const VALID_TRANSITIONS: Record<DispatchStatus, DispatchStatus[]> = {
|
|
35
|
+
dispatched: ["working", "failed", "stuck"],
|
|
36
|
+
working: ["auditing", "failed", "stuck"],
|
|
37
|
+
auditing: ["done", "working", "stuck"], // working = rework (attempt++)
|
|
38
|
+
done: [], // terminal
|
|
39
|
+
failed: [], // terminal
|
|
40
|
+
stuck: [], // terminal
|
|
41
|
+
};
|
|
42
|
+
|
|
21
43
|
export interface ActiveDispatch {
|
|
22
44
|
issueId: string;
|
|
23
45
|
issueIdentifier: string;
|
|
@@ -25,10 +47,16 @@ export interface ActiveDispatch {
|
|
|
25
47
|
branch: string;
|
|
26
48
|
tier: Tier;
|
|
27
49
|
model: string;
|
|
28
|
-
status:
|
|
50
|
+
status: DispatchStatus;
|
|
29
51
|
dispatchedAt: string;
|
|
30
52
|
agentSessionId?: string;
|
|
31
53
|
project?: string;
|
|
54
|
+
|
|
55
|
+
// v2 fields
|
|
56
|
+
attempt: number; // monotonic: 0 on first run, increments on rework
|
|
57
|
+
workerSessionKey?: string; // session key for current worker sub-agent
|
|
58
|
+
auditSessionKey?: string; // session key for current audit sub-agent
|
|
59
|
+
stuckReason?: string; // only set when status === "stuck"
|
|
32
60
|
}
|
|
33
61
|
|
|
34
62
|
export interface CompletedDispatch {
|
|
@@ -38,6 +66,14 @@ export interface CompletedDispatch {
|
|
|
38
66
|
completedAt: string;
|
|
39
67
|
prUrl?: string;
|
|
40
68
|
project?: string;
|
|
69
|
+
totalAttempts?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Maps session keys to dispatch context for agent_end hook lookup */
|
|
73
|
+
export interface SessionMapping {
|
|
74
|
+
dispatchId: string; // issueIdentifier
|
|
75
|
+
phase: "worker" | "audit";
|
|
76
|
+
attempt: number;
|
|
41
77
|
}
|
|
42
78
|
|
|
43
79
|
export interface DispatchState {
|
|
@@ -45,6 +81,10 @@ export interface DispatchState {
|
|
|
45
81
|
active: Record<string, ActiveDispatch>;
|
|
46
82
|
completed: Record<string, CompletedDispatch>;
|
|
47
83
|
};
|
|
84
|
+
/** Session key → dispatch mapping for agent_end hook */
|
|
85
|
+
sessionMap: Record<string, SessionMapping>;
|
|
86
|
+
/** Set of processed event keys for idempotency */
|
|
87
|
+
processedEvents: string[];
|
|
48
88
|
}
|
|
49
89
|
|
|
50
90
|
// ---------------------------------------------------------------------------
|
|
@@ -52,6 +92,7 @@ export interface DispatchState {
|
|
|
52
92
|
// ---------------------------------------------------------------------------
|
|
53
93
|
|
|
54
94
|
const DEFAULT_STATE_PATH = path.join(homedir(), ".openclaw", "linear-dispatch-state.json");
|
|
95
|
+
const MAX_PROCESSED_EVENTS = 200; // Keep last N events for dedup
|
|
55
96
|
|
|
56
97
|
function resolveStatePath(configPath?: string): string {
|
|
57
98
|
if (!configPath) return DEFAULT_STATE_PATH;
|
|
@@ -110,14 +151,34 @@ async function releaseLock(statePath: string): Promise<void> {
|
|
|
110
151
|
// ---------------------------------------------------------------------------
|
|
111
152
|
|
|
112
153
|
function emptyState(): DispatchState {
|
|
113
|
-
return {
|
|
154
|
+
return {
|
|
155
|
+
dispatches: { active: {}, completed: {} },
|
|
156
|
+
sessionMap: {},
|
|
157
|
+
processedEvents: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Migrate v1 state (no sessionMap/processedEvents) to v2 */
|
|
162
|
+
function migrateState(raw: any): DispatchState {
|
|
163
|
+
const state = raw as DispatchState;
|
|
164
|
+
if (!state.sessionMap) state.sessionMap = {};
|
|
165
|
+
if (!state.processedEvents) state.processedEvents = [];
|
|
166
|
+
// Ensure all active dispatches have attempt field
|
|
167
|
+
for (const d of Object.values(state.dispatches.active)) {
|
|
168
|
+
if ((d as any).attempt === undefined) (d as any).attempt = 0;
|
|
169
|
+
}
|
|
170
|
+
// Migrate old status "running" → "working"
|
|
171
|
+
for (const d of Object.values(state.dispatches.active)) {
|
|
172
|
+
if ((d as any).status === "running") (d as any).status = "working";
|
|
173
|
+
}
|
|
174
|
+
return state;
|
|
114
175
|
}
|
|
115
176
|
|
|
116
177
|
export async function readDispatchState(configPath?: string): Promise<DispatchState> {
|
|
117
178
|
const filePath = resolveStatePath(configPath);
|
|
118
179
|
try {
|
|
119
180
|
const raw = await fs.readFile(filePath, "utf-8");
|
|
120
|
-
return JSON.parse(raw)
|
|
181
|
+
return migrateState(JSON.parse(raw));
|
|
121
182
|
} catch (err: any) {
|
|
122
183
|
if (err.code === "ENOENT") return emptyState();
|
|
123
184
|
throw err;
|
|
@@ -127,13 +188,158 @@ export async function readDispatchState(configPath?: string): Promise<DispatchSt
|
|
|
127
188
|
async function writeDispatchState(filePath: string, data: DispatchState): Promise<void> {
|
|
128
189
|
const dir = path.dirname(filePath);
|
|
129
190
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
191
|
+
// Trim processedEvents to avoid unbounded growth
|
|
192
|
+
if (data.processedEvents.length > MAX_PROCESSED_EVENTS) {
|
|
193
|
+
data.processedEvents = data.processedEvents.slice(-MAX_PROCESSED_EVENTS);
|
|
194
|
+
}
|
|
130
195
|
const tmpPath = filePath + ".tmp";
|
|
131
196
|
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
132
197
|
await fs.rename(tmpPath, filePath);
|
|
133
198
|
}
|
|
134
199
|
|
|
135
200
|
// ---------------------------------------------------------------------------
|
|
136
|
-
//
|
|
201
|
+
// Atomic transitions (CAS)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
export class TransitionError extends Error {
|
|
205
|
+
constructor(
|
|
206
|
+
public dispatchId: string,
|
|
207
|
+
public fromStatus: DispatchStatus,
|
|
208
|
+
public toStatus: DispatchStatus,
|
|
209
|
+
public actualStatus: DispatchStatus,
|
|
210
|
+
) {
|
|
211
|
+
super(
|
|
212
|
+
`CAS transition failed for ${dispatchId}: ` +
|
|
213
|
+
`expected ${fromStatus} → ${toStatus}, but current status is ${actualStatus}`,
|
|
214
|
+
);
|
|
215
|
+
this.name = "TransitionError";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Atomic compare-and-swap status transition.
|
|
221
|
+
* Rejects if current status doesn't match `fromStatus`.
|
|
222
|
+
* Returns the updated dispatch.
|
|
223
|
+
*/
|
|
224
|
+
export async function transitionDispatch(
|
|
225
|
+
issueIdentifier: string,
|
|
226
|
+
fromStatus: DispatchStatus,
|
|
227
|
+
toStatus: DispatchStatus,
|
|
228
|
+
updates?: Partial<Pick<ActiveDispatch, "workerSessionKey" | "auditSessionKey" | "stuckReason" | "attempt">>,
|
|
229
|
+
configPath?: string,
|
|
230
|
+
): Promise<ActiveDispatch> {
|
|
231
|
+
const filePath = resolveStatePath(configPath);
|
|
232
|
+
await acquireLock(filePath);
|
|
233
|
+
try {
|
|
234
|
+
const data = await readDispatchState(configPath);
|
|
235
|
+
const dispatch = data.dispatches.active[issueIdentifier];
|
|
236
|
+
if (!dispatch) {
|
|
237
|
+
throw new Error(`No active dispatch for ${issueIdentifier}`);
|
|
238
|
+
}
|
|
239
|
+
if (dispatch.status !== fromStatus) {
|
|
240
|
+
throw new TransitionError(issueIdentifier, fromStatus, toStatus, dispatch.status);
|
|
241
|
+
}
|
|
242
|
+
const allowed = VALID_TRANSITIONS[fromStatus];
|
|
243
|
+
if (!allowed.includes(toStatus)) {
|
|
244
|
+
throw new Error(`Invalid transition: ${fromStatus} → ${toStatus}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
dispatch.status = toStatus;
|
|
248
|
+
if (updates) {
|
|
249
|
+
if (updates.workerSessionKey !== undefined) dispatch.workerSessionKey = updates.workerSessionKey;
|
|
250
|
+
if (updates.auditSessionKey !== undefined) dispatch.auditSessionKey = updates.auditSessionKey;
|
|
251
|
+
if (updates.stuckReason !== undefined) dispatch.stuckReason = updates.stuckReason;
|
|
252
|
+
if (updates.attempt !== undefined) dispatch.attempt = updates.attempt;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
await writeDispatchState(filePath, data);
|
|
256
|
+
return dispatch;
|
|
257
|
+
} finally {
|
|
258
|
+
await releaseLock(filePath);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Session map operations
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Register a session key → dispatch mapping.
|
|
268
|
+
* Called when spawning a worker or audit sub-agent.
|
|
269
|
+
*/
|
|
270
|
+
export async function registerSessionMapping(
|
|
271
|
+
sessionKey: string,
|
|
272
|
+
mapping: SessionMapping,
|
|
273
|
+
configPath?: string,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
const filePath = resolveStatePath(configPath);
|
|
276
|
+
await acquireLock(filePath);
|
|
277
|
+
try {
|
|
278
|
+
const data = await readDispatchState(configPath);
|
|
279
|
+
data.sessionMap[sessionKey] = mapping;
|
|
280
|
+
await writeDispatchState(filePath, data);
|
|
281
|
+
} finally {
|
|
282
|
+
await releaseLock(filePath);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Lookup a session key in the map.
|
|
288
|
+
* Used by agent_end hook to identify dispatch context.
|
|
289
|
+
*/
|
|
290
|
+
export function lookupSessionMapping(
|
|
291
|
+
state: DispatchState,
|
|
292
|
+
sessionKey: string,
|
|
293
|
+
): SessionMapping | null {
|
|
294
|
+
return state.sessionMap[sessionKey] ?? null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Remove a session mapping (cleanup after processing).
|
|
299
|
+
*/
|
|
300
|
+
export async function removeSessionMapping(
|
|
301
|
+
sessionKey: string,
|
|
302
|
+
configPath?: string,
|
|
303
|
+
): Promise<void> {
|
|
304
|
+
const filePath = resolveStatePath(configPath);
|
|
305
|
+
await acquireLock(filePath);
|
|
306
|
+
try {
|
|
307
|
+
const data = await readDispatchState(configPath);
|
|
308
|
+
delete data.sessionMap[sessionKey];
|
|
309
|
+
await writeDispatchState(filePath, data);
|
|
310
|
+
} finally {
|
|
311
|
+
await releaseLock(filePath);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Idempotency
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Check if an event has already been processed. If not, mark it.
|
|
321
|
+
* Returns true if the event is NEW (should be processed).
|
|
322
|
+
* Returns false if it's a duplicate (skip).
|
|
323
|
+
*/
|
|
324
|
+
export async function markEventProcessed(
|
|
325
|
+
eventKey: string,
|
|
326
|
+
configPath?: string,
|
|
327
|
+
): Promise<boolean> {
|
|
328
|
+
const filePath = resolveStatePath(configPath);
|
|
329
|
+
await acquireLock(filePath);
|
|
330
|
+
try {
|
|
331
|
+
const data = await readDispatchState(configPath);
|
|
332
|
+
if (data.processedEvents.includes(eventKey)) return false;
|
|
333
|
+
data.processedEvents.push(eventKey);
|
|
334
|
+
await writeDispatchState(filePath, data);
|
|
335
|
+
return true;
|
|
336
|
+
} finally {
|
|
337
|
+
await releaseLock(filePath);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Legacy-compatible operations (still used by existing code)
|
|
137
343
|
// ---------------------------------------------------------------------------
|
|
138
344
|
|
|
139
345
|
export async function registerDispatch(
|
|
@@ -145,6 +351,8 @@ export async function registerDispatch(
|
|
|
145
351
|
await acquireLock(filePath);
|
|
146
352
|
try {
|
|
147
353
|
const data = await readDispatchState(configPath);
|
|
354
|
+
// Ensure v2 fields have defaults
|
|
355
|
+
if (dispatch.attempt === undefined) dispatch.attempt = 0;
|
|
148
356
|
data.dispatches.active[issueIdentifier] = dispatch;
|
|
149
357
|
await writeDispatchState(filePath, data);
|
|
150
358
|
} finally {
|
|
@@ -162,6 +370,12 @@ export async function completeDispatch(
|
|
|
162
370
|
try {
|
|
163
371
|
const data = await readDispatchState(configPath);
|
|
164
372
|
const active = data.dispatches.active[issueIdentifier];
|
|
373
|
+
// Clean up session mappings for this dispatch
|
|
374
|
+
for (const [key, mapping] of Object.entries(data.sessionMap)) {
|
|
375
|
+
if (mapping.dispatchId === issueIdentifier) {
|
|
376
|
+
delete data.sessionMap[key];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
165
379
|
delete data.dispatches.active[issueIdentifier];
|
|
166
380
|
data.dispatches.completed[issueIdentifier] = {
|
|
167
381
|
issueIdentifier,
|
|
@@ -170,6 +384,7 @@ export async function completeDispatch(
|
|
|
170
384
|
completedAt: result.completedAt,
|
|
171
385
|
prUrl: result.prUrl,
|
|
172
386
|
project: active?.project ?? result.project,
|
|
387
|
+
totalAttempts: active?.attempt ?? 0,
|
|
173
388
|
};
|
|
174
389
|
await writeDispatchState(filePath, data);
|
|
175
390
|
} finally {
|
|
@@ -179,7 +394,7 @@ export async function completeDispatch(
|
|
|
179
394
|
|
|
180
395
|
export async function updateDispatchStatus(
|
|
181
396
|
issueIdentifier: string,
|
|
182
|
-
status:
|
|
397
|
+
status: DispatchStatus,
|
|
183
398
|
configPath?: string,
|
|
184
399
|
): Promise<void> {
|
|
185
400
|
const filePath = resolveStatePath(configPath);
|
|
@@ -218,6 +433,17 @@ export function listStaleDispatches(
|
|
|
218
433
|
});
|
|
219
434
|
}
|
|
220
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Find dispatches that need recovery after restart:
|
|
438
|
+
* - Status "working" with a workerSessionKey but no auditSessionKey
|
|
439
|
+
* (worker completed but audit wasn't triggered before crash)
|
|
440
|
+
*/
|
|
441
|
+
export function listRecoverableDispatches(state: DispatchState): ActiveDispatch[] {
|
|
442
|
+
return Object.values(state.dispatches.active).filter((d) =>
|
|
443
|
+
d.status === "working" && d.workerSessionKey && !d.auditSessionKey,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
221
447
|
/**
|
|
222
448
|
* Remove completed dispatches older than maxAgeMs.
|
|
223
449
|
* Returns the number of entries pruned.
|
|
@@ -257,6 +483,12 @@ export async function removeActiveDispatch(
|
|
|
257
483
|
await acquireLock(filePath);
|
|
258
484
|
try {
|
|
259
485
|
const data = await readDispatchState(configPath);
|
|
486
|
+
// Clean up session mappings for this dispatch
|
|
487
|
+
for (const [key, mapping] of Object.entries(data.sessionMap)) {
|
|
488
|
+
if (mapping.dispatchId === issueIdentifier) {
|
|
489
|
+
delete data.sessionMap[key];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
260
492
|
delete data.dispatches.active[issueIdentifier];
|
|
261
493
|
await writeDispatchState(filePath, data);
|
|
262
494
|
} finally {
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify.ts — Simple notification function for dispatch lifecycle events.
|
|
3
|
+
*
|
|
4
|
+
* One concrete Discord implementation + noop fallback.
|
|
5
|
+
* No abstract class — add provider abstraction only when a second
|
|
6
|
+
* backend (Slack, email) actually exists.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Types
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export type NotifyKind =
|
|
14
|
+
| "dispatch" // issue dispatched to worker
|
|
15
|
+
| "working" // worker started
|
|
16
|
+
| "auditing" // audit triggered
|
|
17
|
+
| "audit_pass" // audit passed → done
|
|
18
|
+
| "audit_fail" // audit failed → rework
|
|
19
|
+
| "escalation" // 2x fail or stale → stuck
|
|
20
|
+
| "stuck"; // stale detection
|
|
21
|
+
|
|
22
|
+
export interface NotifyPayload {
|
|
23
|
+
identifier: string;
|
|
24
|
+
title: string;
|
|
25
|
+
status: string;
|
|
26
|
+
attempt?: number;
|
|
27
|
+
reason?: string;
|
|
28
|
+
verdict?: { pass: boolean; gaps?: string[] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type NotifyFn = (kind: NotifyKind, payload: NotifyPayload) => Promise<void>;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Discord implementation
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const DISCORD_API = "https://discord.com/api/v10";
|
|
38
|
+
|
|
39
|
+
function formatDiscordMessage(kind: NotifyKind, payload: NotifyPayload): string {
|
|
40
|
+
const prefix = `**${payload.identifier}**`;
|
|
41
|
+
switch (kind) {
|
|
42
|
+
case "dispatch":
|
|
43
|
+
return `${prefix} dispatched — ${payload.title}`;
|
|
44
|
+
case "working":
|
|
45
|
+
return `${prefix} worker started (attempt ${payload.attempt ?? 0})`;
|
|
46
|
+
case "auditing":
|
|
47
|
+
return `${prefix} audit in progress`;
|
|
48
|
+
case "audit_pass":
|
|
49
|
+
return `${prefix} passed audit. PR ready.`;
|
|
50
|
+
case "audit_fail": {
|
|
51
|
+
const gaps = payload.verdict?.gaps?.join(", ") ?? "unspecified";
|
|
52
|
+
return `${prefix} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
|
|
53
|
+
}
|
|
54
|
+
case "escalation":
|
|
55
|
+
return `🚨 ${prefix} needs human review — ${payload.reason ?? "audit failed 2x"}`;
|
|
56
|
+
case "stuck":
|
|
57
|
+
return `⏰ ${prefix} stuck — ${payload.reason ?? "stale 2h"}`;
|
|
58
|
+
default:
|
|
59
|
+
return `${prefix} — ${kind}: ${payload.status}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createDiscordNotifier(botToken: string, channelId: string): NotifyFn {
|
|
64
|
+
return async (kind, payload) => {
|
|
65
|
+
const message = formatDiscordMessage(kind, payload);
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bot ${botToken}`,
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ content: message }),
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
const body = await res.text().catch(() => "");
|
|
77
|
+
console.error(`Discord notify failed (${res.status}): ${body}`);
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.error("Discord notify error:", err);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Noop fallback
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export function createNoopNotifier(): NotifyFn {
|
|
90
|
+
return async () => {};
|
|
91
|
+
}
|