openclaw-lark-multi-agent 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/dist/cli.js ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { execFileSync } from "node:child_process";
6
+ import { homedir } from "node:os";
7
+ import { startApp } from "./index.js";
8
+ const APP_NAME = "openclaw-lark-multi-agent";
9
+ const HOME_DIR = homedir() || process.env.HOME || process.cwd();
10
+ const DEFAULT_STATE_DIR = resolve(HOME_DIR, ".openclaw", APP_NAME);
11
+ const DEFAULT_CONFIG_PATH = resolve(DEFAULT_STATE_DIR, "config.json");
12
+ function usage(exitCode = 0) {
13
+ console.log(`OpenClaw Lark Multi-Agent
14
+
15
+ Usage:
16
+ ${APP_NAME} start [config]
17
+ ${APP_NAME} init [--state-dir DIR] [--force]
18
+ ${APP_NAME} install-systemd [--user|--system] [--state-dir DIR] [--no-restart]
19
+ ${APP_NAME} install-windows-service [--state-dir DIR] [--no-start]
20
+ ${APP_NAME} doctor [--state-dir DIR]
21
+ ${APP_NAME} --help
22
+
23
+ Examples:
24
+ ${APP_NAME} init
25
+ ${APP_NAME} start ~/.openclaw/${APP_NAME}/config.json
26
+ ${APP_NAME} install-systemd --user
27
+ ${APP_NAME} install-windows-service
28
+ `);
29
+ process.exit(exitCode);
30
+ }
31
+ function takeOption(args, name) {
32
+ const idx = args.indexOf(name);
33
+ if (idx < 0)
34
+ return undefined;
35
+ const value = args[idx + 1];
36
+ if (!value || value.startsWith("--"))
37
+ throw new Error(`${name} requires a value`);
38
+ args.splice(idx, 2);
39
+ return value;
40
+ }
41
+ function hasFlag(args, name) {
42
+ const idx = args.indexOf(name);
43
+ if (idx < 0)
44
+ return false;
45
+ args.splice(idx, 1);
46
+ return true;
47
+ }
48
+ function sampleConfig() {
49
+ return JSON.stringify({
50
+ openclaw: {
51
+ baseUrl: "http://127.0.0.1:18789",
52
+ token: "YOUR_OPENCLAW_GATEWAY_TOKEN",
53
+ },
54
+ bots: [
55
+ {
56
+ name: "GPT",
57
+ appId: "cli_xxx",
58
+ appSecret: "YOUR_LARK_APP_SECRET",
59
+ model: "github-copilot/gpt-5.5",
60
+ },
61
+ {
62
+ name: "Gemini",
63
+ appId: "cli_yyy",
64
+ appSecret: "YOUR_LARK_APP_SECRET",
65
+ model: "github-copilot/gemini-3.1-pro-preview",
66
+ },
67
+ ],
68
+ }, null, 2) + "\n";
69
+ }
70
+ function ensureState(stateDir, force = false) {
71
+ const configPath = resolve(stateDir, "config.json");
72
+ const dataDir = resolve(stateDir, "data");
73
+ mkdirSync(dataDir, { recursive: true });
74
+ if (existsSync(configPath) && !force) {
75
+ console.log(`Config already exists: ${configPath}`);
76
+ }
77
+ else {
78
+ writeFileSync(configPath, sampleConfig(), { mode: 0o600 });
79
+ console.log(`Created config: ${configPath}`);
80
+ }
81
+ console.log(`Data dir: ${dataDir}`);
82
+ return { configPath, dataDir };
83
+ }
84
+ function cmdInit(args) {
85
+ const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
86
+ const force = hasFlag(args, "--force");
87
+ if (args.length > 0)
88
+ throw new Error(`Unknown init arguments: ${args.join(" ")}`);
89
+ ensureState(stateDir, force);
90
+ console.log("Next: edit config.json and fill in your OpenClaw token and Lark app credentials.");
91
+ }
92
+ function buildUnit(params) {
93
+ const nodeBin = process.execPath;
94
+ const cliPath = fileURLToPath(import.meta.url);
95
+ const userLine = params.mode === "system" ? `User=${process.env.USER || "YOUR_USERNAME"}\n` : "";
96
+ return `[Unit]
97
+ Description=OpenClaw Lark Multi-Agent - Multi-bot bridge for OpenClaw
98
+ After=network.target
99
+ Wants=network-online.target
100
+
101
+ [Service]
102
+ Type=simple
103
+ ${userLine}ExecStart=${nodeBin} ${cliPath} start ${params.configPath}
104
+ Restart=always
105
+ RestartSec=5
106
+ Environment=NODE_ENV=production
107
+ Environment=LMA_DATA_DIR=${resolve(params.stateDir, "data")}
108
+ StandardOutput=journal
109
+ StandardError=journal
110
+ SyslogIdentifier=${APP_NAME}
111
+
112
+ [Install]
113
+ WantedBy=${params.mode === "system" ? "multi-user.target" : "default.target"}
114
+ `;
115
+ }
116
+ function run(cmd, args) {
117
+ execFileSync(cmd, args, { stdio: "inherit" });
118
+ }
119
+ function runSudo(args) {
120
+ run("sudo", args);
121
+ }
122
+ function cmdInstallSystemd(args) {
123
+ const mode = hasFlag(args, "--system") ? "system" : "user";
124
+ hasFlag(args, "--user");
125
+ const noRestart = hasFlag(args, "--no-restart");
126
+ const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
127
+ if (args.length > 0)
128
+ throw new Error(`Unknown install-systemd arguments: ${args.join(" ")}`);
129
+ const { configPath } = ensureState(stateDir, false);
130
+ const unit = buildUnit({ mode, configPath, stateDir });
131
+ if (mode === "system") {
132
+ const tmp = `/tmp/${APP_NAME}.service`;
133
+ writeFileSync(tmp, unit);
134
+ runSudo(["install", "-m", "0644", tmp, `/etc/systemd/system/${APP_NAME}.service`]);
135
+ runSudo(["systemctl", "daemon-reload"]);
136
+ runSudo(["systemctl", "enable", `${APP_NAME}.service`]);
137
+ if (!noRestart)
138
+ runSudo(["systemctl", "restart", `${APP_NAME}.service`]);
139
+ console.log(`Installed system service: ${APP_NAME}.service`);
140
+ }
141
+ else {
142
+ const unitDir = resolve(HOME_DIR, ".config", "systemd", "user");
143
+ mkdirSync(unitDir, { recursive: true });
144
+ const unitPath = resolve(unitDir, `${APP_NAME}.service`);
145
+ writeFileSync(unitPath, unit);
146
+ run("systemctl", ["--user", "daemon-reload"]);
147
+ run("systemctl", ["--user", "enable", `${APP_NAME}.service`]);
148
+ if (!noRestart)
149
+ run("systemctl", ["--user", "restart", `${APP_NAME}.service`]);
150
+ console.log(`Installed user service: ${unitPath}`);
151
+ }
152
+ }
153
+ function cmdInstallWindowsService(args) {
154
+ const noStart = hasFlag(args, "--no-start");
155
+ const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
156
+ if (args.length > 0)
157
+ throw new Error(`Unknown install-windows-service arguments: ${args.join(" ")}`);
158
+ if (process.platform !== "win32") {
159
+ console.log("install-windows-service is intended for Windows. On Linux, use install-systemd.");
160
+ }
161
+ const { configPath, dataDir } = ensureState(stateDir, false);
162
+ const cliPath = fileURLToPath(import.meta.url);
163
+ run("nssm", ["install", APP_NAME, process.execPath, cliPath, "start", configPath]);
164
+ run("nssm", ["set", APP_NAME, "AppDirectory", dirname(cliPath)]);
165
+ run("nssm", ["set", APP_NAME, "AppEnvironmentExtra", `NODE_ENV=production`, `LMA_DATA_DIR=${dataDir}`]);
166
+ run("nssm", ["set", APP_NAME, "AppStdout", resolve(stateDir, "stdout.log")]);
167
+ run("nssm", ["set", APP_NAME, "AppStderr", resolve(stateDir, "stderr.log")]);
168
+ run("nssm", ["set", APP_NAME, "AppRotateFiles", "1"]);
169
+ run("nssm", ["set", APP_NAME, "AppRotateBytes", "10485760"]);
170
+ run("nssm", ["set", APP_NAME, "Start", "SERVICE_AUTO_START"]);
171
+ if (!noStart)
172
+ run("nssm", ["start", APP_NAME]);
173
+ console.log(`Installed Windows service: ${APP_NAME}`);
174
+ }
175
+ function cmdDoctor(args) {
176
+ const stateDir = resolve(takeOption(args, "--state-dir") || DEFAULT_STATE_DIR);
177
+ if (args.length > 0)
178
+ throw new Error(`Unknown doctor arguments: ${args.join(" ")}`);
179
+ const configPath = resolve(stateDir, "config.json");
180
+ console.log(`State dir: ${stateDir}`);
181
+ console.log(`Config: ${configPath} ${existsSync(configPath) ? "OK" : "MISSING"}`);
182
+ console.log(`Data dir: ${resolve(stateDir, "data")} ${existsSync(resolve(stateDir, "data")) ? "OK" : "MISSING"}`);
183
+ console.log(`Node: ${process.version}`);
184
+ console.log(`Platform: ${process.platform}`);
185
+ console.log(`CLI: ${fileURLToPath(import.meta.url)}`);
186
+ }
187
+ async function main() {
188
+ const [cmd = "--help", ...args] = process.argv.slice(2);
189
+ if (cmd === "--help" || cmd === "-h")
190
+ usage(0);
191
+ if (cmd === "init")
192
+ return cmdInit(args);
193
+ if (cmd === "install-systemd")
194
+ return cmdInstallSystemd(args);
195
+ if (cmd === "install-windows-service")
196
+ return cmdInstallWindowsService(args);
197
+ if (cmd === "doctor")
198
+ return cmdDoctor(args);
199
+ if (cmd === "start") {
200
+ const configPath = args[0] ? resolve(args[0]) : DEFAULT_CONFIG_PATH;
201
+ return startApp(configPath);
202
+ }
203
+ usage(1);
204
+ }
205
+ main().catch((err) => {
206
+ console.error(err instanceof Error ? err.message : String(err));
207
+ process.exit(1);
208
+ });
@@ -0,0 +1,17 @@
1
+ export interface BotConfig {
2
+ name: string;
3
+ appId: string;
4
+ appSecret: string;
5
+ model: string;
6
+ }
7
+ export interface OpenClawConfig {
8
+ baseUrl: string;
9
+ token: string;
10
+ }
11
+ export interface AppConfig {
12
+ openclaw: OpenClawConfig;
13
+ bots: BotConfig[];
14
+ /** Optional Feishu/Lark open_id for model-drift notifications */
15
+ adminOpenId?: string;
16
+ }
17
+ export declare function loadConfig(path?: string): AppConfig;
package/dist/config.js ADDED
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from "fs";
2
+ import { resolve } from "path";
3
+ export function loadConfig(path) {
4
+ const configPath = path || resolve(process.cwd(), "config.json");
5
+ const raw = readFileSync(configPath, "utf-8");
6
+ const config = JSON.parse(raw);
7
+ if (!config.openclaw?.baseUrl || !config.openclaw?.token) {
8
+ throw new Error("Missing openclaw.baseUrl or openclaw.token in config");
9
+ }
10
+ if (!config.bots || config.bots.length === 0) {
11
+ throw new Error("No bots configured");
12
+ }
13
+ for (const bot of config.bots) {
14
+ if (!bot.appId || !bot.appSecret || !bot.model) {
15
+ throw new Error(`Bot "${bot.name}" missing appId, appSecret, or model`);
16
+ }
17
+ }
18
+ // Validate uniqueness
19
+ const names = config.bots.map((b) => b.name);
20
+ const appIds = config.bots.map((b) => b.appId);
21
+ if (new Set(names).size !== names.length) {
22
+ throw new Error(`Duplicate bot names detected: ${names.join(", ")}`);
23
+ }
24
+ if (new Set(appIds).size !== appIds.length) {
25
+ throw new Error(`Duplicate bot appIds detected`);
26
+ }
27
+ return config;
28
+ }
@@ -0,0 +1,123 @@
1
+ import { BotConfig } from "./config.js";
2
+ import { OpenClawClient } from "./openclaw-client.js";
3
+ import { MessageStore } from "./message-store.js";
4
+ /**
5
+ * Manages a single Feishu bot instance.
6
+ *
7
+ * Each bot owns an OpenClaw session (full agent pipeline).
8
+ * All messages are recorded locally in SQLite.
9
+ * When this bot needs to respond, unsynced messages are batched into
10
+ * a single context catch-up + the actual message → one agent run.
11
+ */
12
+ export declare class FeishuBot {
13
+ readonly config: BotConfig;
14
+ private client;
15
+ private wsClient;
16
+ private eventDispatcher;
17
+ private openclawClient;
18
+ private store;
19
+ private botOpenId;
20
+ /** Tracks which chatId sessions have been initialized */
21
+ private initializedSessions;
22
+ /** Per-chat busy lock: timestamp when became busy (0 = not busy) */
23
+ private busyChats;
24
+ /** Per-chat pending reply message IDs (to ack with DONE when reply arrives) */
25
+ private pendingAckMessages;
26
+ /** Per-chat pending tool message sends (to await before final reply) */
27
+ private pendingToolSends;
28
+ /** Per-chat processQueue lock to avoid duplicate concurrent chat.send runs */
29
+ private queueRuns;
30
+ /** Per-chat serial send queue to guarantee message order */
31
+ private sendQueue;
32
+ private adminOpenId;
33
+ private static allBots;
34
+ constructor(config: BotConfig, openclawClient: OpenClawClient, store: MessageStore, adminOpenId?: string);
35
+ register(): void;
36
+ /**
37
+ * Get the session key for a specific chat.
38
+ * Format: lma-<botname>-<chatId>
39
+ */
40
+ getSessionKey(chatId: string): string;
41
+ /**
42
+ * Ensure the session for a given chatId exists with the correct model.
43
+ * Lazy: only creates on first message in that chat.
44
+ */
45
+ private ensureSession;
46
+ /**
47
+ * Start the Feishu WS connection. Sessions are created lazily per chat.
48
+ */
49
+ start(): Promise<void>;
50
+ /** Resolve this bot's open_id at startup so direct @bot mentions work even
51
+ * when Feishu mention payloads omit app_id and only contain open_id/name. */
52
+ private probeBotIdentity;
53
+ /**
54
+ * On startup, check all known chats for unsynced messages and process them.
55
+ * Also re-subscribe to known sessions for tool events.
56
+ */
57
+ private drainOnStartup;
58
+ static getAllBots(): Map<string, FeishuBot>;
59
+ static findByOpenId(openId: string): FeishuBot | undefined;
60
+ private handleMessage;
61
+ /**
62
+ * Process queued messages for a chat: batch all unsynced messages and send to OpenClaw.
63
+ * Loops until no more unsynced human messages remain.
64
+ */
65
+ private processQueue;
66
+ private processQueueInner;
67
+ private shouldRespond;
68
+ private isMentioned;
69
+ private resolveBotName;
70
+ private resolveHumanName;
71
+ private cleanMentions;
72
+ private replyMessage;
73
+ /**
74
+ * Send a proactive message to a chat (not a reply).
75
+ */
76
+ private sendMessage;
77
+ /**
78
+ * Enqueue a message send to guarantee ordering per chat.
79
+ * All sends for a chat are serialized through this.
80
+ */
81
+ private sendOrdered;
82
+ /**
83
+ * Add a reaction (emoji) to a message.
84
+ */
85
+ private addReaction;
86
+ /**
87
+ * Remove a reaction by emoji type from a message.
88
+ * Finds the bot's own reaction of that type and deletes it.
89
+ */
90
+ private removeReaction;
91
+ /**
92
+ * Handle /status command: show current session info.
93
+ */
94
+ private handleStatusCommand;
95
+ /**
96
+ * Handle /compact command: compress session context.
97
+ */
98
+ private handleCompactCommand;
99
+ /**
100
+ * Handle /reset command: fire sessions.reset and confirm.
101
+ * sessions.reset doesn't return a WS response, so we fire-and-forget
102
+ * then verify via describe.
103
+ */
104
+ private handleResetCommand;
105
+ /**
106
+ * Fetch chat info (name, type, members) via Feishu API and cache in SQLite.
107
+ * Called once per chat on first message.
108
+ */
109
+ private fetchAndCacheChatInfo;
110
+ /**
111
+ * Send a model-drift notification to the affected chat.
112
+ */
113
+ /**
114
+ * Download a resource (image/file/audio) from a Feishu message.
115
+ * Returns the local file path.
116
+ */
117
+ private downloadResource;
118
+ /**
119
+ * Extract text content from a rich text (post) message.
120
+ */
121
+ private extractPostText;
122
+ private notifyModelDrift;
123
+ }