tickflow-assist 0.2.12 → 0.2.14
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 +4 -1
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +11 -2
- package/dist/dev/tickflow-assist-cli.js +165 -0
- package/dist/runtime/openclaw-temp-dir.d.ts +21 -0
- package/dist/runtime/openclaw-temp-dir.js +124 -0
- package/dist/runtime/plugin-api.d.ts +1 -1
- package/dist/services/alert-media-service.d.ts +2 -1
- package/dist/services/alert-media-service.js +6 -2
- package/dist/services/alert-service.js +21 -23
- package/dist/tools/test-alert.tool.d.ts +1 -1
- package/dist/tools/test-alert.tool.js +24 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
基于 [OpenClaw](https://openclaw.ai) 的 A 股监控与分析插件。它使用 [TickFlow](https://tickflow.org/auth/register?ref=BUJ54JEDGE) 获取行情与财务数据,结合 LLM 生成技术面、基本面、资讯面的综合判断,并把结果持久化到本地 LanceDB。
|
|
4
4
|
|
|
5
|
-
最近更新:`v0.2.
|
|
5
|
+
最近更新:`v0.2.14` 对齐 OpenClaw `v2026.3.31` 兼容声明与开发依赖,更新 QQ Bot 内置通道说明,并将 PNG 告警卡临时文件迁移到 OpenClaw 共享 temp root,修复新版本地媒体 allowlist 下的图片投递失败。
|
|
6
|
+
|
|
7
|
+
当前主线按 OpenClaw `v2026.3.31+` 对齐。
|
|
6
8
|
|
|
7
9
|
## 安装
|
|
8
10
|
|
|
@@ -14,6 +16,7 @@ npx -y tickflow-assist configure-openclaw
|
|
|
14
16
|
```
|
|
15
17
|
|
|
16
18
|
安装阶段允许先落插件,再通过第二条命令写入 `tickflowApiKey`、`llmApiKey` 等正式配置。
|
|
19
|
+
在 Linux 上,`configure-openclaw` 还会 best-effort 安装 PNG 告警卡所需的中文字体;如需跳过,可追加 `--no-font-setup`。
|
|
17
20
|
|
|
18
21
|
第二条命令会写入 `~/.openclaw/openclaw.json` 中的 `plugins.entries["tickflow-assist"].config`,并默认执行:
|
|
19
22
|
|
package/dist/bootstrap.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Database } from "./storage/db.js";
|
|
|
3
3
|
import { WatchlistService } from "./services/watchlist-service.js";
|
|
4
4
|
import { MonitorService } from "./services/monitor-service.js";
|
|
5
5
|
import { AlertService } from "./services/alert-service.js";
|
|
6
|
+
import { AlertMediaService } from "./services/alert-media-service.js";
|
|
6
7
|
import type { LocalTool, OpenClawPluginConfig, OpenClawPluginRuntime, RegisteredService } from "./runtime/plugin-api.js";
|
|
7
8
|
import { RealtimeMonitorWorker } from "./background/realtime-monitor.worker.js";
|
|
8
9
|
import { DailyUpdateWorker } from "./background/daily-update.worker.js";
|
|
@@ -19,6 +20,7 @@ export interface AppContext {
|
|
|
19
20
|
};
|
|
20
21
|
services: {
|
|
21
22
|
alertService: AlertService;
|
|
23
|
+
alertMediaService: AlertMediaService;
|
|
22
24
|
monitorService: MonitorService;
|
|
23
25
|
realtimeMonitorWorker: RealtimeMonitorWorker;
|
|
24
26
|
dailyUpdateWorker: DailyUpdateWorker;
|
package/dist/bootstrap.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { TickFlowClient } from "./services/tickflow-client.js";
|
|
2
3
|
import { InstrumentService } from "./services/instrument-service.js";
|
|
3
4
|
import { KlineService } from "./services/kline-service.js";
|
|
@@ -63,6 +64,7 @@ import { testAlertTool } from "./tools/test-alert.tool.js";
|
|
|
63
64
|
import { updateAllTool } from "./tools/update-all.tool.js";
|
|
64
65
|
import { viewAnalysisTool } from "./tools/view-analysis.tool.js";
|
|
65
66
|
import { backtestKeyLevelsTool } from "./tools/backtest-key-levels.tool.js";
|
|
67
|
+
import { resolvePreferredOpenClawTmpDir } from "./runtime/openclaw-temp-dir.js";
|
|
66
68
|
import { RealtimeMonitorWorker } from "./background/realtime-monitor.worker.js";
|
|
67
69
|
import { DailyUpdateWorker } from "./background/daily-update.worker.js";
|
|
68
70
|
export function createAppContext(config, options = {}) {
|
|
@@ -107,7 +109,7 @@ export function createAppContext(config, options = {}) {
|
|
|
107
109
|
}
|
|
108
110
|
: undefined,
|
|
109
111
|
});
|
|
110
|
-
const alertMediaService = new AlertMediaService(config.databasePath);
|
|
112
|
+
const alertMediaService = new AlertMediaService(config.databasePath, undefined, undefined, resolveAlertMediaTempRootDir());
|
|
111
113
|
const indicatorService = new IndicatorService(config.pythonBin, config.pythonArgs, config.pythonWorkdir);
|
|
112
114
|
const watchlistService = new WatchlistService(watchlistRepository, instrumentService, watchlistProfileService);
|
|
113
115
|
const keyLevelsBacktestService = new KeyLevelsBacktestService(keyLevelsHistoryRepository, klinesRepository, intradayKlinesRepository, watchlistService);
|
|
@@ -152,7 +154,7 @@ export function createAppContext(config, options = {}) {
|
|
|
152
154
|
startMonitorTool(monitorService, runtime),
|
|
153
155
|
stopDailyUpdateTool(dailyUpdateWorker, runtime),
|
|
154
156
|
stopMonitorTool(monitorService, runtime),
|
|
155
|
-
testAlertTool(alertService, alertMediaService),
|
|
157
|
+
testAlertTool(alertService, alertMediaService, runtime.configSource),
|
|
156
158
|
updateAllTool(dailyUpdateWorker),
|
|
157
159
|
viewAnalysisTool(analysisViewService),
|
|
158
160
|
],
|
|
@@ -192,6 +194,7 @@ export function createAppContext(config, options = {}) {
|
|
|
192
194
|
runtime,
|
|
193
195
|
services: {
|
|
194
196
|
alertService,
|
|
197
|
+
alertMediaService,
|
|
195
198
|
monitorService,
|
|
196
199
|
realtimeMonitorWorker,
|
|
197
200
|
dailyUpdateWorker,
|
|
@@ -200,6 +203,12 @@ export function createAppContext(config, options = {}) {
|
|
|
200
203
|
},
|
|
201
204
|
};
|
|
202
205
|
}
|
|
206
|
+
function resolveAlertMediaTempRootDir() {
|
|
207
|
+
// OpenClaw 2026.3.31 no longer widens local media roots from tool-created files.
|
|
208
|
+
// Keep PNG alerts under the shared OpenClaw temp root so both runtime sends and
|
|
209
|
+
// `openclaw message send --media ...` can read them without extra allowlist config.
|
|
210
|
+
return path.join(resolvePreferredOpenClawTmpDir(), "tickflow-assist", "alert-media", "tmp");
|
|
211
|
+
}
|
|
203
212
|
export async function buildWatchlistDebugSnapshot(app) {
|
|
204
213
|
const watchlistTableExists = await app.services.database.hasTable("watchlist");
|
|
205
214
|
const watchlistPreview = await app.services.watchlistService.list();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
3
4
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -38,6 +39,7 @@ Options:
|
|
|
38
39
|
--no-enable Do not run 'openclaw plugins enable'
|
|
39
40
|
--no-restart Do not run 'openclaw gateway restart'
|
|
40
41
|
--no-python-setup Do not run 'uv sync' for Python dependencies
|
|
42
|
+
--no-font-setup Do not try to install Linux Chinese fonts for PNG alerts
|
|
41
43
|
--openclaw-bin <path> OpenClaw CLI binary, default: openclaw
|
|
42
44
|
--tickflow-api-key <key>
|
|
43
45
|
--tickflow-api-key-level <Free|Start|Pro|Expert>
|
|
@@ -61,6 +63,7 @@ function parseArgs(argv) {
|
|
|
61
63
|
restart: true,
|
|
62
64
|
enable: true,
|
|
63
65
|
pythonSetup: true,
|
|
66
|
+
fontSetup: true,
|
|
64
67
|
openclawBin: DEFAULTS.openclawCliBin,
|
|
65
68
|
overrides: {},
|
|
66
69
|
};
|
|
@@ -115,6 +118,9 @@ function parseArgs(argv) {
|
|
|
115
118
|
case "--no-python-setup":
|
|
116
119
|
options.pythonSetup = false;
|
|
117
120
|
break;
|
|
121
|
+
case "--no-font-setup":
|
|
122
|
+
options.fontSetup = false;
|
|
123
|
+
break;
|
|
118
124
|
case "--no-enable":
|
|
119
125
|
options.enable = false;
|
|
120
126
|
break;
|
|
@@ -623,6 +629,162 @@ async function setupPythonDeps(pythonWorkdir, nonInteractive) {
|
|
|
623
629
|
}
|
|
624
630
|
console.log("Python dependencies installed successfully.");
|
|
625
631
|
}
|
|
632
|
+
function isCommandAvailable(command) {
|
|
633
|
+
const result = spawnSync("which", [command], { stdio: "ignore" });
|
|
634
|
+
return result.status === 0;
|
|
635
|
+
}
|
|
636
|
+
function isRootUser() {
|
|
637
|
+
return typeof process.getuid === "function" && process.getuid() === 0;
|
|
638
|
+
}
|
|
639
|
+
function hasChineseFonts() {
|
|
640
|
+
const result = spawnSync("fc-list", [":lang=zh", "family"], { encoding: "utf-8" });
|
|
641
|
+
return result.status === 0 && Boolean(result.stdout.trim());
|
|
642
|
+
}
|
|
643
|
+
function detectLinuxDistro() {
|
|
644
|
+
try {
|
|
645
|
+
const raw = readFileSync("/etc/os-release", "utf-8");
|
|
646
|
+
const record = Object.fromEntries(raw
|
|
647
|
+
.split("\n")
|
|
648
|
+
.map((line) => line.trim())
|
|
649
|
+
.filter(Boolean)
|
|
650
|
+
.filter((line) => !line.startsWith("#") && line.includes("="))
|
|
651
|
+
.map((line) => {
|
|
652
|
+
const index = line.indexOf("=");
|
|
653
|
+
const key = line.slice(0, index);
|
|
654
|
+
const value = line.slice(index + 1).replace(/^"/, "").replace(/"$/, "");
|
|
655
|
+
return [key, value];
|
|
656
|
+
}));
|
|
657
|
+
const ids = [
|
|
658
|
+
String(record.ID ?? "").toLowerCase(),
|
|
659
|
+
...String(record.ID_LIKE ?? "")
|
|
660
|
+
.toLowerCase()
|
|
661
|
+
.split(/\s+/)
|
|
662
|
+
.filter(Boolean),
|
|
663
|
+
];
|
|
664
|
+
if (ids.some((value) => ["debian", "ubuntu"].includes(value))) {
|
|
665
|
+
return "debian";
|
|
666
|
+
}
|
|
667
|
+
if (ids.some((value) => ["rhel", "fedora", "centos", "rocky", "almalinux"].includes(value))) {
|
|
668
|
+
return "rhel";
|
|
669
|
+
}
|
|
670
|
+
if (ids.some((value) => ["arch", "manjaro"].includes(value))) {
|
|
671
|
+
return "arch";
|
|
672
|
+
}
|
|
673
|
+
if (ids.includes("alpine")) {
|
|
674
|
+
return "alpine";
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// fall through
|
|
679
|
+
}
|
|
680
|
+
return "unknown";
|
|
681
|
+
}
|
|
682
|
+
function getManualFontCommands(distro) {
|
|
683
|
+
switch (distro) {
|
|
684
|
+
case "debian":
|
|
685
|
+
return ["sudo apt-get update", "sudo apt-get install -y fontconfig fonts-noto-cjk", "fc-cache -fv"];
|
|
686
|
+
case "rhel":
|
|
687
|
+
return [
|
|
688
|
+
"sudo dnf install -y fontconfig google-noto-sans-cjk-ttc-fonts",
|
|
689
|
+
"fc-cache -fv",
|
|
690
|
+
];
|
|
691
|
+
case "arch":
|
|
692
|
+
return ["sudo pacman -Sy --noconfirm fontconfig noto-fonts-cjk", "fc-cache -fv"];
|
|
693
|
+
case "alpine":
|
|
694
|
+
return ["sudo apk add fontconfig font-noto-cjk", "fc-cache -fv"];
|
|
695
|
+
default:
|
|
696
|
+
return [
|
|
697
|
+
"请安装 fontconfig 和任意可用的中文字体包,例如 Noto Sans CJK",
|
|
698
|
+
"安装后执行: fc-cache -fv",
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function buildPrivilegedCommand(argv, nonInteractive) {
|
|
703
|
+
if (isRootUser()) {
|
|
704
|
+
return argv;
|
|
705
|
+
}
|
|
706
|
+
if (!isCommandAvailable("sudo")) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
return nonInteractive ? ["sudo", "-n", ...argv] : ["sudo", ...argv];
|
|
710
|
+
}
|
|
711
|
+
function runSetupCommand(argv, description) {
|
|
712
|
+
console.log(description);
|
|
713
|
+
const result = spawnSync(argv[0], argv.slice(1), { stdio: "inherit" });
|
|
714
|
+
if (result.error) {
|
|
715
|
+
console.warn(`Warning: failed to run ${description}: ${result.error.message}`);
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
if (result.status !== 0) {
|
|
719
|
+
console.warn(`Warning: ${description} exited with status ${result.status}`);
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
async function setupLinuxChineseFonts(nonInteractive) {
|
|
725
|
+
if (process.platform !== "linux") {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (!isCommandAvailable("fc-list")) {
|
|
729
|
+
console.warn("Warning: fontconfig is not installed; PNG alerts may not render Chinese text correctly.");
|
|
730
|
+
}
|
|
731
|
+
else if (hasChineseFonts()) {
|
|
732
|
+
console.log("Chinese fonts detected for PNG alert cards.");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const distro = detectLinuxDistro();
|
|
736
|
+
console.log("Chinese fonts not detected. TickFlow Assist will try to install Noto CJK fonts for PNG alert cards.");
|
|
737
|
+
const attempts = [];
|
|
738
|
+
switch (distro) {
|
|
739
|
+
case "debian":
|
|
740
|
+
attempts.push(["apt-get", "update"]);
|
|
741
|
+
attempts.push(["apt-get", "install", "-y", "fontconfig", "fonts-noto-cjk"]);
|
|
742
|
+
break;
|
|
743
|
+
case "rhel":
|
|
744
|
+
attempts.push(["dnf", "install", "-y", "fontconfig", "google-noto-sans-cjk-ttc-fonts"]);
|
|
745
|
+
attempts.push(["dnf", "install", "-y", "fontconfig", "google-noto-cjk-fonts"]);
|
|
746
|
+
attempts.push(["yum", "install", "-y", "fontconfig", "google-noto-sans-cjk-ttc-fonts"]);
|
|
747
|
+
attempts.push(["yum", "install", "-y", "fontconfig", "google-noto-cjk-fonts"]);
|
|
748
|
+
break;
|
|
749
|
+
case "arch":
|
|
750
|
+
attempts.push(["pacman", "-Sy", "--noconfirm", "fontconfig", "noto-fonts-cjk"]);
|
|
751
|
+
break;
|
|
752
|
+
case "alpine":
|
|
753
|
+
attempts.push(["apk", "add", "fontconfig", "font-noto-cjk"]);
|
|
754
|
+
break;
|
|
755
|
+
default:
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
let attemptedInstall = false;
|
|
759
|
+
for (const baseArgv of attempts) {
|
|
760
|
+
if (!isCommandAvailable(baseArgv[0])) {
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
const argv = buildPrivilegedCommand(baseArgv, nonInteractive);
|
|
764
|
+
if (!argv) {
|
|
765
|
+
break;
|
|
766
|
+
}
|
|
767
|
+
attemptedInstall = true;
|
|
768
|
+
if (!runSetupCommand(argv, `Running ${argv.join(" ")}`)) {
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (isCommandAvailable("fc-cache")) {
|
|
772
|
+
runSetupCommand(["fc-cache", "-fv"], "Refreshing font cache with fc-cache -fv");
|
|
773
|
+
}
|
|
774
|
+
if (isCommandAvailable("fc-list") && hasChineseFonts()) {
|
|
775
|
+
console.log("Chinese fonts installed successfully.");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
console.warn("Warning: automatic Chinese font setup did not complete.");
|
|
780
|
+
if (!attemptedInstall && nonInteractive) {
|
|
781
|
+
console.warn("Non-interactive mode skipped any sudo password prompt.");
|
|
782
|
+
}
|
|
783
|
+
console.warn("Manual install examples:");
|
|
784
|
+
for (const command of getManualFontCommands(distro)) {
|
|
785
|
+
console.warn(` ${command}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
626
788
|
async function configureOpenClaw(options) {
|
|
627
789
|
const configPath = resolveOpenClawConfigPath(options.configPath);
|
|
628
790
|
const stateDir = resolveStateDir(configPath);
|
|
@@ -636,6 +798,9 @@ async function configureOpenClaw(options) {
|
|
|
636
798
|
if (options.pythonSetup) {
|
|
637
799
|
await setupPythonDeps(config.pythonWorkdir, options.nonInteractive);
|
|
638
800
|
}
|
|
801
|
+
if (options.fontSetup) {
|
|
802
|
+
await setupLinuxChineseFonts(options.nonInteractive);
|
|
803
|
+
}
|
|
639
804
|
applyPluginConfig(root, config, target);
|
|
640
805
|
const backupPath = await writeConfig(configPath, root);
|
|
641
806
|
console.log("");
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw";
|
|
2
|
+
interface TempDirStatLike {
|
|
3
|
+
isDirectory(): boolean;
|
|
4
|
+
isSymbolicLink(): boolean;
|
|
5
|
+
mode?: number;
|
|
6
|
+
uid?: number;
|
|
7
|
+
}
|
|
8
|
+
interface ResolvePreferredOpenClawTmpDirOptions {
|
|
9
|
+
accessSync?: (path: string, mode?: number) => void;
|
|
10
|
+
chmodSync?: (path: string, mode: number) => void;
|
|
11
|
+
lstatSync?: (path: string) => TempDirStatLike;
|
|
12
|
+
mkdirSync?: (path: string, opts: {
|
|
13
|
+
recursive: boolean;
|
|
14
|
+
mode?: number;
|
|
15
|
+
}) => void;
|
|
16
|
+
getuid?: () => number | undefined;
|
|
17
|
+
tmpdir?: () => string;
|
|
18
|
+
warn?: (message: string) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare function resolvePreferredOpenClawTmpDir(options?: ResolvePreferredOpenClawTmpDirOptions): string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw";
|
|
5
|
+
const TMP_DIR_ACCESS_MODE = fsSync.constants.W_OK | fsSync.constants.X_OK;
|
|
6
|
+
function isNodeErrorWithCode(error, code) {
|
|
7
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
8
|
+
}
|
|
9
|
+
export function resolvePreferredOpenClawTmpDir(options = {}) {
|
|
10
|
+
const accessSync = options.accessSync ?? fsSync.accessSync;
|
|
11
|
+
const chmodSync = options.chmodSync ?? fsSync.chmodSync;
|
|
12
|
+
const lstatSync = options.lstatSync ?? fsSync.lstatSync;
|
|
13
|
+
const mkdirSync = options.mkdirSync ?? fsSync.mkdirSync;
|
|
14
|
+
const warn = options.warn ?? ((message) => console.warn(message));
|
|
15
|
+
const getuid = options.getuid ?? (() => {
|
|
16
|
+
try {
|
|
17
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const resolveTmpdir = options.tmpdir ?? os.tmpdir;
|
|
24
|
+
const uid = getuid();
|
|
25
|
+
const isSecureDirForUser = (stats) => {
|
|
26
|
+
if (uid === undefined) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (typeof stats.uid === "number" && stats.uid !== uid) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (typeof stats.mode === "number" && (stats.mode & 0o022) !== 0) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
const fallback = () => {
|
|
38
|
+
const suffix = uid === undefined ? "openclaw" : `openclaw-${uid}`;
|
|
39
|
+
return path.join(resolveTmpdir(), suffix);
|
|
40
|
+
};
|
|
41
|
+
const isTrustedTmpDir = (stats) => stats.isDirectory() && !stats.isSymbolicLink() && isSecureDirForUser(stats);
|
|
42
|
+
const resolveDirState = (candidatePath) => {
|
|
43
|
+
try {
|
|
44
|
+
if (!isTrustedTmpDir(lstatSync(candidatePath))) {
|
|
45
|
+
return "invalid";
|
|
46
|
+
}
|
|
47
|
+
accessSync(candidatePath, TMP_DIR_ACCESS_MODE);
|
|
48
|
+
return "available";
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (isNodeErrorWithCode(error, "ENOENT")) {
|
|
52
|
+
return "missing";
|
|
53
|
+
}
|
|
54
|
+
return "invalid";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const tryRepairWritableBits = (candidatePath) => {
|
|
58
|
+
try {
|
|
59
|
+
const stats = lstatSync(candidatePath);
|
|
60
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (uid !== undefined && typeof stats.uid === "number" && stats.uid !== uid) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (typeof stats.mode !== "number" || (stats.mode & 0o022) === 0) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
chmodSync(candidatePath, 0o700);
|
|
70
|
+
warn(`[tickflow-assist] tightened permissions on temp dir: ${candidatePath}`);
|
|
71
|
+
return resolveDirState(candidatePath) === "available";
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const ensureTrustedFallbackDir = () => {
|
|
78
|
+
const fallbackPath = fallback();
|
|
79
|
+
const state = resolveDirState(fallbackPath);
|
|
80
|
+
if (state === "available") {
|
|
81
|
+
return fallbackPath;
|
|
82
|
+
}
|
|
83
|
+
if (state === "invalid") {
|
|
84
|
+
if (tryRepairWritableBits(fallbackPath)) {
|
|
85
|
+
return fallbackPath;
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`);
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(fallbackPath, { recursive: true, mode: 0o700 });
|
|
91
|
+
chmodSync(fallbackPath, 0o700);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw new Error(`Unable to create fallback OpenClaw temp dir: ${fallbackPath}`);
|
|
95
|
+
}
|
|
96
|
+
if (resolveDirState(fallbackPath) !== "available" && !tryRepairWritableBits(fallbackPath)) {
|
|
97
|
+
throw new Error(`Unsafe fallback OpenClaw temp dir: ${fallbackPath}`);
|
|
98
|
+
}
|
|
99
|
+
return fallbackPath;
|
|
100
|
+
};
|
|
101
|
+
const existingPreferredState = resolveDirState(POSIX_OPENCLAW_TMP_DIR);
|
|
102
|
+
if (existingPreferredState === "available") {
|
|
103
|
+
return POSIX_OPENCLAW_TMP_DIR;
|
|
104
|
+
}
|
|
105
|
+
if (existingPreferredState === "invalid") {
|
|
106
|
+
if (tryRepairWritableBits(POSIX_OPENCLAW_TMP_DIR)) {
|
|
107
|
+
return POSIX_OPENCLAW_TMP_DIR;
|
|
108
|
+
}
|
|
109
|
+
return ensureTrustedFallbackDir();
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
accessSync("/tmp", TMP_DIR_ACCESS_MODE);
|
|
113
|
+
mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 0o700 });
|
|
114
|
+
chmodSync(POSIX_OPENCLAW_TMP_DIR, 0o700);
|
|
115
|
+
if (resolveDirState(POSIX_OPENCLAW_TMP_DIR) !== "available"
|
|
116
|
+
&& !tryRepairWritableBits(POSIX_OPENCLAW_TMP_DIR)) {
|
|
117
|
+
return ensureTrustedFallbackDir();
|
|
118
|
+
}
|
|
119
|
+
return POSIX_OPENCLAW_TMP_DIR;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return ensureTrustedFallbackDir();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -12,7 +12,7 @@ export interface PluginEntryResult {
|
|
|
12
12
|
id: string;
|
|
13
13
|
name: string;
|
|
14
14
|
description: string;
|
|
15
|
-
kind?:
|
|
15
|
+
kind?: OpenClawPluginDefinition["kind"];
|
|
16
16
|
register: (api: OpenClawPluginApi) => void;
|
|
17
17
|
}
|
|
18
18
|
export declare function definePluginEntry({ id, name, description, kind, register, }: DefinePluginEntryOptions): PluginEntryResult;
|
|
@@ -8,8 +8,9 @@ export declare class AlertMediaService {
|
|
|
8
8
|
private readonly baseDir;
|
|
9
9
|
private readonly retentionHours;
|
|
10
10
|
private readonly cleanupIntervalMs;
|
|
11
|
+
private readonly tempRootDir?;
|
|
11
12
|
private lastCleanupAt;
|
|
12
|
-
constructor(baseDir: string, retentionHours?: number, cleanupIntervalMs?: number);
|
|
13
|
+
constructor(baseDir: string, retentionHours?: number, cleanupIntervalMs?: number, tempRootDir?: string | undefined);
|
|
13
14
|
getTempRootDir(): string;
|
|
14
15
|
writeAlertCard(params: {
|
|
15
16
|
symbol: string;
|
|
@@ -8,14 +8,18 @@ export class AlertMediaService {
|
|
|
8
8
|
baseDir;
|
|
9
9
|
retentionHours;
|
|
10
10
|
cleanupIntervalMs;
|
|
11
|
+
tempRootDir;
|
|
11
12
|
lastCleanupAt = 0;
|
|
12
|
-
constructor(baseDir, retentionHours = DEFAULT_RETENTION_HOURS, cleanupIntervalMs = DEFAULT_CLEANUP_INTERVAL_MS) {
|
|
13
|
+
constructor(baseDir, retentionHours = DEFAULT_RETENTION_HOURS, cleanupIntervalMs = DEFAULT_CLEANUP_INTERVAL_MS, tempRootDir) {
|
|
13
14
|
this.baseDir = baseDir;
|
|
14
15
|
this.retentionHours = retentionHours;
|
|
15
16
|
this.cleanupIntervalMs = cleanupIntervalMs;
|
|
17
|
+
this.tempRootDir = tempRootDir;
|
|
16
18
|
}
|
|
17
19
|
getTempRootDir() {
|
|
18
|
-
return
|
|
20
|
+
return this.tempRootDir
|
|
21
|
+
? path.resolve(this.tempRootDir)
|
|
22
|
+
: path.resolve(this.baseDir, "..", "alert-media", "tmp");
|
|
19
23
|
}
|
|
20
24
|
async writeAlertCard(params) {
|
|
21
25
|
await this.maybeCleanupExpired();
|
|
@@ -25,6 +25,21 @@ export class AlertService {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
if (payload.mediaPath) {
|
|
28
|
+
if (payload.message.trim()) {
|
|
29
|
+
const mediaOnlyError = await this.trySendPayload({
|
|
30
|
+
...payload,
|
|
31
|
+
message: "",
|
|
32
|
+
});
|
|
33
|
+
if (mediaOnlyError === null) {
|
|
34
|
+
const textFollowupError = await this.trySendPayload({ message: payload.message });
|
|
35
|
+
return {
|
|
36
|
+
ok: textFollowupError === null,
|
|
37
|
+
mediaAttempted: true,
|
|
38
|
+
mediaDelivered: true,
|
|
39
|
+
error: textFollowupError,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
28
43
|
const textFallback = normalizeSendInput(payload.message);
|
|
29
44
|
const textFallbackError = await this.trySendPayload(textFallback);
|
|
30
45
|
if (textFallbackError === null) {
|
|
@@ -32,7 +47,7 @@ export class AlertService {
|
|
|
32
47
|
ok: true,
|
|
33
48
|
mediaAttempted: true,
|
|
34
49
|
mediaDelivered: false,
|
|
35
|
-
error:
|
|
50
|
+
error: primaryError,
|
|
36
51
|
};
|
|
37
52
|
}
|
|
38
53
|
return {
|
|
@@ -137,9 +152,6 @@ export class AlertService {
|
|
|
137
152
|
};
|
|
138
153
|
try {
|
|
139
154
|
switch (this.channel) {
|
|
140
|
-
case "telegram":
|
|
141
|
-
await runtimeContext.runtime.channel.telegram.sendMessageTelegram(this.options.target, payload.message, baseOptions);
|
|
142
|
-
return null;
|
|
143
155
|
case "discord":
|
|
144
156
|
await runtimeContext.runtime.channel.discord.sendMessageDiscord(this.options.target, payload.message, {
|
|
145
157
|
...baseOptions,
|
|
@@ -156,24 +168,9 @@ export class AlertService {
|
|
|
156
168
|
case "signal":
|
|
157
169
|
await runtimeContext.runtime.channel.signal.sendMessageSignal(this.options.target, payload.message, baseOptions);
|
|
158
170
|
return null;
|
|
159
|
-
case "imessage":
|
|
160
|
-
await runtimeContext.runtime.channel.imessage.sendMessageIMessage(this.options.target, payload.message, {
|
|
161
|
-
accountId: this.options.account || undefined,
|
|
162
|
-
config: runtimeContext.config,
|
|
163
|
-
mediaUrl: payload.mediaPath,
|
|
164
|
-
mediaLocalRoots: payload.mediaLocalRoots,
|
|
165
|
-
});
|
|
166
|
-
return null;
|
|
167
|
-
case "whatsapp":
|
|
168
|
-
await runtimeContext.runtime.channel.whatsapp.sendMessageWhatsApp(this.options.target, payload.message, {
|
|
169
|
-
verbose: false,
|
|
170
|
-
cfg: runtimeContext.config,
|
|
171
|
-
accountId: this.options.account || undefined,
|
|
172
|
-
mediaUrl: payload.mediaPath,
|
|
173
|
-
mediaLocalRoots: payload.mediaLocalRoots,
|
|
174
|
-
});
|
|
175
|
-
return null;
|
|
176
171
|
default:
|
|
172
|
+
// OpenClaw 2026.3.31 narrows the typed runtime channel surface.
|
|
173
|
+
// Fall back to `openclaw message send` for channels not exposed here.
|
|
177
174
|
return `runtime delivery not supported for channel: ${this.channel}`;
|
|
178
175
|
}
|
|
179
176
|
}
|
|
@@ -237,9 +234,10 @@ export class AlertService {
|
|
|
237
234
|
"send",
|
|
238
235
|
"--channel",
|
|
239
236
|
this.channel,
|
|
240
|
-
"--message",
|
|
241
|
-
payload.message,
|
|
242
237
|
];
|
|
238
|
+
if (payload.message) {
|
|
239
|
+
args.push("--message", payload.message);
|
|
240
|
+
}
|
|
243
241
|
if (payload.mediaPath) {
|
|
244
242
|
args.push("--media", payload.mediaPath);
|
|
245
243
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AlertService } from "../services/alert-service.js";
|
|
2
2
|
import { AlertMediaService } from "../services/alert-media-service.js";
|
|
3
|
-
export declare function testAlertTool(alertService: AlertService, alertMediaService: AlertMediaService): {
|
|
3
|
+
export declare function testAlertTool(alertService: AlertService, alertMediaService: AlertMediaService, configSource?: "openclaw_plugin" | "local_config"): {
|
|
4
4
|
name: string;
|
|
5
5
|
description: string;
|
|
6
6
|
optional: boolean;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { formatChinaDateTime } from "../utils/china-time.js";
|
|
2
|
-
export function testAlertTool(alertService, alertMediaService) {
|
|
2
|
+
export function testAlertTool(alertService, alertMediaService, configSource = "openclaw_plugin") {
|
|
3
3
|
return {
|
|
4
4
|
name: "test_alert",
|
|
5
|
-
description: "Send a
|
|
5
|
+
description: "Send a test alert through the configured OpenClaw alert delivery path. Plugin mode includes PNG; local mode sends text only.",
|
|
6
6
|
optional: true,
|
|
7
7
|
async run() {
|
|
8
8
|
const now = formatChinaDateTime();
|
|
@@ -10,6 +10,19 @@ export function testAlertTool(alertService, alertMediaService) {
|
|
|
10
10
|
`时间: ${now}`,
|
|
11
11
|
"说明: 这是一条手动触发的测试消息,用于验证文本与 PNG 告警卡投递链路正常。",
|
|
12
12
|
]);
|
|
13
|
+
if (configSource === "local_config") {
|
|
14
|
+
const result = await alertService.sendWithResult({ message });
|
|
15
|
+
if (result.ok) {
|
|
16
|
+
return [
|
|
17
|
+
"✅ 测试告警文本已发送(本地命令模式)",
|
|
18
|
+
"说明: `npm run tool -- test_alert` 仅验证文本链路;请通过 `/ta_testalert` 验证 PNG 图片链路。",
|
|
19
|
+
].join("\n");
|
|
20
|
+
}
|
|
21
|
+
const detail = result.error ?? alertService.getLastError();
|
|
22
|
+
return detail
|
|
23
|
+
? `❌ 测试告警发送失败\n原因: ${detail}`
|
|
24
|
+
: "❌ 测试告警发送失败";
|
|
25
|
+
}
|
|
13
26
|
let mediaFile = null;
|
|
14
27
|
try {
|
|
15
28
|
mediaFile = await alertMediaService.writeAlertCard({
|
|
@@ -41,10 +54,18 @@ export function testAlertTool(alertService, alertMediaService) {
|
|
|
41
54
|
filename: mediaFile.filename,
|
|
42
55
|
});
|
|
43
56
|
if (result.ok && result.mediaDelivered) {
|
|
57
|
+
if (result.error) {
|
|
58
|
+
return [
|
|
59
|
+
"⚠️ PNG 告警卡已发送,但文本补发失败",
|
|
60
|
+
`原因: ${result.error}`,
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
44
63
|
return "✅ 测试告警发送成功(文本 + PNG)";
|
|
45
64
|
}
|
|
46
65
|
if (result.ok) {
|
|
47
|
-
return
|
|
66
|
+
return result.error
|
|
67
|
+
? `⚠️ 测试告警文本已发送,但 PNG 未送达,已回退为纯文本\n原因: ${result.error}`
|
|
68
|
+
: "⚠️ 测试告警文本已发送,但 PNG 未送达,已回退为纯文本";
|
|
48
69
|
}
|
|
49
70
|
const detail = result.error ?? alertService.getLastError();
|
|
50
71
|
return detail
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickflow-assist",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "OpenClaw plugin for TickFlow-
|
|
3
|
+
"version": "0.2.14",
|
|
4
|
+
"description": "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. 面向 A 股投资与盯盘场景的 OpenClaw 智能股票插件,基于 TickFlow API 提供实时监控、收盘后复盘、多维综合分析、关键价位跟踪与告警能力。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/plugin.js",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
},
|
|
39
39
|
"openclaw": {
|
|
40
40
|
"build": {
|
|
41
|
-
"openclawVersion": "2026.3.
|
|
41
|
+
"openclawVersion": "2026.3.31"
|
|
42
42
|
},
|
|
43
43
|
"compat": {
|
|
44
|
-
"pluginApi": "2026.3.
|
|
45
|
-
"minGatewayVersion": "2026.3.
|
|
46
|
-
"builtWithOpenClawVersion": "2026.3.
|
|
44
|
+
"pluginApi": "2026.3.31",
|
|
45
|
+
"minGatewayVersion": "2026.3.31",
|
|
46
|
+
"builtWithOpenClawVersion": "2026.3.31"
|
|
47
47
|
},
|
|
48
48
|
"extensions": [
|
|
49
49
|
"dist/plugin.js"
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/node": "^22.13.11",
|
|
64
|
-
"openclaw": "^2026.3.
|
|
64
|
+
"openclaw": "^2026.3.31",
|
|
65
65
|
"typescript": "^5.8.2"
|
|
66
66
|
}
|
|
67
67
|
}
|