tickflow-assist 0.3.4 → 0.3.6

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.
@@ -1,4 +1,9 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { copyFile, mkdir, unlink } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
1
5
  import { createCommandRunner } from "../runtime/command-runner.js";
6
+ import { basenameOrUndefined, buildAlertMessageHash, buildAlertSendId, truncateDiagnosticText, } from "../utils/alert-diagnostic-log.js";
2
7
  import { calculateProfitPct, formatCostPrice } from "../utils/cost-price.js";
3
8
  export class AlertService {
4
9
  options;
@@ -16,55 +21,84 @@ export class AlertService {
16
21
  async sendWithResult(input) {
17
22
  this.lastError = null;
18
23
  const payload = normalizeSendInput(input);
24
+ const sendId = buildAlertSendId(payload.message);
25
+ const messageHash = buildAlertMessageHash(payload.message);
26
+ const diagnosticContext = {
27
+ sendId,
28
+ step: "primary",
29
+ messageHash,
30
+ };
19
31
  const mediaAttempted = Boolean(payload.mediaPath);
20
- const primaryError = await this.trySendPayload(payload);
21
- if (primaryError === null) {
22
- return {
32
+ await this.logDiagnostic("send_started", {
33
+ sendId,
34
+ step: diagnosticContext.step,
35
+ channel: this.channel,
36
+ messageHash,
37
+ messageLength: payload.message.length,
38
+ hasMedia: mediaAttempted,
39
+ mediaFile: basenameOrUndefined(payload.mediaPath),
40
+ filename: payload.filename,
41
+ accountConfigured: Boolean(this.options.account.trim()),
42
+ targetConfigured: Boolean(this.options.target.trim()),
43
+ runtimeAvailable: Boolean(this.options.runtime),
44
+ messagePreview: truncateDiagnosticText(payload.message.split("\n")[0] ?? ""),
45
+ });
46
+ const primaryFailure = await this.trySendPayload(payload, diagnosticContext);
47
+ if (primaryFailure === null) {
48
+ const result = {
23
49
  ok: true,
24
50
  mediaAttempted,
25
51
  mediaDelivered: mediaAttempted,
26
52
  error: null,
27
53
  };
54
+ await this.logCompletion(sendId, messageHash, payload, result);
55
+ return result;
28
56
  }
29
57
  if (payload.mediaPath) {
30
- if (payload.message.trim()) {
31
- const mediaOnlyError = await this.trySendPayload({
32
- ...payload,
33
- message: "",
34
- });
35
- if (mediaOnlyError === null) {
36
- const textFollowupError = await this.trySendPayload({ message: payload.message });
37
- return {
38
- ok: textFollowupError === null,
39
- mediaAttempted: true,
40
- mediaDelivered: true,
41
- error: textFollowupError,
42
- };
43
- }
58
+ if (primaryFailure.ambiguous) {
59
+ const result = {
60
+ ok: false,
61
+ mediaAttempted: true,
62
+ mediaDelivered: false,
63
+ error: primaryFailure.error,
64
+ deliveryUncertain: true,
65
+ };
66
+ await this.logCompletion(sendId, messageHash, payload, result);
67
+ return result;
44
68
  }
45
69
  const textFallback = normalizeSendInput(payload.message);
46
- const textFallbackError = await this.trySendPayload(textFallback);
47
- if (textFallbackError === null) {
48
- return {
70
+ const textFallbackFailure = await this.trySendPayload(textFallback, {
71
+ sendId,
72
+ step: "text_fallback",
73
+ messageHash,
74
+ });
75
+ if (textFallbackFailure === null) {
76
+ const result = {
49
77
  ok: true,
50
78
  mediaAttempted: true,
51
79
  mediaDelivered: false,
52
- error: primaryError,
80
+ error: primaryFailure.error,
53
81
  };
82
+ await this.logCompletion(sendId, messageHash, payload, result);
83
+ return result;
54
84
  }
55
- return {
85
+ const result = {
56
86
  ok: false,
57
87
  mediaAttempted: true,
58
88
  mediaDelivered: false,
59
- error: this.combineErrors(primaryError, textFallbackError),
89
+ error: this.combineErrors(primaryFailure.error, textFallbackFailure.error),
60
90
  };
91
+ await this.logCompletion(sendId, messageHash, payload, result);
92
+ return result;
61
93
  }
62
- return {
94
+ const result = {
63
95
  ok: false,
64
96
  mediaAttempted: false,
65
97
  mediaDelivered: false,
66
- error: primaryError,
98
+ error: primaryFailure.error,
67
99
  };
100
+ await this.logCompletion(sendId, messageHash, payload, result);
101
+ return result;
68
102
  }
69
103
  getLastError() {
70
104
  return this.lastError;
@@ -132,17 +166,35 @@ export class AlertService {
132
166
  }
133
167
  return `${runtimeError}; ${fallbackError}`;
134
168
  }
135
- async trySendPayload(payload) {
136
- const runtimeError = await this.trySendViaRuntime(payload);
137
- if (runtimeError === null) {
169
+ async trySendPayload(payload, context) {
170
+ // OpenClaw documents `api.runtime.channel` as channel-plugin-specific helper
171
+ // surface. For a regular tool/service plugin like tickflow-assist, Telegram
172
+ // and QQ Bot delivery are more reliable via the shared
173
+ // `openclaw message send` CLI path.
174
+ if (this.channel === "telegram" || this.channel === "qqbot") {
175
+ return await this.trySendViaCommand(payload, context);
176
+ }
177
+ const runtimeFailure = await this.trySendViaRuntime(payload, context);
178
+ if (runtimeFailure === null) {
138
179
  return null;
139
180
  }
140
- return await this.trySendViaCommand(payload);
181
+ // Only image/media sends are risky to replay after an ambiguous transport error.
182
+ // Text-only notifications (for example session boundary notifications) should
183
+ // still fall back to the CLI path so transient runtime failures do not drop them.
184
+ if (runtimeFailure.ambiguous && payload.mediaPath) {
185
+ return runtimeFailure;
186
+ }
187
+ return await this.trySendViaCommand(payload, context);
141
188
  }
142
- async trySendViaRuntime(payload) {
189
+ async trySendViaRuntime(payload, context) {
143
190
  const runtimeContext = this.options.runtime;
144
191
  if (!runtimeContext || !this.options.target.trim()) {
145
- return "runtime delivery unavailable";
192
+ const failure = {
193
+ error: "runtime delivery unavailable",
194
+ ambiguous: false,
195
+ };
196
+ await this.logTransportFailure("runtime_unavailable", context, payload, failure);
197
+ return failure;
146
198
  }
147
199
  const baseOptions = {
148
200
  accountId: this.options.account || undefined,
@@ -152,33 +204,51 @@ export class AlertService {
152
204
  };
153
205
  try {
154
206
  switch (this.channel) {
155
- case "telegram":
156
- await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "telegram", "sendMessageTelegram", this.options.target, payload.message, baseOptions);
157
- return null;
158
207
  case "discord":
159
208
  await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "discord", "sendMessageDiscord", this.options.target, payload.message, {
160
209
  ...baseOptions,
161
210
  filename: payload.filename,
162
211
  });
163
- return null;
212
+ break;
164
213
  case "slack":
165
214
  await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "slack", "sendMessageSlack", this.options.target, payload.message, {
166
215
  ...baseOptions,
167
216
  uploadFileName: payload.filename,
168
217
  uploadTitle: payload.filename,
169
218
  });
170
- return null;
219
+ break;
171
220
  case "signal":
172
221
  await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "signal", "sendMessageSignal", this.options.target, payload.message, baseOptions);
173
- return null;
222
+ break;
174
223
  default:
175
224
  // OpenClaw 2026.3.31 narrows the typed runtime channel surface.
176
225
  // Fall back to `openclaw message send` for channels not exposed here.
177
- return `runtime delivery not supported for channel: ${this.channel}`;
226
+ const failure = {
227
+ error: `runtime delivery not supported for channel: ${this.channel}`,
228
+ ambiguous: false,
229
+ };
230
+ await this.logTransportFailure("runtime_unsupported", context, payload, failure);
231
+ return failure;
178
232
  }
233
+ await this.logDiagnostic("transport_success", {
234
+ sendId: context.sendId,
235
+ step: context.step,
236
+ transport: "runtime",
237
+ channel: this.channel,
238
+ messageHash: context.messageHash,
239
+ hasMedia: Boolean(payload.mediaPath),
240
+ mediaFile: basenameOrUndefined(payload.mediaPath),
241
+ });
242
+ return null;
179
243
  }
180
244
  catch (error) {
181
- return `runtime delivery failed: ${formatErrorMessage(error)}`;
245
+ const detail = formatErrorMessage(error);
246
+ const failure = {
247
+ error: `runtime delivery failed: ${detail}`,
248
+ ambiguous: !isRuntimeCapabilityUnavailableError(detail),
249
+ };
250
+ await this.logTransportFailure("runtime_failed", context, payload, failure);
251
+ return failure;
182
252
  }
183
253
  }
184
254
  async invokeRuntimeChannelSend(runtimeChannel, channelName, methodName, target, message, options) {
@@ -189,19 +259,94 @@ export class AlertService {
189
259
  }
190
260
  await method.call(channelApi, target, message, options);
191
261
  }
192
- async trySendViaCommand(payload) {
262
+ async trySendViaCommand(payload, context) {
263
+ const prepared = await this.prepareCommandPayload(payload);
264
+ const commandOptions = this.getCommandRunOptions(prepared.payload);
193
265
  try {
194
- const result = await this.runCommandWithTimeout(this.buildCliArgs(payload), { timeoutMs: 15_000 });
266
+ const result = await this.runCommandWithTimeout(this.buildCliArgs(prepared.payload), commandOptions);
195
267
  if (result.code === 0) {
268
+ const commandOutcome = inspectCommandDeliveryResult(result.stdout);
269
+ if (commandOutcome?.error) {
270
+ const failure = {
271
+ error: commandOutcome.error,
272
+ ambiguous: false,
273
+ };
274
+ await this.logTransportFailure("command_reported_error", context, payload, failure, {
275
+ timeoutMs: commandOptions.timeoutMs,
276
+ termination: result.termination,
277
+ messageId: commandOutcome.messageId,
278
+ stdout: truncateDiagnosticText(result.stdout.trim()),
279
+ });
280
+ return failure;
281
+ }
282
+ await this.logDiagnostic("transport_success", {
283
+ sendId: context.sendId,
284
+ step: context.step,
285
+ transport: "command",
286
+ channel: this.channel,
287
+ messageHash: context.messageHash,
288
+ hasMedia: Boolean(payload.mediaPath),
289
+ mediaFile: basenameOrUndefined(payload.mediaPath),
290
+ timeoutMs: commandOptions.timeoutMs,
291
+ termination: result.termination,
292
+ messageId: commandOutcome?.messageId,
293
+ });
196
294
  return null;
197
295
  }
198
- return (result.stderr.trim()
199
- || result.stdout.trim()
200
- || `command exited with ${result.code ?? "unknown"}`);
296
+ const failure = {
297
+ error: result.stderr.trim()
298
+ || result.stdout.trim()
299
+ || `command exited with ${result.code ?? "unknown"}`,
300
+ ambiguous: true,
301
+ };
302
+ await this.logTransportFailure("command_failed", context, payload, failure, {
303
+ code: result.code,
304
+ timeoutMs: commandOptions.timeoutMs,
305
+ termination: result.termination,
306
+ stderr: truncateDiagnosticText(result.stderr.trim()),
307
+ stdout: truncateDiagnosticText(result.stdout.trim()),
308
+ });
309
+ return failure;
201
310
  }
202
311
  catch (error) {
203
- return `command delivery failed: ${formatErrorMessage(error)}`;
312
+ const failure = {
313
+ error: `command delivery failed: ${formatErrorMessage(error)}`,
314
+ ambiguous: false,
315
+ };
316
+ await this.logTransportFailure("command_error", context, payload, failure);
317
+ return failure;
318
+ }
319
+ finally {
320
+ await prepared.cleanup();
321
+ }
322
+ }
323
+ async prepareCommandPayload(payload) {
324
+ if (!shouldStageQQBotMedia(this.channel, payload.mediaPath)) {
325
+ return {
326
+ payload,
327
+ cleanup: async () => { },
328
+ };
329
+ }
330
+ const stagedMediaPath = await stageQQBotMediaFile(payload.mediaPath);
331
+ return {
332
+ payload: {
333
+ ...payload,
334
+ mediaPath: stagedMediaPath,
335
+ },
336
+ cleanup: async () => {
337
+ await unlink(stagedMediaPath).catch((error) => {
338
+ if (error.code !== "ENOENT") {
339
+ throw error;
340
+ }
341
+ });
342
+ },
343
+ };
344
+ }
345
+ getCommandRunOptions(payload) {
346
+ if (payload.mediaPath) {
347
+ return { timeoutMs: 45_000 };
204
348
  }
349
+ return { timeoutMs: 15_000 };
205
350
  }
206
351
  buildCliArgs(payload) {
207
352
  const args = [
@@ -223,8 +368,40 @@ export class AlertService {
223
368
  if (this.options.account) {
224
369
  args.push("--account", this.options.account);
225
370
  }
371
+ args.push("--json");
226
372
  return args;
227
373
  }
374
+ async logCompletion(sendId, messageHash, payload, result) {
375
+ await this.logDiagnostic("send_completed", {
376
+ sendId,
377
+ channel: this.channel,
378
+ messageHash,
379
+ hasMedia: Boolean(payload.mediaPath),
380
+ mediaFile: basenameOrUndefined(payload.mediaPath),
381
+ ok: result.ok,
382
+ mediaAttempted: result.mediaAttempted,
383
+ mediaDelivered: result.mediaDelivered,
384
+ deliveryUncertain: result.deliveryUncertain === true,
385
+ error: result.error ? truncateDiagnosticText(result.error) : null,
386
+ });
387
+ }
388
+ async logTransportFailure(event, context, payload, failure, extra = {}) {
389
+ await this.logDiagnostic(event, {
390
+ sendId: context.sendId,
391
+ step: context.step,
392
+ transport: event.startsWith("command") ? "command" : "runtime",
393
+ channel: this.channel,
394
+ messageHash: context.messageHash,
395
+ hasMedia: Boolean(payload.mediaPath),
396
+ mediaFile: basenameOrUndefined(payload.mediaPath),
397
+ ambiguous: failure.ambiguous,
398
+ error: truncateDiagnosticText(failure.error),
399
+ ...extra,
400
+ });
401
+ }
402
+ async logDiagnostic(event, details) {
403
+ await this.options.diagnosticLogger?.append("alert_service", event, details);
404
+ }
228
405
  }
229
406
  function formatErrorMessage(error) {
230
407
  if (error instanceof Error) {
@@ -240,11 +417,62 @@ function formatErrorMessage(error) {
240
417
  return String(error);
241
418
  }
242
419
  }
420
+ function isRuntimeCapabilityUnavailableError(detail) {
421
+ return /runtime channel .* unavailable/i.test(detail);
422
+ }
243
423
  function normalizeSendInput(input) {
244
424
  return typeof input === "string"
245
425
  ? { message: input }
246
426
  : input;
247
427
  }
428
+ function inspectCommandDeliveryResult(stdout) {
429
+ const payload = extractCommandJsonPayload(stdout);
430
+ if (!payload) {
431
+ return null;
432
+ }
433
+ const directResult = isRecord(payload.result) ? payload.result : null;
434
+ const directResultMeta = isRecord(directResult?.meta) ? directResult.meta : null;
435
+ const error = getNonEmptyString(directResult?.error)
436
+ ?? getNonEmptyString(directResultMeta?.error)
437
+ ?? getNonEmptyString(payload.error);
438
+ const messageId = getNonEmptyString(directResult?.messageId)
439
+ ?? getNonEmptyString(directResultMeta?.messageId)
440
+ ?? getNonEmptyString(payload.messageId);
441
+ if (!error && !messageId) {
442
+ return null;
443
+ }
444
+ return {
445
+ ...(error ? { error } : {}),
446
+ ...(messageId ? { messageId } : {}),
447
+ };
448
+ }
449
+ function extractCommandJsonPayload(stdout) {
450
+ const root = extractTrailingJsonObject(stdout);
451
+ if (!root) {
452
+ return null;
453
+ }
454
+ return isRecord(root.payload) ? root.payload : root;
455
+ }
456
+ function extractTrailingJsonObject(stdout) {
457
+ const trimmed = stdout.trim();
458
+ if (!trimmed) {
459
+ return null;
460
+ }
461
+ for (let start = trimmed.lastIndexOf("{"); start >= 0; start = trimmed.lastIndexOf("{", start - 1)) {
462
+ const candidate = trimmed.slice(start);
463
+ try {
464
+ const parsed = JSON.parse(candidate);
465
+ const record = isRecord(parsed) ? parsed : null;
466
+ if (record) {
467
+ return record;
468
+ }
469
+ }
470
+ catch {
471
+ // Keep scanning backward until the trailing JSON object is found.
472
+ }
473
+ }
474
+ return null;
475
+ }
248
476
  function getRuntimeChannelApi(runtimeChannel, channelName) {
249
477
  if (!isRecord(runtimeChannel)) {
250
478
  return null;
@@ -255,6 +483,60 @@ function getRuntimeChannelApi(runtimeChannel, channelName) {
255
483
  function isRecord(value) {
256
484
  return typeof value === "object" && value !== null && !Array.isArray(value);
257
485
  }
486
+ function getNonEmptyString(value) {
487
+ if (typeof value !== "string") {
488
+ return undefined;
489
+ }
490
+ const trimmed = value.trim();
491
+ return trimmed.length > 0 ? trimmed : undefined;
492
+ }
493
+ function shouldStageQQBotMedia(channel, mediaPath) {
494
+ return channel === "qqbot"
495
+ && typeof mediaPath === "string"
496
+ && mediaPath.length > 0
497
+ && !isRemoteMediaPath(mediaPath)
498
+ && !isUnderQQBotManagedMediaRoot(mediaPath);
499
+ }
500
+ function isRemoteMediaPath(mediaPath) {
501
+ return /^(?:https?:|data:)/i.test(mediaPath.trim());
502
+ }
503
+ function isUnderQQBotManagedMediaRoot(mediaPath) {
504
+ const candidate = path.resolve(mediaPath);
505
+ const mediaRoot = path.resolve(getQQBotManagedMediaRoot());
506
+ const relative = path.relative(mediaRoot, candidate);
507
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
508
+ }
509
+ async function stageQQBotMediaFile(mediaPath) {
510
+ const destinationDir = path.join(getQQBotManagedMediaRoot(), "tickflow-assist");
511
+ await mkdir(destinationDir, { recursive: true });
512
+ const sourceExt = path.extname(mediaPath);
513
+ const sourceBase = path.basename(mediaPath, sourceExt);
514
+ const safeBase = sanitizeFileNamePart(sourceBase) || "alert-media";
515
+ const safeExt = sanitizeFileExtension(sourceExt);
516
+ const stagedName = `${safeBase}-${Date.now()}-${randomBytes(3).toString("hex")}${safeExt}`;
517
+ const stagedPath = path.join(destinationDir, stagedName);
518
+ await copyFile(mediaPath, stagedPath);
519
+ return stagedPath;
520
+ }
521
+ function getQQBotManagedMediaRoot() {
522
+ return path.join(resolveHomeDir(), ".openclaw", "media", "qqbot");
523
+ }
524
+ function resolveHomeDir() {
525
+ const fromOs = os.homedir();
526
+ if (fromOs) {
527
+ return fromOs;
528
+ }
529
+ return process.env.HOME || process.env.USERPROFILE || ".";
530
+ }
531
+ function sanitizeFileNamePart(value) {
532
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "_");
533
+ }
534
+ function sanitizeFileExtension(value) {
535
+ if (!value) {
536
+ return "";
537
+ }
538
+ return /^\.[a-zA-Z0-9]+$/.test(value) ? value.toLowerCase() : "";
539
+ }
258
540
  function getAlertStyle(ruleCode, fallbackTitle) {
259
541
  switch (ruleCode) {
260
542
  case "stop_loss_hit":
@@ -1,3 +1,4 @@
1
+ import { formatConfigEnvFallback } from "../config/env.js";
1
2
  export class AnalysisService {
2
3
  llmBaseUrl;
3
4
  llmApiKey;
@@ -14,13 +15,13 @@ export class AnalysisService {
14
15
  }
15
16
  getConfigurationError() {
16
17
  if (!this.llmBaseUrl.trim()) {
17
- return "LLM 未配置接口地址,请设置 llmBaseUrl";
18
+ return `LLM 未配置接口地址,请设置 llmBaseUrl 或环境变量 ${formatConfigEnvFallback("llmBaseUrl")}`;
18
19
  }
19
20
  if (!this.llmApiKey.trim()) {
20
- return "LLM 未配置 API Key,请设置 llmApiKey";
21
+ return `LLM 未配置 API Key,请设置 llmApiKey 或环境变量 ${formatConfigEnvFallback("llmApiKey")}`;
21
22
  }
22
23
  if (!this.llmModel.trim()) {
23
- return "LLM 未配置模型,请设置 llmModel";
24
+ return `LLM 未配置模型,请设置 llmModel 或环境变量 ${formatConfigEnvFallback("llmModel")}`;
24
25
  }
25
26
  return null;
26
27
  }
@@ -1,3 +1,4 @@
1
+ import { formatConfigEnvFallback } from "../config/env.js";
1
2
  export class Jin10McpService {
2
3
  serverUrl;
3
4
  apiToken;
@@ -14,10 +15,10 @@ export class Jin10McpService {
14
15
  }
15
16
  getConfigurationError() {
16
17
  if (!this.serverUrl.trim()) {
17
- return "Jin10 MCP 未配置接口地址,请设置 jin10McpUrl";
18
+ return `Jin10 MCP 未配置接口地址,请设置 jin10McpUrl 或环境变量 ${formatConfigEnvFallback("jin10McpUrl")}`;
18
19
  }
19
20
  if (!this.apiToken.trim()) {
20
- return "Jin10 MCP 未配置 API Token,请设置 jin10ApiToken";
21
+ return `Jin10 MCP 未配置 API Token,请设置 jin10ApiToken 或环境变量 ${formatConfigEnvFallback("jin10ApiToken")}`;
21
22
  }
22
23
  return null;
23
24
  }
@@ -1,4 +1,5 @@
1
1
  import type { MonitorState } from "../types/monitor.js";
2
+ import { AlertDiagnosticLogger } from "../utils/alert-diagnostic-log.js";
2
3
  import { QuoteService } from "./quote-service.js";
3
4
  import { TradingCalendarService } from "./trading-calendar-service.js";
4
5
  import { WatchlistService } from "./watchlist-service.js";
@@ -23,7 +24,8 @@ export declare class MonitorService {
23
24
  private readonly klineService;
24
25
  private readonly alertService;
25
26
  private readonly alertMediaService;
26
- constructor(baseDir: string, requestInterval: number, alertChannel: string, watchlistService: WatchlistService, quoteService: QuoteService, tradingCalendarService: TradingCalendarService, keyLevelsRepository: KeyLevelsRepository, alertLogRepository: AlertLogRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, klineService: KlineService, alertService: AlertService, alertMediaService: AlertMediaService);
27
+ private readonly diagnosticLogger?;
28
+ constructor(baseDir: string, requestInterval: number, alertChannel: string, watchlistService: WatchlistService, quoteService: QuoteService, tradingCalendarService: TradingCalendarService, keyLevelsRepository: KeyLevelsRepository, alertLogRepository: AlertLogRepository, klinesRepository: KlinesRepository, intradayKlinesRepository: IntradayKlinesRepository, klineService: KlineService, alertService: AlertService, alertMediaService: AlertMediaService, diagnosticLogger?: AlertDiagnosticLogger | undefined);
27
29
  start(): Promise<string>;
28
30
  stop(): Promise<string>;
29
31
  enableManagedLoop(): Promise<{
@@ -59,4 +61,5 @@ export declare class MonitorService {
59
61
  private cleanupAlertMedia;
60
62
  private getRunLockFilePath;
61
63
  private getAlertClaimFilePath;
64
+ private logDiagnostic;
62
65
  }
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { basenameOrUndefined, buildAlertMessageHash, truncateDiagnosticText, } from "../utils/alert-diagnostic-log.js";
3
4
  import { formatChinaDateTime } from "../utils/china-time.js";
4
5
  import { calculateProfitPct, formatCostPrice } from "../utils/cost-price.js";
5
6
  const DEFAULT_STATE = {
@@ -36,7 +37,8 @@ export class MonitorService {
36
37
  klineService;
37
38
  alertService;
38
39
  alertMediaService;
39
- constructor(baseDir, requestInterval, alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService) {
40
+ diagnosticLogger;
41
+ constructor(baseDir, requestInterval, alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService, diagnosticLogger) {
40
42
  this.baseDir = baseDir;
41
43
  this.requestInterval = requestInterval;
42
44
  this.alertChannel = alertChannel;
@@ -50,6 +52,7 @@ export class MonitorService {
50
52
  this.klineService = klineService;
51
53
  this.alertService = alertService;
52
54
  this.alertMediaService = alertMediaService;
55
+ this.diagnosticLogger = diagnosticLogger;
53
56
  }
54
57
  async start() {
55
58
  const watchlist = await this.watchlistService.list();
@@ -464,21 +467,54 @@ export class MonitorService {
464
467
  }
465
468
  async trySendAlert(symbol, ruleName, input) {
466
469
  const sessionKey = getSessionKey();
470
+ const message = typeof input === "string" ? input : input.message;
471
+ const messageHash = buildAlertMessageHash(message);
472
+ const hasMedia = typeof input !== "string" && Boolean(input.mediaPath);
473
+ await this.logDiagnostic("try_send_alert_enter", {
474
+ symbol,
475
+ ruleName,
476
+ sessionKey,
477
+ messageHash,
478
+ hasMedia,
479
+ mediaFile: typeof input === "string" ? undefined : basenameOrUndefined(input.mediaPath),
480
+ });
467
481
  const claim = await this.tryAcquireAlertClaim(symbol, ruleName, sessionKey);
468
482
  if (!claim) {
483
+ await this.logDiagnostic("try_send_alert_claim_busy", {
484
+ symbol,
485
+ ruleName,
486
+ sessionKey,
487
+ messageHash,
488
+ });
469
489
  await this.cleanupAlertMedia(input);
470
490
  return false;
471
491
  }
472
492
  try {
473
493
  if (await this.alertLogRepository.isSentThisSession(symbol, ruleName, sessionKey)) {
494
+ await this.logDiagnostic("try_send_alert_already_sent", {
495
+ symbol,
496
+ ruleName,
497
+ sessionKey,
498
+ messageHash,
499
+ });
474
500
  await this.cleanupAlertMedia(input);
475
501
  return false;
476
502
  }
477
503
  const result = await this.sendAlertAndCleanupMedia(input);
478
- if (!result.ok) {
504
+ await this.logDiagnostic("try_send_alert_result", {
505
+ symbol,
506
+ ruleName,
507
+ sessionKey,
508
+ messageHash,
509
+ ok: result.ok,
510
+ mediaAttempted: result.mediaAttempted,
511
+ mediaDelivered: result.mediaDelivered,
512
+ deliveryUncertain: result.deliveryUncertain === true,
513
+ error: result.error ? truncateDiagnosticText(result.error) : null,
514
+ });
515
+ if (!result.ok && !result.deliveryUncertain) {
479
516
  return false;
480
517
  }
481
- const message = typeof input === "string" ? input : input.message;
482
518
  await this.alertLogRepository.append({
483
519
  symbol,
484
520
  alert_date: sessionKey,
@@ -486,6 +522,12 @@ export class MonitorService {
486
522
  message,
487
523
  triggered_at: formatChinaDateTime(),
488
524
  });
525
+ await this.logDiagnostic("try_send_alert_logged", {
526
+ symbol,
527
+ ruleName,
528
+ sessionKey,
529
+ messageHash,
530
+ });
489
531
  return true;
490
532
  }
491
533
  finally {
@@ -616,6 +658,9 @@ export class MonitorService {
616
658
  getAlertClaimFilePath(symbol, ruleName, sessionKey) {
617
659
  return path.join(this.baseDir, "alert-claims", `${sanitizeAlertClaimPart(sessionKey)}_${sanitizeAlertClaimPart(symbol)}_${sanitizeAlertClaimPart(ruleName)}.lock`);
618
660
  }
661
+ async logDiagnostic(event, details) {
662
+ await this.diagnosticLogger?.append("monitor_service", event, details);
663
+ }
619
664
  }
620
665
  function formatRunningState(state, requestInterval) {
621
666
  const heartbeat = getHeartbeatStatus(state, requestInterval);
@@ -1,3 +1,4 @@
1
+ import { formatConfigEnvFallback } from "../config/env.js";
1
2
  export class MxSearchServiceError extends Error {
2
3
  constructor(message) {
3
4
  super(message);
@@ -16,10 +17,10 @@ export class MxApiService {
16
17
  }
17
18
  getConfigurationError() {
18
19
  if (!this.apiBaseUrl.trim()) {
19
- return "mx_search 未配置接口地址,请设置 mxSearchApiUrl 或环境变量 MX_SEARCH_API_URL";
20
+ return `mx_search 未配置接口地址,请设置 mxSearchApiUrl 或环境变量 ${formatConfigEnvFallback("mxSearchApiUrl")}`;
20
21
  }
21
22
  if (!this.apiKey.trim()) {
22
- return "mx_search 未配置 API Key,请设置插件配置 mxSearchApiKey 或环境变量 MX_APIKEY";
23
+ return `mx_search 未配置 API Key,请设置插件配置 mxSearchApiKey 或环境变量 ${formatConfigEnvFallback("mxSearchApiKey")}`;
23
24
  }
24
25
  return null;
25
26
  }