tickflow-assist 0.3.5 → 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.
- package/README.md +3 -2
- package/dist/bootstrap.js +4 -1
- package/dist/services/alert-service.d.ts +8 -0
- package/dist/services/alert-service.js +327 -45
- package/dist/services/monitor-service.d.ts +4 -1
- package/dist/services/monitor-service.js +48 -3
- package/dist/tools/test-alert.tool.js +5 -0
- package/dist/utils/alert-diagnostic-log.d.ts +12 -0
- package/dist/utils/alert-diagnostic-log.js +60 -0
- package/openclaw.plugin.json +30 -1
- package/package.json +7 -2
- package/skills/stock-analysis/SKILL.md +1 -2
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
其中,`configure-openclaw` 会把上述配置写入 `~/.openclaw/openclaw.json` 的 `plugins.entries["tickflow-assist"].config`,插件启用后会在本地 `databasePath` 下持久化 LanceDB 数据,并运行监控 / 日更等后台服务。
|
|
18
18
|
如果你不想把密钥写进配置文件,运行时也支持环境变量回退,优先级是 `openclaw.json / local.config.json` > 环境变量 > 默认值。
|
|
19
19
|
常用环境变量:`TICKFLOW_ASSIST_TICKFLOW_API_KEY` / `TICKFLOW_API_KEY`、`TICKFLOW_ASSIST_LLM_API_KEY` / `LLM_API_KEY`、`TICKFLOW_ASSIST_LLM_BASE_URL` / `LLM_BASE_URL`、`TICKFLOW_ASSIST_LLM_MODEL` / `LLM_MODEL`、`TICKFLOW_ASSIST_MX_SEARCH_API_KEY` / `MX_SEARCH_API_KEY` / `MX_APIKEY`、`TICKFLOW_ASSIST_JIN10_API_TOKEN` / `JIN10_API_TOKEN`。
|
|
20
|
+
如果你希望尽量避免把密钥落盘,推荐先把这些变量写进 `~/.openclaw/.env`,再运行配置向导补齐非密钥项。
|
|
20
21
|
|
|
21
22
|
## 安装
|
|
22
23
|
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
27
|
openclaw plugins install tickflow-assist
|
|
27
|
-
|
|
28
|
+
node ~/.openclaw/extensions/tickflow-assist/dist/dev/tickflow-assist-cli.js configure-openclaw
|
|
28
29
|
cd ~/.openclaw/extensions/tickflow-assist/python && uv sync
|
|
29
30
|
openclaw plugins enable tickflow-assist
|
|
30
31
|
openclaw config validate
|
|
@@ -38,7 +39,7 @@ openclaw gateway restart
|
|
|
38
39
|
如果你希望先审阅配置,再只打印最少的后续步骤,可使用:
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
|
|
42
|
+
node ~/.openclaw/extensions/tickflow-assist/dist/dev/tickflow-assist-cli.js configure-openclaw --no-enable --no-restart
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
如果你在 Linux 或 macOS 上需要 PNG 告警卡正常显示中文,请额外手动安装 `fontconfig` 与 Noto CJK 一类中文字体,例如:
|
package/dist/bootstrap.js
CHANGED
|
@@ -75,6 +75,7 @@ import { resolvePreferredOpenClawTmpDir } from "./runtime/openclaw-temp-dir.js";
|
|
|
75
75
|
import { RealtimeMonitorWorker } from "./background/realtime-monitor.worker.js";
|
|
76
76
|
import { DailyUpdateWorker } from "./background/daily-update.worker.js";
|
|
77
77
|
import { Jin10FlashWorker } from "./background/jin10-flash.worker.js";
|
|
78
|
+
import { createAlertDiagnosticLogger } from "./utils/alert-diagnostic-log.js";
|
|
78
79
|
export function createAppContext(config, options = {}) {
|
|
79
80
|
const runtime = {
|
|
80
81
|
configSource: options.configSource ?? "local_config",
|
|
@@ -109,6 +110,7 @@ export function createAppContext(config, options = {}) {
|
|
|
109
110
|
const analysisService = new AnalysisService(config.llmBaseUrl, config.llmApiKey, config.llmModel, analysisLogRepository);
|
|
110
111
|
const watchlistProfileService = new WatchlistProfileService(mxApiService, analysisService);
|
|
111
112
|
const tradingCalendarService = new TradingCalendarService(config.calendarFile);
|
|
113
|
+
const alertDiagnosticLogger = createAlertDiagnosticLogger(config.databasePath);
|
|
112
114
|
const alertService = new AlertService({
|
|
113
115
|
openclawCliBin: config.openclawCliBin,
|
|
114
116
|
channel: config.alertChannel,
|
|
@@ -120,6 +122,7 @@ export function createAppContext(config, options = {}) {
|
|
|
120
122
|
runtime: runtime.pluginRuntime,
|
|
121
123
|
}
|
|
122
124
|
: undefined,
|
|
125
|
+
diagnosticLogger: alertDiagnosticLogger,
|
|
123
126
|
});
|
|
124
127
|
const alertMediaService = new AlertMediaService(config.databasePath, undefined, undefined, resolveAlertMediaTempRootDir());
|
|
125
128
|
const indicatorService = new IndicatorService(config.pythonBin, config.pythonArgs, config.pythonWorkdir, runCommandWithTimeout);
|
|
@@ -137,7 +140,7 @@ export function createAppContext(config, options = {}) {
|
|
|
137
140
|
const postCloseReviewTask = new PostCloseReviewTask();
|
|
138
141
|
const compositeStockAnalysisTask = new CompositeStockAnalysisTask(keyLevelsRepository, analysisLogRepository);
|
|
139
142
|
const compositeAnalysisOrchestrator = new CompositeAnalysisOrchestrator(analysisService, marketAnalysisProvider, financialAnalysisProvider, newsAnalysisProvider, klineTechnicalSignalTask, financialFundamentalTask, financialFundamentalLiteTask, newsCatalystTask, compositeStockAnalysisTask, technicalAnalysisRepository, financialAnalysisRepository, newsAnalysisRepository, compositeAnalysisRepository);
|
|
140
|
-
const monitorService = new MonitorService(config.databasePath, config.requestInterval, config.alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService);
|
|
143
|
+
const monitorService = new MonitorService(config.databasePath, config.requestInterval, config.alertChannel, watchlistService, quoteService, tradingCalendarService, keyLevelsRepository, alertLogRepository, klinesRepository, intradayKlinesRepository, klineService, alertService, alertMediaService, alertDiagnosticLogger);
|
|
141
144
|
const jin10FlashMonitorService = new Jin10FlashMonitorService(config.databasePath, config.jin10FlashPollInterval, config.jin10FlashRetentionDays, config.jin10FlashNightAlert, watchlistService, jin10McpService, analysisService, alertService, jin10FlashRepository, jin10FlashDeliveryRepository);
|
|
142
145
|
const updateService = new UpdateService(klineService, config.tickflowApiKeyLevel, indicatorService, klinesRepository, indicatorsRepository, intradayKlinesRepository, watchlistService, tradingCalendarService);
|
|
143
146
|
const postCloseReviewService = new PostCloseReviewService(watchlistService, compositeAnalysisOrchestrator, analysisService, postCloseReviewTask, keyLevelsRepository, keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, jin10FlashDeliveryRepository, jin10FlashRepository);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OpenClawPluginConfig, OpenClawPluginRuntime } from "../runtime/plugin-api.js";
|
|
2
2
|
import type { KeyLevels } from "../types/domain.js";
|
|
3
|
+
import { AlertDiagnosticLogger } from "../utils/alert-diagnostic-log.js";
|
|
3
4
|
interface AlertRuntimeContext {
|
|
4
5
|
config: OpenClawPluginConfig;
|
|
5
6
|
runtime: OpenClawPluginRuntime;
|
|
@@ -10,6 +11,7 @@ interface AlertServiceOptions {
|
|
|
10
11
|
account: string;
|
|
11
12
|
target: string;
|
|
12
13
|
runtime?: AlertRuntimeContext;
|
|
14
|
+
diagnosticLogger?: AlertDiagnosticLogger;
|
|
13
15
|
}
|
|
14
16
|
export interface AlertSendInput {
|
|
15
17
|
message: string;
|
|
@@ -22,6 +24,7 @@ export interface AlertSendResult {
|
|
|
22
24
|
mediaAttempted: boolean;
|
|
23
25
|
mediaDelivered: boolean;
|
|
24
26
|
error: string | null;
|
|
27
|
+
deliveryUncertain?: boolean;
|
|
25
28
|
}
|
|
26
29
|
export declare class AlertService {
|
|
27
30
|
private readonly options;
|
|
@@ -62,6 +65,11 @@ export declare class AlertService {
|
|
|
62
65
|
private trySendViaRuntime;
|
|
63
66
|
private invokeRuntimeChannelSend;
|
|
64
67
|
private trySendViaCommand;
|
|
68
|
+
private prepareCommandPayload;
|
|
69
|
+
private getCommandRunOptions;
|
|
65
70
|
private buildCliArgs;
|
|
71
|
+
private logCompletion;
|
|
72
|
+
private logTransportFailure;
|
|
73
|
+
private logDiagnostic;
|
|
66
74
|
}
|
|
67
75
|
export {};
|
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 (
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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:
|
|
80
|
+
error: primaryFailure.error,
|
|
53
81
|
};
|
|
82
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
83
|
+
return result;
|
|
54
84
|
}
|
|
55
|
-
|
|
85
|
+
const result = {
|
|
56
86
|
ok: false,
|
|
57
87
|
mediaAttempted: true,
|
|
58
88
|
mediaDelivered: false,
|
|
59
|
-
error: this.combineErrors(
|
|
89
|
+
error: this.combineErrors(primaryFailure.error, textFallbackFailure.error),
|
|
60
90
|
};
|
|
91
|
+
await this.logCompletion(sendId, messageHash, payload, result);
|
|
92
|
+
return result;
|
|
61
93
|
}
|
|
62
|
-
|
|
94
|
+
const result = {
|
|
63
95
|
ok: false,
|
|
64
96
|
mediaAttempted: false,
|
|
65
97
|
mediaDelivered: false,
|
|
66
|
-
error:
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
+
break;
|
|
171
220
|
case "signal":
|
|
172
221
|
await this.invokeRuntimeChannelSend(runtimeContext.runtime.channel, "signal", "sendMessageSignal", this.options.target, payload.message, baseOptions);
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -53,6 +53,11 @@ export function testAlertTool(alertService, alertMediaService, configSource = "o
|
|
|
53
53
|
mediaLocalRoots: mediaFile.mediaLocalRoots,
|
|
54
54
|
filename: mediaFile.filename,
|
|
55
55
|
});
|
|
56
|
+
if (result.deliveryUncertain) {
|
|
57
|
+
return result.error
|
|
58
|
+
? `⚠️ PNG 告警疑似已送达,但通道返回异常;为避免重复未执行拆分补发\n原因: ${result.error}`
|
|
59
|
+
: "⚠️ PNG 告警疑似已送达,但通道返回异常;为避免重复未执行拆分补发";
|
|
60
|
+
}
|
|
56
61
|
if (result.ok && result.mediaDelivered) {
|
|
57
62
|
if (result.error) {
|
|
58
63
|
return [
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const ALERT_DIAGNOSTIC_LOG_ENV = "TICKFLOW_ALERT_DIAGNOSTIC_LOG";
|
|
2
|
+
export declare class AlertDiagnosticLogger {
|
|
3
|
+
private readonly filePath;
|
|
4
|
+
constructor(baseDir: string, fileName?: string);
|
|
5
|
+
getFilePath(): string;
|
|
6
|
+
append(scope: string, event: string, details?: Record<string, unknown>): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createAlertDiagnosticLogger(baseDir: string, env?: NodeJS.ProcessEnv): AlertDiagnosticLogger | undefined;
|
|
9
|
+
export declare function buildAlertMessageHash(message: string): string;
|
|
10
|
+
export declare function buildAlertSendId(message: string): string;
|
|
11
|
+
export declare function basenameOrUndefined(filePath?: string): string | undefined;
|
|
12
|
+
export declare function truncateDiagnosticText(value: string, maxLength?: number): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { formatChinaDateTime } from "./china-time.js";
|
|
5
|
+
const DEFAULT_LOG_FILE = "alert-delivery-debug.ndjson";
|
|
6
|
+
export const ALERT_DIAGNOSTIC_LOG_ENV = "TICKFLOW_ALERT_DIAGNOSTIC_LOG";
|
|
7
|
+
export class AlertDiagnosticLogger {
|
|
8
|
+
filePath;
|
|
9
|
+
constructor(baseDir, fileName = DEFAULT_LOG_FILE) {
|
|
10
|
+
this.filePath = path.join(baseDir, fileName);
|
|
11
|
+
}
|
|
12
|
+
getFilePath() {
|
|
13
|
+
return this.filePath;
|
|
14
|
+
}
|
|
15
|
+
async append(scope, event, details = {}) {
|
|
16
|
+
try {
|
|
17
|
+
await mkdir(path.dirname(this.filePath), { recursive: true });
|
|
18
|
+
await appendFile(this.filePath, `${JSON.stringify({
|
|
19
|
+
at: formatChinaDateTime(),
|
|
20
|
+
scope,
|
|
21
|
+
event,
|
|
22
|
+
...details,
|
|
23
|
+
})}\n`, "utf-8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Diagnostic logging must never break alert delivery.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function createAlertDiagnosticLogger(baseDir, env = process.env) {
|
|
31
|
+
if (!isAlertDiagnosticLogEnabled(env)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return new AlertDiagnosticLogger(baseDir);
|
|
35
|
+
}
|
|
36
|
+
function isAlertDiagnosticLogEnabled(env) {
|
|
37
|
+
const raw = env[ALERT_DIAGNOSTIC_LOG_ENV]?.trim().toLowerCase();
|
|
38
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
39
|
+
}
|
|
40
|
+
export function buildAlertMessageHash(message) {
|
|
41
|
+
return createHash("sha1")
|
|
42
|
+
.update(message)
|
|
43
|
+
.digest("hex")
|
|
44
|
+
.slice(0, 12);
|
|
45
|
+
}
|
|
46
|
+
export function buildAlertSendId(message) {
|
|
47
|
+
return `${formatChinaDateTime().replace(/[^0-9]/g, "").slice(0, 14)}-${buildAlertMessageHash(message)}`;
|
|
48
|
+
}
|
|
49
|
+
export function basenameOrUndefined(filePath) {
|
|
50
|
+
if (!filePath) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return path.basename(filePath);
|
|
54
|
+
}
|
|
55
|
+
export function truncateDiagnosticText(value, maxLength = 160) {
|
|
56
|
+
if (value.length <= maxLength) {
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
return `${value.slice(0, maxLength)}...`;
|
|
60
|
+
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "tickflow-assist",
|
|
3
3
|
"name": "TickFlow Assist",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.6",
|
|
5
5
|
"description": "A-share watchlist analysis, monitoring, and alert delivery powered by TickFlow and OpenClaw.",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills"
|
|
@@ -10,6 +10,35 @@
|
|
|
10
10
|
"onCapabilities": ["tool", "hook"]
|
|
11
11
|
},
|
|
12
12
|
"setup": {
|
|
13
|
+
"providers": [
|
|
14
|
+
{
|
|
15
|
+
"id": "tickflow",
|
|
16
|
+
"authMethods": ["api-key"],
|
|
17
|
+
"envVars": ["TICKFLOW_ASSIST_TICKFLOW_API_KEY", "TICKFLOW_API_KEY"]
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "llm",
|
|
21
|
+
"authMethods": ["api-key"],
|
|
22
|
+
"envVars": [
|
|
23
|
+
"TICKFLOW_ASSIST_LLM_BASE_URL",
|
|
24
|
+
"LLM_BASE_URL",
|
|
25
|
+
"TICKFLOW_ASSIST_LLM_API_KEY",
|
|
26
|
+
"LLM_API_KEY",
|
|
27
|
+
"TICKFLOW_ASSIST_LLM_MODEL",
|
|
28
|
+
"LLM_MODEL"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "mx-search",
|
|
33
|
+
"authMethods": ["api-key"],
|
|
34
|
+
"envVars": ["TICKFLOW_ASSIST_MX_SEARCH_API_KEY", "MX_SEARCH_API_KEY", "MX_APIKEY"]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "jin10",
|
|
38
|
+
"authMethods": ["api-key"],
|
|
39
|
+
"envVars": ["TICKFLOW_ASSIST_JIN10_API_TOKEN", "JIN10_API_TOKEN"]
|
|
40
|
+
}
|
|
41
|
+
],
|
|
13
42
|
"requiresRuntime": true
|
|
14
43
|
},
|
|
15
44
|
"configContracts": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickflow-assist",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "面向 A 股投资与盯盘场景的 OpenClaw 智能股票插件,基于 TickFlow API 提供实时监控、收盘后复盘、多维综合分析、关键价位跟踪与告警能力。OpenClaw smart stock plugin for A-share investing and watchlist workflows, powered by TickFlow API for realtime monitoring, post-close review, multi-dimensional analysis, key level tracking, and alerts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -53,6 +53,11 @@
|
|
|
53
53
|
"minGatewayVersion": "2026.3.31",
|
|
54
54
|
"builtWithOpenClawVersion": "2026.4.11"
|
|
55
55
|
},
|
|
56
|
+
"install": {
|
|
57
|
+
"npmSpec": "tickflow-assist",
|
|
58
|
+
"defaultChoice": "npm",
|
|
59
|
+
"minHostVersion": ">=2026.3.31"
|
|
60
|
+
},
|
|
56
61
|
"extensions": [
|
|
57
62
|
"dist/plugin.js"
|
|
58
63
|
]
|
|
@@ -72,6 +77,6 @@
|
|
|
72
77
|
"openclaw": "^2026.4.11",
|
|
73
78
|
"typescript": "^5.8.2"
|
|
74
79
|
},
|
|
75
|
-
"readme": "# TickFlow Assist\n\n基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,并可选接入 [金十数据 MCP](https://mcp.jin10.com/app/) 快讯流,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。\n\n最近更新:`v0.3.5` 对齐 OpenClaw `2026.4.11` metadata 与社区安装提示,修复源码升级时本地链接扫描 `node_modules` 失败的问题,并支持通过环境变量回退 TickFlow / LLM / MX / Jin10 配置。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。\n\n当前主线按 OpenClaw `v2026.3.31+` 对齐,并已验证社区安装在 `v2026.4.11` 上兼容。\n\n## 安装前准备\n\n在执行社区安装前,建议先确认你已经准备好以下配置:\n\n- 核心必需:`tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel`\n- 告警投递:`alertChannel`、`alertTarget`、`alertAccount`\n- 可选增强:`mxSearchApiKey`、`jin10ApiToken`\n\n其中,`configure-openclaw` 会把上述配置写入 `~/.openclaw/openclaw.json` 的 `plugins.entries[\"tickflow-assist\"].config`,插件启用后会在本地 `databasePath` 下持久化 LanceDB 数据,并运行监控 / 日更等后台服务。\n如果你不想把密钥写进配置文件,运行时也支持环境变量回退,优先级是 `openclaw.json / local.config.json` > 环境变量 > 默认值。\n常用环境变量:`TICKFLOW_ASSIST_TICKFLOW_API_KEY` / `TICKFLOW_API_KEY`、`TICKFLOW_ASSIST_LLM_API_KEY` / `LLM_API_KEY`、`TICKFLOW_ASSIST_LLM_BASE_URL` / `LLM_BASE_URL`、`TICKFLOW_ASSIST_LLM_MODEL` / `LLM_MODEL`、`TICKFLOW_ASSIST_MX_SEARCH_API_KEY` / `MX_SEARCH_API_KEY` / `MX_APIKEY`、`TICKFLOW_ASSIST_JIN10_API_TOKEN` / `JIN10_API_TOKEN`。\n\n## 安装\n\n社区安装:\n\n```bash\nopenclaw plugins install tickflow-assist\
|
|
80
|
+
"readme": "# TickFlow Assist\n\n基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,并可选接入 [金十数据 MCP](https://mcp.jin10.com/app/) 快讯流,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。\n\n最近更新:`v0.3.5` 对齐 OpenClaw `2026.4.11` metadata 与社区安装提示,修复源码升级时本地链接扫描 `node_modules` 失败的问题,并支持通过环境变量回退 TickFlow / LLM / MX / Jin10 配置。完整发布记录见 <https://github.com/robinspt/tickflow-assist/blob/main/CHANGELOG.md>。\n\n当前主线按 OpenClaw `v2026.3.31+` 对齐,并已验证社区安装在 `v2026.4.11` 上兼容。\n\n## 安装前准备\n\n在执行社区安装前,建议先确认你已经准备好以下配置:\n\n- 核心必需:`tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel`\n- 告警投递:`alertChannel`、`alertTarget`、`alertAccount`\n- 可选增强:`mxSearchApiKey`、`jin10ApiToken`\n\n其中,`configure-openclaw` 会把上述配置写入 `~/.openclaw/openclaw.json` 的 `plugins.entries[\"tickflow-assist\"].config`,插件启用后会在本地 `databasePath` 下持久化 LanceDB 数据,并运行监控 / 日更等后台服务。\n如果你不想把密钥写进配置文件,运行时也支持环境变量回退,优先级是 `openclaw.json / local.config.json` > 环境变量 > 默认值。\n常用环境变量:`TICKFLOW_ASSIST_TICKFLOW_API_KEY` / `TICKFLOW_API_KEY`、`TICKFLOW_ASSIST_LLM_API_KEY` / `LLM_API_KEY`、`TICKFLOW_ASSIST_LLM_BASE_URL` / `LLM_BASE_URL`、`TICKFLOW_ASSIST_LLM_MODEL` / `LLM_MODEL`、`TICKFLOW_ASSIST_MX_SEARCH_API_KEY` / `MX_SEARCH_API_KEY` / `MX_APIKEY`、`TICKFLOW_ASSIST_JIN10_API_TOKEN` / `JIN10_API_TOKEN`。\n如果你希望尽量避免把密钥落盘,推荐先把这些变量写进 `~/.openclaw/.env`,再运行配置向导补齐非密钥项。\n\n## 安装\n\n社区安装:\n\n```bash\nopenclaw plugins install tickflow-assist\nnode ~/.openclaw/extensions/tickflow-assist/dist/dev/tickflow-assist-cli.js configure-openclaw\ncd ~/.openclaw/extensions/tickflow-assist/python && uv sync\nopenclaw plugins enable tickflow-assist\nopenclaw config validate\nopenclaw gateway restart\n```\n\n安装阶段允许先落插件,再通过第二条命令写入 `tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel` 等正式配置。\n`configure-openclaw` 会写入 `~/.openclaw/openclaw.json` 中的 `plugins.entries[\"tickflow-assist\"].config`,并打印后续建议执行的命令;它不再自动执行 `openclaw`、`uv` 或系统包安装命令,也不会重新执行插件安装;如果你已经设置了环境变量,密钥项可留空,输入 `-` 可主动清空已有配置并切回环境变量。\n如果检测到 `plugins.installs[\"tickflow-assist\"]` 来自 `clawhub`,向导还会把被旧版本钉死的 `spec` 归一化为 `clawhub:tickflow-assist`,避免后续升级继续锁在旧版本。\n\n如果你希望先审阅配置,再只打印最少的后续步骤,可使用:\n\n```bash\nnode ~/.openclaw/extensions/tickflow-assist/dist/dev/tickflow-assist-cli.js configure-openclaw --no-enable --no-restart\n```\n\n如果你在 Linux 或 macOS 上需要 PNG 告警卡正常显示中文,请额外手动安装 `fontconfig` 与 Noto CJK 一类中文字体,例如:\n\n```bash\n# Debian / Ubuntu\nsudo apt-get update\nsudo apt-get install -y fontconfig fonts-noto-cjk\nfc-cache -fv\n\n# RHEL / Fedora / Rocky / AlmaLinux\nsudo dnf install -y fontconfig google-noto-sans-cjk-ttc-fonts\nfc-cache -fv\n\n# Arch / Manjaro\nsudo pacman -Sy --noconfirm fontconfig noto-fonts-cjk\nfc-cache -fv\n\n# Alpine\nsudo apk add fontconfig font-noto-cjk\nfc-cache -fv\n\n# macOS (Homebrew)\nbrew install fontconfig\nbrew install --cask font-noto-sans-cjk\nfc-cache -fv\n```\n\n社区安装后的升级方式:\n\n```bash\nopenclaw plugins update tickflow-assist\nopenclaw gateway restart\n```\n\n## 配置\n\n插件正式运行读取:\n\n```text\n~/.openclaw/openclaw.json\n```\n\n配置路径:\n\n```text\nplugins.entries[\"tickflow-assist\"].config\n```\n\n建议按完整功能显式填写以下字段,不要只填 API Key:\n\n- 核心运行:`tickflowApiKey`、`llmApiKey`、`llmBaseUrl`、`llmModel`\n- 本地数据:`databasePath`、`calendarFile`\n- 告警投递:`alertChannel`、`alertTarget`、`alertAccount`\n- 能力补充:`mxSearchApiKey`、`jin10ApiToken`\n\n其中,`mxSearchApiKey` 用于 `mx_search`、`mx_select_stock` 以及非 `Expert` 财务链路的 lite 补充;`jin10ApiToken` 用于 24 小时金十数据快讯监控;`jin10FlashNightAlert` 默认 `false`(开启夜间静默),设为 `true` 可恢复 24 小时快讯告警;`alertTarget`、`alertAccount` 建议在准备启用 `test_alert`、实时监控告警、金十数据快讯告警和定时通知前一并配好,避免配置不完整导致功能缺失。\n如果你使用环境变量,运行时支持以下回退:\n\n- `tickflowApiUrl`:`TICKFLOW_ASSIST_TICKFLOW_API_URL` / `TICKFLOW_API_URL`\n- `tickflowApiKey`:`TICKFLOW_ASSIST_TICKFLOW_API_KEY` / `TICKFLOW_API_KEY`\n- `tickflowApiKeyLevel`:`TICKFLOW_ASSIST_TICKFLOW_API_KEY_LEVEL` / `TICKFLOW_API_KEY_LEVEL`\n- `llmBaseUrl`:`TICKFLOW_ASSIST_LLM_BASE_URL` / `LLM_BASE_URL`\n- `llmApiKey`:`TICKFLOW_ASSIST_LLM_API_KEY` / `LLM_API_KEY`\n- `llmModel`:`TICKFLOW_ASSIST_LLM_MODEL` / `LLM_MODEL`\n- `mxSearchApiUrl`:`TICKFLOW_ASSIST_MX_SEARCH_API_URL` / `MX_SEARCH_API_URL`\n- `mxSearchApiKey`:`TICKFLOW_ASSIST_MX_SEARCH_API_KEY` / `MX_SEARCH_API_KEY` / `MX_APIKEY`\n- `jin10McpUrl`:`TICKFLOW_ASSIST_JIN10_MCP_URL` / `JIN10_MCP_URL`\n- `jin10ApiToken`:`TICKFLOW_ASSIST_JIN10_API_TOKEN` / `JIN10_API_TOKEN`\n\n## 功能\n\n- 自选股管理、日 K / 分钟 K 抓取与指标计算\n- 技术面、财务面、资讯面的综合分析\n- 实时监控、定时日更、收盘后复盘\n- 金十数据 24 小时快讯监控与自选关联提醒\n- 本地 LanceDB 数据留痕与分析结果查看\n\n## 运行说明\n\n- 插件会在本地 `databasePath` 下持久化 LanceDB 数据。\n- 后台服务会按配置执行定时日更、实时监控与金十数据快讯监控。\n- Python 子模块仅用于技术指标计算,不承担主业务流程。\n\n## 依赖与可选能力\n\n- [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE):提供日线、分钟线、实时行情与财务数据接口。\n- [金十数据 MCP](https://mcp.jin10.com/app/):可选,用于 24 小时快讯流接入、自选关联筛选与事件驱动告警。\n- [东方财富妙想 Skills](https://marketing.dfcfs.com/views/finskillshub/):可选,用于 `mx_search`、`mx_select_stock` 与非 `Expert` 财务链路的 lite 补充。\n\n## 仓库\n\n- GitHub: [robinspt/tickflow-assist](https://github.com/robinspt/tickflow-assist)\n",
|
|
76
81
|
"readmeFilename": "README.md"
|
|
77
82
|
}
|
|
@@ -7,12 +7,11 @@ metadata:
|
|
|
7
7
|
requires:
|
|
8
8
|
config:
|
|
9
9
|
- plugins.entries.tickflow-assist.enabled
|
|
10
|
-
- plugins.entries.tickflow-assist.config.tickflowApiKey
|
|
11
|
-
- plugins.entries.tickflow-assist.config.llmApiKey
|
|
12
10
|
---
|
|
13
11
|
# 股票分析与监控
|
|
14
12
|
|
|
15
13
|
这是 TickFlow Assist 插件内置的技能,用于通过插件工具完成 A 股自选股管理、日线更新、分钟K抓取、技术分析、关键价位回测、实时监控、定时日更、结果查看与告警测试。
|
|
14
|
+
运行前需要提供 TickFlow / LLM 等凭证;这些值既可以来自 `plugins.entries["tickflow-assist"].config`,也可以来自文档约定的环境变量 fallback,不要求必须明文写在插件配置里。
|
|
16
15
|
|
|
17
16
|
此技能随插件加载,不需要手动复制到 workspace。
|
|
18
17
|
|