@moikapy/lich 0.3.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/CHANGELOG.md +24 -0
- package/README.md +186 -0
- package/dist/chunk-P52U5M3L.js +3431 -0
- package/dist/chunk-P52U5M3L.js.map +1 -0
- package/dist/chunk-ZVK3MUPC.js +7 -0
- package/dist/chunk-ZVK3MUPC.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +409 -0
- package/dist/cli.js.map +1 -0
- package/dist/gateway-CWPVIU3W.js +752 -0
- package/dist/gateway-CWPVIU3W.js.map +1 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/tui-V7ATLIKW.js +430 -0
- package/dist/tui-V7ATLIKW.js.map +1 -0
- package/docs/.vitepress/config.mts +55 -0
- package/docs/architecture/agent-loop.md +234 -0
- package/docs/architecture/extending.md +284 -0
- package/docs/architecture/overview.md +188 -0
- package/docs/architecture/plugins.md +91 -0
- package/docs/architecture/providers.md +273 -0
- package/docs/architecture/tools.md +180 -0
- package/docs/design/council/architecture-review.md +47 -0
- package/docs/design/council/security-review.md +39 -0
- package/docs/design/council/simplicity-review.md +45 -0
- package/docs/design/self-improvement-loop.md +166 -0
- package/docs/getting-started.md +133 -0
- package/docs/index.md +68 -0
- package/docs/user-guide/cli.md +182 -0
- package/docs/user-guide/gateway.md +168 -0
- package/docs/user-guide/library.md +181 -0
- package/docs/user-guide/plugins.md +120 -0
- package/docs/user-guide/tui.md +76 -0
- package/package.json +54 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import {
|
|
2
|
+
create_agent,
|
|
3
|
+
logger,
|
|
4
|
+
sleep
|
|
5
|
+
} from "./chunk-P52U5M3L.js";
|
|
6
|
+
|
|
7
|
+
// src/gateway/types.ts
|
|
8
|
+
var ERROR_SNIPPET_CHARS = 300;
|
|
9
|
+
function sanitize_agent_error(error) {
|
|
10
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
11
|
+
const flat = raw.replace(/\s+/g, " ").trim();
|
|
12
|
+
return `agent error: ${flat.slice(0, ERROR_SNIPPET_CHARS) || "unknown"}`;
|
|
13
|
+
}
|
|
14
|
+
function create_idle_adapter(name, reason) {
|
|
15
|
+
logger.warn(`gateway ${name} adapter idle: ${reason}`);
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
start: async () => void 0,
|
|
19
|
+
stop: async () => void 0
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function open_socket(url) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const ctor = globalThis.WebSocket;
|
|
25
|
+
if (ctor === void 0) {
|
|
26
|
+
reject(new Error("runtime does not expose a WebSocket constructor"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const socket = new ctor(url);
|
|
30
|
+
socket.onopen = () => resolve(socket);
|
|
31
|
+
socket.onerror = () => reject(new Error(`websocket connect failed: ${url}`));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function run_inbound_message(handle, platform, chat_id, user_id, text) {
|
|
35
|
+
try {
|
|
36
|
+
return await handle(platform, chat_id, user_id, text) ?? "";
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.error(`gateway ${platform} message handling failed`, error);
|
|
39
|
+
return sanitize_agent_error(error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/gateway/bus.ts
|
|
44
|
+
var DEFAULT_HISTORY_CAP = 40;
|
|
45
|
+
var DEFAULT_MAX_CONVERSATIONS = 200;
|
|
46
|
+
var GatewayBus = class {
|
|
47
|
+
config;
|
|
48
|
+
agent_factory;
|
|
49
|
+
agent;
|
|
50
|
+
histories = /* @__PURE__ */ new Map();
|
|
51
|
+
chains = /* @__PURE__ */ new Map();
|
|
52
|
+
history_cap;
|
|
53
|
+
max_conversations;
|
|
54
|
+
stop_logging;
|
|
55
|
+
constructor(params, options) {
|
|
56
|
+
this.config = params.config;
|
|
57
|
+
this.agent_factory = params.agent_factory;
|
|
58
|
+
this.history_cap = options?.history_cap ?? DEFAULT_HISTORY_CAP;
|
|
59
|
+
this.max_conversations = options?.max_conversations ?? DEFAULT_MAX_CONVERSATIONS;
|
|
60
|
+
if (params.wire_tool_logging === true) {
|
|
61
|
+
this.wire_tool_logging();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Serializes runs per conversation and resolves to the reply text. */
|
|
65
|
+
async handle(platform, chat_id, user_id, text) {
|
|
66
|
+
const key = conversation_key(platform, chat_id);
|
|
67
|
+
const previous = this.chains.get(key) ?? Promise.resolve();
|
|
68
|
+
const run = previous.then(() => this.run_once(key, platform, chat_id, user_id, text));
|
|
69
|
+
this.chains.set(
|
|
70
|
+
key,
|
|
71
|
+
run.then(
|
|
72
|
+
() => void 0,
|
|
73
|
+
() => void 0
|
|
74
|
+
)
|
|
75
|
+
);
|
|
76
|
+
return run;
|
|
77
|
+
}
|
|
78
|
+
/** Unsubscribes the debug tool logger (bus owns no other resources). */
|
|
79
|
+
stop() {
|
|
80
|
+
this.stop_logging?.();
|
|
81
|
+
this.stop_logging = void 0;
|
|
82
|
+
}
|
|
83
|
+
async run_once(key, platform, chat_id, user_id, text) {
|
|
84
|
+
const input = text.startsWith("/start") === true ? "hello" : text;
|
|
85
|
+
const history = this.history_for(key);
|
|
86
|
+
const agent = this.ensure_agent();
|
|
87
|
+
try {
|
|
88
|
+
const result = await agent.run({ input, history, label: `gw:${platform}:${chat_id}` });
|
|
89
|
+
this.histories.set(key, cap_history(result.messages, this.history_cap));
|
|
90
|
+
return final_reply_text(result.outcome.final?.content);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
logger.error(`gateway bus run failed for ${key} (user ${user_id})`, error);
|
|
93
|
+
return sanitize_agent_error(error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
ensure_agent() {
|
|
97
|
+
if (this.agent === void 0) {
|
|
98
|
+
this.agent = this.agent_factory();
|
|
99
|
+
}
|
|
100
|
+
return this.agent;
|
|
101
|
+
}
|
|
102
|
+
/** Oldest-first eviction keeps the conversation map bounded. */
|
|
103
|
+
history_for(key) {
|
|
104
|
+
while (this.histories.size >= this.max_conversations && this.histories.has(key) === false) {
|
|
105
|
+
const oldest = this.histories.keys().next();
|
|
106
|
+
if (oldest.done === true) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
this.histories.delete(oldest.value);
|
|
110
|
+
}
|
|
111
|
+
return this.histories.get(key) ?? [];
|
|
112
|
+
}
|
|
113
|
+
/** Logs completed tool calls at debug level for gateway observability. */
|
|
114
|
+
wire_tool_logging() {
|
|
115
|
+
const agent = this.agent_factory();
|
|
116
|
+
this.agent = agent;
|
|
117
|
+
this.stop_logging = agent.events.on((event) => {
|
|
118
|
+
if (event.type === "tool_call_end") {
|
|
119
|
+
logger.debug(`tool ${event.call.name} ${event.result.ok === true ? "ok" : "failed"}`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
function conversation_key(platform, chat_id) {
|
|
125
|
+
return `${platform}:${chat_id}`;
|
|
126
|
+
}
|
|
127
|
+
function cap_history(messages, cap) {
|
|
128
|
+
const overflow = messages.length - cap;
|
|
129
|
+
if (overflow <= 0) {
|
|
130
|
+
return messages;
|
|
131
|
+
}
|
|
132
|
+
return messages.slice(overflow);
|
|
133
|
+
}
|
|
134
|
+
function final_reply_text(content) {
|
|
135
|
+
return content === void 0 || content.length === 0 ? void 0 : content;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/gateway/discord.ts
|
|
139
|
+
var DISCORD_API = "https://discord.com/api/v10";
|
|
140
|
+
var GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
|
141
|
+
var INTENTS = 512 | 32768;
|
|
142
|
+
var heartbeat_timers = /* @__PURE__ */ new WeakMap();
|
|
143
|
+
function create_discord_adapter(params) {
|
|
144
|
+
const token = process.env.LICH_DISCORD_BOT_TOKEN;
|
|
145
|
+
if (token === void 0 || token.length === 0) {
|
|
146
|
+
return create_idle_adapter("discord", "LICH_DISCORD_BOT_TOKEN not set");
|
|
147
|
+
}
|
|
148
|
+
let running = false;
|
|
149
|
+
let socket;
|
|
150
|
+
return {
|
|
151
|
+
name: "discord",
|
|
152
|
+
start: async () => {
|
|
153
|
+
running = true;
|
|
154
|
+
void connect_loop(params, token, () => running, (opened) => {
|
|
155
|
+
socket = opened;
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
stop: async () => {
|
|
159
|
+
running = false;
|
|
160
|
+
socket?.close();
|
|
161
|
+
socket = void 0;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async function connect_loop(params, token, keep_running, set_socket) {
|
|
166
|
+
while (keep_running()) {
|
|
167
|
+
try {
|
|
168
|
+
const socket = await open_socket(GATEWAY_URL);
|
|
169
|
+
set_socket(socket);
|
|
170
|
+
await socket_session(socket, token, params);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
logger.warn("gateway discord connection failed; reconnecting in 5s", error);
|
|
173
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function socket_session(socket, token, params) {
|
|
178
|
+
let heartbeat;
|
|
179
|
+
const done = new Promise((resolve) => {
|
|
180
|
+
socket.onclose = () => resolve();
|
|
181
|
+
});
|
|
182
|
+
socket.onmessage = (event) => {
|
|
183
|
+
const payload = parse_payload(event.data);
|
|
184
|
+
if (payload === void 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
handle_discord_payload(socket, token, params, payload);
|
|
188
|
+
};
|
|
189
|
+
await done;
|
|
190
|
+
heartbeat = heartbeat_timers.get(socket);
|
|
191
|
+
if (heartbeat !== void 0) {
|
|
192
|
+
clearInterval(heartbeat);
|
|
193
|
+
heartbeat_timers.delete(socket);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function handle_discord_payload(socket, token, params, payload) {
|
|
197
|
+
if (payload.op === 10 && is_hello(payload.d)) {
|
|
198
|
+
socket.send(JSON.stringify({ op: 2, d: identify_body(token) }));
|
|
199
|
+
schedule_heartbeat(socket, payload.d.heartbeat_interval);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (payload.t === "MESSAGE_CREATE") {
|
|
203
|
+
void on_message_create(params, payload.d);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function schedule_heartbeat(socket, interval_ms) {
|
|
207
|
+
const interval = typeof interval_ms === "number" ? interval_ms : 45e3;
|
|
208
|
+
const timer = setInterval(() => {
|
|
209
|
+
socket.send(JSON.stringify({ op: 1, d: null }));
|
|
210
|
+
}, Math.max(1e3, interval - 1e3));
|
|
211
|
+
heartbeat_timers.set(socket, timer);
|
|
212
|
+
}
|
|
213
|
+
function identify_body(token) {
|
|
214
|
+
return { token, intents: INTENTS, properties: { os: "linux", browser: "lich", device: "lich" } };
|
|
215
|
+
}
|
|
216
|
+
async function on_message_create(params, data) {
|
|
217
|
+
const message = normalize_message(data);
|
|
218
|
+
if (message === void 0) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const reply = await run_inbound_message(
|
|
222
|
+
params.handle_message,
|
|
223
|
+
"discord",
|
|
224
|
+
message.channel_id,
|
|
225
|
+
message.author_id,
|
|
226
|
+
message.content
|
|
227
|
+
);
|
|
228
|
+
await rest_send_message(message.channel_id, reply);
|
|
229
|
+
}
|
|
230
|
+
async function rest_send_message(channel_id, text) {
|
|
231
|
+
const token = process.env.LICH_DISCORD_BOT_TOKEN;
|
|
232
|
+
if (token === void 0 || channel_id === "") {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
for (const chunk of split_chunks(text, 2e3)) {
|
|
236
|
+
const response = await fetch(`${DISCORD_API}/channels/${channel_id}/messages`, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: { authorization: `Bot ${token}`, "content-type": "application/json" },
|
|
239
|
+
body: JSON.stringify({ content: chunk }),
|
|
240
|
+
signal: AbortSignal.timeout(3e4)
|
|
241
|
+
});
|
|
242
|
+
if (response.ok === false) {
|
|
243
|
+
logger.warn(`gateway discord sendMessage failed with http ${response.status}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function normalize_message(data) {
|
|
248
|
+
if (data === void 0) {
|
|
249
|
+
return void 0;
|
|
250
|
+
}
|
|
251
|
+
const author = data.author;
|
|
252
|
+
if (typeof author !== "object" || author === null) {
|
|
253
|
+
return void 0;
|
|
254
|
+
}
|
|
255
|
+
const info = author;
|
|
256
|
+
if (info.bot === true) {
|
|
257
|
+
return void 0;
|
|
258
|
+
}
|
|
259
|
+
const channel_id = typeof data.channel_id === "string" ? data.channel_id : "";
|
|
260
|
+
const content = strip_mention(typeof data.content === "string" ? data.content : "");
|
|
261
|
+
return { channel_id, content, author_id: typeof info.id === "string" ? info.id : "" };
|
|
262
|
+
}
|
|
263
|
+
function strip_mention(content) {
|
|
264
|
+
const bot_id = process.env.LICH_DISCORD_BOT_ID;
|
|
265
|
+
if (bot_id === void 0) {
|
|
266
|
+
return content.trim();
|
|
267
|
+
}
|
|
268
|
+
const mention = `<@${bot_id}>`;
|
|
269
|
+
return content.startsWith(mention) === true ? content.slice(mention.length).trim() : content.trim();
|
|
270
|
+
}
|
|
271
|
+
function is_hello(d) {
|
|
272
|
+
return d !== void 0 && typeof d.heartbeat_interval === "number";
|
|
273
|
+
}
|
|
274
|
+
function parse_payload(data) {
|
|
275
|
+
if (typeof data !== "string") {
|
|
276
|
+
return void 0;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return JSON.parse(data);
|
|
280
|
+
} catch {
|
|
281
|
+
return void 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function split_chunks(text, limit) {
|
|
285
|
+
const chunks = [];
|
|
286
|
+
let rest = text;
|
|
287
|
+
while (rest.length > limit) {
|
|
288
|
+
chunks.push(rest.slice(0, limit));
|
|
289
|
+
rest = rest.slice(limit);
|
|
290
|
+
}
|
|
291
|
+
if (rest.length > 0) {
|
|
292
|
+
chunks.push(rest);
|
|
293
|
+
}
|
|
294
|
+
return chunks;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/gateway/telegram.ts
|
|
298
|
+
var TELEGRAM_MAX_MESSAGE_CHARS = 4096;
|
|
299
|
+
var TELEGRAM_BACKOFF_MS = [2e3, 4e3, 8e3, 16e3, 3e4];
|
|
300
|
+
function create_telegram_adapter(params) {
|
|
301
|
+
const token = process.env.LICH_TELEGRAM_BOT_TOKEN;
|
|
302
|
+
if (token === void 0 || token.length === 0) {
|
|
303
|
+
return create_idle_adapter("telegram", "LICH_TELEGRAM_BOT_TOKEN not set");
|
|
304
|
+
}
|
|
305
|
+
let running = false;
|
|
306
|
+
return {
|
|
307
|
+
name: "telegram",
|
|
308
|
+
start: async () => {
|
|
309
|
+
running = true;
|
|
310
|
+
void poll_loop(params, token, () => running);
|
|
311
|
+
},
|
|
312
|
+
stop: async () => {
|
|
313
|
+
running = false;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
async function poll_loop(params, token, keep_running) {
|
|
318
|
+
let offset = 0;
|
|
319
|
+
let backoff_index = 0;
|
|
320
|
+
while (keep_running()) {
|
|
321
|
+
try {
|
|
322
|
+
const updates = await fetch_updates(token, offset);
|
|
323
|
+
backoff_index = 0;
|
|
324
|
+
for (const update of updates) {
|
|
325
|
+
offset = update.update_id !== void 0 ? update.update_id + 1 : offset;
|
|
326
|
+
void deliver_update(params, token, update);
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger.warn("gateway telegram poll failed; backing off", error);
|
|
330
|
+
await sleep(TELEGRAM_BACKOFF_MS[backoff_index] ?? 3e4);
|
|
331
|
+
backoff_index = Math.min(backoff_index + 1, TELEGRAM_BACKOFF_MS.length - 1);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
async function fetch_updates(token, offset) {
|
|
336
|
+
const url = api_url(token, "getUpdates") + `?timeout=50&offset=${offset}`;
|
|
337
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(6e4) });
|
|
338
|
+
if (response.ok === false) {
|
|
339
|
+
throw new Error(`getUpdates http ${response.status}`);
|
|
340
|
+
}
|
|
341
|
+
const body = await response.json();
|
|
342
|
+
return Array.isArray(body.result) === true ? body.result : [];
|
|
343
|
+
}
|
|
344
|
+
async function deliver_update(params, token, update) {
|
|
345
|
+
const message = update.message;
|
|
346
|
+
if (message === void 0 || message.from?.is_bot === true) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const chat_id = message.chat?.id;
|
|
350
|
+
if (chat_id === void 0) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const text = message.text ?? "media not supported yet";
|
|
354
|
+
const reply = await run_inbound_message(
|
|
355
|
+
params.handle_message,
|
|
356
|
+
"telegram",
|
|
357
|
+
String(chat_id),
|
|
358
|
+
String(message.from?.id ?? "unknown"),
|
|
359
|
+
text
|
|
360
|
+
);
|
|
361
|
+
await send_reply(token, String(chat_id), reply);
|
|
362
|
+
}
|
|
363
|
+
async function send_reply(token, chat_id, text) {
|
|
364
|
+
for (const chunk of split_text(text, TELEGRAM_MAX_MESSAGE_CHARS)) {
|
|
365
|
+
await post_json(api_url(token, "sendMessage"), { chat_id, text: chunk });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
async function post_json(url, payload) {
|
|
369
|
+
const response = await fetch(url, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: { "content-type": "application/json" },
|
|
372
|
+
body: JSON.stringify(payload),
|
|
373
|
+
signal: AbortSignal.timeout(3e4)
|
|
374
|
+
});
|
|
375
|
+
if (response.ok === false) {
|
|
376
|
+
logger.warn(`gateway telegram sendMessage failed with http ${response.status}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function api_url(token, method) {
|
|
380
|
+
return `https://api.telegram.org/bot${token}/${method}`;
|
|
381
|
+
}
|
|
382
|
+
function split_text(text, limit) {
|
|
383
|
+
const chunks = [];
|
|
384
|
+
let rest = text;
|
|
385
|
+
while (rest.length > limit) {
|
|
386
|
+
const window = rest.slice(0, limit + 1);
|
|
387
|
+
const newline = window.lastIndexOf("\n");
|
|
388
|
+
const space = window.lastIndexOf(" ");
|
|
389
|
+
const cut = newline > 0 ? newline : space > 0 ? space : limit;
|
|
390
|
+
chunks.push(rest.slice(0, cut));
|
|
391
|
+
rest = rest.slice(cut).trimStart();
|
|
392
|
+
}
|
|
393
|
+
if (rest.length > 0) {
|
|
394
|
+
chunks.push(rest);
|
|
395
|
+
}
|
|
396
|
+
return chunks;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/gateway/twitch.ts
|
|
400
|
+
var TWITCH_IRC_URL = "wss://irc-ws.chat.twitch.tv:443";
|
|
401
|
+
var TWITCH_MESSAGE_CAP = 512;
|
|
402
|
+
function create_twitch_adapter(params) {
|
|
403
|
+
const twitch = read_twitch_env();
|
|
404
|
+
if (twitch === void 0) {
|
|
405
|
+
return create_idle_adapter("twitch", "LICH_TWITCH_OAUTH_TOKEN / NICK not set");
|
|
406
|
+
}
|
|
407
|
+
let running = false;
|
|
408
|
+
let socket;
|
|
409
|
+
return {
|
|
410
|
+
name: "twitch",
|
|
411
|
+
start: async () => {
|
|
412
|
+
running = true;
|
|
413
|
+
void irc_loop(params, twitch, () => running, (opened) => {
|
|
414
|
+
socket = opened;
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
stop: async () => {
|
|
418
|
+
running = false;
|
|
419
|
+
socket?.close();
|
|
420
|
+
socket = void 0;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function read_twitch_env() {
|
|
425
|
+
const raw_token = process.env.LICH_TWITCH_OAUTH_TOKEN;
|
|
426
|
+
const nick = process.env.LICH_TWITCH_NICK;
|
|
427
|
+
const channels_env = process.env.LICH_TWITCH_CHANNELS;
|
|
428
|
+
if (raw_token === void 0 || raw_token.length === 0 || nick === void 0 || nick.length === 0) {
|
|
429
|
+
return void 0;
|
|
430
|
+
}
|
|
431
|
+
const token = raw_token.startsWith("oauth:") === true ? raw_token : `oauth:${raw_token}`;
|
|
432
|
+
const channels = (channels_env ?? "").split(",").map((channel) => channel.trim().toLowerCase()).filter((channel) => channel.length > 0);
|
|
433
|
+
if (channels.length === 0) {
|
|
434
|
+
return void 0;
|
|
435
|
+
}
|
|
436
|
+
return { token, nick, channels };
|
|
437
|
+
}
|
|
438
|
+
async function irc_loop(params, twitch, keep_running, set_socket) {
|
|
439
|
+
while (keep_running()) {
|
|
440
|
+
try {
|
|
441
|
+
const socket = await open_socket(TWITCH_IRC_URL);
|
|
442
|
+
set_socket(socket);
|
|
443
|
+
await irc_session(params, twitch, socket, keep_running);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
logger.warn("gateway twitch connection failed; reconnecting in 5s", error);
|
|
446
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function irc_session(params, twitch, socket, keep_running) {
|
|
451
|
+
const closed = new Promise((resolve) => {
|
|
452
|
+
socket.onclose = () => resolve();
|
|
453
|
+
});
|
|
454
|
+
socket.onmessage = (event) => {
|
|
455
|
+
for (const line of String(event.data).split("\r\n")) {
|
|
456
|
+
void handle_irc_line(params, twitch, socket, line);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
socket.send("CAP REQ :twitch.tv/tags twitch.tv/commands");
|
|
460
|
+
socket.send(`PASS ${twitch.token}`);
|
|
461
|
+
socket.send(`NICK ${twitch.nick}`);
|
|
462
|
+
for (const channel of twitch.channels) {
|
|
463
|
+
socket.send(`JOIN #${channel}`);
|
|
464
|
+
}
|
|
465
|
+
await closed;
|
|
466
|
+
}
|
|
467
|
+
async function handle_irc_line(params, twitch, socket, line) {
|
|
468
|
+
if (line.length === 0) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const parsed = parse_irc_line(line);
|
|
472
|
+
if (parsed.kind === "ping") {
|
|
473
|
+
socket.send("PONG :tmi.twitch.tv");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (parsed.kind !== "privmsg" || parsed.user === twitch.nick) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const reply = await run_inbound_message(
|
|
480
|
+
params.handle_message,
|
|
481
|
+
"twitch",
|
|
482
|
+
parsed.channel,
|
|
483
|
+
parsed.user,
|
|
484
|
+
parsed.text
|
|
485
|
+
);
|
|
486
|
+
send_twitch_message(socket, parsed.channel, reply);
|
|
487
|
+
}
|
|
488
|
+
function send_twitch_message(socket, channel, text) {
|
|
489
|
+
for (const chunk of split_chunks2(text, TWITCH_MESSAGE_CAP)) {
|
|
490
|
+
socket.send(`PRIVMSG #${channel} :${chunk}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function parse_irc_line(line) {
|
|
494
|
+
if (line.startsWith("PING") === true) {
|
|
495
|
+
return { kind: "ping", channel: "", user: "", text: "" };
|
|
496
|
+
}
|
|
497
|
+
const privmsg = match_privmsg(line);
|
|
498
|
+
if (privmsg === void 0) {
|
|
499
|
+
return { kind: "other", channel: "", user: "", text: "" };
|
|
500
|
+
}
|
|
501
|
+
return privmsg;
|
|
502
|
+
}
|
|
503
|
+
function match_privmsg(line) {
|
|
504
|
+
const without_tags = line.startsWith("@") === true ? line.slice(line.indexOf(" ") + 1) : line;
|
|
505
|
+
const body = / PRIVMSG #([\w]+) :/.exec(without_tags);
|
|
506
|
+
if (body === null || body.index < 0) {
|
|
507
|
+
return void 0;
|
|
508
|
+
}
|
|
509
|
+
const prefix = without_tags.slice(0, body.index);
|
|
510
|
+
const ident = prefix.startsWith(":") === true ? prefix.slice(1) : prefix;
|
|
511
|
+
const user = ident.slice(ident.lastIndexOf("!") + 1).split("@")[0] ?? "";
|
|
512
|
+
const channel = body[1] ?? "";
|
|
513
|
+
const text = without_tags.slice(body.index + body[0].length);
|
|
514
|
+
return { kind: "privmsg", channel, user, text };
|
|
515
|
+
}
|
|
516
|
+
function split_chunks2(text, limit) {
|
|
517
|
+
const chunks = [];
|
|
518
|
+
let rest = text;
|
|
519
|
+
while (rest.length > limit) {
|
|
520
|
+
chunks.push(rest.slice(0, limit));
|
|
521
|
+
rest = rest.slice(limit);
|
|
522
|
+
}
|
|
523
|
+
if (rest.length > 0) {
|
|
524
|
+
chunks.push(rest);
|
|
525
|
+
}
|
|
526
|
+
return chunks;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// src/gateway/webhook.ts
|
|
530
|
+
import { createServer } from "http";
|
|
531
|
+
|
|
532
|
+
// src/gateway/format.ts
|
|
533
|
+
var USAGE_FOOTER_PREFIX = "\n\n_tokens: ";
|
|
534
|
+
function format_agent_reply(text, usage, platform) {
|
|
535
|
+
if (platform === "webhook") {
|
|
536
|
+
return JSON.stringify({ reply: text, usage: usage ?? null });
|
|
537
|
+
}
|
|
538
|
+
const total_tokens = usage?.total_tokens ?? 0;
|
|
539
|
+
if (total_tokens > 0) {
|
|
540
|
+
return `${text}${USAGE_FOOTER_PREFIX}${total_tokens}_`;
|
|
541
|
+
}
|
|
542
|
+
return text;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/gateway/webhook.ts
|
|
546
|
+
var DEFAULT_GATEWAY_PORT = 8089;
|
|
547
|
+
function create_webhook_adapter(params) {
|
|
548
|
+
const port = params.port ?? read_port_env() ?? DEFAULT_GATEWAY_PORT;
|
|
549
|
+
const token = process.env.LICH_GATEWAY_TOKEN;
|
|
550
|
+
let server;
|
|
551
|
+
return {
|
|
552
|
+
name: "webhook",
|
|
553
|
+
start: async () => {
|
|
554
|
+
server = createServer((request, response) => {
|
|
555
|
+
void dispatch_webhook(params, request, response, token);
|
|
556
|
+
});
|
|
557
|
+
await listen_on(server, port, (bound) => {
|
|
558
|
+
params.on_listening?.(bound);
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
stop: async () => {
|
|
562
|
+
await close_server(server);
|
|
563
|
+
server = void 0;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
async function dispatch_webhook(params, request, response, token) {
|
|
568
|
+
try {
|
|
569
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
570
|
+
send_json(response, 200, { status: "ok" });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (request.method !== "POST" || request.url !== "/message") {
|
|
574
|
+
send_json(response, 404, { error: "not found" });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (token !== void 0 && request.headers["x-lich-token"] !== token) {
|
|
578
|
+
send_json(response, 401, { error: "unauthorized" });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
await handle_message_post(params, request, response);
|
|
582
|
+
} catch (error) {
|
|
583
|
+
logger.error("gateway webhook request failed", error);
|
|
584
|
+
if (response.headersSent === false) {
|
|
585
|
+
send_json(response, 500, { error: "internal error" });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function handle_message_post(params, request, response) {
|
|
590
|
+
const body = await read_body(request);
|
|
591
|
+
const payload = parse_payload2(body);
|
|
592
|
+
const text = payload.text;
|
|
593
|
+
if (text === void 0) {
|
|
594
|
+
send_json(response, 400, { error: "text is required" });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const platform = payload.platform ?? "webhook";
|
|
598
|
+
const chat_id = payload.chat_id ?? "default";
|
|
599
|
+
const user_id = payload.user_id ?? "anonymous";
|
|
600
|
+
const reply = await params.handle_message(String(platform), String(chat_id), String(user_id), String(text));
|
|
601
|
+
respond_json_text(response, 200, format_agent_reply(reply ?? "", void 0, "webhook"));
|
|
602
|
+
}
|
|
603
|
+
function read_body(request) {
|
|
604
|
+
return new Promise((resolve, reject) => {
|
|
605
|
+
let body = "";
|
|
606
|
+
request.on("data", (chunk) => {
|
|
607
|
+
body += chunk.toString("utf8");
|
|
608
|
+
});
|
|
609
|
+
request.on("end", () => resolve(body));
|
|
610
|
+
request.on("error", reject);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
function parse_payload2(body) {
|
|
614
|
+
if (body.trim().length === 0) {
|
|
615
|
+
return {};
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
const parsed = JSON.parse(body);
|
|
619
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
620
|
+
return {};
|
|
621
|
+
}
|
|
622
|
+
return parsed;
|
|
623
|
+
} catch {
|
|
624
|
+
return {};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function send_json(response, status, payload) {
|
|
628
|
+
respond_json_text(response, status, JSON.stringify(payload));
|
|
629
|
+
}
|
|
630
|
+
function respond_json_text(response, status, json_text) {
|
|
631
|
+
response.statusCode = status;
|
|
632
|
+
response.setHeader("content-type", "application/json");
|
|
633
|
+
response.end(json_text);
|
|
634
|
+
}
|
|
635
|
+
function listen_on(server, port, on_listening) {
|
|
636
|
+
return new Promise((resolve, reject) => {
|
|
637
|
+
server.once("error", reject);
|
|
638
|
+
server.listen(port, "0.0.0.0", () => {
|
|
639
|
+
const bound = server.address()?.port ?? port;
|
|
640
|
+
logger.info(`gateway webhook listening on :${bound}`);
|
|
641
|
+
on_listening(bound);
|
|
642
|
+
resolve();
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
async function close_server(server) {
|
|
647
|
+
if (server === void 0) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
await new Promise((resolve) => {
|
|
651
|
+
server.close(() => resolve());
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function read_port_env() {
|
|
655
|
+
const raw = process.env.LICH_GATEWAY_PORT;
|
|
656
|
+
if (raw === void 0 || raw.length === 0) {
|
|
657
|
+
return void 0;
|
|
658
|
+
}
|
|
659
|
+
const port = Number(raw);
|
|
660
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// src/gateway/runner.ts
|
|
664
|
+
async function run_gateway(config, platforms) {
|
|
665
|
+
const valid = [];
|
|
666
|
+
for (const platform of platforms) {
|
|
667
|
+
if (is_known_platform(platform) === true) {
|
|
668
|
+
valid.push(platform);
|
|
669
|
+
} else {
|
|
670
|
+
process.stderr.write(`lich: unknown gateway platform "${platform}" (skipping)
|
|
671
|
+
`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (valid.length === 0) {
|
|
675
|
+
process.stderr.write("lich: gateway needs at least one valid platform (webhook|telegram|discord|twitch)\n");
|
|
676
|
+
return 1;
|
|
677
|
+
}
|
|
678
|
+
const bus = new GatewayBus({ config, agent_factory: () => create_agent(config), wire_tool_logging: true });
|
|
679
|
+
const adapters = build_adapters(config, bus, valid);
|
|
680
|
+
install_signal_handlers(bus, adapters);
|
|
681
|
+
logger.info(`gateway starting: platforms=${valid.join(",")}, port=${process.env.LICH_GATEWAY_PORT ?? "8089"}, pid=${process.pid}`);
|
|
682
|
+
await start_all_adapters(adapters);
|
|
683
|
+
return await new Promise(() => void 0);
|
|
684
|
+
}
|
|
685
|
+
function is_known_platform(platform) {
|
|
686
|
+
return platform === "webhook" || platform === "telegram" || platform === "discord" || platform === "twitch";
|
|
687
|
+
}
|
|
688
|
+
function build_adapters(config, bus, platforms) {
|
|
689
|
+
const params = {
|
|
690
|
+
config,
|
|
691
|
+
handle_message: (platform, chat_id, user_id, text) => bus.handle(platform, chat_id, user_id, text),
|
|
692
|
+
get_agent: () => {
|
|
693
|
+
throw new Error("get_agent is reserved for future use");
|
|
694
|
+
},
|
|
695
|
+
reply_router: () => void 0
|
|
696
|
+
};
|
|
697
|
+
const adapters = [];
|
|
698
|
+
for (const platform of platforms) {
|
|
699
|
+
const adapter = create_platform_adapter(params, platform);
|
|
700
|
+
if (adapter !== void 0) {
|
|
701
|
+
adapters.push(adapter);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return adapters;
|
|
705
|
+
}
|
|
706
|
+
function create_platform_adapter(params, platform) {
|
|
707
|
+
switch (platform) {
|
|
708
|
+
case "webhook":
|
|
709
|
+
return create_webhook_adapter({ ...params, port: read_webhook_port() });
|
|
710
|
+
case "telegram":
|
|
711
|
+
return create_telegram_adapter(params);
|
|
712
|
+
case "discord":
|
|
713
|
+
return create_discord_adapter(params);
|
|
714
|
+
case "twitch":
|
|
715
|
+
return create_twitch_adapter(params);
|
|
716
|
+
default:
|
|
717
|
+
return void 0;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function read_webhook_port() {
|
|
721
|
+
const raw = process.env.LICH_GATEWAY_PORT;
|
|
722
|
+
if (raw === void 0 || raw.length === 0) {
|
|
723
|
+
return void 0;
|
|
724
|
+
}
|
|
725
|
+
const port = Number(raw);
|
|
726
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
727
|
+
}
|
|
728
|
+
async function start_all_adapters(adapters) {
|
|
729
|
+
for (const adapter of adapters) {
|
|
730
|
+
try {
|
|
731
|
+
await adapter.start();
|
|
732
|
+
logger.info(`gateway adapter started: ${adapter.name}`);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
logger.error(`gateway adapter failed to start: ${adapter.name}`, error);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function install_signal_handlers(bus, adapters) {
|
|
739
|
+
const shutdown = () => {
|
|
740
|
+
for (const adapter of adapters) {
|
|
741
|
+
void adapter.stop().catch((error) => logger.warn(`gateway adapter stop failed: ${adapter.name}`, error));
|
|
742
|
+
}
|
|
743
|
+
bus.stop();
|
|
744
|
+
process.exit(0);
|
|
745
|
+
};
|
|
746
|
+
process.once("SIGINT", shutdown);
|
|
747
|
+
process.once("SIGTERM", shutdown);
|
|
748
|
+
}
|
|
749
|
+
export {
|
|
750
|
+
run_gateway
|
|
751
|
+
};
|
|
752
|
+
//# sourceMappingURL=gateway-CWPVIU3W.js.map
|