clawvif 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -0
- package/dist/file-store-Cp6U-dO6.mjs +98 -0
- package/dist/file-store-Cp6U-dO6.mjs.map +1 -0
- package/dist/index.d.mts +35 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1538 -0
- package/dist/index.mjs.map +1 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +70 -0
- package/skills/clawvif/alert-dispatcher/SKILL.md +40 -0
- package/skills/clawvif/audit-logger/SKILL.md +41 -0
- package/skills/clawvif/onchain-monitor/SKILL.md +86 -0
- package/skills/clawvif/onchain-monitor/references/alert-format.md +47 -0
- package/skills/clawvif/sentiment-radar/SKILL.md +65 -0
- package/skills/clawvif/sentiment-radar/references/scoring.md +38 -0
- package/skills/clawvif/tg-group-scanner/SKILL.md +65 -0
- package/skills/clawvif/whale-tracker/SKILL.md +75 -0
- package/workspace/SOUL.md +22 -0
- package/workspace/USER.md +18 -0
- package/workspace/config/alert-rules.json +37 -0
- package/workspace/config/kol-list.json +3 -0
- package/workspace/config/tg-groups.json +3 -0
- package/workspace/config/watchlist.json +3 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
import { a as readJsonl, i as readJsonOr, n as ensureDir, o as readJsonlTail, s as writeJson, t as appendJsonl } from "./file-store-Cp6U-dO6.mjs";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Bot } from "grammy";
|
|
5
|
+
//#region src/lib/audit-logger.ts
|
|
6
|
+
/**
|
|
7
|
+
* Audit logger for Clawvif.
|
|
8
|
+
*
|
|
9
|
+
* Records all sensitive operations to a JSONL audit trail.
|
|
10
|
+
* Every vault sign, trade execution, risk check, and configuration
|
|
11
|
+
* change is logged with timestamp, source skill, and relevant data.
|
|
12
|
+
*/
|
|
13
|
+
let logDir = join(process.env["HOME"] || process.env["USERPROFILE"] || "/tmp", ".clawvif", "logs");
|
|
14
|
+
/**
|
|
15
|
+
* Get the path to the audit log file.
|
|
16
|
+
*/
|
|
17
|
+
function getAuditLogPath() {
|
|
18
|
+
return join(logDir, "audit.jsonl");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Write an audit log entry.
|
|
22
|
+
*
|
|
23
|
+
* @param skill - Name of the skill/tool performing the action
|
|
24
|
+
* @param action - Action being performed (e.g., "sign_transaction", "import_key")
|
|
25
|
+
* @param data - Relevant data (NEVER include private keys or passwords)
|
|
26
|
+
*/
|
|
27
|
+
function auditLog(skill, action, data = {}) {
|
|
28
|
+
const entry = {
|
|
29
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
30
|
+
skill,
|
|
31
|
+
action,
|
|
32
|
+
data
|
|
33
|
+
};
|
|
34
|
+
appendJsonl(getAuditLogPath(), entry);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Query recent audit log entries.
|
|
38
|
+
*
|
|
39
|
+
* @param count - Number of recent entries to return
|
|
40
|
+
* @returns Most recent entries (newest first)
|
|
41
|
+
*/
|
|
42
|
+
function queryAuditLog(count = 50) {
|
|
43
|
+
return readJsonlTail(getAuditLogPath(), count);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Query audit log entries filtered by skill and/or action.
|
|
47
|
+
*
|
|
48
|
+
* @param filters - Optional filters
|
|
49
|
+
* @param count - Maximum entries to return
|
|
50
|
+
*/
|
|
51
|
+
function queryAuditLogFiltered(filters, count = 50) {
|
|
52
|
+
return queryAuditLog(count * 3).filter((entry) => {
|
|
53
|
+
if (filters.skill && entry.skill !== filters.skill) return false;
|
|
54
|
+
if (filters.action && entry.action !== filters.action) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}).slice(0, count);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/tools/audit.ts
|
|
60
|
+
/**
|
|
61
|
+
* Audit Tool ā Query and manage the Clawvif audit trail.
|
|
62
|
+
*
|
|
63
|
+
* All sensitive operations across all skills are recorded in audit.jsonl.
|
|
64
|
+
* This tool provides read access to the audit log for review and analysis.
|
|
65
|
+
*/
|
|
66
|
+
function textResult$6(text) {
|
|
67
|
+
return { content: [{
|
|
68
|
+
type: "text",
|
|
69
|
+
text
|
|
70
|
+
}] };
|
|
71
|
+
}
|
|
72
|
+
function registerAuditTools(api) {
|
|
73
|
+
api.registerTool({
|
|
74
|
+
name: "audit_log",
|
|
75
|
+
description: "Write an entry to the audit log. Use this to record important agent actions that should be tracked for security review.",
|
|
76
|
+
parameters: Type.Object({
|
|
77
|
+
skill: Type.String({ description: "Name of the skill performing the action" }),
|
|
78
|
+
action: Type.String({ description: "Action description (e.g., \"strategy_enabled\", \"config_changed\")" }),
|
|
79
|
+
data: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Additional structured data (NEVER include private keys or passwords)" }))
|
|
80
|
+
}),
|
|
81
|
+
async execute(_id, params) {
|
|
82
|
+
const { skill, action, data } = params;
|
|
83
|
+
auditLog(skill, action, data || {});
|
|
84
|
+
return textResult$6(`š Audit entry recorded: [${skill}] ${action}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
api.registerTool({
|
|
88
|
+
name: "audit_query",
|
|
89
|
+
description: "Query the audit log to review past operations. Returns recent entries optionally filtered by skill name and/or action type.",
|
|
90
|
+
parameters: Type.Object({
|
|
91
|
+
skill: Type.Optional(Type.String({ description: "Filter by skill name (e.g., \"vault\", \"dex-swap-executor\")" })),
|
|
92
|
+
action: Type.Optional(Type.String({ description: "Filter by action type (e.g., \"sign_transaction\", \"trade_executed\")" })),
|
|
93
|
+
count: Type.Optional(Type.Number({
|
|
94
|
+
description: "Number of recent entries to return (default: 20, max: 100)",
|
|
95
|
+
minimum: 1,
|
|
96
|
+
maximum: 100
|
|
97
|
+
}))
|
|
98
|
+
}),
|
|
99
|
+
async execute(_id, params) {
|
|
100
|
+
const { skill, action, count = 20 } = params;
|
|
101
|
+
const clampedCount = Math.min(Math.max(count, 1), 100);
|
|
102
|
+
let entries;
|
|
103
|
+
if (skill || action) entries = queryAuditLogFiltered({
|
|
104
|
+
skill,
|
|
105
|
+
action
|
|
106
|
+
}, clampedCount);
|
|
107
|
+
else entries = queryAuditLog(clampedCount);
|
|
108
|
+
if (entries.length === 0) return textResult$6("š No audit entries found matching your criteria.");
|
|
109
|
+
const formatted = entries.map((e) => {
|
|
110
|
+
const dataStr = Object.keys(e.data).length > 0 ? `\n Data: ${JSON.stringify(e.data)}` : "";
|
|
111
|
+
return `⢠[${e.ts}] **${e.skill}** ā ${e.action}${dataStr}`;
|
|
112
|
+
});
|
|
113
|
+
return textResult$6(`${skill || action ? `š Audit Log (filtered: ${[skill && `skill=${skill}`, action && `action=${action}`].filter(Boolean).join(", ")})` : "š Recent Audit Log"}\n\n${formatted.join("\n\n")}\n\nShowing ${entries.length} entries.`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/tools/alert.ts
|
|
119
|
+
/**
|
|
120
|
+
* Alert Dispatcher Tool ā Unified alert management for Clawvif.
|
|
121
|
+
*
|
|
122
|
+
* All skills route alerts through this dispatcher, which handles:
|
|
123
|
+
* - Alert level classification (CRITICAL / HIGH / MEDIUM / LOW)
|
|
124
|
+
* - Deduplication (same event within cooldown = suppressed)
|
|
125
|
+
* - Message formatting for Telegram delivery
|
|
126
|
+
* - Alert history tracking
|
|
127
|
+
*/
|
|
128
|
+
/** In-memory dedup cache: dedupKey ā last sent timestamp */
|
|
129
|
+
const dedupCache = /* @__PURE__ */ new Map();
|
|
130
|
+
/** Default cooldown in seconds */
|
|
131
|
+
const DEFAULT_COOLDOWN_SECONDS = 300;
|
|
132
|
+
/** Alert level emoji mapping */
|
|
133
|
+
const LEVEL_EMOJI = {
|
|
134
|
+
critical: "š“",
|
|
135
|
+
high: "š ",
|
|
136
|
+
medium: "š”",
|
|
137
|
+
low: "š¢"
|
|
138
|
+
};
|
|
139
|
+
function getWorkspacePath$4() {
|
|
140
|
+
return process.env["CLAWVIF_WORKSPACE_PATH"] || join(process.env["HOME"] || process.env["USERPROFILE"] || "/tmp", ".clawvif", "workspace");
|
|
141
|
+
}
|
|
142
|
+
function getAlertLogPath() {
|
|
143
|
+
return join(getWorkspacePath$4(), "logs", "alerts.jsonl");
|
|
144
|
+
}
|
|
145
|
+
function getAlertRulesPath() {
|
|
146
|
+
return join(getWorkspacePath$4(), "config", "alert-rules.json");
|
|
147
|
+
}
|
|
148
|
+
function loadAlertRules() {
|
|
149
|
+
return readJsonOr(getAlertRulesPath(), {
|
|
150
|
+
rules: [],
|
|
151
|
+
defaultCooldownSeconds: DEFAULT_COOLDOWN_SECONDS
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function isDuplicate(dedupKey, cooldownSeconds) {
|
|
155
|
+
const lastSent = dedupCache.get(dedupKey);
|
|
156
|
+
if (!lastSent) return false;
|
|
157
|
+
return (Date.now() - lastSent) / 1e3 < cooldownSeconds;
|
|
158
|
+
}
|
|
159
|
+
function formatAlert(alert) {
|
|
160
|
+
let message = `${LEVEL_EMOJI[alert.level]} **${alert.level.toUpperCase()}** ā ${alert.title}\n\n${alert.body}`;
|
|
161
|
+
if (alert.data && Object.keys(alert.data).length > 0) {
|
|
162
|
+
const dataLines = Object.entries(alert.data).map(([k, v]) => ` ⢠${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`).join("\n");
|
|
163
|
+
message += `\n\nš Details:\n${dataLines}`;
|
|
164
|
+
}
|
|
165
|
+
message += `\n\nš ${alert.timestamp}`;
|
|
166
|
+
return message;
|
|
167
|
+
}
|
|
168
|
+
function textResult$5(text) {
|
|
169
|
+
return { content: [{
|
|
170
|
+
type: "text",
|
|
171
|
+
text
|
|
172
|
+
}] };
|
|
173
|
+
}
|
|
174
|
+
function registerAlertTools(api) {
|
|
175
|
+
api.registerTool({
|
|
176
|
+
name: "alert_send",
|
|
177
|
+
description: "Send an alert through the Clawvif alert dispatcher. Handles deduplication, level-based formatting, and history tracking. The formatted alert message should be forwarded to the user via the active messaging channel.",
|
|
178
|
+
parameters: Type.Object({
|
|
179
|
+
level: Type.Union([
|
|
180
|
+
Type.Literal("critical"),
|
|
181
|
+
Type.Literal("high"),
|
|
182
|
+
Type.Literal("medium"),
|
|
183
|
+
Type.Literal("low")
|
|
184
|
+
], { description: "Alert level: critical (fund safety), high (actionable signal), medium (threshold trigger), low (informational)" }),
|
|
185
|
+
title: Type.String({ description: "Short alert title" }),
|
|
186
|
+
body: Type.String({ description: "Detailed alert body text" }),
|
|
187
|
+
source: Type.String({ description: "Source skill name (e.g., \"onchain-monitor\", \"risk-guardian\")" }),
|
|
188
|
+
dedupKey: Type.Optional(Type.String({ description: "Deduplication key ā same key within cooldown period will be suppressed" })),
|
|
189
|
+
data: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Additional structured data to include in the alert" }))
|
|
190
|
+
}),
|
|
191
|
+
async execute(_id, params) {
|
|
192
|
+
const { level, title, body, source, dedupKey, data } = params;
|
|
193
|
+
const cooldown = loadAlertRules().defaultCooldownSeconds || DEFAULT_COOLDOWN_SECONDS;
|
|
194
|
+
if (dedupKey && isDuplicate(dedupKey, cooldown)) return textResult$5(`āļø Alert suppressed (duplicate within ${cooldown}s cooldown): ${title}`);
|
|
195
|
+
const alert = {
|
|
196
|
+
id: `alert_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
197
|
+
level,
|
|
198
|
+
title,
|
|
199
|
+
body,
|
|
200
|
+
data,
|
|
201
|
+
source,
|
|
202
|
+
dedupKey,
|
|
203
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
204
|
+
};
|
|
205
|
+
if (dedupKey) dedupCache.set(dedupKey, Date.now());
|
|
206
|
+
ensureDir(join(getWorkspacePath$4(), "logs"));
|
|
207
|
+
appendJsonl(getAlertLogPath(), alert);
|
|
208
|
+
auditLog("alert-dispatcher", "alert_sent", {
|
|
209
|
+
alertId: alert.id,
|
|
210
|
+
level,
|
|
211
|
+
title,
|
|
212
|
+
source
|
|
213
|
+
});
|
|
214
|
+
return textResult$5(formatAlert(alert));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
api.registerTool({
|
|
218
|
+
name: "alert_configure",
|
|
219
|
+
description: "View or modify alert dispatcher configuration, including cooldown periods and alert rules.",
|
|
220
|
+
parameters: Type.Object({
|
|
221
|
+
action: Type.Union([Type.Literal("get"), Type.Literal("set_cooldown")], { description: "Action: \"get\" to view config, \"set_cooldown\" to change default cooldown" }),
|
|
222
|
+
cooldownSeconds: Type.Optional(Type.Number({
|
|
223
|
+
description: "New default cooldown in seconds (for set_cooldown action)",
|
|
224
|
+
minimum: 0,
|
|
225
|
+
maximum: 3600
|
|
226
|
+
}))
|
|
227
|
+
}),
|
|
228
|
+
async execute(_id, params) {
|
|
229
|
+
const { action, cooldownSeconds } = params;
|
|
230
|
+
const rules = loadAlertRules();
|
|
231
|
+
if (action === "get") return textResult$5(`š Alert Configuration\n\nDefault cooldown: ${rules.defaultCooldownSeconds}s\nActive rules: ${rules.rules.length}\nDedup cache entries: ${dedupCache.size}`);
|
|
232
|
+
if (action === "set_cooldown" && cooldownSeconds !== void 0) {
|
|
233
|
+
rules.defaultCooldownSeconds = cooldownSeconds;
|
|
234
|
+
const rulesPath = getAlertRulesPath();
|
|
235
|
+
ensureDir(join(getWorkspacePath$4(), "config"));
|
|
236
|
+
const { writeJson } = await import("./file-store-Cp6U-dO6.mjs").then((n) => n.r);
|
|
237
|
+
writeJson(rulesPath, rules);
|
|
238
|
+
auditLog("alert-dispatcher", "config_changed", { cooldownSeconds });
|
|
239
|
+
return textResult$5(`ā
Default cooldown updated to ${cooldownSeconds}s`);
|
|
240
|
+
}
|
|
241
|
+
return textResult$5("ā Invalid action or missing parameters.");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/lib/chains.ts
|
|
247
|
+
/**
|
|
248
|
+
* Build an Alchemy HTTP/WSS URL for a given network.
|
|
249
|
+
*/
|
|
250
|
+
function alchemyUrl(network, protocol = "https") {
|
|
251
|
+
return `${protocol}://${network}.g.alchemy.com/v2/${process.env["ALCHEMY_API_KEY"] || ""}`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Build a Helius URL for Solana.
|
|
255
|
+
*/
|
|
256
|
+
function heliusUrl(protocol = "https") {
|
|
257
|
+
const key = process.env["HELIUS_API_KEY"] || "";
|
|
258
|
+
if (protocol === "wss") return `wss://mainnet.helius-rpc.com/?api-key=${key}`;
|
|
259
|
+
return `https://mainnet.helius-rpc.com/?api-key=${key}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* All supported chain configurations.
|
|
263
|
+
*/
|
|
264
|
+
const CHAIN_CONFIGS = {
|
|
265
|
+
ethereum: {
|
|
266
|
+
chain: "ethereum",
|
|
267
|
+
family: "evm",
|
|
268
|
+
name: "Ethereum",
|
|
269
|
+
nativeToken: "ETH",
|
|
270
|
+
rpc: {
|
|
271
|
+
http: process.env["ETH_RPC_HTTP"] || alchemyUrl("eth-mainnet"),
|
|
272
|
+
wss: process.env["ETH_RPC_WSS"] || alchemyUrl("eth-mainnet", "wss"),
|
|
273
|
+
chainId: 1
|
|
274
|
+
},
|
|
275
|
+
explorerUrl: "https://etherscan.io",
|
|
276
|
+
contracts: {
|
|
277
|
+
uniswapV3Router: "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
|
278
|
+
uniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
279
|
+
uniswapV2Factory: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
|
|
280
|
+
weth: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
|
|
281
|
+
oneInchRouter: "0x1111111254EEB25477B68fb85Ed929f73A960582"
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
base: {
|
|
285
|
+
chain: "base",
|
|
286
|
+
family: "evm",
|
|
287
|
+
name: "Base",
|
|
288
|
+
nativeToken: "ETH",
|
|
289
|
+
rpc: {
|
|
290
|
+
http: process.env["BASE_RPC_HTTP"] || alchemyUrl("base-mainnet"),
|
|
291
|
+
wss: process.env["BASE_RPC_WSS"] || alchemyUrl("base-mainnet", "wss"),
|
|
292
|
+
chainId: 8453
|
|
293
|
+
},
|
|
294
|
+
explorerUrl: "https://basescan.org",
|
|
295
|
+
contracts: {
|
|
296
|
+
uniswapV3Router: "0x2626664c2603336E57B271c5C0b26F421741e481",
|
|
297
|
+
uniswapV3Factory: "0x33128a8fC17869897dcE68Ed026d694621f6FDfD",
|
|
298
|
+
weth: "0x4200000000000000000000000000000000000006",
|
|
299
|
+
oneInchRouter: "0x1111111254EEB25477B68fb85Ed929f73A960582"
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
arbitrum: {
|
|
303
|
+
chain: "arbitrum",
|
|
304
|
+
family: "evm",
|
|
305
|
+
name: "Arbitrum One",
|
|
306
|
+
nativeToken: "ETH",
|
|
307
|
+
rpc: {
|
|
308
|
+
http: process.env["ARB_RPC_HTTP"] || alchemyUrl("arb-mainnet"),
|
|
309
|
+
wss: process.env["ARB_RPC_WSS"] || alchemyUrl("arb-mainnet", "wss"),
|
|
310
|
+
chainId: 42161
|
|
311
|
+
},
|
|
312
|
+
explorerUrl: "https://arbiscan.io",
|
|
313
|
+
contracts: {
|
|
314
|
+
uniswapV3Router: "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
|
315
|
+
uniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
316
|
+
weth: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
|
|
317
|
+
oneInchRouter: "0x1111111254EEB25477B68fb85Ed929f73A960582"
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
polygon: {
|
|
321
|
+
chain: "polygon",
|
|
322
|
+
family: "evm",
|
|
323
|
+
name: "Polygon",
|
|
324
|
+
nativeToken: "MATIC",
|
|
325
|
+
rpc: {
|
|
326
|
+
http: process.env["POLYGON_RPC_HTTP"] || alchemyUrl("polygon-mainnet"),
|
|
327
|
+
wss: process.env["POLYGON_RPC_WSS"] || alchemyUrl("polygon-mainnet", "wss"),
|
|
328
|
+
chainId: 137
|
|
329
|
+
},
|
|
330
|
+
explorerUrl: "https://polygonscan.com",
|
|
331
|
+
contracts: {
|
|
332
|
+
uniswapV3Router: "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
|
333
|
+
uniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
334
|
+
wmatic: "0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270",
|
|
335
|
+
oneInchRouter: "0x1111111254EEB25477B68fb85Ed929f73A960582"
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
bsc: {
|
|
339
|
+
chain: "bsc",
|
|
340
|
+
family: "evm",
|
|
341
|
+
name: "BNB Smart Chain",
|
|
342
|
+
nativeToken: "BNB",
|
|
343
|
+
rpc: {
|
|
344
|
+
http: process.env["BSC_RPC_HTTP"] || "https://bsc-dataseed.binance.org",
|
|
345
|
+
wss: process.env["BSC_RPC_WSS"] || "wss://bsc-ws-node.nariox.org",
|
|
346
|
+
chainId: 56
|
|
347
|
+
},
|
|
348
|
+
explorerUrl: "https://bscscan.com",
|
|
349
|
+
contracts: {
|
|
350
|
+
pancakeFactory: "0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73",
|
|
351
|
+
pancakeRouter: "0x10ED43C718714eb63d5aA57B78B54704E256024E",
|
|
352
|
+
wbnb: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c",
|
|
353
|
+
oneInchRouter: "0x1111111254EEB25477B68fb85Ed929f73A960582"
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
optimism: {
|
|
357
|
+
chain: "optimism",
|
|
358
|
+
family: "evm",
|
|
359
|
+
name: "Optimism",
|
|
360
|
+
nativeToken: "ETH",
|
|
361
|
+
rpc: {
|
|
362
|
+
http: process.env["OP_RPC_HTTP"] || alchemyUrl("opt-mainnet"),
|
|
363
|
+
wss: process.env["OP_RPC_WSS"] || alchemyUrl("opt-mainnet", "wss"),
|
|
364
|
+
chainId: 10
|
|
365
|
+
},
|
|
366
|
+
explorerUrl: "https://optimistic.etherscan.io",
|
|
367
|
+
contracts: {
|
|
368
|
+
uniswapV3Router: "0xE592427A0AEce92De3Edee1F18E0157C05861564",
|
|
369
|
+
uniswapV3Factory: "0x1F98431c8aD98523631AE4a59f267346ea31F984",
|
|
370
|
+
weth: "0x4200000000000000000000000000000000000006",
|
|
371
|
+
oneInchRouter: "0x1111111254EEB25477B68fb85Ed929f73A960582"
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
solana: {
|
|
375
|
+
chain: "solana",
|
|
376
|
+
family: "solana",
|
|
377
|
+
name: "Solana",
|
|
378
|
+
nativeToken: "SOL",
|
|
379
|
+
rpc: {
|
|
380
|
+
http: process.env["SOLANA_RPC_HTTP"] || heliusUrl("https"),
|
|
381
|
+
wss: process.env["SOLANA_RPC_WSS"] || heliusUrl("wss")
|
|
382
|
+
},
|
|
383
|
+
explorerUrl: "https://solscan.io",
|
|
384
|
+
contracts: {
|
|
385
|
+
jupiterAggregator: "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4",
|
|
386
|
+
raydiumAmm: "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8",
|
|
387
|
+
pumpFun: "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P",
|
|
388
|
+
wsol: "So11111111111111111111111111111111111111112"
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Get chain config by chain name.
|
|
394
|
+
*/
|
|
395
|
+
function getChainConfig(chain) {
|
|
396
|
+
const config = CHAIN_CONFIGS[chain];
|
|
397
|
+
if (!config) throw new Error(`Unsupported chain: ${chain}`);
|
|
398
|
+
return config;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Get explorer URL for a transaction.
|
|
402
|
+
*/
|
|
403
|
+
function getExplorerTxUrl(chain, txHash) {
|
|
404
|
+
const config = getChainConfig(chain);
|
|
405
|
+
if (chain === "solana") return `${config.explorerUrl}/tx/${txHash}`;
|
|
406
|
+
return `${config.explorerUrl}/tx/${txHash}`;
|
|
407
|
+
}
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/tools/chain-monitor.ts
|
|
410
|
+
/**
|
|
411
|
+
* Chain Monitor Tool ā On-chain event monitoring and query.
|
|
412
|
+
*
|
|
413
|
+
* Provides tools for:
|
|
414
|
+
* - Managing chain monitoring subscriptions (watchlist, event types)
|
|
415
|
+
* - Querying transaction details
|
|
416
|
+
* - Retrieving recent on-chain events
|
|
417
|
+
*/
|
|
418
|
+
function getWorkspacePath$3() {
|
|
419
|
+
return process.env["CLAWVIF_WORKSPACE_PATH"] || join(process.env["HOME"] || process.env["USERPROFILE"] || "/tmp", ".clawvif", "workspace");
|
|
420
|
+
}
|
|
421
|
+
function getWatchlistPath() {
|
|
422
|
+
return join(getWorkspacePath$3(), "config", "watchlist.json");
|
|
423
|
+
}
|
|
424
|
+
function getEventsDir() {
|
|
425
|
+
return join(getWorkspacePath$3(), "memory", "market-intel", "onchain-events");
|
|
426
|
+
}
|
|
427
|
+
function loadWatchlist() {
|
|
428
|
+
return readJsonOr(getWatchlistPath(), { addresses: [] });
|
|
429
|
+
}
|
|
430
|
+
function saveWatchlist(watchlist) {
|
|
431
|
+
ensureDir(join(getWorkspacePath$3(), "config"));
|
|
432
|
+
writeJson(getWatchlistPath(), watchlist);
|
|
433
|
+
}
|
|
434
|
+
function loadRecentEvents(limit = 50, chain, type) {
|
|
435
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
436
|
+
const yesterday = (/* @__PURE__ */ new Date(Date.now() - 864e5)).toISOString().split("T")[0];
|
|
437
|
+
const events = [];
|
|
438
|
+
for (const dateStr of [today, yesterday]) {
|
|
439
|
+
const filePath = join(getEventsDir(), `${dateStr}.jsonl`);
|
|
440
|
+
try {
|
|
441
|
+
const dayEvents = readJsonl(filePath);
|
|
442
|
+
events.push(...dayEvents);
|
|
443
|
+
} catch {}
|
|
444
|
+
}
|
|
445
|
+
let filtered = events;
|
|
446
|
+
if (chain) filtered = filtered.filter((e) => e.chain === chain);
|
|
447
|
+
if (type) filtered = filtered.filter((e) => e.type === type);
|
|
448
|
+
return filtered.slice(-limit).reverse();
|
|
449
|
+
}
|
|
450
|
+
function textResult$4(text) {
|
|
451
|
+
return { content: [{
|
|
452
|
+
type: "text",
|
|
453
|
+
text
|
|
454
|
+
}] };
|
|
455
|
+
}
|
|
456
|
+
function registerChainMonitorTools(api) {
|
|
457
|
+
api.registerTool({
|
|
458
|
+
name: "chain_subscribe",
|
|
459
|
+
description: "Manage on-chain monitoring subscriptions. Add or remove addresses from the watchlist, or list currently monitored addresses.",
|
|
460
|
+
parameters: Type.Object({
|
|
461
|
+
action: Type.Union([
|
|
462
|
+
Type.Literal("add"),
|
|
463
|
+
Type.Literal("remove"),
|
|
464
|
+
Type.Literal("list")
|
|
465
|
+
], { description: "Action to perform on the watchlist" }),
|
|
466
|
+
address: Type.Optional(Type.String({ description: "Address to add or remove" })),
|
|
467
|
+
chain: Type.Optional(Type.Union([
|
|
468
|
+
Type.Literal("ethereum"),
|
|
469
|
+
Type.Literal("base"),
|
|
470
|
+
Type.Literal("arbitrum"),
|
|
471
|
+
Type.Literal("solana"),
|
|
472
|
+
Type.Literal("polygon"),
|
|
473
|
+
Type.Literal("bsc")
|
|
474
|
+
], { description: "Chain for the address" })),
|
|
475
|
+
label: Type.Optional(Type.String({ description: "Label for the address (e.g., \"Jump Trading\")" })),
|
|
476
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags for categorization (e.g., [\"vc\", \"market-maker\"])" }))
|
|
477
|
+
}),
|
|
478
|
+
async execute(_id, params) {
|
|
479
|
+
const { action, address, chain, label, tags } = params;
|
|
480
|
+
const watchlist = loadWatchlist();
|
|
481
|
+
if (action === "list") {
|
|
482
|
+
if (watchlist.addresses.length === 0) return textResult$4("š Watchlist is empty. Use action \"add\" to monitor addresses.");
|
|
483
|
+
const lines = watchlist.addresses.map((a, i) => `${i + 1}. **${a.label}** [${a.chain}]\n \`${a.address}\`\n Tags: ${a.tags.join(", ") || "none"}`);
|
|
484
|
+
return textResult$4(`šļø Monitored Addresses (${watchlist.addresses.length}):\n\n${lines.join("\n\n")}`);
|
|
485
|
+
}
|
|
486
|
+
if (action === "add") {
|
|
487
|
+
if (!address || !chain) return textResult$4("ā Address and chain are required for adding to watchlist.");
|
|
488
|
+
if (watchlist.addresses.some((a) => a.address.toLowerCase() === address.toLowerCase())) return textResult$4(`ā ļø Address ${address} is already in the watchlist.`);
|
|
489
|
+
const entry = {
|
|
490
|
+
address,
|
|
491
|
+
chain,
|
|
492
|
+
label: label || address.slice(0, 8) + "...",
|
|
493
|
+
tags: tags || [],
|
|
494
|
+
addedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
495
|
+
};
|
|
496
|
+
watchlist.addresses.push(entry);
|
|
497
|
+
saveWatchlist(watchlist);
|
|
498
|
+
auditLog("onchain-monitor", "watchlist_add", {
|
|
499
|
+
address,
|
|
500
|
+
chain,
|
|
501
|
+
label: entry.label
|
|
502
|
+
});
|
|
503
|
+
return textResult$4(`ā
Added to watchlist:\n Address: \`${address}\`\n Chain: ${chain}\n Label: ${entry.label}`);
|
|
504
|
+
}
|
|
505
|
+
if (action === "remove") {
|
|
506
|
+
if (!address) return textResult$4("ā Address is required for removal.");
|
|
507
|
+
const idx = watchlist.addresses.findIndex((a) => a.address.toLowerCase() === address.toLowerCase());
|
|
508
|
+
if (idx === -1) return textResult$4(`ā ļø Address ${address} not found in watchlist.`);
|
|
509
|
+
const removed = watchlist.addresses.splice(idx, 1)[0];
|
|
510
|
+
saveWatchlist(watchlist);
|
|
511
|
+
auditLog("onchain-monitor", "watchlist_remove", {
|
|
512
|
+
address,
|
|
513
|
+
label: removed.label
|
|
514
|
+
});
|
|
515
|
+
return textResult$4(`ā
Removed from watchlist: ${removed.label} (${address})`);
|
|
516
|
+
}
|
|
517
|
+
return textResult$4("ā Invalid action.");
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
api.registerTool({
|
|
521
|
+
name: "chain_query_tx",
|
|
522
|
+
description: "Query details of a specific transaction by hash. Returns decoded information including method, parameters, and value.",
|
|
523
|
+
parameters: Type.Object({
|
|
524
|
+
chain: Type.Union([
|
|
525
|
+
Type.Literal("ethereum"),
|
|
526
|
+
Type.Literal("base"),
|
|
527
|
+
Type.Literal("arbitrum"),
|
|
528
|
+
Type.Literal("solana"),
|
|
529
|
+
Type.Literal("polygon"),
|
|
530
|
+
Type.Literal("bsc")
|
|
531
|
+
], { description: "Chain to query" }),
|
|
532
|
+
txHash: Type.String({ description: "Transaction hash to look up" })
|
|
533
|
+
}),
|
|
534
|
+
async execute(_id, params) {
|
|
535
|
+
const { chain, txHash } = params;
|
|
536
|
+
return textResult$4(`š Transaction Details\n\nChain: ${chain}\nHash: \`${txHash}\`\nExplorer: ${getExplorerTxUrl(chain, txHash)}\n\n_Full transaction decoding requires active RPC connection._`);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
api.registerTool({
|
|
540
|
+
name: "chain_get_events",
|
|
541
|
+
description: "Retrieve recent on-chain events detected by the monitoring system. Can filter by chain and event type.",
|
|
542
|
+
parameters: Type.Object({
|
|
543
|
+
chain: Type.Optional(Type.Union([
|
|
544
|
+
Type.Literal("ethereum"),
|
|
545
|
+
Type.Literal("base"),
|
|
546
|
+
Type.Literal("arbitrum"),
|
|
547
|
+
Type.Literal("solana"),
|
|
548
|
+
Type.Literal("polygon"),
|
|
549
|
+
Type.Literal("bsc")
|
|
550
|
+
], { description: "Filter by chain (optional)" })),
|
|
551
|
+
type: Type.Optional(Type.Union([
|
|
552
|
+
Type.Literal("large_transfer"),
|
|
553
|
+
Type.Literal("new_pool"),
|
|
554
|
+
Type.Literal("smart_money_move"),
|
|
555
|
+
Type.Literal("contract_deploy")
|
|
556
|
+
], { description: "Filter by event type (optional)" })),
|
|
557
|
+
limit: Type.Optional(Type.Number({
|
|
558
|
+
description: "Number of events to return (default: 20, max: 100)",
|
|
559
|
+
minimum: 1,
|
|
560
|
+
maximum: 100
|
|
561
|
+
}))
|
|
562
|
+
}),
|
|
563
|
+
async execute(_id, params) {
|
|
564
|
+
const { chain, type, limit = 20 } = params;
|
|
565
|
+
const events = loadRecentEvents(Math.min(limit, 100), chain, type);
|
|
566
|
+
if (events.length === 0) return textResult$4("š No on-chain events found matching your criteria.");
|
|
567
|
+
const formatted = events.map((e) => {
|
|
568
|
+
const label = e.fromLabel ? ` (${e.fromLabel})` : "";
|
|
569
|
+
return `⢠**${e.type}** [${e.chain}] ā ${e.confidence} confidence\n ${e.action}\n From: \`${e.from}\`${label}\n TX: \`${e.txHash.slice(0, 16)}...\`\n ${e.timestamp}`;
|
|
570
|
+
});
|
|
571
|
+
const filterDesc = [chain && `chain=${chain}`, type && `type=${type}`].filter(Boolean).join(", ");
|
|
572
|
+
return textResult$4(`${filterDesc ? `š On-Chain Events (${filterDesc})` : "š Recent On-Chain Events"}\n\n${formatted.join("\n\n")}\n\nShowing ${events.length} events.`);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/tools/sentiment.ts
|
|
578
|
+
/**
|
|
579
|
+
* Sentiment Analysis Tool ā Multi-source social media sentiment tracking.
|
|
580
|
+
*
|
|
581
|
+
* Aggregates sentiment data from:
|
|
582
|
+
* - X/Twitter (KOL mentions, volume spikes)
|
|
583
|
+
* - Telegram groups (alpha calls, CA extraction)
|
|
584
|
+
* - Discord (community sentiment)
|
|
585
|
+
*
|
|
586
|
+
* Uses LLM for sentiment classification and produces
|
|
587
|
+
* standardized sentiment scores per token.
|
|
588
|
+
*/
|
|
589
|
+
function getWorkspacePath$2() {
|
|
590
|
+
return process.env["CLAWVIF_WORKSPACE_PATH"] || join(process.env["HOME"] || process.env["USERPROFILE"] || "/tmp", ".clawvif", "workspace");
|
|
591
|
+
}
|
|
592
|
+
function getSentimentDir() {
|
|
593
|
+
return join(getWorkspacePath$2(), "memory", "market-intel", "sentiment");
|
|
594
|
+
}
|
|
595
|
+
function loadRecentSentiment(token, limit = 50) {
|
|
596
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
597
|
+
const filePath = join(getSentimentDir(), `${today}.jsonl`);
|
|
598
|
+
try {
|
|
599
|
+
let entries = readJsonl(filePath);
|
|
600
|
+
if (token) entries = entries.filter((e) => e.token.toLowerCase() === token.toLowerCase());
|
|
601
|
+
return entries.slice(-limit).reverse();
|
|
602
|
+
} catch {
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function saveSentimentScore(score) {
|
|
607
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
608
|
+
ensureDir(getSentimentDir());
|
|
609
|
+
appendJsonl(join(getSentimentDir(), `${today}.jsonl`), score);
|
|
610
|
+
}
|
|
611
|
+
function formatSentimentScore(s) {
|
|
612
|
+
const emoji = s.sentimentScore > .3 ? "š¢" : s.sentimentScore < -.3 ? "š“" : "š”";
|
|
613
|
+
const deltaEmoji = s.sentimentDelta1h > 0 ? "ā" : s.sentimentDelta1h < 0 ? "ā" : "ā";
|
|
614
|
+
const spikeFlag = s.mentionSpike ? "š„ SPIKE" : "";
|
|
615
|
+
let text = `${emoji} **${s.token}** ā Sentiment: ${s.sentimentScore.toFixed(2)} ${spikeFlag}\n Score delta (1h): ${deltaEmoji}${s.sentimentDelta1h.toFixed(2)}\n Mentions (1h): ${s.mentionCount1h}\n TG Alpha hits: ${s.telegramAlphaHits}`;
|
|
616
|
+
if (s.topKolSignals.length > 0) {
|
|
617
|
+
const kolLines = s.topKolSignals.map((k) => ` ⢠${k.handle}: ${k.stance} (reach: ${k.reach.toLocaleString()})`);
|
|
618
|
+
text += `\n KOL Signals:\n${kolLines.join("\n")}`;
|
|
619
|
+
}
|
|
620
|
+
if (s.extractedCa) text += `\n CA: \`${s.extractedCa}\``;
|
|
621
|
+
text += `\n Updated: ${s.timestamp}`;
|
|
622
|
+
return text;
|
|
623
|
+
}
|
|
624
|
+
function textResult$3(text) {
|
|
625
|
+
return { content: [{
|
|
626
|
+
type: "text",
|
|
627
|
+
text
|
|
628
|
+
}] };
|
|
629
|
+
}
|
|
630
|
+
function registerSentimentTools(api) {
|
|
631
|
+
api.registerTool({
|
|
632
|
+
name: "sentiment_query",
|
|
633
|
+
description: "Query the current sentiment score for a specific token. Returns the latest sentiment data including score (-1.0 to 1.0), delta, mention count, KOL signals, and Telegram alpha group hits.",
|
|
634
|
+
parameters: Type.Object({
|
|
635
|
+
token: Type.String({ description: "Token symbol (e.g., \"BTC\", \"ETH\", \"BONK\") or contract address" }),
|
|
636
|
+
chain: Type.Optional(Type.String({ description: "Chain to filter by (optional)" }))
|
|
637
|
+
}),
|
|
638
|
+
async execute(_id, params) {
|
|
639
|
+
const { token } = params;
|
|
640
|
+
const scores = loadRecentSentiment(token, 5);
|
|
641
|
+
if (scores.length === 0) return textResult$3(`š No sentiment data available for **${token}**.\n\nUse \`sentiment_scan\` to trigger a fresh scan, or wait for the next scheduled scan.`);
|
|
642
|
+
const latest = scores[0];
|
|
643
|
+
const formatted = formatSentimentScore(latest);
|
|
644
|
+
let trend = "";
|
|
645
|
+
if (scores.length > 1) {
|
|
646
|
+
const oldest = scores[scores.length - 1];
|
|
647
|
+
const change = latest.sentimentScore - oldest.sentimentScore;
|
|
648
|
+
trend = `\n\nš Trend (last ${scores.length} readings): ${change > 0 ? "ā" : "ā"} ${change.toFixed(2)}`;
|
|
649
|
+
}
|
|
650
|
+
return textResult$3(`š Sentiment Analysis ā ${token}\n\n${formatted}${trend}`);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
api.registerTool({
|
|
654
|
+
name: "sentiment_scan",
|
|
655
|
+
description: "Trigger a comprehensive sentiment scan across all data sources. Scans X/Twitter, Telegram groups, and Discord for market sentiment. Results are stored in market-intel/sentiment/ for later query. Returns a summary of the scan results.",
|
|
656
|
+
parameters: Type.Object({ tokens: Type.Optional(Type.Array(Type.String(), { description: "Specific tokens to scan (default: scan trending tokens)" })) }),
|
|
657
|
+
async execute(_id, params) {
|
|
658
|
+
const { tokens } = params;
|
|
659
|
+
const scanTargets = tokens || [
|
|
660
|
+
"BTC",
|
|
661
|
+
"ETH",
|
|
662
|
+
"SOL"
|
|
663
|
+
];
|
|
664
|
+
const results = [];
|
|
665
|
+
for (const token of scanTargets) {
|
|
666
|
+
const score = {
|
|
667
|
+
token,
|
|
668
|
+
sentimentScore: 0,
|
|
669
|
+
sentimentDelta1h: 0,
|
|
670
|
+
mentionCount1h: 0,
|
|
671
|
+
mentionSpike: false,
|
|
672
|
+
topKolSignals: [],
|
|
673
|
+
telegramAlphaHits: 0,
|
|
674
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
675
|
+
};
|
|
676
|
+
saveSentimentScore(score);
|
|
677
|
+
results.push(score);
|
|
678
|
+
}
|
|
679
|
+
auditLog("sentiment-radar", "scan_completed", {
|
|
680
|
+
tokens: scanTargets,
|
|
681
|
+
resultCount: results.length
|
|
682
|
+
});
|
|
683
|
+
const summary = results.map((r) => formatSentimentScore(r)).join("\n\n");
|
|
684
|
+
return textResult$3(`š Sentiment Scan Complete\n\nScanned ${scanTargets.length} tokens.\n\n${summary}\n\n_Note: For real-time data, ensure X API and Telegram API credentials are configured._`);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/tools/whale.ts
|
|
690
|
+
/**
|
|
691
|
+
* Whale Tracker Tool ā High-level whale behavior analysis.
|
|
692
|
+
*
|
|
693
|
+
* Built on top of the on-chain monitor output, provides:
|
|
694
|
+
* - Recent whale movement summaries
|
|
695
|
+
* - Address behavior profiling (preferred chains, DEXes, win rate)
|
|
696
|
+
* - Accumulation/distribution pattern detection
|
|
697
|
+
* - Cross-correlation signals (multiple whales buying same token)
|
|
698
|
+
*/
|
|
699
|
+
function getWorkspacePath$1() {
|
|
700
|
+
return process.env["CLAWVIF_WORKSPACE_PATH"] || join(process.env["HOME"] || process.env["USERPROFILE"] || "/tmp", ".clawvif", "workspace");
|
|
701
|
+
}
|
|
702
|
+
function getWhaleDir() {
|
|
703
|
+
return join(getWorkspacePath$1(), "memory", "market-intel", "whale-movements");
|
|
704
|
+
}
|
|
705
|
+
function getProfilesDir() {
|
|
706
|
+
return join(getWorkspacePath$1(), "memory", "knowledge", "wallet-profiles");
|
|
707
|
+
}
|
|
708
|
+
function loadRecentWhaleAlerts(limit = 50, chain) {
|
|
709
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
710
|
+
const yesterday = (/* @__PURE__ */ new Date(Date.now() - 864e5)).toISOString().split("T")[0];
|
|
711
|
+
const alerts = [];
|
|
712
|
+
for (const dateStr of [today, yesterday]) {
|
|
713
|
+
const filePath = join(getWhaleDir(), `${dateStr}.jsonl`);
|
|
714
|
+
try {
|
|
715
|
+
alerts.push(...readJsonl(filePath));
|
|
716
|
+
} catch {}
|
|
717
|
+
}
|
|
718
|
+
let filtered = alerts;
|
|
719
|
+
if (chain) filtered = filtered.filter((a) => a.chain === chain);
|
|
720
|
+
return filtered.slice(-limit).reverse();
|
|
721
|
+
}
|
|
722
|
+
function loadWhaleProfile(address) {
|
|
723
|
+
const safeName = address.replace(/[^a-zA-Z0-9]/g, "_");
|
|
724
|
+
return readJsonOr(join(getProfilesDir(), `${safeName}.json`), null);
|
|
725
|
+
}
|
|
726
|
+
function saveWhaleProfile(profile) {
|
|
727
|
+
const safeName = profile.address.replace(/[^a-zA-Z0-9]/g, "_");
|
|
728
|
+
ensureDir(getProfilesDir());
|
|
729
|
+
writeJson(join(getProfilesDir(), `${safeName}.json`), profile);
|
|
730
|
+
}
|
|
731
|
+
function formatWhaleAlert(alert) {
|
|
732
|
+
const labelStr = alert.addressLabel ? ` (${alert.addressLabel})` : "";
|
|
733
|
+
const patternStr = alert.pattern && alert.pattern !== "unknown" ? `\n Pattern: ${alert.pattern}` : "";
|
|
734
|
+
return `⢠š **${alert.action}**\n Address: \`${alert.address}\`${labelStr}\n Token: ${alert.token.symbol} ā ${alert.token.amount}\n Value: $${alert.valueUsd.toLocaleString()}\n Chain: ${alert.chain} | TX: \`${alert.txHash.slice(0, 16)}...\`${patternStr}\n ${alert.timestamp}`;
|
|
735
|
+
}
|
|
736
|
+
function formatWhaleProfile(profile) {
|
|
737
|
+
const labelStr = profile.label ? ` (${profile.label})` : "";
|
|
738
|
+
let text = `š Whale Profile: \`${profile.address}\`${labelStr}\n\nš Statistics:\n Active chains: ${profile.activeChains.join(", ")}\n Preferred DEXes: ${profile.preferredDexes.join(", ") || "Unknown"}\n Avg holding period: ${profile.avgHoldingPeriodHours.toFixed(1)}h\n Win rate: ${(profile.winRate * 100).toFixed(1)}%\n Total trades tracked: ${profile.totalTrades}\n Last updated: ${profile.lastUpdated}`;
|
|
739
|
+
if (profile.recentActivity.length > 0) {
|
|
740
|
+
const activities = profile.recentActivity.slice(0, 5).map((a) => ` ⢠${a.action} ${a.token} ā $${a.valueUsd.toLocaleString()} (${a.timestamp})`);
|
|
741
|
+
text += `\n\nš Recent Activity:\n${activities.join("\n")}`;
|
|
742
|
+
}
|
|
743
|
+
return text;
|
|
744
|
+
}
|
|
745
|
+
function textResult$2(text) {
|
|
746
|
+
return { content: [{
|
|
747
|
+
type: "text",
|
|
748
|
+
text
|
|
749
|
+
}] };
|
|
750
|
+
}
|
|
751
|
+
function registerWhaleTools(api) {
|
|
752
|
+
api.registerTool({
|
|
753
|
+
name: "whale_query",
|
|
754
|
+
description: "Query recent whale movements. Returns large transactions from tracked wallets with pattern analysis (accumulation/distribution detection).",
|
|
755
|
+
parameters: Type.Object({
|
|
756
|
+
chain: Type.Optional(Type.Union([
|
|
757
|
+
Type.Literal("ethereum"),
|
|
758
|
+
Type.Literal("base"),
|
|
759
|
+
Type.Literal("arbitrum"),
|
|
760
|
+
Type.Literal("solana"),
|
|
761
|
+
Type.Literal("polygon"),
|
|
762
|
+
Type.Literal("bsc")
|
|
763
|
+
], { description: "Filter by chain (optional)" })),
|
|
764
|
+
timeframe: Type.Optional(Type.Union([
|
|
765
|
+
Type.Literal("1h"),
|
|
766
|
+
Type.Literal("24h"),
|
|
767
|
+
Type.Literal("7d")
|
|
768
|
+
], { description: "Time window for query (default: 24h)" })),
|
|
769
|
+
limit: Type.Optional(Type.Number({
|
|
770
|
+
description: "Number of results (default: 20, max: 100)",
|
|
771
|
+
minimum: 1,
|
|
772
|
+
maximum: 100
|
|
773
|
+
}))
|
|
774
|
+
}),
|
|
775
|
+
async execute(_id, params) {
|
|
776
|
+
const { chain, limit = 20 } = params;
|
|
777
|
+
const alerts = loadRecentWhaleAlerts(Math.min(limit, 100), chain);
|
|
778
|
+
if (alerts.length === 0) return textResult$2("š No whale movements detected in the selected timeframe.\n\nEnsure on-chain monitoring is active and addresses are in the watchlist.");
|
|
779
|
+
const formatted = alerts.map(formatWhaleAlert);
|
|
780
|
+
return textResult$2(`š Whale Movements (${chain || "all chains"})\n\n${formatted.join("\n\n")}\n\nShowing ${alerts.length} movements.`);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
api.registerTool({
|
|
784
|
+
name: "whale_profile",
|
|
785
|
+
description: "Get or build a behavior profile for a specific address. Shows preferred chains, DEXes, average holding period, historical win rate, and recent activity.",
|
|
786
|
+
parameters: Type.Object({ address: Type.String({ description: "Wallet address to profile" }) }),
|
|
787
|
+
async execute(_id, params) {
|
|
788
|
+
const { address } = params;
|
|
789
|
+
let profile = loadWhaleProfile(address);
|
|
790
|
+
if (!profile) {
|
|
791
|
+
profile = {
|
|
792
|
+
address,
|
|
793
|
+
activeChains: [],
|
|
794
|
+
preferredDexes: [],
|
|
795
|
+
avgHoldingPeriodHours: 0,
|
|
796
|
+
winRate: 0,
|
|
797
|
+
totalTrades: 0,
|
|
798
|
+
recentActivity: [],
|
|
799
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
|
|
800
|
+
};
|
|
801
|
+
saveWhaleProfile(profile);
|
|
802
|
+
return textResult$2(`š New Profile Created for \`${address}\`\n\nNo historical data yet. The profile will be enriched as on-chain activity is monitored.\n\nTo build a profile faster, add this address to the watchlist:\n\`chain_subscribe add ${address}\``);
|
|
803
|
+
}
|
|
804
|
+
return textResult$2(formatWhaleProfile(profile));
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/services/telegram-scanner.ts
|
|
810
|
+
/**
|
|
811
|
+
* Telegram Group Scanner Service ā Deep scanning of Telegram groups
|
|
812
|
+
* for alpha calls, contract addresses, and bot signals.
|
|
813
|
+
*
|
|
814
|
+
* Uses Telegram MTProto API via session string authentication.
|
|
815
|
+
* Falls back to HTTPS Bot API when MTProto is unavailable.
|
|
816
|
+
*
|
|
817
|
+
* Architecture:
|
|
818
|
+
* - Subscriptions stored in memory + persisted to tg-scanner-state.json
|
|
819
|
+
* - Messages processed through CA extraction pipeline
|
|
820
|
+
* - First-call detection via JSONL history
|
|
821
|
+
* - Call performance tracked with entry price snapshots
|
|
822
|
+
*/
|
|
823
|
+
const EVM_CA_REGEX = /\b(0x[a-fA-F0-9]{40})\b/g;
|
|
824
|
+
const SOLANA_CA_REGEX = /\b([1-9A-HJ-NP-Za-km-z]{32,44})\b/g;
|
|
825
|
+
const TICKER_REGEX = /\$([A-Z]{2,10})\b/g;
|
|
826
|
+
const URL_REGEX = /https?:\/\/[^\s<>)"]+/g;
|
|
827
|
+
const KNOWN_BOT_PATTERNS = [
|
|
828
|
+
{
|
|
829
|
+
name: "Maestro",
|
|
830
|
+
pattern: /maestro|š¤.*sniper|auto.?buy/i
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: "BananaGun",
|
|
834
|
+
pattern: /banana\s?gun|š.*snipe/i
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
name: "Unibot",
|
|
838
|
+
pattern: /unibot|š.*limit.*order/i
|
|
839
|
+
},
|
|
840
|
+
{
|
|
841
|
+
name: "BONKbot",
|
|
842
|
+
pattern: /bonkbot|š¶.*swap/i
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
name: "Trojan",
|
|
846
|
+
pattern: /trojan.*bot|šļø.*swap/i
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
name: "SolTradingBot",
|
|
850
|
+
pattern: /sol.*trading.*bot/i
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
name: "BullX",
|
|
854
|
+
pattern: /bullx|š.*trade/i
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
name: "Photon",
|
|
858
|
+
pattern: /photon.*bot|ā”.*swap/i
|
|
859
|
+
}
|
|
860
|
+
];
|
|
861
|
+
const SOLANA_ADDR_EXCLUDED = new Set([
|
|
862
|
+
"So11111111111111111111111111111111111111112",
|
|
863
|
+
"11111111111111111111111111111111",
|
|
864
|
+
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
|
|
865
|
+
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
|
|
866
|
+
"SysvarRent111111111111111111111111111111111",
|
|
867
|
+
"SysvarC1ock11111111111111111111111111111111"
|
|
868
|
+
]);
|
|
869
|
+
function getWorkspacePath() {
|
|
870
|
+
return process.env["CLAWVIF_WORKSPACE_PATH"] || join(process.env["HOME"] || process.env["USERPROFILE"] || "/tmp", ".clawvif", "workspace");
|
|
871
|
+
}
|
|
872
|
+
function getScannerDir() {
|
|
873
|
+
return join(getWorkspacePath(), "memory", "market-intel", "tg-scanner");
|
|
874
|
+
}
|
|
875
|
+
function getStatePath() {
|
|
876
|
+
return join(getScannerDir(), "scanner-state.json");
|
|
877
|
+
}
|
|
878
|
+
function getCaRegistryPath() {
|
|
879
|
+
return join(getScannerDir(), "ca-registry.json");
|
|
880
|
+
}
|
|
881
|
+
function getMessagesLogPath() {
|
|
882
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
883
|
+
return join(getScannerDir(), "messages", `${today}.jsonl`);
|
|
884
|
+
}
|
|
885
|
+
function getFirstCallsLogPath() {
|
|
886
|
+
return join(getScannerDir(), "first-calls.jsonl");
|
|
887
|
+
}
|
|
888
|
+
function loadScannerState() {
|
|
889
|
+
return readJsonOr(getStatePath(), {
|
|
890
|
+
subscriptions: [],
|
|
891
|
+
totalMessagesProcessed: 0,
|
|
892
|
+
totalCaExtracted: 0,
|
|
893
|
+
lastScanAt: null
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
function saveScannerState(state) {
|
|
897
|
+
ensureDir(getScannerDir());
|
|
898
|
+
writeJson(getStatePath(), state);
|
|
899
|
+
}
|
|
900
|
+
function loadCaRegistry() {
|
|
901
|
+
return readJsonOr(getCaRegistryPath(), {});
|
|
902
|
+
}
|
|
903
|
+
function saveCaRegistry(registry) {
|
|
904
|
+
ensureDir(getScannerDir());
|
|
905
|
+
writeJson(getCaRegistryPath(), registry);
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Extract contract addresses from a message text.
|
|
909
|
+
*/
|
|
910
|
+
function extractContractAddresses(text) {
|
|
911
|
+
const results = [];
|
|
912
|
+
const seen = /* @__PURE__ */ new Set();
|
|
913
|
+
for (const match of text.matchAll(EVM_CA_REGEX)) {
|
|
914
|
+
const addr = match[1];
|
|
915
|
+
if (!seen.has(addr.toLowerCase())) {
|
|
916
|
+
seen.add(addr.toLowerCase());
|
|
917
|
+
results.push({
|
|
918
|
+
address: addr,
|
|
919
|
+
chain: "evm"
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
for (const match of text.matchAll(SOLANA_CA_REGEX)) {
|
|
924
|
+
const addr = match[1];
|
|
925
|
+
if (!seen.has(addr) && !SOLANA_ADDR_EXCLUDED.has(addr) && /[A-Z]/.test(addr) && /[a-z]/.test(addr) && /\d/.test(addr) && addr.length >= 32) {
|
|
926
|
+
seen.add(addr);
|
|
927
|
+
results.push({
|
|
928
|
+
address: addr,
|
|
929
|
+
chain: "solana"
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return results;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Extract ticker symbols ($XXX) from message text.
|
|
937
|
+
*/
|
|
938
|
+
function extractTickers(text) {
|
|
939
|
+
const tickers = [];
|
|
940
|
+
const seen = /* @__PURE__ */ new Set();
|
|
941
|
+
for (const match of text.matchAll(TICKER_REGEX)) {
|
|
942
|
+
const ticker = match[1].toUpperCase();
|
|
943
|
+
if (!seen.has(ticker)) {
|
|
944
|
+
seen.add(ticker);
|
|
945
|
+
tickers.push(ticker);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return tickers;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Extract URLs from message text.
|
|
952
|
+
*/
|
|
953
|
+
function extractUrls(text) {
|
|
954
|
+
const urls = [];
|
|
955
|
+
for (const match of text.matchAll(URL_REGEX)) urls.push(match[0]);
|
|
956
|
+
return urls;
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Detect if a message is from a known trading bot.
|
|
960
|
+
*/
|
|
961
|
+
function detectBotSignal(text) {
|
|
962
|
+
for (const { name, pattern } of KNOWN_BOT_PATTERNS) if (pattern.test(text)) return {
|
|
963
|
+
isBot: true,
|
|
964
|
+
botName: name,
|
|
965
|
+
confidence: .85
|
|
966
|
+
};
|
|
967
|
+
const botIndicators = [
|
|
968
|
+
/\b(buy|sell)\s+\d+(\.\d+)?\s+(SOL|ETH|BNB)\b/i,
|
|
969
|
+
/tx:\s*[A-Za-z0-9]{40,}/i,
|
|
970
|
+
/slippage:\s*\d+%/i,
|
|
971
|
+
/gas:\s*\d+\s*gwei/i
|
|
972
|
+
];
|
|
973
|
+
let indicatorCount = 0;
|
|
974
|
+
for (const indicator of botIndicators) if (indicator.test(text)) indicatorCount++;
|
|
975
|
+
if (indicatorCount >= 2) return {
|
|
976
|
+
isBot: true,
|
|
977
|
+
botName: "unknown",
|
|
978
|
+
confidence: .6
|
|
979
|
+
};
|
|
980
|
+
return {
|
|
981
|
+
isBot: false,
|
|
982
|
+
botName: null,
|
|
983
|
+
confidence: 0
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Process a message: extract CAs, tickers, detect bots, and update state.
|
|
988
|
+
*/
|
|
989
|
+
function processMessage(groupId, groupName, senderId, senderName, text, timestamp) {
|
|
990
|
+
const ts = timestamp || (/* @__PURE__ */ new Date()).toISOString();
|
|
991
|
+
const cas = extractContractAddresses(text);
|
|
992
|
+
const tickers = extractTickers(text);
|
|
993
|
+
const urls = extractUrls(text);
|
|
994
|
+
const botDetection = detectBotSignal(text);
|
|
995
|
+
const message = {
|
|
996
|
+
groupId,
|
|
997
|
+
groupName,
|
|
998
|
+
senderId,
|
|
999
|
+
senderName,
|
|
1000
|
+
contractAddresses: cas.map((c) => c.address),
|
|
1001
|
+
tickers,
|
|
1002
|
+
urls,
|
|
1003
|
+
isBot: botDetection.isBot,
|
|
1004
|
+
text,
|
|
1005
|
+
timestamp: ts
|
|
1006
|
+
};
|
|
1007
|
+
ensureDir(join(getScannerDir(), "messages"));
|
|
1008
|
+
appendJsonl(getMessagesLogPath(), message);
|
|
1009
|
+
const registry = loadCaRegistry();
|
|
1010
|
+
const newCas = [];
|
|
1011
|
+
for (const ca of cas) {
|
|
1012
|
+
const key = ca.address.toLowerCase();
|
|
1013
|
+
const existing = registry[key];
|
|
1014
|
+
if (!existing) {
|
|
1015
|
+
registry[key] = {
|
|
1016
|
+
contractAddress: ca.address,
|
|
1017
|
+
chain: ca.chain,
|
|
1018
|
+
firstSeenAt: ts,
|
|
1019
|
+
firstSeenGroupId: groupId,
|
|
1020
|
+
firstSeenGroupName: groupName,
|
|
1021
|
+
firstSeenBy: senderName,
|
|
1022
|
+
mentionCount: 1,
|
|
1023
|
+
groups: [groupId],
|
|
1024
|
+
entryPriceUsd: null,
|
|
1025
|
+
currentPriceUsd: null,
|
|
1026
|
+
performancePct: null,
|
|
1027
|
+
lastUpdated: ts
|
|
1028
|
+
};
|
|
1029
|
+
appendJsonl(getFirstCallsLogPath(), {
|
|
1030
|
+
contractAddress: ca.address,
|
|
1031
|
+
chain: ca.chain,
|
|
1032
|
+
groupId,
|
|
1033
|
+
groupName,
|
|
1034
|
+
callerName: senderName,
|
|
1035
|
+
timestamp: ts
|
|
1036
|
+
});
|
|
1037
|
+
newCas.push({
|
|
1038
|
+
...ca,
|
|
1039
|
+
isFirstCall: true
|
|
1040
|
+
});
|
|
1041
|
+
} else {
|
|
1042
|
+
existing.mentionCount++;
|
|
1043
|
+
if (!existing.groups.includes(groupId)) existing.groups.push(groupId);
|
|
1044
|
+
existing.lastUpdated = ts;
|
|
1045
|
+
newCas.push({
|
|
1046
|
+
...ca,
|
|
1047
|
+
isFirstCall: false
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
saveCaRegistry(registry);
|
|
1052
|
+
const state = loadScannerState();
|
|
1053
|
+
state.totalMessagesProcessed++;
|
|
1054
|
+
state.totalCaExtracted += cas.length;
|
|
1055
|
+
state.lastScanAt = ts;
|
|
1056
|
+
const sub = state.subscriptions.find((s) => s.groupId === groupId);
|
|
1057
|
+
if (sub) {
|
|
1058
|
+
sub.lastMessageAt = ts;
|
|
1059
|
+
sub.messageCount++;
|
|
1060
|
+
}
|
|
1061
|
+
saveScannerState(state);
|
|
1062
|
+
return {
|
|
1063
|
+
message,
|
|
1064
|
+
newCas,
|
|
1065
|
+
botDetection
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Subscribe to a Telegram group for scanning.
|
|
1070
|
+
*/
|
|
1071
|
+
function subscribeGroup(groupId, groupName, type) {
|
|
1072
|
+
const state = loadScannerState();
|
|
1073
|
+
const existing = state.subscriptions.find((s) => s.groupId === groupId);
|
|
1074
|
+
if (existing) {
|
|
1075
|
+
existing.type = type;
|
|
1076
|
+
existing.enabled = true;
|
|
1077
|
+
saveScannerState(state);
|
|
1078
|
+
auditLog("tg-scanner", "group_updated", {
|
|
1079
|
+
groupId,
|
|
1080
|
+
groupName,
|
|
1081
|
+
type
|
|
1082
|
+
});
|
|
1083
|
+
return existing;
|
|
1084
|
+
}
|
|
1085
|
+
const sub = {
|
|
1086
|
+
groupId,
|
|
1087
|
+
groupName,
|
|
1088
|
+
type,
|
|
1089
|
+
enabled: true,
|
|
1090
|
+
subscribedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1091
|
+
lastMessageAt: null,
|
|
1092
|
+
messageCount: 0
|
|
1093
|
+
};
|
|
1094
|
+
state.subscriptions.push(sub);
|
|
1095
|
+
saveScannerState(state);
|
|
1096
|
+
auditLog("tg-scanner", "group_subscribed", {
|
|
1097
|
+
groupId,
|
|
1098
|
+
groupName,
|
|
1099
|
+
type
|
|
1100
|
+
});
|
|
1101
|
+
return sub;
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Check if a CA is a first call in the registry.
|
|
1105
|
+
*/
|
|
1106
|
+
function checkFirstCall(contractAddress) {
|
|
1107
|
+
const record = loadCaRegistry()[contractAddress.toLowerCase()] || null;
|
|
1108
|
+
if (!record) return {
|
|
1109
|
+
isFirstCall: true,
|
|
1110
|
+
record: null
|
|
1111
|
+
};
|
|
1112
|
+
return {
|
|
1113
|
+
isFirstCall: record.mentionCount <= 1,
|
|
1114
|
+
record
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Get call performance data for a contract address.
|
|
1119
|
+
*/
|
|
1120
|
+
function getCallPerformance(contractAddress) {
|
|
1121
|
+
const firstCalls = readJsonl(getFirstCallsLogPath());
|
|
1122
|
+
const registry = loadCaRegistry();
|
|
1123
|
+
const key = contractAddress.toLowerCase();
|
|
1124
|
+
const caRecord = registry[key];
|
|
1125
|
+
return firstCalls.filter((fc) => fc.contractAddress.toLowerCase() === key).map((fc) => ({
|
|
1126
|
+
contractAddress: fc.contractAddress,
|
|
1127
|
+
chain: fc.chain || "solana",
|
|
1128
|
+
callerName: fc.callerName,
|
|
1129
|
+
groupId: fc.groupId,
|
|
1130
|
+
groupName: fc.groupName,
|
|
1131
|
+
callTimestamp: fc.timestamp,
|
|
1132
|
+
entryPriceUsd: caRecord?.entryPriceUsd ?? null,
|
|
1133
|
+
peakPriceUsd: null,
|
|
1134
|
+
currentPriceUsd: caRecord?.currentPriceUsd ?? null,
|
|
1135
|
+
returnPct: caRecord?.performancePct ?? null,
|
|
1136
|
+
peakReturnPct: null
|
|
1137
|
+
}));
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Get caller stats for a group or specific sender.
|
|
1141
|
+
*/
|
|
1142
|
+
function getCallerStats(groupId) {
|
|
1143
|
+
const firstCalls = readJsonl(getFirstCallsLogPath());
|
|
1144
|
+
const filtered = groupId ? firstCalls.filter((fc) => fc.groupId === groupId) : firstCalls;
|
|
1145
|
+
const statsMap = /* @__PURE__ */ new Map();
|
|
1146
|
+
for (const fc of filtered) {
|
|
1147
|
+
const key = `${fc.callerName}:${fc.groupId}`;
|
|
1148
|
+
const existing = statsMap.get(key);
|
|
1149
|
+
if (existing) existing.totalCalls++;
|
|
1150
|
+
else statsMap.set(key, {
|
|
1151
|
+
callerName: fc.callerName,
|
|
1152
|
+
totalCalls: 1,
|
|
1153
|
+
groupId: fc.groupId
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
return Array.from(statsMap.values()).sort((a, b) => b.totalCalls - a.totalCalls);
|
|
1157
|
+
}
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/tools/tg-scanner.ts
|
|
1160
|
+
/**
|
|
1161
|
+
* Telegram Group Scanner Tool ā Deep scanning of Telegram groups
|
|
1162
|
+
* for alpha calls, contract addresses, and bot signal detection.
|
|
1163
|
+
*
|
|
1164
|
+
* Registers 5 tools as specified in tg-group-scanner SKILL.md:
|
|
1165
|
+
* - tg_scanner_subscribe
|
|
1166
|
+
* - tg_scanner_extract_ca
|
|
1167
|
+
* - tg_scanner_first_call_check
|
|
1168
|
+
* - tg_scanner_call_performance
|
|
1169
|
+
* - tg_scanner_detect_bot_signal
|
|
1170
|
+
*/
|
|
1171
|
+
function textResult$1(text) {
|
|
1172
|
+
return { content: [{
|
|
1173
|
+
type: "text",
|
|
1174
|
+
text
|
|
1175
|
+
}] };
|
|
1176
|
+
}
|
|
1177
|
+
function registerTgScannerTools(api) {
|
|
1178
|
+
api.registerTool({
|
|
1179
|
+
name: "tg_scanner_subscribe",
|
|
1180
|
+
description: "Subscribe to a Telegram group for deep scanning. Monitors messages for contract addresses, alpha calls, and bot signals. Supports group types: alpha, dev, community, signal.",
|
|
1181
|
+
parameters: Type.Object({
|
|
1182
|
+
groupId: Type.String({ description: "Telegram group/channel ID or @username" }),
|
|
1183
|
+
groupName: Type.String({ description: "Display name for the group" }),
|
|
1184
|
+
type: Type.Union([
|
|
1185
|
+
Type.Literal("alpha"),
|
|
1186
|
+
Type.Literal("dev"),
|
|
1187
|
+
Type.Literal("community"),
|
|
1188
|
+
Type.Literal("signal")
|
|
1189
|
+
], { description: "Group type classification" })
|
|
1190
|
+
}),
|
|
1191
|
+
async execute(_id, params) {
|
|
1192
|
+
const { groupId, groupName, type } = params;
|
|
1193
|
+
const sub = subscribeGroup(groupId, groupName, type);
|
|
1194
|
+
const totalSubs = loadScannerState().subscriptions.filter((s) => s.enabled).length;
|
|
1195
|
+
const hasCreds = !!process.env["TG_API_ID"] && !!process.env["TG_API_HASH"] && !!process.env["TG_SESSION_STRING"];
|
|
1196
|
+
let text = `ā
Subscribed to Telegram group\n\nGroup: **${groupName}**\nID: \`${groupId}\`\nType: ${type}\nSubscribed at: ${sub.subscribedAt}\nTotal active subscriptions: ${totalSubs}\n`;
|
|
1197
|
+
if (!hasCreds) text += "\nā ļø MTProto credentials not configured.\nSet TG_API_ID, TG_API_HASH, and TG_SESSION_STRING in .env for live scanning.\nManual message processing via tg_scanner_extract_ca is still available.";
|
|
1198
|
+
else text += `\nš¢ MTProto credentials detected ā live scanning available.`;
|
|
1199
|
+
return textResult$1(text);
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
api.registerTool({
|
|
1203
|
+
name: "tg_scanner_extract_ca",
|
|
1204
|
+
description: "Extract contract addresses from a Telegram message text. Detects EVM (0x...) and Solana addresses, extracts ticker symbols ($XXX), URLs, and detects bot signals. Automatically checks if any CA is a first call.",
|
|
1205
|
+
parameters: Type.Object({
|
|
1206
|
+
text: Type.String({ description: "Message text to analyze" }),
|
|
1207
|
+
groupId: Type.Optional(Type.String({ description: "Group ID the message is from (for tracking)" })),
|
|
1208
|
+
groupName: Type.Optional(Type.String({ description: "Group display name" })),
|
|
1209
|
+
senderId: Type.Optional(Type.String({ description: "Sender user ID" })),
|
|
1210
|
+
senderName: Type.Optional(Type.String({ description: "Sender display name" }))
|
|
1211
|
+
}),
|
|
1212
|
+
async execute(_id, params) {
|
|
1213
|
+
const { text: msgText, groupId = "manual", groupName = "Manual Input", senderId = "unknown", senderName = "Unknown" } = params;
|
|
1214
|
+
const { message, newCas, botDetection } = processMessage(groupId, groupName, senderId, senderName, msgText);
|
|
1215
|
+
let resultText = `š CA Extraction Results\n\n`;
|
|
1216
|
+
if (newCas.length === 0) resultText += `No contract addresses found in the message.\n`;
|
|
1217
|
+
else {
|
|
1218
|
+
resultText += `Found ${newCas.length} contract address(es):\n\n`;
|
|
1219
|
+
for (const ca of newCas) {
|
|
1220
|
+
const firstCallTag = ca.isFirstCall ? " š FIRST CALL" : "";
|
|
1221
|
+
resultText += ` ⢠\`${ca.address}\` [${ca.chain.toUpperCase()}]${firstCallTag}\n`;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (message.tickers.length > 0) resultText += `\nTickers: ${message.tickers.map((t) => `$${t}`).join(", ")}\n`;
|
|
1225
|
+
if (botDetection.isBot) resultText += `\nš¤ Bot signal detected: **${botDetection.botName}** (confidence: ${(botDetection.confidence * 100).toFixed(0)}%)\n`;
|
|
1226
|
+
if (message.urls.length > 0) resultText += `\nURLs: ${message.urls.length} link(s) found\n`;
|
|
1227
|
+
auditLog("tg-scanner", "ca_extracted", {
|
|
1228
|
+
groupId,
|
|
1229
|
+
casFound: newCas.length,
|
|
1230
|
+
firstCalls: newCas.filter((c) => c.isFirstCall).length,
|
|
1231
|
+
isBot: botDetection.isBot
|
|
1232
|
+
});
|
|
1233
|
+
return textResult$1(resultText);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
api.registerTool({
|
|
1237
|
+
name: "tg_scanner_first_call_check",
|
|
1238
|
+
description: "Check if a contract address is a first call (never seen before in any alpha group). Returns first-call status and historical data if the CA has been seen before.",
|
|
1239
|
+
parameters: Type.Object({ contractAddress: Type.String({ description: "Contract address to check (EVM 0x... or Solana base58)" }) }),
|
|
1240
|
+
async execute(_id, params) {
|
|
1241
|
+
const { contractAddress } = params;
|
|
1242
|
+
const { isFirstCall, record } = checkFirstCall(contractAddress);
|
|
1243
|
+
if (isFirstCall && !record) return textResult$1(`š **FIRST CALL** ā This CA has never been seen before!\n\nCA: \`${contractAddress}\`\nStatus: Never mentioned in any tracked group\n\nā” Consider running risk_check_contract to verify safety before any action.`);
|
|
1244
|
+
if (isFirstCall && record) return textResult$1(`š **FIRST CALL** ā Only 1 mention so far\n\nCA: \`${record.contractAddress}\`\nChain: ${record.chain}\nFirst seen: ${record.firstSeenAt}\nFirst seen in: ${record.firstSeenGroupName}\nFirst called by: ${record.firstSeenBy}\nTotal mentions: ${record.mentionCount}\nGroups: ${record.groups.length}`);
|
|
1245
|
+
if (record) {
|
|
1246
|
+
const perfText = record.performancePct !== null ? `${record.performancePct >= 0 ? "+" : ""}${record.performancePct.toFixed(1)}%` : "N/A";
|
|
1247
|
+
return textResult$1(`š CA already tracked ā Not a first call\n\nCA: \`${record.contractAddress}\`\nChain: ${record.chain}\nFirst seen: ${record.firstSeenAt}\nFirst seen in: ${record.firstSeenGroupName}\nFirst called by: ${record.firstSeenBy}\nTotal mentions: ${record.mentionCount}\nGroups mentioning: ${record.groups.length}\nEntry price: ${record.entryPriceUsd !== null ? `$${record.entryPriceUsd}` : "N/A"}\nPerformance: ${perfText}`);
|
|
1248
|
+
}
|
|
1249
|
+
return textResult$1(`š **FIRST CALL** ā CA not in registry\n\nCA: \`${contractAddress}\``);
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
api.registerTool({
|
|
1253
|
+
name: "tg_scanner_call_performance",
|
|
1254
|
+
description: "Query call performance for a contract address or a group. Shows price change since first call, caller stats, and historical performance data.",
|
|
1255
|
+
parameters: Type.Object({
|
|
1256
|
+
contractAddress: Type.Optional(Type.String({ description: "Specific CA to check performance for" })),
|
|
1257
|
+
groupId: Type.Optional(Type.String({ description: "Group ID to get caller stats for" }))
|
|
1258
|
+
}),
|
|
1259
|
+
async execute(_id, params) {
|
|
1260
|
+
const { contractAddress, groupId } = params;
|
|
1261
|
+
if (contractAddress) {
|
|
1262
|
+
const performances = getCallPerformance(contractAddress);
|
|
1263
|
+
if (performances.length === 0) return textResult$1(`š No call performance data for \`${contractAddress}\`\n\nThis CA has not been tracked through the scanner yet.`);
|
|
1264
|
+
let text = `š Call Performance: \`${contractAddress}\`\n\n`;
|
|
1265
|
+
for (const perf of performances) {
|
|
1266
|
+
const returnText = perf.returnPct !== null ? `${perf.returnPct >= 0 ? "š¢ +" : "š“ "}${perf.returnPct.toFixed(1)}%` : "ā³ Pending";
|
|
1267
|
+
text += `Caller: **${perf.callerName}** in ${perf.groupName}\nCalled at: ${perf.callTimestamp}\nEntry: ${perf.entryPriceUsd !== null ? `$${perf.entryPriceUsd}` : "N/A"}\nCurrent: ${perf.currentPriceUsd !== null ? `$${perf.currentPriceUsd}` : "N/A"}\nReturn: ${returnText}\n\n`;
|
|
1268
|
+
}
|
|
1269
|
+
return textResult$1(text);
|
|
1270
|
+
}
|
|
1271
|
+
const stats = getCallerStats(groupId);
|
|
1272
|
+
if (stats.length === 0) return textResult$1(`š No caller stats available${groupId ? ` for group ${groupId}` : ""}.\n\nProcess some messages with tg_scanner_extract_ca first.`);
|
|
1273
|
+
let text = `š Caller Stats${groupId ? ` ā Group ${groupId}` : ""}\n\n`;
|
|
1274
|
+
text += `Top callers by first-call count:\n\n`;
|
|
1275
|
+
for (const stat of stats.slice(0, 20)) text += ` ⢠**${stat.callerName}**: ${stat.totalCalls} first calls (${stat.groupId})\n`;
|
|
1276
|
+
return textResult$1(text);
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
api.registerTool({
|
|
1280
|
+
name: "tg_scanner_detect_bot_signal",
|
|
1281
|
+
description: "Detect if a message is from a known trading bot (Maestro, BananaGun, Unibot, etc.). Also extracts any contract addresses found in the bot message.",
|
|
1282
|
+
parameters: Type.Object({ text: Type.String({ description: "Message text to analyze for bot patterns" }) }),
|
|
1283
|
+
async execute(_id, params) {
|
|
1284
|
+
const { text: msgText } = params;
|
|
1285
|
+
const detection = detectBotSignal(msgText);
|
|
1286
|
+
const cas = extractContractAddresses(msgText);
|
|
1287
|
+
const tickers = extractTickers(msgText);
|
|
1288
|
+
let resultText = `š¤ Bot Signal Detection\n\n`;
|
|
1289
|
+
if (detection.isBot) resultText += `**Bot detected: ${detection.botName}**\nConfidence: ${(detection.confidence * 100).toFixed(0)}%\n\n`;
|
|
1290
|
+
else resultText += `No bot pattern detected ā likely a human message.\n\n`;
|
|
1291
|
+
if (cas.length > 0) {
|
|
1292
|
+
resultText += `Contract addresses in message:\n`;
|
|
1293
|
+
for (const ca of cas) resultText += ` ⢠\`${ca.address}\` [${ca.chain.toUpperCase()}]\n`;
|
|
1294
|
+
}
|
|
1295
|
+
if (tickers.length > 0) resultText += `\nTickers: ${tickers.map((t) => `$${t}`).join(", ")}\n`;
|
|
1296
|
+
resultText += "\nš” Bot signals can indicate trending tokens but may have higher risk due to front-running and sandwich attacks.";
|
|
1297
|
+
return textResult$1(resultText);
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/services/telegram-listener.ts
|
|
1303
|
+
/**
|
|
1304
|
+
* Telegram Listener Service ā Bot API based message listener.
|
|
1305
|
+
*
|
|
1306
|
+
* Connects to Telegram using a Bot Token (from @BotFather) and receives
|
|
1307
|
+
* messages via long polling. The bot must be added to groups/channels
|
|
1308
|
+
* as an admin to receive messages.
|
|
1309
|
+
*
|
|
1310
|
+
* Forwards incoming messages from subscribed groups/channels to the
|
|
1311
|
+
* existing telegram-scanner pipeline for CA extraction, bot detection, etc.
|
|
1312
|
+
*/
|
|
1313
|
+
let bot = null;
|
|
1314
|
+
let stats = {
|
|
1315
|
+
running: false,
|
|
1316
|
+
connectedAt: null,
|
|
1317
|
+
messagesReceived: 0,
|
|
1318
|
+
messagesProcessed: 0,
|
|
1319
|
+
errors: 0,
|
|
1320
|
+
subscribedGroupIds: []
|
|
1321
|
+
};
|
|
1322
|
+
let externalHandlers = [];
|
|
1323
|
+
function getSubscribedGroupIds() {
|
|
1324
|
+
const state = loadScannerState();
|
|
1325
|
+
return new Set(state.subscriptions.filter((s) => s.enabled).map((s) => s.groupId));
|
|
1326
|
+
}
|
|
1327
|
+
function isSubscribed(chatId) {
|
|
1328
|
+
const subscribed = getSubscribedGroupIds();
|
|
1329
|
+
if (subscribed.size === 0) return true;
|
|
1330
|
+
return subscribed.has(chatId) || subscribed.has(`-${chatId}`) || subscribed.has(`-100${chatId}`);
|
|
1331
|
+
}
|
|
1332
|
+
function handleMessage(ctx) {
|
|
1333
|
+
stats.messagesReceived++;
|
|
1334
|
+
try {
|
|
1335
|
+
const msg = ctx.message ?? ctx.channelPost;
|
|
1336
|
+
if (!msg) return;
|
|
1337
|
+
const text = msg.text ?? msg.caption ?? "";
|
|
1338
|
+
if (!text.trim()) return;
|
|
1339
|
+
const chatId = String(msg.chat.id);
|
|
1340
|
+
if (!isSubscribed(chatId.replace(/^-100/, "")) && !isSubscribed(chatId)) return;
|
|
1341
|
+
const groupName = "title" in msg.chat && msg.chat.title ? msg.chat.title : `chat:${chatId}`;
|
|
1342
|
+
let senderId = "unknown";
|
|
1343
|
+
let senderName = "Unknown";
|
|
1344
|
+
if (msg.from) {
|
|
1345
|
+
senderId = String(msg.from.id);
|
|
1346
|
+
senderName = [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" ");
|
|
1347
|
+
}
|
|
1348
|
+
const timestamp = (/* @__PURE__ */ new Date(msg.date * 1e3)).toISOString();
|
|
1349
|
+
processMessage(chatId, groupName, senderId, senderName, text, timestamp);
|
|
1350
|
+
stats.messagesProcessed++;
|
|
1351
|
+
for (const handler of externalHandlers) try {
|
|
1352
|
+
handler({
|
|
1353
|
+
groupId: chatId,
|
|
1354
|
+
groupName,
|
|
1355
|
+
senderId,
|
|
1356
|
+
senderName,
|
|
1357
|
+
text,
|
|
1358
|
+
timestamp
|
|
1359
|
+
});
|
|
1360
|
+
} catch {}
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
stats.errors++;
|
|
1363
|
+
auditLog("tg-listener", "message_error", { error: err instanceof Error ? err.message : String(err) });
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
async function startTgListener(config) {
|
|
1367
|
+
if (bot && stats.running) {
|
|
1368
|
+
auditLog("tg-listener", "start_skipped", { reason: "already_running" });
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
bot = new Bot(config.botToken);
|
|
1372
|
+
bot.on("message:text", handleMessage);
|
|
1373
|
+
bot.on("message:caption", handleMessage);
|
|
1374
|
+
bot.on("channel_post:text", handleMessage);
|
|
1375
|
+
bot.on("channel_post:caption", handleMessage);
|
|
1376
|
+
bot.catch((err) => {
|
|
1377
|
+
stats.errors++;
|
|
1378
|
+
auditLog("tg-listener", "bot_error", { error: err.message || String(err) });
|
|
1379
|
+
});
|
|
1380
|
+
bot.start({ onStart: () => {
|
|
1381
|
+
auditLog("tg-listener", "polling_started", {});
|
|
1382
|
+
} });
|
|
1383
|
+
stats = {
|
|
1384
|
+
running: true,
|
|
1385
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1386
|
+
messagesReceived: 0,
|
|
1387
|
+
messagesProcessed: 0,
|
|
1388
|
+
errors: 0,
|
|
1389
|
+
subscribedGroupIds: Array.from(getSubscribedGroupIds())
|
|
1390
|
+
};
|
|
1391
|
+
auditLog("tg-listener", "started", { subscribedGroups: stats.subscribedGroupIds.length });
|
|
1392
|
+
}
|
|
1393
|
+
async function stopTgListener() {
|
|
1394
|
+
if (bot) {
|
|
1395
|
+
try {
|
|
1396
|
+
await bot.stop();
|
|
1397
|
+
} catch {}
|
|
1398
|
+
bot = null;
|
|
1399
|
+
}
|
|
1400
|
+
stats.running = false;
|
|
1401
|
+
auditLog("tg-listener", "stopped", {
|
|
1402
|
+
totalReceived: stats.messagesReceived,
|
|
1403
|
+
totalProcessed: stats.messagesProcessed,
|
|
1404
|
+
totalErrors: stats.errors
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
function getTgListenerStats() {
|
|
1408
|
+
if (stats.running) stats.subscribedGroupIds = Array.from(getSubscribedGroupIds());
|
|
1409
|
+
return { ...stats };
|
|
1410
|
+
}
|
|
1411
|
+
function isTgListenerRunning() {
|
|
1412
|
+
return stats.running;
|
|
1413
|
+
}
|
|
1414
|
+
//#endregion
|
|
1415
|
+
//#region src/tools/tg-listener.ts
|
|
1416
|
+
/**
|
|
1417
|
+
* Telegram Listener Tool ā Registers tools for controlling the Bot API
|
|
1418
|
+
* message listener via the OpenClaw plugin API.
|
|
1419
|
+
*
|
|
1420
|
+
* Tools:
|
|
1421
|
+
* - tg_listener_start: Start listening for messages
|
|
1422
|
+
* - tg_listener_stop: Stop the listener
|
|
1423
|
+
* - tg_listener_status: Get current listener status and stats
|
|
1424
|
+
*/
|
|
1425
|
+
function textResult(text) {
|
|
1426
|
+
return { content: [{
|
|
1427
|
+
type: "text",
|
|
1428
|
+
text
|
|
1429
|
+
}] };
|
|
1430
|
+
}
|
|
1431
|
+
function registerTgListenerTools(api) {
|
|
1432
|
+
api.registerTool({
|
|
1433
|
+
name: "tg_listener_start",
|
|
1434
|
+
description: "Start the Telegram Bot listener to receive real-time messages from groups and channels. The bot must be added as an admin to target groups. Messages are automatically processed through the tg-scanner pipeline.",
|
|
1435
|
+
parameters: Type.Object({ botToken: Type.Optional(Type.String({ description: "Bot token from @BotFather (defaults to TG_BOT_TOKEN env var)" })) }),
|
|
1436
|
+
async execute(_id, params) {
|
|
1437
|
+
if (isTgListenerRunning()) {
|
|
1438
|
+
const stats = getTgListenerStats();
|
|
1439
|
+
return textResult(`ā ļø Telegram listener is already running.\n\nConnected since: ${stats.connectedAt}\nMessages received: ${stats.messagesReceived}\nMessages processed: ${stats.messagesProcessed}\nSubscribed groups: ${stats.subscribedGroupIds.length}`);
|
|
1440
|
+
}
|
|
1441
|
+
const botToken = params.botToken ?? process.env["TG_BOT_TOKEN"];
|
|
1442
|
+
if (!botToken) return textResult("ā Missing Bot Token.\n\nSet TG_BOT_TOKEN in .env or pass it as a parameter.\n\nTo create a bot:\n 1. Open Telegram and find @BotFather\n 2. Send /newbot and follow the instructions\n 3. Copy the token to .env as TG_BOT_TOKEN");
|
|
1443
|
+
try {
|
|
1444
|
+
await startTgListener({ botToken });
|
|
1445
|
+
const stats = getTgListenerStats();
|
|
1446
|
+
return textResult(`ā
Telegram Bot listener started.\n\nConnected at: ${stats.connectedAt}\nSubscribed groups: ${stats.subscribedGroupIds.length}\n\nThe bot will receive messages from groups/channels where it's been added as an admin.\n\nš” Use tg_scanner_subscribe to filter specific groups, or leave the subscription list empty to process messages from ALL groups.`);
|
|
1447
|
+
} catch (err) {
|
|
1448
|
+
return textResult(`ā Failed to start Telegram listener.\n\nError: ${err instanceof Error ? err.message : String(err)}\n\nCommon causes:\n ⢠Invalid bot token\n ⢠Network connectivity issues\n ⢠Bot was revoked via @BotFather`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
api.registerTool({
|
|
1453
|
+
name: "tg_listener_stop",
|
|
1454
|
+
description: "Stop the Telegram Bot listener gracefully.",
|
|
1455
|
+
parameters: Type.Object({}),
|
|
1456
|
+
async execute() {
|
|
1457
|
+
if (!isTgListenerRunning()) return textResult("ā¹ļø Telegram listener is not running.");
|
|
1458
|
+
const statsBefore = getTgListenerStats();
|
|
1459
|
+
await stopTgListener();
|
|
1460
|
+
return textResult(`ā
Telegram listener stopped.
|
|
1461
|
+
|
|
1462
|
+
Session summary:
|
|
1463
|
+
⢠Connected since: ${statsBefore.connectedAt}\n ⢠Messages received: ${statsBefore.messagesReceived}\n ⢠Messages processed: ${statsBefore.messagesProcessed}\n ⢠Errors: ${statsBefore.errors}`);
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
api.registerTool({
|
|
1467
|
+
name: "tg_listener_status",
|
|
1468
|
+
description: "Get the current status of the Telegram Bot listener, including connection state, message counts, and subscribed groups.",
|
|
1469
|
+
parameters: Type.Object({}),
|
|
1470
|
+
async execute() {
|
|
1471
|
+
const stats = getTgListenerStats();
|
|
1472
|
+
if (!stats.running) {
|
|
1473
|
+
const hasToken = !!process.env["TG_BOT_TOKEN"];
|
|
1474
|
+
return textResult(`ā¹ļø Telegram listener is **not running**.\n\nBot token configured: ${hasToken ? "ā
Yes" : "ā No"}\n\n` + (hasToken ? `Use tg_listener_start to begin listening.` : `Set TG_BOT_TOKEN in .env first (get it from @BotFather).`));
|
|
1475
|
+
}
|
|
1476
|
+
const uptimeStr = formatUptime(stats.connectedAt ? Math.round((Date.now() - new Date(stats.connectedAt).getTime()) / 1e3) : 0);
|
|
1477
|
+
let text = `š¢ Telegram listener is **running**\n\nConnected since: ${stats.connectedAt}\nUptime: ${uptimeStr}\nMessages received: ${stats.messagesReceived}\nMessages processed: ${stats.messagesProcessed}\nErrors: ${stats.errors}\n`;
|
|
1478
|
+
if (stats.subscribedGroupIds.length > 0) {
|
|
1479
|
+
text += `\nSubscribed groups (${stats.subscribedGroupIds.length}):\n`;
|
|
1480
|
+
for (const gid of stats.subscribedGroupIds) text += ` ⢠${gid}\n`;
|
|
1481
|
+
} else {
|
|
1482
|
+
text += `\nš” Processing messages from ALL groups where bot is admin.\n`;
|
|
1483
|
+
text += `Use tg_scanner_subscribe to filter specific groups.`;
|
|
1484
|
+
}
|
|
1485
|
+
return textResult(text);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
function formatUptime(seconds) {
|
|
1490
|
+
const d = Math.floor(seconds / 86400);
|
|
1491
|
+
const h = Math.floor(seconds % 86400 / 3600);
|
|
1492
|
+
const m = Math.floor(seconds % 3600 / 60);
|
|
1493
|
+
const s = seconds % 60;
|
|
1494
|
+
const parts = [];
|
|
1495
|
+
if (d > 0) parts.push(`${d}d`);
|
|
1496
|
+
if (h > 0) parts.push(`${h}h`);
|
|
1497
|
+
if (m > 0) parts.push(`${m}m`);
|
|
1498
|
+
parts.push(`${s}s`);
|
|
1499
|
+
return parts.join(" ");
|
|
1500
|
+
}
|
|
1501
|
+
//#endregion
|
|
1502
|
+
//#region src/index.ts
|
|
1503
|
+
/**
|
|
1504
|
+
* Clawvif ā OpenClaw Plugin Entry Point
|
|
1505
|
+
*
|
|
1506
|
+
* This is the main entry point loaded by OpenClaw's plugin system via jiti.
|
|
1507
|
+
* It registers all Clawvif tools that the agent can invoke.
|
|
1508
|
+
*
|
|
1509
|
+
* IMPORTANT: This default export function must be SYNCHRONOUS.
|
|
1510
|
+
* OpenClaw's plugin loader ignores async activate functions.
|
|
1511
|
+
*/
|
|
1512
|
+
/**
|
|
1513
|
+
* Plugin entry ā called synchronously by OpenClaw at load time.
|
|
1514
|
+
*/
|
|
1515
|
+
function clawvifPlugin(api) {
|
|
1516
|
+
registerAuditTools(api);
|
|
1517
|
+
registerAlertTools(api);
|
|
1518
|
+
registerChainMonitorTools(api);
|
|
1519
|
+
registerSentimentTools(api);
|
|
1520
|
+
registerWhaleTools(api);
|
|
1521
|
+
registerTgScannerTools(api);
|
|
1522
|
+
registerTgListenerTools(api);
|
|
1523
|
+
const pluginConfig = api.pluginConfig ?? {};
|
|
1524
|
+
const botToken = pluginConfig.tgBotToken || process.env["TG_BOT_TOKEN"];
|
|
1525
|
+
const autoStart = pluginConfig.tgAutoStart !== false;
|
|
1526
|
+
if (botToken && autoStart) startTgListener({ botToken }).catch((err) => {
|
|
1527
|
+
auditLog("tg-listener", "auto_start_failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1528
|
+
});
|
|
1529
|
+
else if (!botToken) auditLog("tg-listener", "auto_start_skipped", {
|
|
1530
|
+
reason: "no_bot_token",
|
|
1531
|
+
hint: "Set tgBotToken in plugin config or TG_BOT_TOKEN in .env"
|
|
1532
|
+
});
|
|
1533
|
+
else auditLog("tg-listener", "auto_start_skipped", { reason: "tgAutoStart_disabled" });
|
|
1534
|
+
}
|
|
1535
|
+
//#endregion
|
|
1536
|
+
export { clawvifPlugin as default };
|
|
1537
|
+
|
|
1538
|
+
//# sourceMappingURL=index.mjs.map
|