@hedgehog-finance/hedgehog-plugin 1.0.11

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/src/channel.ts ADDED
@@ -0,0 +1,704 @@
1
+ import * as os from "node:os";
2
+ import * as fs from "node:fs";
3
+ import * as fsAsync from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { WebSocket, RawData } from "ws";
6
+ import { emptyChannelConfigSchema } from "openclaw/plugin-sdk/core";
7
+ import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
8
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
9
+ import type {
10
+ ChannelGatewayContext,
11
+ ChannelAccountSnapshot,
12
+ ChannelStatusIssue
13
+ } from "openclaw/plugin-sdk/channel-contract";
14
+ import { getHedgehogRuntime } from "./runtime";
15
+ import { logger } from "./core/logger";
16
+ import type {
17
+ HedgehogFinanceResolvedAccount,
18
+ RelayInboundMessage
19
+ } from "./types";
20
+ import { allFeaturesTools } from "./features";
21
+
22
+
23
+
24
+ /**
25
+ * Unix timestamp
26
+ */
27
+ function getCurrentTimestamp(): number {
28
+ return Date.now();
29
+ }
30
+
31
+ /**
32
+ * 获取 OpenClaw state 目录
33
+ */
34
+ function getStateDir(): string {
35
+ return process.env.OPENCLAW_STATE_DIR ||
36
+ process.env.CLAWD_STATE_DIR ||
37
+ path.join(os.homedir(), ".openclaw");
38
+ }
39
+
40
+ /**
41
+ * [修改] 异步从 sessions.json 获取 session entry
42
+ */
43
+ async function getSessionEntryAsync(agentId: string, sessionKey: string) {
44
+ try {
45
+ const stateDir = getStateDir();
46
+ const sessionStorePath = path.join(stateDir, "agents", agentId, "sessions", "sessions.json");
47
+
48
+ if (!fs.existsSync(sessionStorePath)) {
49
+ return null;
50
+ }
51
+
52
+ const content = await fsAsync.readFile(sessionStorePath, "utf-8");
53
+ const storeData = JSON.parse(content);
54
+ const entry = storeData[sessionKey];
55
+
56
+ if (!entry?.sessionId) {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ sessionId: entry.sessionId,
62
+ inputTokens: entry.inputTokens || 0,
63
+ outputTokens: entry.outputTokens || 0,
64
+ totalTokens: entry.totalTokens || 0,
65
+ model: entry.model,
66
+ modelProvider: entry.modelProvider,
67
+ };
68
+ } catch (err) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * [修改] 异步获取 .jsonl 文件的当前行数
75
+ */
76
+ async function getJsonlLineCountAsync(agentId: string, sessionId: string): Promise<number> {
77
+ try {
78
+ const stateDir = getStateDir();
79
+ const jsonlPath = path.join(stateDir, "agents", agentId, "sessions", `${sessionId}.jsonl`);
80
+
81
+ if (!fs.existsSync(jsonlPath)) {
82
+ return 0;
83
+ }
84
+
85
+ const content = await fsAsync.readFile(jsonlPath, "utf-8");
86
+ return content.trim().split("\n").filter(Boolean).length;
87
+ } catch {
88
+ return 0;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * [修改] 异步从 .jsonl session 文件读取指定行号之后的第一条 assistant 消息的 usage
94
+ */
95
+ async function readUsageFromJsonlAsync(agentId: string, sessionId: string, afterLine: number) {
96
+ try {
97
+ const stateDir = getStateDir();
98
+ const jsonlPath = path.join(stateDir, "agents", agentId, "sessions", `${sessionId}.jsonl`);
99
+
100
+ if (!fs.existsSync(jsonlPath)) {
101
+ return null;
102
+ }
103
+
104
+ const content = await fsAsync.readFile(jsonlPath, "utf-8");
105
+ const lines = content.trim().split("\n").filter(Boolean);
106
+
107
+ // 从 afterLine 开始向后找第一条 assistant 消息
108
+ for (let i = afterLine; i < lines.length; i++) {
109
+ try {
110
+ const entry = JSON.parse(lines[i]);
111
+ if (entry.type === "message" && entry.message?.role === "assistant" && entry.message?.usage) {
112
+ const u = entry.message.usage;
113
+ return {
114
+ input: u.input || 0,
115
+ output: u.output || 0,
116
+ total: u.totalTokens || 0,
117
+ model: entry.message.model,
118
+ provider: entry.message.provider,
119
+ };
120
+ }
121
+ } catch {
122
+ continue;
123
+ }
124
+ }
125
+ return null;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+
132
+ /**
133
+ * [修改] 异步获取本轮对话的 token 用量
134
+ */
135
+ async function getCurrentTurnUsageAsync(
136
+ agentId: string,
137
+ sessionKey: string,
138
+ sessionIdBefore: string | null,
139
+ lineCountBefore: number,
140
+ maxRetries: number = 60,
141
+ retryDelayMs: number = 100
142
+ ) {
143
+ // 第一阶段:尝试从 sessions.json 读取
144
+ for (let attempt = 0; attempt < 40; attempt++) {
145
+ if (attempt > 0) {
146
+ await new Promise(r => setTimeout(r, retryDelayMs));
147
+ }
148
+
149
+ const entry = await getSessionEntryAsync(agentId, sessionKey);
150
+ if (entry && entry.inputTokens > 0) {
151
+ return {
152
+ input: entry.inputTokens,
153
+ output: entry.outputTokens,
154
+ total: entry.totalTokens,
155
+ model: entry.model,
156
+ provider: entry.modelProvider,
157
+ };
158
+ }
159
+ }
160
+
161
+ // 第二阶段:Fallback 到 .jsonl 文件
162
+ const entry = await getSessionEntryAsync(agentId, sessionKey);
163
+ const sessionId = entry?.sessionId || sessionIdBefore;
164
+
165
+ if (!sessionId) {
166
+ return null;
167
+ }
168
+
169
+ for (let attempt = 0; attempt < 20; attempt++) {
170
+ if (attempt > 0) {
171
+ await new Promise(r => setTimeout(r, 200));
172
+ }
173
+
174
+ const startLine = (sessionIdBefore === sessionId) ? lineCountBefore : 0;
175
+ const usage = await readUsageFromJsonlAsync(agentId, sessionId, startLine);
176
+
177
+ if (usage && usage.input > 0) {
178
+ return {
179
+ input: usage.input,
180
+ output: usage.output,
181
+ total: usage.total,
182
+ model: usage.model,
183
+ provider: usage.provider,
184
+ };
185
+ }
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * Hedgehog Finance Channel Plugin
193
+ */
194
+ export const hedgehogFinancePlugin: ChannelPlugin<HedgehogFinanceResolvedAccount> = {
195
+ id: "hedgehog-finance",
196
+
197
+ meta: {
198
+ id: "hedgehog-finance",
199
+ label: "Hedgehog Finance",
200
+ selectionLabel: "Hedgehog Finance",
201
+ blurb: "Custom WebSocket relay channel for Hedgehog App",
202
+ docsPath: "",
203
+ order: 100,
204
+ },
205
+
206
+ configSchema: emptyChannelConfigSchema(),
207
+
208
+ capabilities: {
209
+ chatTypes: ["direct"],
210
+ media: false,
211
+ reactions: false,
212
+ threads: false,
213
+ blockStreaming: false,
214
+ },
215
+
216
+ config: {
217
+ listAccountIds: (cfg: OpenClawConfig): string[] => {
218
+ const channelConfig = (cfg.channels?.['hedgehog-finance'] || {}) as any;
219
+
220
+ if (channelConfig.accounts) {
221
+ if (Array.isArray(channelConfig.accounts)) {
222
+ return channelConfig.accounts.map((a: any) => a.accountId || a.id).filter(Boolean);
223
+ }
224
+ return Object.keys(channelConfig.accounts);
225
+ }
226
+
227
+ if (channelConfig.accountId) {
228
+ return [channelConfig.accountId];
229
+ }
230
+
231
+ return ["default"];
232
+ },
233
+
234
+ resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): HedgehogFinanceResolvedAccount => {
235
+ const channelConfig = (cfg.channels?.['hedgehog-finance'] || {}) as any;
236
+ const id = accountId || channelConfig.accountId || "default";
237
+
238
+ let accountInfo: any;
239
+ if (channelConfig.accounts) {
240
+ if (Array.isArray(channelConfig.accounts)) {
241
+ accountInfo = channelConfig.accounts.find((a: any) => (a.accountId || a.id) === id);
242
+ } else {
243
+ accountInfo = channelConfig.accounts[id];
244
+ }
245
+ }
246
+
247
+ const { accounts: _, ...defaults } = channelConfig;
248
+
249
+ const finalConfig: any = { ...defaults };
250
+ if (typeof accountInfo === "string") {
251
+ finalConfig.token = accountInfo;
252
+ } else if (accountInfo && typeof accountInfo === "object") {
253
+ Object.assign(finalConfig, accountInfo.config || accountInfo);
254
+ }
255
+
256
+ const finalAccountId = finalConfig.accountId || id;
257
+
258
+ return {
259
+ accountId: finalAccountId,
260
+ config: {
261
+ token: finalConfig.token || "",
262
+ code: finalConfig.code || `OpenClaw-${os.hostname()}`,
263
+ },
264
+ enabled: accountInfo?.enabled !== false,
265
+ configured: Boolean(finalConfig.token),
266
+ };
267
+ },
268
+
269
+ defaultAccountId: (cfg: OpenClawConfig): string => {
270
+ const channelConfig = (cfg as any)?.channels?.['hedgehog-finance'];
271
+ return channelConfig?.accountId || "default";
272
+ },
273
+ },
274
+
275
+ gateway: {
276
+ startAccount: async (ctx: ChannelGatewayContext<HedgehogFinanceResolvedAccount>) => {
277
+ const { account, cfg, log, abortSignal } = ctx;
278
+ const rt = getHedgehogRuntime();
279
+ const accountId = String(account.accountId);
280
+ const childLogger = logger.child({ accountId });
281
+ const token = account.config.token || "";
282
+ const code = account.config.code || `OpenClaw-${os.hostname()}`;
283
+ const relayUrl = `wss://relay.ciweiai.com/relay?id=${accountId}&token=${token}&role=provider&code=${code}`;
284
+
285
+ let ws: WebSocket | null = null;
286
+ let isClosing = false;
287
+ let heartbeatInterval: NodeJS.Timeout | null = null;
288
+
289
+ // [状态管理分离]
290
+ const sentLengthMap: Record<string, number> = {};
291
+ const reasoningLengthMap: Record<string, number> = {};
292
+ const commandOutputMap = new Map<string, string>(); // [新增] 命令输出缓存: itemId -> fullOutput
293
+
294
+ const clearStreamStates = () => {
295
+ Object.keys(sentLengthMap).forEach(k => delete sentLengthMap[k]);
296
+ Object.keys(reasoningLengthMap).forEach(k => delete reasoningLengthMap[k]);
297
+ commandOutputMap.clear();
298
+ };
299
+
300
+ let resolveStop: (value: void | PromiseLike<void>) => void;
301
+ const stopPromise = new Promise<void>((resolve) => {
302
+ resolveStop = resolve;
303
+ });
304
+
305
+ const stopClient = () => {
306
+ if (isClosing) return;
307
+ isClosing = true;
308
+
309
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
310
+ clearStreamStates(); // [生命周期管理]:清理内存状态
311
+
312
+ childLogger.info("Stopping gateway...");
313
+
314
+ try {
315
+ ws?.close();
316
+ } catch (err: any) {
317
+ childLogger.warn({ err: err.message }, "Error during close");
318
+ }
319
+
320
+ ctx.setStatus({
321
+ ...ctx.getStatus(),
322
+ running: false,
323
+ lastStopAt: getCurrentTimestamp(),
324
+ });
325
+
326
+ resolveStop();
327
+ };
328
+
329
+ // [事件处理分离]:抽离出独立的 async 消息处理器
330
+
331
+ // channel.ts 中的 handleInboundMessage
332
+ const handleInboundMessage = async (data: RawData) => {
333
+ ctx.setStatus({
334
+ ...ctx.getStatus(),
335
+ lastEventAt: getCurrentTimestamp(),
336
+ });
337
+
338
+ try {
339
+ const appPayload: RelayInboundMessage = JSON.parse(data.toString());
340
+ // ==========================================
341
+ // 【新增】手动识别并拦截 RPC 请求 (type: "req")
342
+ // ==========================================
343
+ if (appPayload.type === "req") {
344
+ const { id, method, params } = appPayload;
345
+
346
+ if (!method) return;
347
+
348
+ // 从中央工具注册表中查找方法
349
+ const tool = allFeaturesTools[method];
350
+
351
+ if (tool && typeof tool.execute === 'function') {
352
+ childLogger.debug({ method }, "拦截到 RPC 请求");
353
+
354
+ try {
355
+ // 直接使用websocket中的 accountId 作为绝对安全的 userId。
356
+ const runContext = {
357
+ userId: accountId,
358
+ runtime: rt
359
+ };
360
+
361
+ // 执行业务逻辑:传入业务参数 (params) 和安全上下文 (runContext)
362
+ const resultStr = await tool.execute(params, runContext);
363
+ const resultObj = JSON.parse(resultStr);
364
+
365
+ // 按照 OpenClaw 官方 res 协议手动回包(成功状态)
366
+ if (ws?.readyState === WebSocket.OPEN) {
367
+ ws.send(JSON.stringify({
368
+ type: "res",
369
+ id: id,
370
+ ok: true,
371
+ payload: resultObj
372
+ }));
373
+ }
374
+ } catch (err: any) {
375
+ childLogger.error({ err: err.message, method }, "RPC 执行失败");
376
+
377
+ // 按照 OpenClaw 官方 res 协议手动回包(失败状态)
378
+ if (ws?.readyState === WebSocket.OPEN) {
379
+ ws.send(JSON.stringify({
380
+ type: "res",
381
+ id: id,
382
+ ok: false,
383
+ error: { message: err.message || "RPC execution failed" }
384
+ }));
385
+ }
386
+ }
387
+ return;
388
+ }
389
+ }
390
+
391
+ const { from, text, chatId, id } = appPayload;
392
+
393
+ if (!text) return;
394
+
395
+ const route = rt.channel.routing.resolveAgentRoute({
396
+ cfg,
397
+ channel: "hedgehog-finance",
398
+ accountId: String(accountId),
399
+ peer: { kind: "direct", id: chatId },
400
+ });
401
+
402
+ const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, {
403
+ agentId: route.agentId,
404
+ });
405
+ const sessionKey = route.sessionKey;
406
+ const agentId = route.agentId;
407
+
408
+ const entryBefore = await getSessionEntryAsync(agentId, sessionKey);
409
+ const sessionIdBefore = entryBefore?.sessionId || null;
410
+ const lineCountBefore = sessionIdBefore ? await getJsonlLineCountAsync(agentId, sessionIdBefore) : 0;
411
+
412
+ const context = rt.channel.reply.finalizeInboundContext({
413
+ Body: text,
414
+ From: from,
415
+ To: chatId,
416
+ SessionKey: sessionKey,
417
+ AccountId: route.accountId,
418
+ AgentId: agentId,
419
+ AgentWorkspace: (route as any).agentWorkspace,
420
+ Provider: "hedgehog-finance",
421
+ MessageSid: id,
422
+ });
423
+
424
+ await rt.channel.session.recordInboundSession({
425
+ storePath,
426
+ sessionKey: context.SessionKey || sessionKey,
427
+ ctx: context,
428
+ updateLastRoute: {
429
+ sessionKey: route.mainSessionKey,
430
+ channel: "hedgehog-finance",
431
+ to: chatId,
432
+ accountId: String(accountId),
433
+ },
434
+ onRecordError: (err: unknown) => {
435
+ childLogger.error({ err: String(err) }, "Failed to record inbound session");
436
+ },
437
+ });
438
+
439
+ const startTime = Date.now();
440
+
441
+ const sendEvent = (type: string, data: any = {}) => {
442
+ if (ws?.readyState === WebSocket.OPEN) {
443
+ ws.send(JSON.stringify({
444
+ to: from,
445
+ chatId,
446
+ replyTo: id,
447
+ agentId,
448
+ fromCode: code,
449
+ ...data,
450
+ type
451
+ }));
452
+ }
453
+ };
454
+
455
+
456
+ const normalizeId = (rawId?: string) => rawId?.replace(/^(command:|tool:|call_)/, '');
457
+
458
+ const replyOpts = {
459
+ verboseLevel: 'full',
460
+ shouldEmitToolResult: true,
461
+ shouldEmitToolOutput: true,
462
+ onPartialReply: (payload: { text?: string }) => {
463
+ if (payload.text) {
464
+ const prev = sentLengthMap[chatId] || 0;
465
+ const delta = payload.text.slice(prev);
466
+ sentLengthMap[chatId] = payload.text.length;
467
+ if (delta) sendEvent("reply", { text: delta, isPartial: true });
468
+ }
469
+ },
470
+ onReasoningStream: (payload: { text?: string }) => {
471
+ if (payload.text) {
472
+ const prev = reasoningLengthMap[chatId] || 0;
473
+ const delta = payload.text.slice(prev);
474
+ reasoningLengthMap[chatId] = payload.text.length;
475
+ if (delta) sendEvent("reasoning", { text: delta });
476
+ }
477
+ },
478
+ onReasoningEnd: () => sendEvent("reasoning_end"),
479
+ onAssistantMessageStart: () => sendEvent("assistant_message_start"),
480
+ onItemEvent: (payload: { itemId?: string; toolCallId?: string; kind?: string; title?: string; name?: string; status?: string; summary?: string }) => {
481
+ const rawId = payload.itemId || payload.toolCallId || `temp_${payload.kind || 'item'}_${payload.title || payload.name || 'unnamed'}`;
482
+ const itemId = normalizeId(rawId);
483
+ sendEvent("item_event", { ...payload, itemId, toolCallId: itemId });
484
+ },
485
+ onCommandOutput: (payload: { itemId?: string; toolCallId?: string; phase?: string; output?: string; exitCode?: number | null; status?: string }) => {
486
+ const itemId = normalizeId(payload.itemId || payload.toolCallId || 'global')!;
487
+ const last = commandOutputMap.get(itemId) || "";
488
+ const full = payload.phase === 'delta' ? (last + (payload.output || "")) : (payload.output || "");
489
+ commandOutputMap.set(itemId, full);
490
+ if (payload.output || payload.exitCode !== undefined || payload.status === 'completed') {
491
+ sendEvent("command_output", { ...payload, output: full, itemId });
492
+ }
493
+ },
494
+ onEnd: async () => {
495
+ const durationMs = Date.now() - startTime;
496
+
497
+ if (ws?.readyState === WebSocket.OPEN) {
498
+ ws.send(JSON.stringify({
499
+ type: "reply",
500
+ to: from,
501
+ chatId: chatId,
502
+ replyTo: id,
503
+ isFinal: true,
504
+ fromCode: code
505
+ }));
506
+
507
+ const turnUsage = await getCurrentTurnUsageAsync(
508
+ agentId,
509
+ sessionKey,
510
+ sessionIdBefore,
511
+ lineCountBefore
512
+ );
513
+
514
+ if (turnUsage) {
515
+ ws.send(JSON.stringify({
516
+ type: "usage",
517
+ to: from,
518
+ chatId: chatId,
519
+ replyTo: id,
520
+ usage: {
521
+ input: turnUsage.input,
522
+ output: turnUsage.output,
523
+ total: turnUsage.total,
524
+ },
525
+ durationMs: durationMs,
526
+ model: turnUsage.model,
527
+ provider: turnUsage.provider,
528
+ fromCode: code
529
+ }));
530
+ }
531
+ }
532
+ delete sentLengthMap[chatId];
533
+ delete reasoningLengthMap[chatId];
534
+ }
535
+ };
536
+ // 1. 显式类型化的配置 (保证观测开启)
537
+ const finalCfg: OpenClawConfig = {
538
+ ...cfg,
539
+ agents: {
540
+ ...cfg.agents,
541
+ defaults: {
542
+ ...(cfg.agents?.defaults || {}),
543
+ verboseDefault: 'full'
544
+ }
545
+ }
546
+ };
547
+
548
+ // 2. 类型推导 (保证不瞎搞类型)
549
+ type DispatchParams = Parameters<typeof rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher>[0];
550
+ type RawReplyOptions = NonNullable<DispatchParams["replyOptions"]>;
551
+
552
+ // 3. 准备回复选项
553
+ const finalReplyOpts = { ...replyOpts } as RawReplyOptions;
554
+
555
+ // 4. 执行稳定分发
556
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
557
+ cfg: finalCfg,
558
+ ctx: context,
559
+ replyOptions: finalReplyOpts,
560
+ dispatcherOptions: {
561
+ deliver: async (payload, info) => {
562
+ // 原有的兜底逻辑保持不变
563
+ const cd = payload.channelData;
564
+ if (cd && (cd.toolCallId || cd.itemId)) {
565
+ sendEvent("tool_result", {
566
+ ...payload,
567
+ toolCallId: normalizeId(String(cd.toolCallId || cd.itemId || ""))
568
+ });
569
+ }
570
+ },
571
+ }
572
+ });
573
+ } catch (err: any) {
574
+ childLogger.error({ err: err.message }, "Dispatch error");
575
+ }
576
+ };
577
+
578
+ const connect = () => {
579
+ if (isClosing) return;
580
+
581
+ childLogger.debug({ relayUrl }, "Connecting to relay");
582
+ ws = new WebSocket(relayUrl);
583
+
584
+ ws.on("open", () => {
585
+ childLogger.info({ code }, "Connected");
586
+ ctx.setStatus({
587
+ ...ctx.getStatus(),
588
+ running: true,
589
+ lastStartAt: getCurrentTimestamp(),
590
+ lastEventAt: getCurrentTimestamp(),
591
+ lastError: null,
592
+ });
593
+
594
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
595
+ heartbeatInterval = setInterval(() => {
596
+ if (ws?.readyState === WebSocket.OPEN) {
597
+ ws.ping();
598
+ }
599
+ }, 30000);
600
+ });
601
+
602
+ // 绑定消息处理
603
+ ws.on("message", handleInboundMessage);
604
+
605
+ ws.on("error", (err) => {
606
+ childLogger.error({ err: err.message }, "WebSocket error");
607
+ ctx.setStatus({
608
+ ...ctx.getStatus(),
609
+ lastError: `Connection error: ${err.message}`,
610
+ });
611
+ });
612
+
613
+ ws.on("close", (closeCode, reason) => {
614
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
615
+ clearStreamStates(); // [生命周期管理]:断开时清理状态
616
+
617
+ if (!isClosing) {
618
+ const retryDelay = 5000 + Math.random() * 5000;
619
+ childLogger.warn({ closeCode, retryDelay: Math.round(retryDelay / 1000) }, "Connection dropped. Retrying...");
620
+ ctx.setStatus({
621
+ ...ctx.getStatus(),
622
+ running: false,
623
+ });
624
+ setTimeout(connect, retryDelay);
625
+ }
626
+ });
627
+ };
628
+
629
+ connect();
630
+
631
+ abortSignal?.addEventListener("abort", () => {
632
+ log?.info?.(`[hedgehog-app][${accountId}] Abort signal received`);
633
+ stopClient();
634
+ });
635
+
636
+ await stopPromise;
637
+
638
+ return {
639
+ stop: () => {
640
+ stopClient();
641
+ },
642
+ };
643
+ }
644
+ },
645
+
646
+ status: {
647
+ defaultRuntime: {
648
+ accountId: "default",
649
+ running: false,
650
+ lastEventAt: null,
651
+ lastStartAt: null,
652
+ lastStopAt: null,
653
+ lastError: null,
654
+ },
655
+ collectStatusIssues: (accounts: ChannelAccountSnapshot[]): ChannelStatusIssue[] => {
656
+ return accounts.flatMap((account) => {
657
+ if (!account.configured) {
658
+ return [
659
+ {
660
+ channel: "hedgehog-finance",
661
+ accountId: account.accountId,
662
+ kind: "config" as const,
663
+ message: "Account not configured (missing relay token)",
664
+ },
665
+ ];
666
+ }
667
+ return [];
668
+ });
669
+ },
670
+ buildChannelSummary: ({ snapshot }: { snapshot: ChannelAccountSnapshot }) => ({
671
+ configured: snapshot?.configured ?? false,
672
+ running: snapshot?.running ?? false,
673
+ lastStartAt: snapshot?.lastStartAt ?? null,
674
+ lastStopAt: snapshot?.lastStopAt ?? null,
675
+ lastError: snapshot?.lastError ?? null,
676
+ }),
677
+ probeAccount: async ({ account }: { account: HedgehogFinanceResolvedAccount }) => {
678
+ if (!account.configured || !account.config?.token) {
679
+ return { ok: false, error: "Token not configured" };
680
+ }
681
+ return { ok: true, details: { relay: "wss://relay.ciweiai.com/relay" } };
682
+ },
683
+ buildAccountSnapshot: ({ account, runtime, snapshot, probe }: {
684
+ account: HedgehogFinanceResolvedAccount,
685
+ runtime?: ChannelAccountSnapshot,
686
+ snapshot?: ChannelAccountSnapshot,
687
+ probe?: any
688
+ }): ChannelAccountSnapshot => {
689
+ const running = runtime?.running ?? snapshot?.running ?? false;
690
+ return {
691
+ ...snapshot,
692
+ accountId: account.accountId,
693
+ enabled: account.enabled,
694
+ configured: account.configured,
695
+ running,
696
+ lastEventAt: runtime?.lastEventAt ?? snapshot?.lastEventAt ?? null,
697
+ lastStartAt: runtime?.lastStartAt ?? snapshot?.lastStartAt ?? null,
698
+ lastStopAt: runtime?.lastStopAt ?? snapshot?.lastStopAt ?? null,
699
+ lastError: runtime?.lastError ?? snapshot?.lastError ?? null,
700
+ probe,
701
+ };
702
+ },
703
+ },
704
+ };