@gamalan/pi-gateway 1.0.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 +366 -0
- package/config/config.default.json +51 -0
- package/dist/adapters/base.d.ts +85 -0
- package/dist/adapters/base.js +34 -0
- package/dist/adapters/base.js.map +1 -0
- package/dist/adapters/discord.d.ts +53 -0
- package/dist/adapters/discord.js +224 -0
- package/dist/adapters/discord.js.map +1 -0
- package/dist/adapters/index.d.ts +13 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/slack.d.ts +49 -0
- package/dist/adapters/slack.js +231 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/adapters/telegram.d.ts +64 -0
- package/dist/adapters/telegram.js +274 -0
- package/dist/adapters/telegram.js.map +1 -0
- package/dist/adapters/twitch.d.ts +75 -0
- package/dist/adapters/twitch.js +222 -0
- package/dist/adapters/twitch.js.map +1 -0
- package/dist/adapters/websocket.d.ts +30 -0
- package/dist/adapters/websocket.js +132 -0
- package/dist/adapters/websocket.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +49 -0
- package/dist/adapters/whatsapp.js +251 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/background/index.d.ts +1 -0
- package/dist/background/index.js +2 -0
- package/dist/background/index.js.map +1 -0
- package/dist/background/manager.d.ts +70 -0
- package/dist/background/manager.js +291 -0
- package/dist/background/manager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +1275 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +32 -0
- package/dist/paths.js.map +1 -0
- package/dist/security/auth.d.ts +91 -0
- package/dist/security/auth.js +316 -0
- package/dist/security/auth.js.map +1 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/tool-policy.d.ts +66 -0
- package/dist/security/tool-policy.js +467 -0
- package/dist/security/tool-policy.js.map +1 -0
- package/dist/sessions/index.d.ts +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/store.d.ts +64 -0
- package/dist/sessions/store.js +247 -0
- package/dist/sessions/store.js.map +1 -0
- package/package.json +57 -0
- package/src/adapters/base.ts +124 -0
- package/src/adapters/discord.ts +270 -0
- package/src/adapters/index.ts +13 -0
- package/src/adapters/slack.ts +313 -0
- package/src/adapters/telegram.ts +394 -0
- package/src/adapters/twitch.ts +316 -0
- package/src/adapters/websocket.ts +158 -0
- package/src/adapters/whatsapp.ts +296 -0
- package/src/background/index.ts +1 -0
- package/src/background/manager.ts +395 -0
- package/src/index.ts +1665 -0
- package/src/logger.ts +43 -0
- package/src/paths.ts +40 -0
- package/src/security/auth.ts +458 -0
- package/src/security/index.ts +1 -0
- package/src/security/tool-policy.ts +568 -0
- package/src/sessions/index.ts +1 -0
- package/src/sessions/store.ts +360 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-gateway - Hermes-style Messaging Gateway
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - Single background process
|
|
6
|
+
* - Platform adapters (Discord, Telegram, etc.)
|
|
7
|
+
* - Per-chat session management
|
|
8
|
+
* - Background task support
|
|
9
|
+
* - Security (allowlists, pairing)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* /gateway start [port] - Start the gateway
|
|
13
|
+
* /gateway stop - Stop the gateway
|
|
14
|
+
* /gateway status - Show status
|
|
15
|
+
* /gateway pair <code> - Approve pairing code
|
|
16
|
+
*/
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { existsSync, readFileSync, copyFileSync, mkdirSync } from "node:fs";
|
|
19
|
+
import { createServer, } from "node:http";
|
|
20
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { Type } from "@sinclair/typebox";
|
|
24
|
+
import { initSessionStore, getOrCreateSession, listSessions, touchSession, } from "./sessions/store.js";
|
|
25
|
+
import { logger } from "./logger.js";
|
|
26
|
+
import { GATEWAY_CONFIG_DIR, GATEWAY_CONFIG_FILE, getPackageRoot, } from "./paths.js";
|
|
27
|
+
import { initSecurityStore, isUserAllowed, approvePairingCode, generatePairingCode, listPendingPairingCodes, addToAllowlist, listAllowlistedUsers, revokeUserAccess, addAdmin, removeAdmin, listAdmins, } from "./security/auth.js";
|
|
28
|
+
import { setToolPolicy, removeToolPolicy, listToolPolicies, resetToolPolicies, getEffectivePolicySummary, buildPolicyGuard, } from "./security/tool-policy.js";
|
|
29
|
+
import { initBackgroundTasks, startBackgroundTask, getPendingResultsForSession, markTaskDelivered, listTasks, } from "./background/manager.js";
|
|
30
|
+
import { DiscordAdapter } from "./adapters/discord.js";
|
|
31
|
+
import { TwitchAdapter } from "./adapters/twitch.js";
|
|
32
|
+
import { TelegramAdapter } from "./adapters/telegram.js";
|
|
33
|
+
import { SlackAdapter } from "./adapters/slack.js";
|
|
34
|
+
import { WhatsAppAdapter } from "./adapters/whatsapp.js";
|
|
35
|
+
const DEFAULT_CONFIG = {
|
|
36
|
+
port: 3847,
|
|
37
|
+
host: "localhost",
|
|
38
|
+
tokens: [],
|
|
39
|
+
corsOrigins: ["*"],
|
|
40
|
+
enableWebSocket: true,
|
|
41
|
+
enableHttp: true,
|
|
42
|
+
security: {
|
|
43
|
+
allowAll: true,
|
|
44
|
+
requirePairing: false,
|
|
45
|
+
allowedUids: {},
|
|
46
|
+
adminUids: {},
|
|
47
|
+
rateLimit: { maxRequests: 60, windowMs: 60000 },
|
|
48
|
+
},
|
|
49
|
+
sessions: {
|
|
50
|
+
resetPolicy: "idle",
|
|
51
|
+
dailyHour: 4,
|
|
52
|
+
idleMinutes: 1440,
|
|
53
|
+
},
|
|
54
|
+
platforms: {},
|
|
55
|
+
};
|
|
56
|
+
let config;
|
|
57
|
+
let state;
|
|
58
|
+
let server = null;
|
|
59
|
+
let wss = null;
|
|
60
|
+
let globalCtx = null;
|
|
61
|
+
let rpcProcess = null;
|
|
62
|
+
let cronInterval = null;
|
|
63
|
+
const pendingRequests = [];
|
|
64
|
+
const pendingCompletions = [];
|
|
65
|
+
// Load/save config
|
|
66
|
+
function loadConfig() {
|
|
67
|
+
try {
|
|
68
|
+
if (existsSync(GATEWAY_CONFIG_FILE)) {
|
|
69
|
+
return {
|
|
70
|
+
...DEFAULT_CONFIG,
|
|
71
|
+
...JSON.parse(readFileSync(GATEWAY_CONFIG_FILE, "utf-8")),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// No config file yet — seed from default template
|
|
75
|
+
const packageRoot = getPackageRoot(import.meta.url);
|
|
76
|
+
const defaultConfigPath = join(packageRoot, "config", "config.default.json");
|
|
77
|
+
if (existsSync(defaultConfigPath)) {
|
|
78
|
+
if (!existsSync(GATEWAY_CONFIG_DIR)) {
|
|
79
|
+
mkdirSync(GATEWAY_CONFIG_DIR, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
copyFileSync(defaultConfigPath, GATEWAY_CONFIG_FILE);
|
|
82
|
+
logger.info("[gateway] Seeded default config at", GATEWAY_CONFIG_FILE);
|
|
83
|
+
return {
|
|
84
|
+
...DEFAULT_CONFIG,
|
|
85
|
+
...JSON.parse(readFileSync(GATEWAY_CONFIG_FILE, "utf-8")),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
logger.error("[gateway] Failed to parse config file — using defaults. Error:", err);
|
|
91
|
+
}
|
|
92
|
+
return { ...DEFAULT_CONFIG };
|
|
93
|
+
}
|
|
94
|
+
// Token auth
|
|
95
|
+
function verifyToken(token) {
|
|
96
|
+
if (config.tokens.length === 0)
|
|
97
|
+
return true;
|
|
98
|
+
return config.tokens.includes(token);
|
|
99
|
+
}
|
|
100
|
+
function authenticate(req) {
|
|
101
|
+
const auth = req.headers.authorization;
|
|
102
|
+
if (!auth)
|
|
103
|
+
return verifyToken("");
|
|
104
|
+
if (auth.startsWith("Bearer "))
|
|
105
|
+
return verifyToken(auth.slice(7));
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
// WebSocket helpers
|
|
109
|
+
function sendWs(ws, msg) {
|
|
110
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
111
|
+
ws.send(JSON.stringify(msg));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function broadcastClients(event, data) {
|
|
115
|
+
for (const ws of state.clients.values()) {
|
|
116
|
+
sendWs(ws, { type: event, data });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// RPC to pi agent
|
|
120
|
+
function createRpcProcess() {
|
|
121
|
+
const proc = spawn("pi", ["--mode", "rpc"], {
|
|
122
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
123
|
+
env: {
|
|
124
|
+
...process.env,
|
|
125
|
+
OLLAMA_HOST: process.env.OLLAMA_HOST || "localhost:11434",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
proc.stdout?.on("data", (data) => {
|
|
129
|
+
const lines = data.toString().split("\n").filter(Boolean);
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
try {
|
|
132
|
+
const msg = JSON.parse(line);
|
|
133
|
+
if (msg.id) {
|
|
134
|
+
const idx = pendingRequests.findIndex((r) => r.id === msg.id);
|
|
135
|
+
if (idx !== -1) {
|
|
136
|
+
const req = pendingRequests.splice(idx, 1)[0];
|
|
137
|
+
req.resolve(msg);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// agent_end carries the full response — resolve pending completions
|
|
141
|
+
if (msg.type === "agent_end") {
|
|
142
|
+
const text = extractAgentEndText(msg);
|
|
143
|
+
logger.info(`[gateway] agent_end received, text length: ${text.length}`);
|
|
144
|
+
const completion = pendingCompletions.shift();
|
|
145
|
+
if (completion) {
|
|
146
|
+
clearTimeout(completion.timer);
|
|
147
|
+
completion.resolve(text);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Broadcast events
|
|
151
|
+
if (msg.type === "response") {
|
|
152
|
+
broadcastClients("response", msg);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
broadcastClients("event", msg);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* not JSON */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
proc.stderr?.on("data", (data) => {
|
|
164
|
+
logger.info("[gateway] pi stderr:", data.toString().trim());
|
|
165
|
+
});
|
|
166
|
+
proc.on("exit", (code) => {
|
|
167
|
+
logger.info("[gateway] pi process exited");
|
|
168
|
+
// Reject any pending completions so they don't hang forever
|
|
169
|
+
while (pendingCompletions.length > 0) {
|
|
170
|
+
const completion = pendingCompletions.shift();
|
|
171
|
+
clearTimeout(completion.timer);
|
|
172
|
+
completion.reject(new Error(`pi process exited with code ${code}`));
|
|
173
|
+
}
|
|
174
|
+
rpcProcess = null;
|
|
175
|
+
broadcastClients("agent_disconnected", { code });
|
|
176
|
+
});
|
|
177
|
+
return proc;
|
|
178
|
+
}
|
|
179
|
+
async function sendRpc(command, data = {}) {
|
|
180
|
+
if (!rpcProcess)
|
|
181
|
+
throw new Error("pi agent not running");
|
|
182
|
+
const id = randomBytes(8).toString("hex");
|
|
183
|
+
const payload = { id, type: command, ...data };
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
pendingRequests.push({ id, resolve, reject });
|
|
186
|
+
try {
|
|
187
|
+
rpcProcess.stdin.write(JSON.stringify(payload) + "\n");
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const idx = pendingRequests.findIndex((r) => r.id === id);
|
|
191
|
+
if (idx !== -1)
|
|
192
|
+
pendingRequests.splice(idx, 1);
|
|
193
|
+
reject(err);
|
|
194
|
+
}
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
const idx = pendingRequests.findIndex((r) => r.id === id);
|
|
197
|
+
if (idx !== -1) {
|
|
198
|
+
pendingRequests.splice(idx, 1);
|
|
199
|
+
reject(new Error("Request timeout"));
|
|
200
|
+
}
|
|
201
|
+
}, 30000);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Extract assistant response text from agent_end.messages
|
|
205
|
+
function extractAgentEndText(agentEndMsg) {
|
|
206
|
+
const messages = agentEndMsg.messages;
|
|
207
|
+
if (!messages)
|
|
208
|
+
return "";
|
|
209
|
+
const parts = [];
|
|
210
|
+
for (const msg of messages) {
|
|
211
|
+
if (msg.role === "assistant") {
|
|
212
|
+
const content = msg.content;
|
|
213
|
+
if (Array.isArray(content)) {
|
|
214
|
+
for (const block of content) {
|
|
215
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
216
|
+
parts.push(block.text);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return parts.join("\n");
|
|
223
|
+
}
|
|
224
|
+
// Send a prompt to pi and wait for agent_end to get the full response text.
|
|
225
|
+
// Unlike sendRpc (which resolves with the ACK), this resolves with the
|
|
226
|
+
// actual assistant response text after the agent finishes processing.
|
|
227
|
+
async function sendPromptRpc(message) {
|
|
228
|
+
if (!rpcProcess)
|
|
229
|
+
throw new Error("pi agent not running");
|
|
230
|
+
// Send the prompt and wait for the ACK (so we know the prompt was accepted)
|
|
231
|
+
const ackResponse = await sendRpc("prompt", { message });
|
|
232
|
+
const ack = ackResponse;
|
|
233
|
+
if (!ack.success) {
|
|
234
|
+
throw new Error(`Prompt rejected: ${JSON.stringify(ackResponse)}`);
|
|
235
|
+
}
|
|
236
|
+
logger.info("[gateway] Prompt ACK received, waiting for agent_end...");
|
|
237
|
+
// Wait for agent_end to deliver the full response
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const timer = setTimeout(() => {
|
|
240
|
+
const idx = pendingCompletions.findIndex((c) => c.timer === timer);
|
|
241
|
+
if (idx !== -1)
|
|
242
|
+
pendingCompletions.splice(idx, 1);
|
|
243
|
+
reject(new Error("Prompt completion timeout — no agent_end received within 5 minutes"));
|
|
244
|
+
}, 300000); // 5 minutes
|
|
245
|
+
pendingCompletions.push({ resolve, reject, timer });
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// Extract response text from pi RPC response (kept for backward compat with old sendRpc)
|
|
249
|
+
// pi responds with JSON lines on stdout;
|
|
250
|
+
// the response shape varies but typically contains `text` or `content`
|
|
251
|
+
function extractRpcResponseText(response) {
|
|
252
|
+
if (typeof response === "string")
|
|
253
|
+
return response;
|
|
254
|
+
const r = response;
|
|
255
|
+
if (r.text && typeof r.text === "string")
|
|
256
|
+
return r.text;
|
|
257
|
+
if (r.content) {
|
|
258
|
+
// OpenAI-style content array: [{ type: "text", text: "..." }]
|
|
259
|
+
if (Array.isArray(r.content)) {
|
|
260
|
+
return r.content
|
|
261
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
262
|
+
.map((c) => c.text)
|
|
263
|
+
.join("\n");
|
|
264
|
+
}
|
|
265
|
+
if (typeof r.content === "string")
|
|
266
|
+
return r.content;
|
|
267
|
+
}
|
|
268
|
+
// Fallback — the response might be something we don't recognise
|
|
269
|
+
return "";
|
|
270
|
+
}
|
|
271
|
+
// Platform adapter callbacks
|
|
272
|
+
const adapterCallbacks = {
|
|
273
|
+
onMessage: async (message) => {
|
|
274
|
+
// Get or create session for this chat
|
|
275
|
+
const session = getOrCreateSession(message.platform, message.channelId, message.userId, {
|
|
276
|
+
resetPolicy: config.sessions.resetPolicy,
|
|
277
|
+
dailyHour: config.sessions.dailyHour,
|
|
278
|
+
idleMinutes: config.sessions.idleMinutes,
|
|
279
|
+
});
|
|
280
|
+
// Check allowlist
|
|
281
|
+
if (!isUserAllowed(message.platform, message.userId)) {
|
|
282
|
+
logger.info(`[gateway] User ${message.userId} not in allowlist`);
|
|
283
|
+
// Could send a DM here about pairing flow
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Store session reference
|
|
287
|
+
state.sessions.set(`${message.platform}:${message.channelId}`, session);
|
|
288
|
+
// Send to pi agent with tool policy guard
|
|
289
|
+
if (rpcProcess) {
|
|
290
|
+
const guard = buildPolicyGuard(message.platform, message.userId);
|
|
291
|
+
try {
|
|
292
|
+
logger.info(`[gateway] Sending prompt from ${message.platform}/${message.userId} (session: ${session.id.slice(0, 12)}...)`);
|
|
293
|
+
// Use sendPromptRpc which waits for agent_end (the real response),
|
|
294
|
+
// NOT the ACK which has no content
|
|
295
|
+
const responseText = await sendPromptRpc(`${guard}\n\n${message.content}`);
|
|
296
|
+
logger.info(`[gateway] Response received, length: ${responseText.length}, sending back to ${message.platform}/${message.channelId}`);
|
|
297
|
+
if (responseText) {
|
|
298
|
+
const adapter = state.adapters.get(message.platform);
|
|
299
|
+
if (adapter) {
|
|
300
|
+
await adapter.sendMessage(message.channelId, responseText);
|
|
301
|
+
logger.info("[gateway] Response sent to platform successfully");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
logger.warn("[gateway] Response text was empty — nothing to send");
|
|
306
|
+
const adapter = state.adapters.get(message.platform);
|
|
307
|
+
if (adapter) {
|
|
308
|
+
await adapter.sendMessage(message.channelId, "I processed your message but had no text response. Please try again.");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
logger.error("[gateway] RPC error processing message:", err);
|
|
314
|
+
const adapter = state.adapters.get(message.platform);
|
|
315
|
+
if (adapter) {
|
|
316
|
+
try {
|
|
317
|
+
await adapter.sendMessage(message.channelId, "Sorry, I encountered an error processing your message. Please try again.");
|
|
318
|
+
}
|
|
319
|
+
catch (sendErr) {
|
|
320
|
+
logger.error("[gateway] Failed to send error message:", sendErr);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
logger.warn("[gateway] pi agent not running — cannot process message");
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
onDisconnect: () => {
|
|
330
|
+
logger.info("[gateway] Platform adapter disconnected");
|
|
331
|
+
updateStatus();
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
// Initialize platform adapters
|
|
335
|
+
async function initializeAdapters() {
|
|
336
|
+
// Discord
|
|
337
|
+
if (config.platforms.discord?.enabled && config.platforms.discord.botToken) {
|
|
338
|
+
try {
|
|
339
|
+
const discord = new DiscordAdapter({
|
|
340
|
+
enabled: true,
|
|
341
|
+
platform: "discord",
|
|
342
|
+
botToken: config.platforms.discord.botToken,
|
|
343
|
+
guildId: config.platforms.discord.guildId,
|
|
344
|
+
});
|
|
345
|
+
await discord.initialize();
|
|
346
|
+
await discord.start(adapterCallbacks);
|
|
347
|
+
state.adapters.set("discord", discord);
|
|
348
|
+
logger.info("[gateway] Discord adapter started");
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
logger.error("[gateway] Failed to start Discord adapter:", err);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Twitch
|
|
355
|
+
if (config.platforms.twitch?.enabled &&
|
|
356
|
+
config.platforms.twitch.clientId &&
|
|
357
|
+
config.platforms.twitch.clientSecret) {
|
|
358
|
+
try {
|
|
359
|
+
const twitch = new TwitchAdapter({
|
|
360
|
+
enabled: true,
|
|
361
|
+
platform: "twitch",
|
|
362
|
+
clientId: config.platforms.twitch.clientId,
|
|
363
|
+
clientSecret: config.platforms.twitch.clientSecret,
|
|
364
|
+
channels: config.platforms.twitch.channels,
|
|
365
|
+
});
|
|
366
|
+
await twitch.initialize();
|
|
367
|
+
await twitch.start(adapterCallbacks);
|
|
368
|
+
state.adapters.set("twitch", twitch);
|
|
369
|
+
logger.info("[gateway] Twitch adapter started");
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
logger.error("[gateway] Failed to start Twitch adapter:", err);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Telegram
|
|
376
|
+
if (config.platforms.telegram?.enabled && config.platforms.telegram.token) {
|
|
377
|
+
try {
|
|
378
|
+
const telegram = new TelegramAdapter({
|
|
379
|
+
enabled: true,
|
|
380
|
+
platform: "telegram",
|
|
381
|
+
token: config.platforms.telegram.token,
|
|
382
|
+
webhookUrl: config.platforms.telegram.webhookUrl,
|
|
383
|
+
});
|
|
384
|
+
await telegram.initialize();
|
|
385
|
+
await telegram.start(adapterCallbacks);
|
|
386
|
+
state.adapters.set("telegram", telegram);
|
|
387
|
+
logger.info("[gateway] Telegram adapter started");
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
logger.error("[gateway] Failed to start Telegram adapter:", err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Slack
|
|
394
|
+
if (config.platforms.slack?.enabled &&
|
|
395
|
+
(config.platforms.slack.webhookUrl || config.platforms.slack.botToken)) {
|
|
396
|
+
try {
|
|
397
|
+
const slack = new SlackAdapter({
|
|
398
|
+
enabled: true,
|
|
399
|
+
platform: "slack",
|
|
400
|
+
webhookUrl: config.platforms.slack.webhookUrl,
|
|
401
|
+
botToken: config.platforms.slack.botToken,
|
|
402
|
+
});
|
|
403
|
+
await slack.initialize();
|
|
404
|
+
await slack.start(adapterCallbacks);
|
|
405
|
+
state.adapters.set("slack", slack);
|
|
406
|
+
logger.info("[gateway] Slack adapter started");
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
logger.error("[gateway] Failed to start Slack adapter:", err);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// WhatsApp
|
|
413
|
+
if (config.platforms.whatsapp?.enabled) {
|
|
414
|
+
try {
|
|
415
|
+
const whatsapp = new WhatsAppAdapter({
|
|
416
|
+
enabled: true,
|
|
417
|
+
platform: "whatsapp",
|
|
418
|
+
sessionPath: config.platforms.whatsapp.sessionPath,
|
|
419
|
+
printQr: config.platforms.whatsapp.printQr,
|
|
420
|
+
});
|
|
421
|
+
await whatsapp.initialize();
|
|
422
|
+
await whatsapp.start(adapterCallbacks);
|
|
423
|
+
state.adapters.set("whatsapp", whatsapp);
|
|
424
|
+
logger.info("[gateway] WhatsApp adapter started");
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
logger.error("[gateway] Failed to start WhatsApp adapter:", err);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Cron job for background tasks and session cleanup
|
|
432
|
+
function startCron() {
|
|
433
|
+
cronInterval = setInterval(async () => {
|
|
434
|
+
// Check for pending background results
|
|
435
|
+
for (const session of state.sessions.values()) {
|
|
436
|
+
const pending = getPendingResultsForSession(session.id);
|
|
437
|
+
for (const task of pending) {
|
|
438
|
+
// Deliver result to user via their platform
|
|
439
|
+
const adapter = state.adapters.get(session.platform);
|
|
440
|
+
if (adapter) {
|
|
441
|
+
const resultText = task.status === "completed"
|
|
442
|
+
? `✅ Background task completed:\n\`\`\`\n${JSON.stringify(task.result, null, 2)}\n\`\`\``
|
|
443
|
+
: `❌ Background task failed:\n\`\`\`\n${task.error}\n\`\`\``;
|
|
444
|
+
await adapter.sendMessage(session.channelId, resultText);
|
|
445
|
+
markTaskDelivered(task.id);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// Touch active sessions
|
|
450
|
+
for (const session of state.sessions.values()) {
|
|
451
|
+
touchSession(session.id);
|
|
452
|
+
}
|
|
453
|
+
}, 60000); // Every 60 seconds (Hermes-style)
|
|
454
|
+
}
|
|
455
|
+
function stopCron() {
|
|
456
|
+
if (cronInterval) {
|
|
457
|
+
clearInterval(cronInterval);
|
|
458
|
+
cronInterval = null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// HTTP handlers
|
|
462
|
+
async function handleHttpRequest(req, res) {
|
|
463
|
+
res.setHeader("Access-Control-Allow-Origin", config.corsOrigins.join(",") || "*");
|
|
464
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
465
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
466
|
+
if (req.method === "OPTIONS") {
|
|
467
|
+
res.writeHead(204);
|
|
468
|
+
res.end();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// ── Telegram webhook (unauthenticated — called by Telegram) ──
|
|
472
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
473
|
+
if (url.pathname === "/webhook/telegram" && req.method === "POST") {
|
|
474
|
+
const chunks = [];
|
|
475
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
476
|
+
req.on("end", async () => {
|
|
477
|
+
try {
|
|
478
|
+
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
479
|
+
const telegram = state.adapters.get("telegram");
|
|
480
|
+
if (telegram?.handleWebhookUpdate) {
|
|
481
|
+
await telegram.handleWebhookUpdate(body);
|
|
482
|
+
res.writeHead(200);
|
|
483
|
+
res.end("ok");
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
res.writeHead(503);
|
|
487
|
+
res.end("Telegram adapter not running");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
res.writeHead(400);
|
|
492
|
+
res.end("Invalid request");
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (!authenticate(req)) {
|
|
498
|
+
res.writeHead(401);
|
|
499
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
// API endpoints
|
|
503
|
+
if (url.pathname === "/api/status" && req.method === "GET") {
|
|
504
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
505
|
+
res.end(JSON.stringify({
|
|
506
|
+
running: state.running,
|
|
507
|
+
adapters: Array.from(state.adapters.keys()),
|
|
508
|
+
clients: state.clients.size,
|
|
509
|
+
sessions: state.sessions.size,
|
|
510
|
+
agent: rpcProcess !== null,
|
|
511
|
+
}));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
515
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
516
|
+
res.end(JSON.stringify(listSessions()));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (url.pathname === "/api/background" && req.method === "GET") {
|
|
520
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
521
|
+
res.end(JSON.stringify(listTasks()));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (url.pathname === "/api/allowlist" && req.method === "GET") {
|
|
525
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
526
|
+
res.end(JSON.stringify(listAllowlistedUsers()));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (url.pathname === "/api/pairing" && req.method === "GET") {
|
|
530
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
531
|
+
res.end(JSON.stringify(listPendingPairingCodes()));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
res.writeHead(404);
|
|
535
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
536
|
+
}
|
|
537
|
+
// WebSocket handler
|
|
538
|
+
function handleWebSocket(ws, req) {
|
|
539
|
+
if (!authenticate(req)) {
|
|
540
|
+
ws.close(1008, "Unauthorized");
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const clientId = randomBytes(8).toString("hex");
|
|
544
|
+
state.clients.set(clientId, ws);
|
|
545
|
+
logger.info(`[gateway] WebSocket client connected: ${clientId}`);
|
|
546
|
+
sendWs(ws, { type: "connected", data: { clientId } });
|
|
547
|
+
ws.on("message", async (data) => {
|
|
548
|
+
try {
|
|
549
|
+
const msg = JSON.parse(data.toString());
|
|
550
|
+
switch (msg.type) {
|
|
551
|
+
case "prompt": {
|
|
552
|
+
const result = await sendRpc("prompt", {
|
|
553
|
+
message: msg.data?.message || "",
|
|
554
|
+
});
|
|
555
|
+
sendWs(ws, { type: "response", id: msg.id, data: result });
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
case "background": {
|
|
559
|
+
const task = startBackgroundTask(msg.data?.sessionId || "default", msg.data?.command || "");
|
|
560
|
+
sendWs(ws, { type: "background_started", data: task });
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case "ping": {
|
|
564
|
+
sendWs(ws, { type: "pong", data: { time: Date.now() } });
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
sendWs(ws, { type: "error", data: { error: String(err) } });
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
ws.on("close", () => {
|
|
574
|
+
state.clients.delete(clientId);
|
|
575
|
+
logger.info(`[gateway] WebSocket client disconnected: ${clientId}`);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
// Status update
|
|
579
|
+
function updateStatus() {
|
|
580
|
+
if (!globalCtx)
|
|
581
|
+
return;
|
|
582
|
+
const adapterCount = state.adapters.size;
|
|
583
|
+
const statusText = state.running
|
|
584
|
+
? adapterCount > 0
|
|
585
|
+
? `🟢 Gateway (${adapterCount} platform${adapterCount !== 1 ? "s" : ""})`
|
|
586
|
+
: `🟡 Gateway (waiting)`
|
|
587
|
+
: "🔴 Gateway";
|
|
588
|
+
globalCtx.ui.setStatus("gateway", statusText);
|
|
589
|
+
}
|
|
590
|
+
export default function (pi) {
|
|
591
|
+
config = loadConfig();
|
|
592
|
+
state = {
|
|
593
|
+
running: false,
|
|
594
|
+
adapters: new Map(),
|
|
595
|
+
clients: new Map(),
|
|
596
|
+
sessions: new Map(),
|
|
597
|
+
};
|
|
598
|
+
// Initialize stores
|
|
599
|
+
initSessionStore();
|
|
600
|
+
initSecurityStore();
|
|
601
|
+
initBackgroundTasks();
|
|
602
|
+
// Register commands
|
|
603
|
+
pi.registerCommand("gateway", {
|
|
604
|
+
description: "Manage Hermes-style messaging gateway",
|
|
605
|
+
getArgumentCompletions: (prefix) => {
|
|
606
|
+
const cmds = [
|
|
607
|
+
"start",
|
|
608
|
+
"stop",
|
|
609
|
+
"status",
|
|
610
|
+
"restart",
|
|
611
|
+
"pair",
|
|
612
|
+
"allow",
|
|
613
|
+
"revoke",
|
|
614
|
+
"admin",
|
|
615
|
+
"sessions",
|
|
616
|
+
"tasks",
|
|
617
|
+
"config",
|
|
618
|
+
"tool-policy",
|
|
619
|
+
];
|
|
620
|
+
return cmds
|
|
621
|
+
.filter((c) => c.startsWith(prefix))
|
|
622
|
+
.map((c) => ({ value: c, label: c }));
|
|
623
|
+
},
|
|
624
|
+
handler: async (args, ctx) => {
|
|
625
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
626
|
+
const subcmd = parts[0]?.toLowerCase();
|
|
627
|
+
switch (subcmd) {
|
|
628
|
+
case "start": {
|
|
629
|
+
if (state.running) {
|
|
630
|
+
ctx.ui.notify("Gateway already running", "info");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// Reload config fresh on every start so users can edit
|
|
634
|
+
// ~/.pi/gateway/config.json without restarting pi
|
|
635
|
+
config = loadConfig();
|
|
636
|
+
const port = parseInt(parts[1]) || config.port;
|
|
637
|
+
// Start HTTP server
|
|
638
|
+
server = createServer(handleHttpRequest);
|
|
639
|
+
if (config.enableWebSocket) {
|
|
640
|
+
wss = new WebSocketServer({ server });
|
|
641
|
+
wss.on("connection", handleWebSocket);
|
|
642
|
+
}
|
|
643
|
+
server.listen(port, config.host, () => {
|
|
644
|
+
logger.info(`[gateway] HTTP server started on ${config.host}:${port}`);
|
|
645
|
+
});
|
|
646
|
+
// Start pi agent
|
|
647
|
+
rpcProcess = createRpcProcess();
|
|
648
|
+
// Initialize platform adapters
|
|
649
|
+
await initializeAdapters();
|
|
650
|
+
// Start cron
|
|
651
|
+
startCron();
|
|
652
|
+
state.running = true;
|
|
653
|
+
updateStatus();
|
|
654
|
+
ctx.ui.notify(`✅ Gateway started on http://${config.host}:${port}\n\n` +
|
|
655
|
+
`Platforms: ${state.adapters.size > 0 ? Array.from(state.adapters.keys()).join(", ") : "none"}\n` +
|
|
656
|
+
`Sessions: Idle reset every ${config.sessions.idleMinutes} min`, "info");
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
case "stop": {
|
|
660
|
+
if (!state.running) {
|
|
661
|
+
ctx.ui.notify("Gateway not running", "info");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Stop adapters
|
|
665
|
+
for (const adapter of state.adapters.values()) {
|
|
666
|
+
await adapter.stop();
|
|
667
|
+
}
|
|
668
|
+
state.adapters.clear();
|
|
669
|
+
// Stop cron
|
|
670
|
+
stopCron();
|
|
671
|
+
// Close WebSocket clients
|
|
672
|
+
for (const ws of state.clients.values()) {
|
|
673
|
+
ws.close(1000, "Server shutting down");
|
|
674
|
+
}
|
|
675
|
+
state.clients.clear();
|
|
676
|
+
// Stop HTTP server
|
|
677
|
+
server?.close();
|
|
678
|
+
server = null;
|
|
679
|
+
wss = null;
|
|
680
|
+
// Kill pi process
|
|
681
|
+
if (rpcProcess) {
|
|
682
|
+
rpcProcess.kill();
|
|
683
|
+
rpcProcess = null;
|
|
684
|
+
}
|
|
685
|
+
state.running = false;
|
|
686
|
+
updateStatus();
|
|
687
|
+
ctx.ui.notify("Gateway stopped", "info");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
case "restart": {
|
|
691
|
+
if (state.running) {
|
|
692
|
+
// Stop
|
|
693
|
+
for (const adapter of state.adapters.values()) {
|
|
694
|
+
await adapter.stop();
|
|
695
|
+
}
|
|
696
|
+
state.adapters.clear();
|
|
697
|
+
stopCron();
|
|
698
|
+
for (const ws of state.clients.values()) {
|
|
699
|
+
ws.close(1000, "Server restart");
|
|
700
|
+
}
|
|
701
|
+
state.clients.clear();
|
|
702
|
+
server?.close();
|
|
703
|
+
server = null;
|
|
704
|
+
wss = null;
|
|
705
|
+
if (rpcProcess) {
|
|
706
|
+
rpcProcess.kill();
|
|
707
|
+
rpcProcess = null;
|
|
708
|
+
}
|
|
709
|
+
state.running = false;
|
|
710
|
+
}
|
|
711
|
+
// Reload config and start
|
|
712
|
+
config = loadConfig();
|
|
713
|
+
const port = parseInt(parts[1]) || config.port;
|
|
714
|
+
server = createServer(handleHttpRequest);
|
|
715
|
+
if (config.enableWebSocket) {
|
|
716
|
+
wss = new WebSocketServer({ server });
|
|
717
|
+
wss.on("connection", handleWebSocket);
|
|
718
|
+
}
|
|
719
|
+
server.listen(port, config.host, () => {
|
|
720
|
+
logger.info(`[gateway] HTTP server started on ${config.host}:${port}`);
|
|
721
|
+
});
|
|
722
|
+
rpcProcess = createRpcProcess();
|
|
723
|
+
await initializeAdapters();
|
|
724
|
+
startCron();
|
|
725
|
+
state.running = true;
|
|
726
|
+
updateStatus();
|
|
727
|
+
ctx.ui.notify(`✅ Gateway restarted on http://${config.host}:${port}\n\n` +
|
|
728
|
+
`Platforms: ${state.adapters.size > 0 ? Array.from(state.adapters.keys()).join(", ") : "none"}\n` +
|
|
729
|
+
`Sessions: Idle reset every ${config.sessions.idleMinutes} min`, "info");
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
case "status": {
|
|
733
|
+
const lines = [];
|
|
734
|
+
lines.push(`Status: ${state.running ? "🟢 Running" : "🔴 Stopped"}`);
|
|
735
|
+
lines.push(`Port: ${config.port}`);
|
|
736
|
+
lines.push(`Adapters: ${state.adapters.size}`);
|
|
737
|
+
lines.push(`Clients: ${state.clients.size}`);
|
|
738
|
+
lines.push(`Sessions: ${state.sessions.size}`);
|
|
739
|
+
lines.push(`Agent: ${rpcProcess ? "✅ Connected" : "❌ Disconnected"}`);
|
|
740
|
+
lines.push("");
|
|
741
|
+
lines.push(`Session Reset: ${config.sessions.resetPolicy}`);
|
|
742
|
+
lines.push(` - Daily at ${config.sessions.dailyHour}:00`);
|
|
743
|
+
lines.push(` - Idle after ${config.sessions.idleMinutes} min`);
|
|
744
|
+
lines.push("");
|
|
745
|
+
const adminCount = listAdmins().length +
|
|
746
|
+
Object.values(config.security.adminUids ?? {}).reduce((sum, uids) => sum + uids.length, 0);
|
|
747
|
+
lines.push(`Security: ${config.security.allowAll ? "Allow all" : "Allowlist only"}${Object.values(config.security.allowedUids ?? {}).reduce((sum, uids) => sum + uids.length, 0) > 0 ? ` (+${Object.values(config.security.allowedUids ?? {}).reduce((sum, uids) => sum + uids.length, 0)} config UIDs)` : ""}`);
|
|
748
|
+
lines.push(`Admins: ${adminCount}`);
|
|
749
|
+
ctx.ui.setWidget("gateway-status", lines, {
|
|
750
|
+
placement: "belowEditor",
|
|
751
|
+
});
|
|
752
|
+
setTimeout(() => ctx.ui.setWidget("gateway-status", undefined), 15000);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
case "pair": {
|
|
756
|
+
const code = parts[1]?.toUpperCase();
|
|
757
|
+
const pending = code ? null : listPendingPairingCodes();
|
|
758
|
+
if (pending) {
|
|
759
|
+
ctx.ui.notify("Pending pairing codes:\n" +
|
|
760
|
+
(pending.length > 0
|
|
761
|
+
? pending
|
|
762
|
+
.map((p) => `${p.code} - ${p.platform} (${Math.round(p.expiresIn / 60000)}min)`)
|
|
763
|
+
.join("\n")
|
|
764
|
+
: "None"), "info");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (approvePairingCode(code)) {
|
|
768
|
+
ctx.ui.notify("Pairing code approved", "info");
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
ctx.ui.notify(`❌ Invalid or expired pairing code`, "error");
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
case "allow": {
|
|
776
|
+
const platform = parts[1];
|
|
777
|
+
const userId = parts[2];
|
|
778
|
+
const list = listAllowlistedUsers();
|
|
779
|
+
const configUids = config.security.allowedUids ?? {};
|
|
780
|
+
const configLines = [];
|
|
781
|
+
for (const [plat, uids] of Object.entries(configUids)) {
|
|
782
|
+
for (const uid of uids) {
|
|
783
|
+
configLines.push(`${plat}:${uid} (config)`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (!platform || !userId) {
|
|
787
|
+
ctx.ui.notify("Allowlisted users:\n" +
|
|
788
|
+
(list.length > 0 || configLines.length > 0
|
|
789
|
+
? [
|
|
790
|
+
...list.map((u) => `${u.platform}:${u.userId}`),
|
|
791
|
+
...configLines,
|
|
792
|
+
].join("\n")
|
|
793
|
+
: "None"), "info");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
addToAllowlist(platform, userId);
|
|
797
|
+
ctx.ui.notify(`Added ${userId} to allowlist`, "info");
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
case "revoke": {
|
|
801
|
+
const platform = parts[1];
|
|
802
|
+
const userId = parts[2];
|
|
803
|
+
if (!platform || !userId) {
|
|
804
|
+
ctx.ui.notify("Usage: /gateway revoke <platform> <userId>\n" +
|
|
805
|
+
"Removes a user from the DB allowlist.", "info");
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const removed = revokeUserAccess(platform, userId);
|
|
809
|
+
ctx.ui.notify(removed
|
|
810
|
+
? `Removed ${userId} from allowlist`
|
|
811
|
+
: `${userId} was not in the allowlist`, removed ? "info" : "error");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
case "admin": {
|
|
815
|
+
const action = parts[1]?.toLowerCase();
|
|
816
|
+
switch (action) {
|
|
817
|
+
case "list": {
|
|
818
|
+
const dbAdmins = listAdmins();
|
|
819
|
+
const configAdmins = config.security.adminUids ?? {};
|
|
820
|
+
const configLines = [];
|
|
821
|
+
for (const [plat, uids] of Object.entries(configAdmins)) {
|
|
822
|
+
for (const uid of uids) {
|
|
823
|
+
configLines.push(`${plat}:${uid} (config)`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const dbLines = dbAdmins.map((a) => `${a.platform}:${a.userId}`);
|
|
827
|
+
ctx.ui.notify("Admin users:\n" +
|
|
828
|
+
([...dbLines, ...configLines].length > 0
|
|
829
|
+
? [...dbLines, ...configLines].join("\n")
|
|
830
|
+
: "None"), "info");
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
case "add": {
|
|
834
|
+
const plat = parts[2];
|
|
835
|
+
const uid = parts[3];
|
|
836
|
+
if (!plat || !uid) {
|
|
837
|
+
ctx.ui.notify("Usage: /gateway admin add <platform|*> <userId>\n" +
|
|
838
|
+
"Use * for platform to make admin on all platforms.\n" +
|
|
839
|
+
"Admins bypass all tool restrictions and have full access.", "info");
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
addAdmin(plat, uid);
|
|
843
|
+
ctx.ui.notify(`✅ ${uid} is now admin on ${plat === "*" ? "all platforms" : plat}`, "info");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
case "remove": {
|
|
847
|
+
const plat = parts[2];
|
|
848
|
+
const uid = parts[3];
|
|
849
|
+
if (!plat || !uid) {
|
|
850
|
+
ctx.ui.notify("Usage: /gateway admin remove <platform|*> <userId>", "info");
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (removeAdmin(plat, uid)) {
|
|
854
|
+
ctx.ui.notify(`Removed admin: ${plat}:${uid}`, "info");
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
ctx.ui.notify(`${uid} was not an admin on ${plat}`, "error");
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
default: {
|
|
862
|
+
ctx.ui.notify("/gateway admin commands:\n\n" +
|
|
863
|
+
" list - Show all admins (DB + config)\n" +
|
|
864
|
+
" add <platform|*> <uid> - Grant admin privileges\n" +
|
|
865
|
+
" remove <platform|*> <uid> - Revoke admin privileges\n\n" +
|
|
866
|
+
"Admins bypass all tool restrictions and have full access.\n" +
|
|
867
|
+
"Use * as platform to grant admin on all platforms.\n" +
|
|
868
|
+
"Config-file admins: set adminUids in gateway-security.json", "info");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
case "sessions": {
|
|
874
|
+
const sessions = listSessions();
|
|
875
|
+
ctx.ui.notify("Active sessions:\n" +
|
|
876
|
+
sessions
|
|
877
|
+
.slice(0, 10)
|
|
878
|
+
.map((s) => `${s.platform}:${s.channelId} (${s.id.slice(0, 8)}...)`)
|
|
879
|
+
.join("\n"), "info");
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
case "tasks": {
|
|
883
|
+
const tasks = listTasks();
|
|
884
|
+
ctx.ui.notify("Background tasks:\n" +
|
|
885
|
+
tasks
|
|
886
|
+
.slice(0, 10)
|
|
887
|
+
.map((t) => `${t.id.slice(0, 12)}... - ${t.status} (${t.progress}%)`)
|
|
888
|
+
.join("\n"), "info");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
case "config": {
|
|
892
|
+
const configUidCount2 = Object.values(config.security.allowedUids ?? {}).reduce((sum, uids) => sum + uids.length, 0);
|
|
893
|
+
ctx.ui.notify(`Gateway Config:\n\n` +
|
|
894
|
+
`Port: ${config.port}\n` +
|
|
895
|
+
`Sessions: ${config.sessions.resetPolicy}\n` +
|
|
896
|
+
`Security: ${config.security.allowAll ? "Allow all" : "Allowlist"}` +
|
|
897
|
+
` (${configUidCount2} config UIDs)\n` +
|
|
898
|
+
`Discord: ${config.platforms.discord?.enabled ? "Enabled" : "Disabled"}`, "info");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
case "tool-policy": {
|
|
902
|
+
const action = parts[1]?.toLowerCase();
|
|
903
|
+
switch (action) {
|
|
904
|
+
case "list": {
|
|
905
|
+
const platform = parts[2];
|
|
906
|
+
const userId = parts[3];
|
|
907
|
+
const policies = listToolPolicies(platform, userId);
|
|
908
|
+
if (policies.length === 0) {
|
|
909
|
+
ctx.ui.notify("No explicit tool policies — only defaults active.\n" +
|
|
910
|
+
"Use /gateway tool-policy defaults to see them.", "info");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
ctx.ui.notify("Tool policies:\n" +
|
|
914
|
+
policies
|
|
915
|
+
.map((p) => `#${p.id} ${p.platform ?? "*"}:${p.userId ?? "*"} → ${p.toolName} [${p.action}]`)
|
|
916
|
+
.join("\n"), "info");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
case "defaults": {
|
|
920
|
+
const summary = getEffectivePolicySummary("*", "*");
|
|
921
|
+
ctx.ui.notify("Default Tool Policy (all external users):\n\n" +
|
|
922
|
+
`✅ ALLOWED:\n ${summary.allowed.join("\n ")}\n\n` +
|
|
923
|
+
`🚫 DENIED:\n ${summary.denied.join("\n ")}\n\n` +
|
|
924
|
+
"Use /gateway tool-policy set to override.", "info");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
case "set": {
|
|
928
|
+
const plat = parts[2] || null;
|
|
929
|
+
const uid = parts[3] || null;
|
|
930
|
+
const tool = parts[4];
|
|
931
|
+
const act = parts[5]?.toLowerCase();
|
|
932
|
+
if (!tool || (act !== "allow" && act !== "deny")) {
|
|
933
|
+
ctx.ui.notify("Usage: /gateway tool-policy set [platform] [userId] <toolName> allow|deny\n\n" +
|
|
934
|
+
"Examples:\n" +
|
|
935
|
+
" /gateway tool-policy set discord * bash deny\n" +
|
|
936
|
+
" /gateway tool-policy set discord U123 bash allow\n" +
|
|
937
|
+
" /gateway tool-policy set * * write allow\n" +
|
|
938
|
+
" (Use * for platform/userId to mean all)", "info");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
setToolPolicy({
|
|
942
|
+
platform: plat === "*" ? null : plat,
|
|
943
|
+
userId: uid === "*" ? null : uid,
|
|
944
|
+
toolName: tool,
|
|
945
|
+
action: act,
|
|
946
|
+
priority: 50, // Explicit policies override default (priority 0)
|
|
947
|
+
});
|
|
948
|
+
ctx.ui.notify(`Policy set: ${plat ?? "*"}:${uid ?? "*"} → ${tool} [${act}]`, "info");
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
case "remove": {
|
|
952
|
+
const id = parseInt(parts[2]);
|
|
953
|
+
if (isNaN(id)) {
|
|
954
|
+
ctx.ui.notify("Usage: /gateway tool-policy remove <id>\n" +
|
|
955
|
+
"Use /gateway tool-policy list to see IDs.", "info");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (removeToolPolicy(id)) {
|
|
959
|
+
ctx.ui.notify(`Removed tool policy #${id}`, "info");
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
ctx.ui.notify(`Policy #${id} not found`, "error");
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
case "reset": {
|
|
967
|
+
resetToolPolicies();
|
|
968
|
+
ctx.ui.notify("All tool policies reset to defaults.", "info");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
default: {
|
|
972
|
+
ctx.ui.notify("/gateway tool-policy commands:\n\n" +
|
|
973
|
+
" list [platform] [userId] - List explicit policies\n" +
|
|
974
|
+
" defaults - Show default policy\n" +
|
|
975
|
+
" set <p> <u> <tool> allow|deny - Add/update policy\n" +
|
|
976
|
+
" remove <id> - Delete a policy\n" +
|
|
977
|
+
" reset - Clear all, back to defaults\n\n" +
|
|
978
|
+
"Use * for platform/userId to match all.\n" +
|
|
979
|
+
"Tool names support globs: bash, gateway_*, wiki_*", "info");
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
default: {
|
|
985
|
+
ctx.ui.notify("pi Gateway Commands:\n\n" +
|
|
986
|
+
" /gateway start [port] - Start gateway\n" +
|
|
987
|
+
" /gateway stop - Stop gateway\n" +
|
|
988
|
+
" /gateway restart - Restart gateway\n" +
|
|
989
|
+
" /gateway status - Show status\n" +
|
|
990
|
+
" /gateway pair <code> - Approve pairing\n" +
|
|
991
|
+
" /gateway allow <p> <u>- Add user to allowlist\n" +
|
|
992
|
+
" /gateway revoke <p> <u>- Remove user from allowlist\n" +
|
|
993
|
+
" /gateway admin list - List admin users\n" +
|
|
994
|
+
" /gateway admin add <p|*> <u> - Grant admin\n" +
|
|
995
|
+
" /gateway admin remove <p|*> <u> - Revoke admin\n" +
|
|
996
|
+
" /gateway sessions - List sessions\n" +
|
|
997
|
+
" /gateway tasks - List background tasks\n" +
|
|
998
|
+
" /gateway config - Show config\n" +
|
|
999
|
+
" /gateway tool-policy - Manage tool policies\n\n" +
|
|
1000
|
+
"Hermes-style features:\n" +
|
|
1001
|
+
" - Per-chat sessions with reset policies\n" +
|
|
1002
|
+
" - Platform adapters (Discord, etc.)\n" +
|
|
1003
|
+
" - Background task support\n" +
|
|
1004
|
+
" - Allowlist security (DB + config UIDs)\n" +
|
|
1005
|
+
" - Tool policy (per-user tool allow/deny)", "info");
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
// Register tools
|
|
1011
|
+
pi.registerTool({
|
|
1012
|
+
name: "gateway_status",
|
|
1013
|
+
label: "Gateway Status",
|
|
1014
|
+
description: "Check Hermes-style gateway status",
|
|
1015
|
+
parameters: Type.Object({}),
|
|
1016
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
1017
|
+
return {
|
|
1018
|
+
content: [
|
|
1019
|
+
{
|
|
1020
|
+
type: "text",
|
|
1021
|
+
text: `Gateway: ${state.running ? "Running" : "Stopped"}\n` +
|
|
1022
|
+
`Adapters: ${state.adapters.size}\n` +
|
|
1023
|
+
`Clients: ${state.clients.size}\n` +
|
|
1024
|
+
`Sessions: ${state.sessions.size}\n` +
|
|
1025
|
+
`Agent: ${rpcProcess ? "Connected" : "Disconnected"}`,
|
|
1026
|
+
},
|
|
1027
|
+
],
|
|
1028
|
+
details: {
|
|
1029
|
+
running: state.running,
|
|
1030
|
+
adapters: state.adapters.size,
|
|
1031
|
+
clients: state.clients.size,
|
|
1032
|
+
sessions: state.sessions.size,
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
pi.registerTool({
|
|
1038
|
+
name: "gateway_sessions",
|
|
1039
|
+
label: "Gateway Sessions",
|
|
1040
|
+
description: "List active gateway sessions",
|
|
1041
|
+
parameters: Type.Object({}),
|
|
1042
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
1043
|
+
const sessions = listSessions();
|
|
1044
|
+
return {
|
|
1045
|
+
content: [
|
|
1046
|
+
{
|
|
1047
|
+
type: "text",
|
|
1048
|
+
text: `Active sessions: ${sessions.length}\n` +
|
|
1049
|
+
JSON.stringify(sessions.map((s) => ({
|
|
1050
|
+
id: s.id.slice(0, 12),
|
|
1051
|
+
platform: s.platform,
|
|
1052
|
+
channel: s.channelId,
|
|
1053
|
+
lastActivity: new Date(s.lastActivity).toISOString(),
|
|
1054
|
+
})), null, 2),
|
|
1055
|
+
},
|
|
1056
|
+
],
|
|
1057
|
+
details: { count: sessions.length },
|
|
1058
|
+
};
|
|
1059
|
+
},
|
|
1060
|
+
});
|
|
1061
|
+
pi.registerTool({
|
|
1062
|
+
name: "gateway_background_tasks",
|
|
1063
|
+
label: "Background Tasks",
|
|
1064
|
+
description: "List and manage background tasks",
|
|
1065
|
+
parameters: Type.Object({
|
|
1066
|
+
status: Type.Optional(Type.String()),
|
|
1067
|
+
}),
|
|
1068
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1069
|
+
const tasks = listTasks(params.status);
|
|
1070
|
+
return {
|
|
1071
|
+
content: [
|
|
1072
|
+
{
|
|
1073
|
+
type: "text",
|
|
1074
|
+
text: `Background tasks: ${tasks.length}\n` +
|
|
1075
|
+
JSON.stringify(tasks.map((t) => ({
|
|
1076
|
+
id: t.id.slice(0, 12),
|
|
1077
|
+
status: t.status,
|
|
1078
|
+
progress: t.progress,
|
|
1079
|
+
command: t.command.slice(0, 50),
|
|
1080
|
+
})), null, 2),
|
|
1081
|
+
},
|
|
1082
|
+
],
|
|
1083
|
+
details: { count: tasks.length },
|
|
1084
|
+
};
|
|
1085
|
+
},
|
|
1086
|
+
});
|
|
1087
|
+
pi.registerTool({
|
|
1088
|
+
name: "gateway_pairing",
|
|
1089
|
+
label: "Gateway Pairing",
|
|
1090
|
+
description: "Generate or approve pairing codes",
|
|
1091
|
+
parameters: Type.Object({
|
|
1092
|
+
action: Type.Union([
|
|
1093
|
+
Type.Literal("generate"),
|
|
1094
|
+
Type.Literal("list"),
|
|
1095
|
+
Type.Literal("approve"),
|
|
1096
|
+
]),
|
|
1097
|
+
platform: Type.Optional(Type.String()),
|
|
1098
|
+
userId: Type.Optional(Type.String()),
|
|
1099
|
+
code: Type.Optional(Type.String()),
|
|
1100
|
+
}),
|
|
1101
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1102
|
+
const { action, platform, userId, code } = params;
|
|
1103
|
+
switch (action) {
|
|
1104
|
+
case "generate": {
|
|
1105
|
+
if (!platform || !userId) {
|
|
1106
|
+
return {
|
|
1107
|
+
content: [{ type: "text", text: "platform and userId required" }],
|
|
1108
|
+
details: { error: true },
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
const pairingCode = generatePairingCode(platform, userId);
|
|
1112
|
+
return {
|
|
1113
|
+
content: [
|
|
1114
|
+
{
|
|
1115
|
+
type: "text",
|
|
1116
|
+
text: `Pairing code: ${pairingCode}\n\nShare this code with the user to approve access.`,
|
|
1117
|
+
},
|
|
1118
|
+
],
|
|
1119
|
+
details: { code: pairingCode },
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
case "approve": {
|
|
1123
|
+
if (!code) {
|
|
1124
|
+
return {
|
|
1125
|
+
content: [{ type: "text", text: "code required" }],
|
|
1126
|
+
details: { error: true },
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
const success = approvePairingCode(code);
|
|
1130
|
+
return {
|
|
1131
|
+
content: [
|
|
1132
|
+
{
|
|
1133
|
+
type: "text",
|
|
1134
|
+
text: success ? "✅ Code approved" : "❌ Invalid/expired",
|
|
1135
|
+
},
|
|
1136
|
+
],
|
|
1137
|
+
details: { success },
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
case "list": {
|
|
1141
|
+
const pending = listPendingPairingCodes();
|
|
1142
|
+
return {
|
|
1143
|
+
content: [
|
|
1144
|
+
{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: `Pending codes: ${pending.length}\n` +
|
|
1147
|
+
JSON.stringify(pending, null, 2),
|
|
1148
|
+
},
|
|
1149
|
+
],
|
|
1150
|
+
details: { count: pending.length },
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
});
|
|
1156
|
+
pi.registerTool({
|
|
1157
|
+
name: "gateway_tool_policy",
|
|
1158
|
+
label: "Gateway Tool Policy",
|
|
1159
|
+
description: "Manage tool access policies for external gateway users",
|
|
1160
|
+
parameters: Type.Object({
|
|
1161
|
+
action: Type.Union([
|
|
1162
|
+
Type.Literal("list"),
|
|
1163
|
+
Type.Literal("defaults"),
|
|
1164
|
+
Type.Literal("set"),
|
|
1165
|
+
Type.Literal("remove"),
|
|
1166
|
+
Type.Literal("reset"),
|
|
1167
|
+
]),
|
|
1168
|
+
platform: Type.Optional(Type.String()),
|
|
1169
|
+
userId: Type.Optional(Type.String()),
|
|
1170
|
+
toolName: Type.Optional(Type.String()),
|
|
1171
|
+
policyAction: Type.Optional(Type.Union([Type.Literal("allow"), Type.Literal("deny")])),
|
|
1172
|
+
policyId: Type.Optional(Type.Number()),
|
|
1173
|
+
}),
|
|
1174
|
+
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
1175
|
+
const { action, platform, userId, toolName, policyAction, policyId } = params;
|
|
1176
|
+
switch (action) {
|
|
1177
|
+
case "list": {
|
|
1178
|
+
const policies = listToolPolicies(platform, userId);
|
|
1179
|
+
return {
|
|
1180
|
+
content: [
|
|
1181
|
+
{
|
|
1182
|
+
type: "text",
|
|
1183
|
+
text: policies.length > 0
|
|
1184
|
+
? JSON.stringify(policies, null, 2)
|
|
1185
|
+
: "No explicit policies — only defaults active.",
|
|
1186
|
+
},
|
|
1187
|
+
],
|
|
1188
|
+
details: { count: policies.length, policies },
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
case "defaults": {
|
|
1192
|
+
const summary = getEffectivePolicySummary(platform ?? "*", userId ?? "*");
|
|
1193
|
+
return {
|
|
1194
|
+
content: [
|
|
1195
|
+
{
|
|
1196
|
+
type: "text",
|
|
1197
|
+
text: `Default tool policy:\n\n` +
|
|
1198
|
+
`ALLOWED: ${summary.allowed.join(", ")}\n` +
|
|
1199
|
+
`DENIED: ${summary.denied.join(", ")}`,
|
|
1200
|
+
},
|
|
1201
|
+
],
|
|
1202
|
+
details: summary,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
case "set": {
|
|
1206
|
+
if (!toolName || !policyAction) {
|
|
1207
|
+
return {
|
|
1208
|
+
content: [
|
|
1209
|
+
{
|
|
1210
|
+
type: "text",
|
|
1211
|
+
text: "toolName and policyAction (allow|deny) are required",
|
|
1212
|
+
},
|
|
1213
|
+
],
|
|
1214
|
+
details: { error: true },
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
setToolPolicy({
|
|
1218
|
+
platform: platform ?? null,
|
|
1219
|
+
userId: userId ?? null,
|
|
1220
|
+
toolName,
|
|
1221
|
+
action: policyAction,
|
|
1222
|
+
priority: 50,
|
|
1223
|
+
});
|
|
1224
|
+
return {
|
|
1225
|
+
content: [
|
|
1226
|
+
{
|
|
1227
|
+
type: "text",
|
|
1228
|
+
text: `Policy set: ${platform ?? "*"}:${userId ?? "*"} → ${toolName} [${policyAction}]`,
|
|
1229
|
+
},
|
|
1230
|
+
],
|
|
1231
|
+
details: { success: true },
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
case "remove": {
|
|
1235
|
+
if (policyId == null) {
|
|
1236
|
+
return {
|
|
1237
|
+
content: [
|
|
1238
|
+
{ type: "text", text: "policyId (number) is required" },
|
|
1239
|
+
],
|
|
1240
|
+
details: { error: true },
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
const removed = removeToolPolicy(policyId);
|
|
1244
|
+
return {
|
|
1245
|
+
content: [
|
|
1246
|
+
{
|
|
1247
|
+
type: "text",
|
|
1248
|
+
text: removed
|
|
1249
|
+
? `Removed policy #${policyId}`
|
|
1250
|
+
: `Policy #${policyId} not found`,
|
|
1251
|
+
},
|
|
1252
|
+
],
|
|
1253
|
+
details: { success: removed },
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
case "reset": {
|
|
1257
|
+
resetToolPolicies();
|
|
1258
|
+
return {
|
|
1259
|
+
content: [
|
|
1260
|
+
{ type: "text", text: "All tool policies reset to defaults." },
|
|
1261
|
+
],
|
|
1262
|
+
details: { success: true },
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
});
|
|
1268
|
+
// Notify on session start
|
|
1269
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1270
|
+
globalCtx = ctx;
|
|
1271
|
+
updateStatus();
|
|
1272
|
+
});
|
|
1273
|
+
logger.info("[pi-gateway] Hermes-style gateway extension loaded");
|
|
1274
|
+
}
|
|
1275
|
+
//# sourceMappingURL=index.js.map
|