pretticlaw 0.1.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/CONTRIBUTING.md +123 -0
- package/README.md +150 -0
- package/assets/logo.png +0 -0
- package/dist/agent/context.d.ts +22 -0
- package/dist/agent/context.js +85 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +244 -0
- package/dist/agent/memory.d.ts +16 -0
- package/dist/agent/memory.js +98 -0
- package/dist/agent/skills.d.ts +18 -0
- package/dist/agent/skills.js +121 -0
- package/dist/agent/subagent.d.ts +30 -0
- package/dist/agent/subagent.js +92 -0
- package/dist/agent/tools/base.d.ts +10 -0
- package/dist/agent/tools/base.js +58 -0
- package/dist/agent/tools/cron.d.ts +43 -0
- package/dist/agent/tools/cron.js +83 -0
- package/dist/agent/tools/filesystem.d.ts +79 -0
- package/dist/agent/tools/filesystem.js +125 -0
- package/dist/agent/tools/message.d.ts +41 -0
- package/dist/agent/tools/message.js +55 -0
- package/dist/agent/tools/registry.d.ts +9 -0
- package/dist/agent/tools/registry.js +33 -0
- package/dist/agent/tools/shell.d.ts +26 -0
- package/dist/agent/tools/shell.js +78 -0
- package/dist/agent/tools/spawn.d.ts +27 -0
- package/dist/agent/tools/spawn.js +35 -0
- package/dist/agent/tools/web.d.ts +50 -0
- package/dist/agent/tools/web.js +119 -0
- package/dist/bus/async-queue.d.ts +7 -0
- package/dist/bus/async-queue.js +20 -0
- package/dist/bus/events.d.ts +19 -0
- package/dist/bus/events.js +3 -0
- package/dist/bus/queue.d.ts +12 -0
- package/dist/bus/queue.js +23 -0
- package/dist/channels/base.d.ts +22 -0
- package/dist/channels/base.js +35 -0
- package/dist/channels/discord.d.ts +24 -0
- package/dist/channels/discord.js +133 -0
- package/dist/channels/manager.d.ts +17 -0
- package/dist/channels/manager.js +67 -0
- package/dist/channels/stub.d.ts +10 -0
- package/dist/channels/stub.js +18 -0
- package/dist/channels/telegram.d.ts +20 -0
- package/dist/channels/telegram.js +93 -0
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +552 -0
- package/dist/config/loader.d.ts +5 -0
- package/dist/config/loader.js +55 -0
- package/dist/config/schema.d.ts +246 -0
- package/dist/config/schema.js +94 -0
- package/dist/cron/service.d.ts +33 -0
- package/dist/cron/service.js +195 -0
- package/dist/cron/types.d.ts +47 -0
- package/dist/cron/types.js +1 -0
- package/dist/dashboard/index.html +1567 -0
- package/dist/heartbeat/service.d.ts +21 -0
- package/dist/heartbeat/service.js +101 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/providers/base.d.ts +23 -0
- package/dist/providers/base.js +21 -0
- package/dist/providers/custom-provider.d.ts +16 -0
- package/dist/providers/custom-provider.js +49 -0
- package/dist/providers/litellm-provider.d.ts +19 -0
- package/dist/providers/litellm-provider.js +128 -0
- package/dist/providers/registry.d.ts +5 -0
- package/dist/providers/registry.js +45 -0
- package/dist/session/manager.d.ts +31 -0
- package/dist/session/manager.js +116 -0
- package/dist/skills/README.md +25 -0
- package/dist/skills/clawhub/SKILL.md +53 -0
- package/dist/skills/cron/SKILL.md +57 -0
- package/dist/skills/github/SKILL.md +48 -0
- package/dist/skills/memory/SKILL.md +31 -0
- package/dist/skills/skill-creator/SKILL.md +371 -0
- package/dist/skills/summarize/SKILL.md +67 -0
- package/dist/skills/tmux/SKILL.md +121 -0
- package/dist/skills/tmux/scripts/find-sessions.sh +112 -0
- package/dist/skills/tmux/scripts/wait-for-text.sh +83 -0
- package/dist/skills/weather/SKILL.md +49 -0
- package/dist/templates/AGENTS.md +23 -0
- package/dist/templates/HEARTBEAT.md +16 -0
- package/dist/templates/SOUL.md +21 -0
- package/dist/templates/TOOLS.md +15 -0
- package/dist/templates/USER.md +49 -0
- package/dist/templates/memory/MEMORY.md +23 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +3 -0
- package/dist/utils/helpers.d.ts +5 -0
- package/dist/utils/helpers.js +53 -0
- package/dist/web/server.d.ts +15 -0
- package/dist/web/server.js +169 -0
- package/package.json +37 -0
- package/scripts/copy-assets.mjs +21 -0
- package/src/agent/context.ts +90 -0
- package/src/agent/loop.ts +291 -0
- package/src/agent/memory.ts +104 -0
- package/src/agent/skills.ts +121 -0
- package/src/agent/subagent.ts +96 -0
- package/src/agent/tools/base.ts +59 -0
- package/src/agent/tools/cron.ts +79 -0
- package/src/agent/tools/filesystem.ts +93 -0
- package/src/agent/tools/message.ts +57 -0
- package/src/agent/tools/registry.ts +36 -0
- package/src/agent/tools/shell.ts +69 -0
- package/src/agent/tools/spawn.ts +37 -0
- package/src/agent/tools/web.ts +108 -0
- package/src/bus/async-queue.ts +20 -0
- package/src/bus/events.ts +23 -0
- package/src/bus/queue.ts +31 -0
- package/src/channels/base.ts +36 -0
- package/src/channels/discord.ts +156 -0
- package/src/channels/manager.ts +70 -0
- package/src/channels/stub.ts +20 -0
- package/src/channels/telegram.ts +120 -0
- package/src/cli/commands.ts +581 -0
- package/src/config/loader.ts +58 -0
- package/src/config/schema.ts +144 -0
- package/src/cron/service.ts +190 -0
- package/src/cron/types.ts +36 -0
- package/src/dashboard/index.html +1567 -0
- package/src/heartbeat/service.ts +95 -0
- package/src/index.ts +6 -0
- package/src/providers/base.ts +43 -0
- package/src/providers/custom-provider.ts +46 -0
- package/src/providers/litellm-provider.ts +131 -0
- package/src/providers/registry.ts +48 -0
- package/src/session/manager.ts +129 -0
- package/src/skills/README.md +25 -0
- package/src/skills/clawhub/SKILL.md +53 -0
- package/src/skills/cron/SKILL.md +57 -0
- package/src/skills/github/SKILL.md +48 -0
- package/src/skills/memory/SKILL.md +31 -0
- package/src/skills/skill-creator/SKILL.md +371 -0
- package/src/skills/summarize/SKILL.md +67 -0
- package/src/skills/tmux/SKILL.md +121 -0
- package/src/skills/tmux/scripts/find-sessions.sh +112 -0
- package/src/skills/tmux/scripts/wait-for-text.sh +83 -0
- package/src/skills/weather/SKILL.md +49 -0
- package/src/templates/AGENTS.md +23 -0
- package/src/templates/HEARTBEAT.md +16 -0
- package/src/templates/SOUL.md +21 -0
- package/src/templates/TOOLS.md +15 -0
- package/src/templates/USER.md +49 -0
- package/src/templates/memory/MEMORY.md +23 -0
- package/src/types/prompts.d.ts +14 -0
- package/src/types/ws.d.ts +15 -0
- package/src/types.ts +5 -0
- package/src/utils/helpers.ts +55 -0
- package/src/web/server.ts +198 -0
- package/test/context.test.ts +27 -0
- package/test/cron-service.test.ts +31 -0
- package/test/message-tool.test.ts +10 -0
- package/test/providers.test.ts +43 -0
- package/test/tool-validation.test.ts +61 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { Tool } from "./base.js";
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
export class ExecTool extends Tool {
|
|
9
|
+
readonly name = "exec";
|
|
10
|
+
readonly description = "Execute a shell command and return its output. Use with caution.";
|
|
11
|
+
readonly parameters = {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
command: { type: "string", description: "The shell command to execute" },
|
|
15
|
+
working_dir: { type: "string", description: "Optional working directory for the command" },
|
|
16
|
+
},
|
|
17
|
+
required: ["command"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly timeout = 60,
|
|
22
|
+
private readonly workingDir?: string,
|
|
23
|
+
private readonly restrictToWorkspace = false,
|
|
24
|
+
private readonly pathAppend = "",
|
|
25
|
+
) { super(); }
|
|
26
|
+
|
|
27
|
+
private guard(command: string, cwd: string): string | null {
|
|
28
|
+
const lower = command.toLowerCase();
|
|
29
|
+
const deny = [/\brm\s+-[rf]{1,2}\b/, /\bdel\s+\/[fq]\b/, /\brmdir\s+\/s\b/, /(?:^|[;&|]\s*)format\b/, /\b(mkfs|diskpart)\b/, /\bdd\s+if=/, />\s*\/dev\/sd/, /\b(shutdown|reboot|poweroff)\b/, /:\(\)\s*\{.*\};\s*:/];
|
|
30
|
+
if (deny.some((r) => r.test(lower))) return "Error: Command blocked by safety guard (dangerous pattern detected)";
|
|
31
|
+
if (this.restrictToWorkspace) {
|
|
32
|
+
if (command.includes("../") || command.includes("..\\")) return "Error: Command blocked by safety guard (path traversal detected)";
|
|
33
|
+
const abs = command.match(/[A-Za-z]:\\[^\s"']+/g) ?? [];
|
|
34
|
+
for (const raw of abs) {
|
|
35
|
+
const p = path.resolve(raw);
|
|
36
|
+
const c = path.resolve(cwd);
|
|
37
|
+
if (!p.startsWith(c)) return "Error: Command blocked by safety guard (path outside working dir)";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
44
|
+
const command = String(args.command ?? "");
|
|
45
|
+
const cwd = String(args.working_dir ?? this.workingDir ?? process.cwd());
|
|
46
|
+
const blocked = this.guard(command, cwd);
|
|
47
|
+
if (blocked) return blocked;
|
|
48
|
+
|
|
49
|
+
const env = { ...process.env } as Record<string, string>;
|
|
50
|
+
if (this.pathAppend) env.PATH = `${env.PATH ?? ""}${path.delimiter}${this.pathAppend}`;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const { stdout, stderr } = await execAsync(command, { cwd, env, timeout: this.timeout * 1000, maxBuffer: 1024 * 1024 });
|
|
54
|
+
let out = "";
|
|
55
|
+
if (stdout) out += stdout;
|
|
56
|
+
if (stderr?.trim()) out += `${out ? "\n" : ""}STDERR:\n${stderr}`;
|
|
57
|
+
if (!out) out = "(no output)";
|
|
58
|
+
if (out.length > 10000) out = `${out.slice(0, 10000)}\n... (truncated, ${out.length - 10000} more chars)`;
|
|
59
|
+
return out;
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
if (typeof err?.killed === "boolean" && err.killed) return `Error: Command timed out after ${this.timeout} seconds`;
|
|
62
|
+
const stdout = err?.stdout ? `${err.stdout}\n` : "";
|
|
63
|
+
const stderr = err?.stderr ? `STDERR:\n${err.stderr}\n` : "";
|
|
64
|
+
const code = typeof err?.code === "number" ? `\nExit code: ${err.code}` : "";
|
|
65
|
+
const text = `${stdout}${stderr}${code}`.trim();
|
|
66
|
+
return text || `Error executing command: ${String(err)}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Tool } from "./base.js";
|
|
2
|
+
import type { SubagentManager } from "../subagent.js";
|
|
3
|
+
|
|
4
|
+
export class SpawnTool extends Tool {
|
|
5
|
+
readonly name = "spawn";
|
|
6
|
+
readonly description = "Spawn a subagent to handle a task in the background.";
|
|
7
|
+
readonly parameters = {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
task: { type: "string", description: "The task for the subagent to complete" },
|
|
11
|
+
label: { type: "string", description: "Optional short label" },
|
|
12
|
+
},
|
|
13
|
+
required: ["task"],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
private originChannel = "cli";
|
|
17
|
+
private originChatId = "direct";
|
|
18
|
+
private sessionKey = "cli:direct";
|
|
19
|
+
|
|
20
|
+
constructor(private readonly manager: SubagentManager) { super(); }
|
|
21
|
+
|
|
22
|
+
setContext(channel: string, chatId: string): void {
|
|
23
|
+
this.originChannel = channel;
|
|
24
|
+
this.originChatId = chatId;
|
|
25
|
+
this.sessionKey = `${channel}:${chatId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
29
|
+
return this.manager.spawn({
|
|
30
|
+
task: String(args.task ?? ""),
|
|
31
|
+
label: args.label != null ? String(args.label) : null,
|
|
32
|
+
originChannel: this.originChannel,
|
|
33
|
+
originChatId: this.originChatId,
|
|
34
|
+
sessionKey: this.sessionKey,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Tool } from "./base.js";
|
|
2
|
+
|
|
3
|
+
function stripTags(text: string): string {
|
|
4
|
+
return text.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, "").trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function normalize(text: string): string {
|
|
8
|
+
return text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validateUrl(url: string): [boolean, string] {
|
|
12
|
+
try {
|
|
13
|
+
const u = new URL(url);
|
|
14
|
+
if (!["http:", "https:"].includes(u.protocol)) return [false, `Only http/https allowed, got '${u.protocol.replace(":", "")}'`];
|
|
15
|
+
if (!u.hostname) return [false, "Missing domain"];
|
|
16
|
+
return [true, ""];
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return [false, String(err)];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class WebSearchTool extends Tool {
|
|
23
|
+
readonly name = "web_search";
|
|
24
|
+
readonly description = "Search the web. Returns titles, URLs, and snippets.";
|
|
25
|
+
readonly parameters = {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: { query: { type: "string", description: "Search query" }, count: { type: "integer", minimum: 1, maximum: 10, description: "Results (1-10)" } },
|
|
28
|
+
required: ["query"],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
constructor(private readonly apiKey: string | null, private readonly maxResults = 5) { super(); }
|
|
32
|
+
|
|
33
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
34
|
+
const query = String(args.query ?? "");
|
|
35
|
+
const count = Math.min(Math.max(Number(args.count ?? this.maxResults), 1), 10);
|
|
36
|
+
const key = this.apiKey || process.env.BRAVE_API_KEY || "";
|
|
37
|
+
if (!key) return "Error: Brave Search API key not configured. Set tools.web.search.apiKey or BRAVE_API_KEY.";
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
41
|
+
url.searchParams.set("q", query);
|
|
42
|
+
url.searchParams.set("count", String(count));
|
|
43
|
+
const res = await fetch(url, { headers: { Accept: "application/json", "X-Subscription-Token": key } });
|
|
44
|
+
if (!res.ok) return `Error: ${res.status} ${res.statusText}`;
|
|
45
|
+
const json: any = await res.json();
|
|
46
|
+
const results = json.web?.results ?? [];
|
|
47
|
+
if (!results.length) return `No results for: ${query}`;
|
|
48
|
+
const lines = [`Results for: ${query}`, ""];
|
|
49
|
+
results.slice(0, count).forEach((item: any, i: number) => {
|
|
50
|
+
lines.push(`${i + 1}. ${item.title ?? ""}`);
|
|
51
|
+
lines.push(` ${item.url ?? ""}`);
|
|
52
|
+
if (item.description) lines.push(` ${item.description}`);
|
|
53
|
+
});
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return `Error: ${String(err)}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class WebFetchTool extends Tool {
|
|
62
|
+
readonly name = "web_fetch";
|
|
63
|
+
readonly description = "Fetch URL and extract readable content (HTML to markdown/text).";
|
|
64
|
+
readonly parameters = {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
url: { type: "string", description: "URL to fetch" },
|
|
68
|
+
extractMode: { type: "string", enum: ["markdown", "text"] },
|
|
69
|
+
maxChars: { type: "integer", minimum: 100 },
|
|
70
|
+
},
|
|
71
|
+
required: ["url"],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
constructor(private readonly maxChars = 50000) { super(); }
|
|
75
|
+
|
|
76
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
77
|
+
const url = String(args.url ?? "");
|
|
78
|
+
const extractMode = String(args.extractMode ?? "markdown");
|
|
79
|
+
const maxChars = Number(args.maxChars ?? this.maxChars);
|
|
80
|
+
const [ok, err] = validateUrl(url);
|
|
81
|
+
if (!ok) return JSON.stringify({ error: `URL validation failed: ${err}`, url });
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(url, { redirect: "follow", headers: { "User-Agent": "Mozilla/5.0" } });
|
|
85
|
+
const ctype = res.headers.get("content-type") || "";
|
|
86
|
+
let text = "";
|
|
87
|
+
let extractor = "raw";
|
|
88
|
+
if (ctype.includes("application/json")) {
|
|
89
|
+
text = JSON.stringify(await res.json(), null, 2);
|
|
90
|
+
extractor = "json";
|
|
91
|
+
} else {
|
|
92
|
+
const raw = await res.text();
|
|
93
|
+
if (ctype.includes("text/html") || /^\s*<!doctype|^\s*<html/i.test(raw.slice(0, 256))) {
|
|
94
|
+
const content = extractMode === "text" ? stripTags(raw) : normalize(stripTags(raw));
|
|
95
|
+
text = content;
|
|
96
|
+
extractor = "html";
|
|
97
|
+
} else {
|
|
98
|
+
text = raw;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const truncated = text.length > maxChars;
|
|
102
|
+
const sliced = truncated ? text.slice(0, maxChars) : text;
|
|
103
|
+
return JSON.stringify({ url, finalUrl: res.url, status: res.status, extractor, truncated, length: sliced.length, text: sliced });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
return JSON.stringify({ error: String(e), url });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class AsyncQueue<T> {
|
|
2
|
+
private items: T[] = [];
|
|
3
|
+
private waiters: Array<(v: T) => void> = [];
|
|
4
|
+
|
|
5
|
+
push(v: T): void {
|
|
6
|
+
const waiter = this.waiters.shift();
|
|
7
|
+
if (waiter) waiter(v);
|
|
8
|
+
else this.items.push(v);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async pop(): Promise<T> {
|
|
12
|
+
const item = this.items.shift();
|
|
13
|
+
if (item !== undefined) return item;
|
|
14
|
+
return new Promise<T>((resolve) => this.waiters.push(resolve));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
size(): number {
|
|
18
|
+
return this.items.length;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface InboundMessage {
|
|
2
|
+
channel: string;
|
|
3
|
+
senderId: string;
|
|
4
|
+
chatId: string;
|
|
5
|
+
content: string;
|
|
6
|
+
timestamp?: Date;
|
|
7
|
+
media?: string[];
|
|
8
|
+
metadata?: Record<string, unknown>;
|
|
9
|
+
sessionKeyOverride?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface OutboundMessage {
|
|
13
|
+
channel: string;
|
|
14
|
+
chatId: string;
|
|
15
|
+
content: string;
|
|
16
|
+
replyTo?: string;
|
|
17
|
+
media?: string[];
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function sessionKey(msg: InboundMessage): string {
|
|
22
|
+
return msg.sessionKeyOverride ?? `${msg.channel}:${msg.chatId}`;
|
|
23
|
+
}
|
package/src/bus/queue.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AsyncQueue } from "./async-queue.js";
|
|
2
|
+
import type { InboundMessage, OutboundMessage } from "./events.js";
|
|
3
|
+
|
|
4
|
+
export class MessageBus {
|
|
5
|
+
readonly inbound = new AsyncQueue<InboundMessage>();
|
|
6
|
+
readonly outbound = new AsyncQueue<OutboundMessage>();
|
|
7
|
+
|
|
8
|
+
async publishInbound(msg: InboundMessage): Promise<void> {
|
|
9
|
+
this.inbound.push(msg);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async consumeInbound(): Promise<InboundMessage> {
|
|
13
|
+
return this.inbound.pop();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async publishOutbound(msg: OutboundMessage): Promise<void> {
|
|
17
|
+
this.outbound.push(msg);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async consumeOutbound(): Promise<OutboundMessage> {
|
|
21
|
+
return this.outbound.pop();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get inboundSize(): number {
|
|
25
|
+
return this.inbound.size();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get outboundSize(): number {
|
|
29
|
+
return this.outbound.size();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MessageBus } from "../bus/queue.js";
|
|
2
|
+
import type { OutboundMessage } from "../bus/events.js";
|
|
3
|
+
|
|
4
|
+
export abstract class BaseChannel<TConfig = unknown> {
|
|
5
|
+
protected running = false;
|
|
6
|
+
constructor(protected readonly config: TConfig, protected readonly bus: MessageBus) {}
|
|
7
|
+
abstract readonly name: string;
|
|
8
|
+
abstract start(): Promise<void>;
|
|
9
|
+
abstract stop(): Promise<void>;
|
|
10
|
+
abstract send(msg: OutboundMessage): Promise<void>;
|
|
11
|
+
|
|
12
|
+
protected isAllowed(senderId: string): boolean {
|
|
13
|
+
const allowFrom: string[] = ((this.config as any)?.allowFrom ?? []) as string[];
|
|
14
|
+
if (!allowFrom.length) return true;
|
|
15
|
+
if (allowFrom.includes(String(senderId))) return true;
|
|
16
|
+
if (String(senderId).includes("|")) return String(senderId).split("|").some((p) => allowFrom.includes(p));
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected async handleMessage(input: { senderId: string; chatId: string; content: string; media?: string[]; metadata?: Record<string, unknown>; sessionKey?: string }): Promise<void> {
|
|
21
|
+
if (!this.isAllowed(input.senderId)) return;
|
|
22
|
+
await this.bus.publishInbound({
|
|
23
|
+
channel: this.name,
|
|
24
|
+
senderId: String(input.senderId),
|
|
25
|
+
chatId: String(input.chatId),
|
|
26
|
+
content: input.content,
|
|
27
|
+
media: input.media ?? [],
|
|
28
|
+
metadata: input.metadata ?? {},
|
|
29
|
+
sessionKeyOverride: input.sessionKey,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get isRunning(): boolean {
|
|
34
|
+
return this.running;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import WebSocket, { type RawData } from "ws";
|
|
2
|
+
import type { OutboundMessage } from "../bus/events.js";
|
|
3
|
+
import type { MessageBus } from "../bus/queue.js";
|
|
4
|
+
import { BaseChannel } from "./base.js";
|
|
5
|
+
|
|
6
|
+
type DiscordConfig = {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
token: string;
|
|
9
|
+
allowFrom: string[];
|
|
10
|
+
gatewayUrl: string;
|
|
11
|
+
intents: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type DiscordGatewayPayload = {
|
|
15
|
+
op: number;
|
|
16
|
+
d: any;
|
|
17
|
+
s: number | null;
|
|
18
|
+
t: string | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class DiscordChannel extends BaseChannel<DiscordConfig> {
|
|
22
|
+
readonly name = "discord";
|
|
23
|
+
private ws: WebSocket | null = null;
|
|
24
|
+
private hb: NodeJS.Timeout | null = null;
|
|
25
|
+
private seq: number | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(config: DiscordConfig, bus: MessageBus) {
|
|
28
|
+
super(config, bus);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async start(): Promise<void> {
|
|
32
|
+
if (!this.config.token) {
|
|
33
|
+
console.error("Discord token not configured");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.running = true;
|
|
38
|
+
while (this.running) {
|
|
39
|
+
try {
|
|
40
|
+
await this.connectOnce();
|
|
41
|
+
} catch {
|
|
42
|
+
// reconnect loop
|
|
43
|
+
}
|
|
44
|
+
if (this.running) await new Promise((r) => setTimeout(r, 2000));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stop(): Promise<void> {
|
|
49
|
+
this.running = false;
|
|
50
|
+
if (this.hb) {
|
|
51
|
+
clearInterval(this.hb);
|
|
52
|
+
this.hb = null;
|
|
53
|
+
}
|
|
54
|
+
if (this.ws) {
|
|
55
|
+
try {
|
|
56
|
+
this.ws.close();
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore
|
|
59
|
+
}
|
|
60
|
+
this.ws = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async send(msg: OutboundMessage): Promise<void> {
|
|
65
|
+
if (!this.config.token) return;
|
|
66
|
+
await fetch(`https://discord.com/api/v10/channels/${msg.chatId}/messages`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bot ${this.config.token}`,
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({ content: msg.content || "" }),
|
|
73
|
+
}).catch(() => undefined);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async connectOnce(): Promise<void> {
|
|
77
|
+
await new Promise<void>((resolve) => {
|
|
78
|
+
const ws = new WebSocket(this.config.gatewayUrl);
|
|
79
|
+
this.ws = ws;
|
|
80
|
+
|
|
81
|
+
ws.on("message", async (raw: RawData) => {
|
|
82
|
+
let payload: DiscordGatewayPayload;
|
|
83
|
+
try {
|
|
84
|
+
payload = JSON.parse(raw.toString());
|
|
85
|
+
} catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (payload.s != null) this.seq = payload.s;
|
|
90
|
+
|
|
91
|
+
if (payload.op === 10) {
|
|
92
|
+
const interval = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
93
|
+
this.startHeartbeat(interval);
|
|
94
|
+
this.sendPayload({
|
|
95
|
+
op: 2,
|
|
96
|
+
d: {
|
|
97
|
+
token: this.config.token,
|
|
98
|
+
intents: this.config.intents,
|
|
99
|
+
properties: { os: process.platform, browser: "pretticlaw", device: "pretticlaw" },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (payload.op === 7) {
|
|
106
|
+
ws.close();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (payload.op === 11) return;
|
|
111
|
+
|
|
112
|
+
if (payload.t === "MESSAGE_CREATE") {
|
|
113
|
+
const m = payload.d;
|
|
114
|
+
if (!m || m.author?.bot) return;
|
|
115
|
+
const content = String(m.content ?? "").trim();
|
|
116
|
+
if (!content) return;
|
|
117
|
+
const sender = `${m.author?.id}${m.author?.username ? `|${m.author.username}` : ""}`;
|
|
118
|
+
await this.handleMessage({
|
|
119
|
+
senderId: sender,
|
|
120
|
+
chatId: String(m.channel_id),
|
|
121
|
+
content,
|
|
122
|
+
metadata: { message_id: m.id, guild_id: m.guild_id ?? null },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
ws.on("close", () => {
|
|
128
|
+
if (this.hb) {
|
|
129
|
+
clearInterval(this.hb);
|
|
130
|
+
this.hb = null;
|
|
131
|
+
}
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
ws.on("error", () => {
|
|
136
|
+
if (this.hb) {
|
|
137
|
+
clearInterval(this.hb);
|
|
138
|
+
this.hb = null;
|
|
139
|
+
}
|
|
140
|
+
resolve();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private startHeartbeat(intervalMs: number): void {
|
|
146
|
+
if (this.hb) clearInterval(this.hb);
|
|
147
|
+
this.hb = setInterval(() => {
|
|
148
|
+
this.sendPayload({ op: 1, d: this.seq });
|
|
149
|
+
}, intervalMs);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private sendPayload(data: Record<string, unknown>): void {
|
|
153
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
154
|
+
this.ws.send(JSON.stringify(data));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Config } from "../config/schema.js";
|
|
2
|
+
import type { MessageBus } from "../bus/queue.js";
|
|
3
|
+
import type { OutboundMessage } from "../bus/events.js";
|
|
4
|
+
import type { BaseChannel } from "./base.js";
|
|
5
|
+
import { StubChannel } from "./stub.js";
|
|
6
|
+
import { TelegramChannel } from "./telegram.js";
|
|
7
|
+
import { DiscordChannel } from "./discord.js";
|
|
8
|
+
|
|
9
|
+
export class ChannelManager {
|
|
10
|
+
readonly channels = new Map<string, BaseChannel>();
|
|
11
|
+
private dispatchLoop: Promise<void> | null = null;
|
|
12
|
+
private stopping = false;
|
|
13
|
+
|
|
14
|
+
constructor(private readonly config: Config, private readonly bus: MessageBus) {
|
|
15
|
+
this.initChannels();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private initChannels(): void {
|
|
19
|
+
const add = (name: string, enabled: boolean, channelConfig: any) => {
|
|
20
|
+
if (enabled) this.channels.set(name, new StubChannel(name, channelConfig, this.bus));
|
|
21
|
+
};
|
|
22
|
+
if (this.config.channels.telegram.enabled) {
|
|
23
|
+
this.channels.set("telegram", new TelegramChannel(this.config.channels.telegram, this.bus));
|
|
24
|
+
}
|
|
25
|
+
add("whatsapp", this.config.channels.whatsapp.enabled, this.config.channels.whatsapp);
|
|
26
|
+
if (this.config.channels.discord.enabled) {
|
|
27
|
+
this.channels.set("discord", new DiscordChannel(this.config.channels.discord, this.bus));
|
|
28
|
+
}
|
|
29
|
+
add("feishu", this.config.channels.feishu.enabled, this.config.channels.feishu);
|
|
30
|
+
add("mochat", this.config.channels.mochat.enabled, this.config.channels.mochat);
|
|
31
|
+
add("dingtalk", this.config.channels.dingtalk.enabled, this.config.channels.dingtalk);
|
|
32
|
+
add("email", this.config.channels.email.enabled, this.config.channels.email);
|
|
33
|
+
add("slack", this.config.channels.slack.enabled, this.config.channels.slack);
|
|
34
|
+
add("qq", this.config.channels.qq.enabled, this.config.channels.qq);
|
|
35
|
+
add("matrix", this.config.channels.matrix.enabled, this.config.channels.matrix);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async startAll(): Promise<void> {
|
|
39
|
+
if (!this.channels.size) return;
|
|
40
|
+
this.stopping = false;
|
|
41
|
+
this.dispatchLoop = this.dispatchOutbound();
|
|
42
|
+
await Promise.all([...this.channels.values()].map((c) => c.start().catch(() => undefined)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async stopAll(): Promise<void> {
|
|
46
|
+
this.stopping = true;
|
|
47
|
+
await Promise.all([...this.channels.values()].map((c) => c.stop().catch(() => undefined)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async dispatchOutbound(): Promise<void> {
|
|
51
|
+
while (!this.stopping) {
|
|
52
|
+
const msg = await this.bus.consumeOutbound();
|
|
53
|
+
if (msg.metadata?._progress) {
|
|
54
|
+
const isTool = !!msg.metadata._tool_hint;
|
|
55
|
+
if (isTool && !this.config.channels.sendToolHints) continue;
|
|
56
|
+
if (!isTool && !this.config.channels.sendProgress) continue;
|
|
57
|
+
}
|
|
58
|
+
const ch = this.channels.get(msg.channel);
|
|
59
|
+
if (ch) await ch.send(msg as OutboundMessage).catch(() => undefined);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get enabledChannels(): string[] {
|
|
64
|
+
return [...this.channels.keys()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getStatus(): Record<string, unknown> {
|
|
68
|
+
return Object.fromEntries([...this.channels].map(([name, c]) => [name, { enabled: true, running: c.isRunning }]));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OutboundMessage } from "../bus/events.js";
|
|
2
|
+
import type { MessageBus } from "../bus/queue.js";
|
|
3
|
+
import { BaseChannel } from "./base.js";
|
|
4
|
+
|
|
5
|
+
export class StubChannel extends BaseChannel<any> {
|
|
6
|
+
constructor(public readonly name: string, config: any, bus: MessageBus) { super(config, bus); }
|
|
7
|
+
|
|
8
|
+
async start(): Promise<void> {
|
|
9
|
+
this.running = true;
|
|
10
|
+
await new Promise<void>(() => {});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async stop(): Promise<void> {
|
|
14
|
+
this.running = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async send(_msg: OutboundMessage): Promise<void> {
|
|
18
|
+
// no-op
|
|
19
|
+
}
|
|
20
|
+
}
|