1import log from "../../seed/log.js";
2import GatewayChannel from "./model.js";
3import crypto from "crypto";
4import { getLandUrl } from "../../canopy/identity.js";
5import { getChannelType, getRegisteredTypes, hasChannelType } from "./registry.js";
6
7// Models wired from init() via setModels(). Fallback to direct import for standalone use.
8let Node = null;
9let User = null;
10export function setModels(models) { Node = models.Node; User = models.User; }
11
12// Lazy model access for gateway core (may be called before init in some paths)
13async function ensureModels() {
14 if (!Node) {
15 const mod = await import("../../seed/models/node.js");
16 Node = mod.default;
17 }
18 if (!User) {
19 const mod = await import("../../seed/models/user.js");
20 User = mod.default;
21 }
22}
23
24const ENCRYPTION_KEY = process.env.CUSTOM_LLM_API_SECRET_KEY;
25const ALGORITHM = "aes-256-cbc";
26
27// ─────────────────────────────────────────────────────────────────────────
28// ENCRYPTION (same pattern as seed/llm/connections.js, reusing CUSTOM_LLM_API_SECRET_KEY)
29// ─────────────────────────────────────────────────────────────────────────
30
31function getEncryptionKey() {
32 if (!ENCRYPTION_KEY || ENCRYPTION_KEY.length < 32) {
33 throw new Error("CUSTOM_LLM_API_SECRET_KEY must be at least 32 characters");
34 }
35 return Buffer.from(ENCRYPTION_KEY.slice(0, 32));
36}
37
38function encrypt(text) {
39 const key = getEncryptionKey();
40 const iv = crypto.randomBytes(16);
41 const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
42 let encrypted = cipher.update(text, "utf8", "hex");
43 encrypted += cipher.final("hex");
44 return iv.toString("hex") + ":" + encrypted;
45}
46
47function decrypt(encryptedText) {
48 const parts = encryptedText.split(":");
49 const iv = Buffer.from(parts[0], "hex");
50 const key = Buffer.from(ENCRYPTION_KEY.padEnd(32).slice(0, 32));
51 const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
52 let decrypted = decipher.update(parts[1], "hex", "utf8");
53 decrypted += decipher.final("utf8");
54 return decrypted;
55}
56
57// ─────────────────────────────────────────────────────────────────────────
58// VALIDATION (delegates to registered channel type handlers)
59// ─────────────────────────────────────────────────────────────────────────
60
61const VALID_DIRECTIONS = ["input", "input-output", "output"];
62const VALID_MODES = ["read", "read-write", "write"];
63const KNOWN_NOTIFICATION_TYPES = ["dream-summary", "dream-thought"];
64const MAX_CHANNELS_PER_ROOT = 10;
65
66function validateType(type) {
67 if (!type || !hasChannelType(type)) {
68 throw new Error(
69 "Unknown channel type: " + type + ". Registered types: " + getRegisteredTypes().join(", "),
70 );
71 }
72}
73
74function validateNotificationTypes(types) {
75 if (!Array.isArray(types)) {
76 throw new Error("notificationTypes must be an array");
77 }
78 for (const t of types) {
79 if (typeof t !== "string" || !KNOWN_NOTIFICATION_TYPES.includes(t)) {
80 throw new Error(
81 "Unknown notification type: " +
82 t +
83 ". Must be one of: " +
84 KNOWN_NOTIFICATION_TYPES.join(", "),
85 );
86 }
87 }
88}
89
90function validateConfigForType(type, config, direction) {
91 if (!config || typeof config !== "object") {
92 throw new Error("config is required");
93 }
94 const handler = getChannelType(type);
95 if (!handler) throw new Error("Unknown channel type: " + type);
96 handler.validateConfig(config, direction);
97}
98
99function buildEncryptedConfig(type, config, direction) {
100 const handler = getChannelType(type);
101 if (!handler) throw new Error("Unknown channel type: " + type);
102 const result = handler.buildEncryptedConfig(config, direction);
103 return {
104 encryptedPayload: encrypt(JSON.stringify(result.secrets)),
105 displayIdentifier: result.displayIdentifier || config.displayIdentifier || null,
106 metadata: result.metadata || {},
107 };
108}
109
110// ─────────────────────────────────────────────────────────────────────────
111// HELPERS
112// ─────────────────────────────────────────────────────────────────────────
113
114async function verifyRootAccess(userId, rootId) {
115 const root = await Node.findById(rootId)
116 .select("rootOwner contributors")
117 .lean();
118 if (!root) throw new Error("Root not found");
119 if (!root.rootOwner) throw new Error("Node is not a root");
120
121 const isOwner = root.rootOwner.toString() === userId.toString();
122 const isContributor = (root.contributors || []).some(
123 (c) => c.toString() === userId.toString(),
124 );
125
126 if (!isOwner && !isContributor) {
127 throw new Error(
128 "Only the root owner or contributors can manage gateway channels",
129 );
130 }
131
132 return { root, isOwner };
133}
134
135function sanitizeChannel(channel) {
136 const obj =
137 typeof channel.toObject === "function"
138 ? channel.toObject()
139 : { ...channel };
140 if (obj.config) {
141 delete obj.config.encryptedPayload;
142 }
143 return obj;
144}
145
146// ─────────────────────────────────────────────────────────────────────────
147// PUBLIC API
148// ─────────────────────────────────────────────────────────────────────────
149
150export async function addGatewayChannel(
151 userId,
152 rootId,
153 { name, type, direction, mode, config, notificationTypes, queueBehavior },
154) {
155 await verifyRootAccess(userId, rootId);
156
157 const count = await GatewayChannel.countDocuments({ rootId });
158 if (count >= MAX_CHANNELS_PER_ROOT) {
159 throw new Error(
160 "Maximum of " + MAX_CHANNELS_PER_ROOT + " channels per root reached",
161 );
162 }
163
164 if (!name || typeof name !== "string" || name.length > 100) {
165 throw new Error("Invalid channel name");
166 }
167
168 validateType(type);
169 const handler = getChannelType(type);
170
171 // Validate direction and mode
172 const safeDirection = direction || "output";
173 const safeMode = mode || "write";
174 if (!VALID_DIRECTIONS.includes(safeDirection)) {
175 throw new Error(
176 "Invalid direction. Must be one of: " + VALID_DIRECTIONS.join(", "),
177 );
178 }
179 if (!VALID_MODES.includes(safeMode)) {
180 throw new Error(
181 "Invalid mode. Must be one of: " + VALID_MODES.join(", "),
182 );
183 }
184
185 // Check if this channel type supports the requested direction
186 if (!handler.allowedDirections.includes(safeDirection)) {
187 throw new Error(
188 type + " channels only support: " + handler.allowedDirections.join(", "),
189 );
190 }
191
192 // Tier check for input channels (if handler requires it)
193 const hasInput = safeDirection === "input" || safeDirection === "input-output";
194 if (hasInput && handler.requiredTiers && handler.requiredTiers.length > 0) {
195 const user = await User.findById(userId).select("isAdmin metadata").lean();
196 const userPlan = (user?.metadata?.tiers?.plan) || "basic";
197 if (!user || (!user.isAdmin && !handler.requiredTiers.includes(userPlan))) {
198 throw new Error(
199 type + " input channels require a " + handler.requiredTiers.join(" or ") + " tier subscription",
200 );
201 }
202 }
203
204 const hasOutput =
205 safeDirection === "output" || safeDirection === "input-output";
206
207 // Validate config
208 let encConfig = {
209 encryptedPayload: null,
210 displayIdentifier: null,
211 metadata: {},
212 };
213 if (hasOutput || hasInput) {
214 validateConfigForType(type, config, safeDirection);
215 encConfig = buildEncryptedConfig(type, config, safeDirection);
216 }
217
218 let types = [];
219 if (hasOutput) {
220 types = notificationTypes || KNOWN_NOTIFICATION_TYPES;
221 validateNotificationTypes(types);
222 }
223
224 const safeQueueBehavior = queueBehavior === "silent" ? "silent" : "respond";
225
226 const channel = await GatewayChannel.create({
227 userId,
228 rootId,
229 name: name.trim(),
230 type,
231 direction: safeDirection,
232 mode: safeMode,
233 enabled: true,
234 config: encConfig,
235 notificationTypes: types,
236 queueBehavior: safeQueueBehavior,
237 });
238
239 // Register input webhooks/bots after creation
240 if (hasInput && channel.enabled) {
241 registerInputChannel(channel).catch((err) =>
242 log.error("Gateway",
243 `Failed to register input for channel ${channel._id}:`,
244 err.message,
245 ),
246 );
247 }
248
249 return sanitizeChannel(channel);
250}
251
252export async function updateGatewayChannel(userId, channelId, updates) {
253 const channel = await GatewayChannel.findOne({ _id: channelId, userId });
254 if (!channel) throw new Error("Channel not found");
255
256 const wasEnabled = channel.enabled;
257 const hasInput =
258 channel.direction === "input" || channel.direction === "input-output";
259
260 if (updates.name !== undefined) {
261 if (typeof updates.name !== "string" || updates.name.length > 100) {
262 throw new Error("Invalid channel name");
263 }
264 channel.name = updates.name.trim();
265 }
266
267 if (updates.enabled !== undefined) {
268 channel.enabled = Boolean(updates.enabled);
269 }
270
271 if (updates.queueBehavior !== undefined) {
272 channel.queueBehavior =
273 updates.queueBehavior === "silent" ? "silent" : "respond";
274 }
275
276 if (updates.notificationTypes !== undefined) {
277 validateNotificationTypes(updates.notificationTypes);
278 channel.notificationTypes = updates.notificationTypes;
279 }
280
281 if (updates.config !== undefined) {
282 validateConfigForType(channel.type, updates.config, channel.direction);
283 channel.config = buildEncryptedConfig(
284 channel.type,
285 updates.config,
286 channel.direction,
287 );
288 }
289
290 await channel.save();
291
292 // Handle input channel lifecycle on enable/disable changes
293 if (hasInput) {
294 if (!wasEnabled && channel.enabled) {
295 registerInputChannel(channel).catch((err) =>
296 log.error("Gateway",
297 `Failed to register input for channel ${channel._id}:`,
298 err.message,
299 ),
300 );
301 } else if (wasEnabled && !channel.enabled) {
302 unregisterInputChannel(channel).catch((err) =>
303 log.error("Gateway",
304 `Failed to unregister input for channel ${channel._id}:`,
305 err.message,
306 ),
307 );
308 }
309 }
310
311 return sanitizeChannel(channel);
312}
313
314export async function deleteGatewayChannel(userId, channelId) {
315 const channel = await GatewayChannel.findOneAndDelete({
316 _id: channelId,
317 userId,
318 });
319 if (!channel) throw new Error("Channel not found");
320
321 const hasInput =
322 channel.direction === "input" || channel.direction === "input-output";
323 if (hasInput) {
324 unregisterInputChannel(channel).catch((err) =>
325 log.error("Gateway",
326 `Failed to unregister input for channel ${channel._id}:`,
327 err.message,
328 ),
329 );
330 }
331
332 return { removed: true };
333}
334
335export async function getChannelsForRoot(rootId) {
336 const channels = await GatewayChannel.find({ rootId })
337 .select("-config.encryptedPayload")
338 .sort({ createdAt: -1 })
339 .lean();
340 return channels;
341}
342
343export async function getChannelWithSecrets(channelId) {
344 const channel = await GatewayChannel.findById(channelId).lean();
345 if (!channel) return null;
346
347 if (channel.config && channel.config.encryptedPayload) {
348 try {
349 channel.config.decryptedSecrets = JSON.parse(
350 decrypt(channel.config.encryptedPayload),
351 );
352 } catch (err) {
353 channel.config.decryptedSecrets = null;
354 }
355 }
356
357 return channel;
358}
359
360export { decrypt as decryptPayload };
361
362// ─────────────────────────────────────────────────────────────────────────
363// INPUT CHANNEL LIFECYCLE (delegates to registered handler)
364// ─────────────────────────────────────────────────────────────────────────
365
366async function registerInputChannel(channel) {
367 const handler = getChannelType(channel.type);
368 if (!handler || !handler.registerInput) return;
369 const secrets = JSON.parse(decrypt(channel.config.encryptedPayload));
370 await handler.registerInput(channel, secrets);
371}
372
373async function unregisterInputChannel(channel) {
374 const handler = getChannelType(channel.type);
375 if (!handler || !handler.unregisterInput) return;
376 try {
377 const secrets = JSON.parse(decrypt(channel.config.encryptedPayload));
378 await handler.unregisterInput(channel, secrets);
379 } catch (err) {
380 log.error("Gateway", `Failed to unregister input for channel ${channel._id}:`, err.message);
381 }
382}
383
384export { registerInputChannel, unregisterInputChannel };
385
1import log from "../../seed/log.js";
2import GatewayChannel from "./model.js";
3import { getChannelType } from "./registry.js";
4import crypto from "crypto";
5import dotenv from "dotenv";
6import path from "path";
7import { fileURLToPath } from "url";
8const __filename = fileURLToPath(import.meta.url);
9const __dirname = path.dirname(__filename);
10
11dotenv.config({ path: path.resolve(__dirname, "../..", ".env") });
12
13const ENCRYPTION_KEY = process.env.CUSTOM_LLM_API_SECRET_KEY;
14const ALGORITHM = "aes-256-cbc";
15
16function decrypt(encryptedText) {
17 const parts = encryptedText.split(":");
18 const iv = Buffer.from(parts[0], "hex");
19 const key = Buffer.from(ENCRYPTION_KEY.padEnd(32).slice(0, 32));
20 const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
21 let decrypted = decipher.update(parts[1], "hex", "utf8");
22 decrypted += decipher.final("utf8");
23 return decrypted;
24}
25
26function decryptChannelSecrets(channel) {
27 if (!channel.config || !channel.config.encryptedPayload) return null;
28 try {
29 return JSON.parse(decrypt(channel.config.encryptedPayload));
30 } catch {
31 return null;
32 }
33}
34
35// ─────────────────────────────────────────────────────────────────────────
36// DISPATCH (delegates to registered channel type senders)
37// ─────────────────────────────────────────────────────────────────────────
38
39export async function dispatchNotifications(rootId, notifications) {
40 if (!notifications || notifications.length === 0) return;
41
42 const channels = await GatewayChannel.find({
43 rootId,
44 enabled: true,
45 direction: { $in: ["output", "input-output"] },
46 }).lean();
47
48 if (channels.length === 0) return;
49
50 const results = [];
51
52 for (const channel of channels) {
53 const matching = notifications.filter(
54 (n) => channel.notificationTypes.includes(n.type),
55 );
56
57 if (matching.length === 0) continue;
58
59 const secrets = decryptChannelSecrets(channel);
60 if (!secrets) {
61 log.error("Gateway", `Failed to decrypt secrets for channel ${channel._id}`);
62 await GatewayChannel.findByIdAndUpdate(channel._id, {
63 $set: { lastError: "Failed to decrypt channel secrets" },
64 });
65 continue;
66 }
67
68 const handler = getChannelType(channel.type);
69 if (!handler || !handler.send) {
70 log.error("Gateway", `No registered sender for channel type "${channel.type}"`);
71 continue;
72 }
73
74 for (const notification of matching) {
75 try {
76 await handler.send(secrets, channel.config.metadata || {}, {
77 ...notification,
78 _channelId: channel._id,
79 });
80
81 await GatewayChannel.findByIdAndUpdate(channel._id, {
82 $set: { lastDispatchAt: new Date(), lastError: null },
83 });
84
85 results.push({ channelId: channel._id, type: channel.type, status: "ok" });
86 } catch (err) {
87 log.error("Gateway", `Dispatch error for channel ${channel._id} (${channel.type}):`, err.message);
88
89 await GatewayChannel.findByIdAndUpdate(channel._id, {
90 $set: { lastError: err.message },
91 });
92
93 results.push({ channelId: channel._id, type: channel.type, status: "error", error: err.message });
94 }
95 }
96 }
97
98 if (results.length > 0) {
99 const ok = results.filter((r) => r.status === "ok").length;
100 const fail = results.filter((r) => r.status === "error").length;
101 log.verbose("Gateway", `Dispatched ${ok} ok, ${fail} failed for root ${rootId}`);
102 }
103
104 return results;
105}
106
107export async function dispatchTestNotification(channelId) {
108 const channel = await GatewayChannel.findById(channelId).lean();
109 if (!channel) throw new Error("Channel not found");
110 if (!channel.enabled) throw new Error("Channel is disabled");
111
112 const secrets = decryptChannelSecrets(channel);
113 if (!secrets) throw new Error("Failed to decrypt channel secrets");
114
115 const handler = getChannelType(channel.type);
116 if (!handler || !handler.send) throw new Error("No sender for channel type: " + channel.type);
117
118 const testNotification = {
119 type: "test",
120 title: "Test Notification",
121 content: "This is a test notification from your tree. If you see this, your channel is working correctly!",
122 _channelId: channel._id,
123 };
124
125 await handler.send(secrets, channel.config.metadata || {}, testNotification);
126
127 await GatewayChannel.findByIdAndUpdate(channel._id, {
128 $set: { lastDispatchAt: new Date(), lastError: null },
129 });
130
131 return { success: true };
132}
133
1import express from "express";
2import log from "../../seed/log.js";
3import Node from "../../seed/models/node.js";
4import { sendError, ERR } from "../../seed/protocol.js";
5import urlAuth from "../html-rendering/urlAuth.js";
6import { htmlOnly, buildQS, tokenQS } from "../html-rendering/htmlHelpers.js";
7import { getExtension } from "../loader.js";
8import { renderGateway } from "./pages/gateway.js";
9
10export default function buildGatewayHtmlRoutes() {
11 const router = express.Router();
12
13 router.get("/root/:rootId/gateway", urlAuth, htmlOnly, async (req, res) => {
14 try {
15 const { rootId } = req.params;
16 const root = await Node.findById(rootId).select("name rootOwner contributors").lean();
17 if (!root) return sendError(res, 404, ERR.TREE_NOT_FOUND, "Tree not found");
18 if (String(root.rootOwner) !== String(req.userId)) {
19 return sendError(res, 403, ERR.FORBIDDEN, "Owner only");
20 }
21
22 let channels = [];
23 try {
24 const gw = getExtension("gateway");
25 if (gw?.exports?.getChannelsForRoot) channels = await gw.exports.getChannelsForRoot(rootId);
26 } catch {}
27
28 return res.send(renderGateway({
29 rootId, rootName: root.name, queryString: buildQS(req), channels,
30 }));
31 } catch (err) {
32 log.error("HTML", "Gateway render error:", err.message);
33 sendError(res, 500, ERR.INTERNAL, err.message);
34 }
35 });
36
37 return router;
38}
39
1import router from "./routes.js";
2import { resolveHtmlAuth } from "./routes.js";
3import { dispatchNotifications, dispatchTestNotification } from "./dispatch.js";
4import { registerChannelType, getChannelType, getRegisteredTypes } from "./registry.js";
5import { processGatewayMessage } from "./input.js";
6import { getChannelWithSecrets, getChannelsForRoot, addGatewayChannel, updateGatewayChannel, deleteGatewayChannel } from "./core.js";
7import GatewayChannel from "./model.js";
8
9export async function init(core) {
10 resolveHtmlAuth();
11 const { setModels } = await import("./core.js");
12 setModels(core.models);
13
14 try {
15 const { getExtension } = await import("../loader.js");
16 const htmlExt = getExtension("html-rendering");
17 if (htmlExt) {
18 const { default: buildHtmlRoutes } = await import("./htmlRoutes.js");
19 htmlExt.router.use("/", buildHtmlRoutes());
20 }
21 } catch {}
22
23 // Register tree owner section (gateway management panel)
24 try {
25 const treeos = getExtension("treeos-base");
26 treeos?.exports?.registerSlot?.("tree-owner-sections", "gateway", ({ rootId, queryString }) =>
27 `<div class="content-card">
28 <div class="section-header"><h2>Gateway</h2></div>
29 <p style="color:rgba(255,255,255,0.7);font-size:0.85rem;margin:0 0 12px">
30 Manage output channels for this tree. Send dream summaries and notifications to Telegram, Discord, or your browser.
31 </p>
32 <a href="/api/v1/root/${rootId}/gateway${queryString}"
33 style="display:inline-block;padding:8px 16px;border-radius:8px;
34 border:1px solid rgba(115,111,230,0.4);background:rgba(115,111,230,0.15);
35 color:rgba(200,200,255,0.95);font-weight:600;text-decoration:none;
36 font-size:0.9rem;cursor:pointer">
37 Manage Channels
38 </a>
39 </div>`,
40 { priority: 30 }
41 );
42 } catch {}
43
44 return {
45 router,
46 exports: {
47 registerChannelType,
48 getChannelType,
49 getRegisteredTypes,
50 dispatchNotifications,
51 dispatchTestNotification,
52 processGatewayMessage,
53 getChannelWithSecrets,
54 getChannelsForRoot,
55 addGatewayChannel,
56 updateGatewayChannel,
57 deleteGatewayChannel,
58 GatewayChannel,
59 },
60 };
61}
62
1// core/gatewayInput.js
2// Central processor for incoming gateway messages (Telegram, Discord).
3// Mirrors the tree.js API endpoint pattern but with per-channel queue + cancel.
4
5import log from "../../seed/log.js";
6import { OrchestratorRuntime } from "../../seed/orchestrators/runtime.js";
7import GatewayChannel from "./model.js";
8import Node from "../../seed/models/node.js";
9import User from "../../seed/models/user.js";
10import { getOrchestrator } from "../../seed/orchestrators/registry.js";
11import {
12 userHasLlm,
13 LLM_PRIORITY,
14} from "../../seed/llm/conversation.js";
15import { enqueue, getQueueDepth } from "../../seed/ws/requestQueue.js";
16import {
17 setSessionAbort,
18 clearSessionAbort,
19 endSession,
20 abortSessionsByScope,
21 SESSION_TYPES,
22} from "../../seed/ws/sessionRegistry.js";
23import { resolveTreeAccess } from "../../seed/tree/treeAccess.js";
24import { nullSocket } from "../../seed/orchestrators/helpers.js";
25
26const BUSY_MESSAGE =
27 "I'm already processing your last 2 messages. Please send again later.";
28const MAX_CONCURRENT = 2;
29
30// Per-channel abort controllers: channelId -> Set<AbortController>
31// Needed because maxConcurrent=2 means multiple messages share a session,
32// but each needs its own abort controller for cancel to work on all.
33const channelAborts = new Map();
34
35/**
36 * Process an incoming message from a gateway channel.
37 *
38 * @param {string} channelId
39 * @param {object} opts
40 * @param {string} opts.senderName - display name of the sender
41 * @param {string} opts.senderPlatformId - platform-specific user ID
42 * @param {string} opts.messageText - the message content
43 * @returns {Promise<object>} { queued, cancelled, reply, result }
44 */
45export async function processGatewayMessage(
46 channelId,
47 { senderName, senderPlatformId, messageText },
48) {
49 // 1. Load and validate channel
50 const channel = await GatewayChannel.findById(channelId).lean();
51 if (!channel) return { error: "Channel not found" };
52 if (!channel.enabled) return { error: "Channel is disabled" };
53
54 const hasInput =
55 channel.direction === "input" || channel.direction === "input-output";
56 if (!hasInput) return { error: "Channel does not accept input" };
57
58 if (!messageText || typeof messageText !== "string" || !messageText.trim()) {
59 return { error: "Empty message" };
60 }
61 const trimmed = messageText.trim();
62 if (trimmed.length > 5000) {
63 return { error: "Message too long (max 5000 characters)" };
64 }
65
66 // 2. Loop prevention: ignore exact busy message echo
67 if (trimmed === BUSY_MESSAGE) {
68 return { ignored: true };
69 }
70
71 // 3. Cancel/stop command
72 const lowerTrimmed = trimmed.toLowerCase();
73 if (lowerTrimmed === "cancel" || lowerTrimmed === "stop") {
74 // Abort ALL in-flight abort controllers for this channel
75 const aborts = channelAborts.get(channelId);
76 let abortCount = 0;
77 if (aborts) {
78 for (const ac of aborts) {
79 ac.abort();
80 abortCount++;
81 }
82 aborts.clear();
83 }
84
85 // Also abort the session itself
86 const scopeKey = "gw:" + channelId;
87 abortSessionsByScope(scopeKey);
88
89 log.verbose("Gateway",
90 `Gateway: ${lowerTrimmed} command for channel ${channelId}, aborted ${abortCount} in-flight message(s)`,
91 );
92
93 // Finalize any open chats that were in-flight
94 try {
95 const Chat = (await import("../../seed/models/chat.js")).default;
96 await Chat.updateMany(
97 {
98 userId: channel.userId,
99 "endMessage.time": null,
100 "aiContext.zone": { $in: ["tree", "classifier", "gateway"] },
101 },
102 {
103 $set: {
104 "endMessage.content": "Cancelled by user",
105 "endMessage.time": new Date(),
106 "endMessage.stopped": true,
107 },
108 },
109 );
110 } catch (err) {
111 log.error("Gateway",
112 "Gateway: failed to finalize chats on cancel:",
113 err.message,
114 );
115 }
116
117 return { cancelled: true, reply: "All active tasks cancelled." };
118 }
119
120 // 3. Queue depth check
121 const queueKey = "gw:" + channelId;
122 const depth = getQueueDepth(queueKey);
123 if (depth >= MAX_CONCURRENT) {
124 if (channel.queueBehavior === "silent") {
125 return { queued: false, reply: null };
126 }
127 return { queued: false, reply: BUSY_MESSAGE };
128 }
129
130 // 4. Resolve channel creator's user
131 const user = await User.findById(channel.userId).select("_id username").lean();
132 if (!user) return { error: "Channel owner not found" };
133
134 // 5. Check tree access
135 const access = await resolveTreeAccess(channel.rootId, channel.userId);
136 if (!access.isOwner && !access.isContributor) {
137 return { error: "Channel owner no longer has tree access" };
138 }
139
140 // 6. Check LLM access
141 const rootCheck = await Node.findById(channel.rootId)
142 .select("rootOwner llmAssignments")
143 .lean();
144 const hasUserLlm = await userHasLlm(channel.userId);
145 const hasRootLlm = !!(rootCheck?.llmDefault && rootCheck.llmDefault !== "none");
146 if (!hasUserLlm && !hasRootLlm) {
147 return { error: "No LLM connection configured" };
148 }
149
150 // 7. Determine orchestrator flags based on channel mode
151 const skipRespond = channel.mode === "write";
152 const forceQueryOnly = channel.mode === "read";
153 const sourceType = "gateway-" + channel.type;
154
155 // 8. Label message with sender identity
156 const labeledMessage = senderName ? `${senderName}: "${trimmed}"` : trimmed;
157
158 // 9. Pre-queue abort tracking (must exist before enqueue for cancel to work)
159 const abort = new AbortController();
160 if (!channelAborts.has(channelId)) channelAborts.set(channelId, new Set());
161 channelAborts.get(channelId).add(abort);
162
163 const modeKey = "tree:" +
164 (channel.mode === "write"
165 ? "place"
166 : channel.mode === "read"
167 ? "query"
168 : "chat");
169
170 // 10. Enqueue with max concurrent 2
171 const visitorId = `gateway:${channel.type}:${channelId}:${Date.now()}`;
172
173 const result = await enqueue(
174 queueKey,
175 async () => {
176 // Create runtime for session + MCP + Chat lifecycle
177 const rt = new OrchestratorRuntime({
178 rootId: channel.rootId,
179 userId: channel.userId,
180 username: user.username,
181 visitorId,
182 sessionType: SESSION_TYPES.GATEWAY_INPUT,
183 description: `Gateway ${channel.type} input on root ${channel.rootId}`,
184 modeKeyForLlm: modeKey,
185 source: "gateway",
186 llmPriority: LLM_PRIORITY.GATEWAY,
187 });
188
189 await rt.init(trimmed.slice(0, 5000));
190 setSessionAbort(rt.sessionId, abort);
191
192 let timedOut = false;
193 const TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes for gateway
194 const timer = setTimeout(async () => {
195 timedOut = true;
196 rt.setError("Request timed out", "gateway:timeout");
197 await rt.cleanup().catch(() => {});
198 }, TIMEOUT_MS);
199
200 try {
201 const treeOrch = getOrchestrator("tree");
202 if (!treeOrch) throw new Error("No tree orchestrator installed");
203 const orchResult = await treeOrch.handle({
204 visitorId,
205 message: labeledMessage,
206 socket: nullSocket,
207 username: user.username,
208 userId: channel.userId,
209 signal: abort.signal,
210 sessionId: rt.sessionId,
211 rootId: channel.rootId,
212 skipRespond,
213 forceQueryOnly,
214 rootChatId: rt.mainChatId || null,
215 sourceType,
216 });
217
218 clearTimeout(timer);
219 if (timedOut) return { success: false, answer: "Request timed out." };
220
221 const wasAborted = abort.signal.aborted;
222 const answer = wasAborted
223 ? "Cancelled by user"
224 : orchResult?.answer || orchResult?.reason || null;
225 rt.setResult(answer, orchResult?.modeKey || "tree:orchestrator");
226
227 return (
228 orchResult || {
229 success: false,
230 answer: "Could not process your message.",
231 }
232 );
233 } catch (err) {
234 clearTimeout(timer);
235 if (timedOut) return { success: false, answer: "Request timed out." };
236 log.error("Gateway", "Gateway: orchestration error:", err.message);
237
238 rt.setError(
239 abort.signal.aborted ? "Cancelled by user" : "Error: " + err.message,
240 modeKey,
241 );
242
243 return { success: false, answer: "Something went wrong." };
244 } finally {
245 clearTimeout(timer);
246 if (!timedOut) {
247 await rt.cleanup();
248 }
249 // Remove this abort controller from the channel tracking
250 const abortSet = channelAborts.get(channelId);
251 if (abortSet) {
252 abortSet.delete(abort);
253 if (abortSet.size === 0) channelAborts.delete(channelId);
254 }
255 }
256 },
257 { maxConcurrent: MAX_CONCURRENT },
258 );
259
260 // 11. Build reply based on mode
261 let reply = null;
262 const hasOutput = channel.direction === "input-output";
263
264 if (hasOutput && result.success) {
265 if (skipRespond) {
266 // Place mode: brief confirmation
267 reply = result.stepSummaries?.length
268 ? "Placed: " + result.stepSummaries.join(", ")
269 : "Content placed on tree.";
270 } else {
271 reply = result.answer || null;
272 }
273 }
274
275 return { queued: true, result, reply };
276}
277
1export default {
2 name: "gateway",
3 version: "1.0.1",
4 builtFor: "treeos-connect",
5 description:
6 "A tree is not limited to the TreeOS interface. The gateway extension is the core " +
7 "abstraction that connects trees to external platforms: Discord, Slack, Telegram, email, " +
8 "Matrix, Reddit, X, and any future channel type. It provides the channel model, the " +
9 "CRUD API, the type registry, the dispatch system, and the input processor. Platform-" +
10 "specific extensions (gateway-discord, gateway-slack, gateway-email, etc.) register " +
11 "their handlers with this core and inherit all shared infrastructure." +
12 "\n\n" +
13 "Each channel binds a tree root to an external destination with three configuration " +
14 "dimensions. Direction controls data flow: output (tree pushes notifications outward), " +
15 "input (external messages flow into the tree), or input-output (bidirectional). Mode " +
16 "controls AI behavior on inbound messages: write maps to place (content goes onto the " +
17 "tree silently), read maps to query (AI answers without modifying the tree), and " +
18 "read-write maps to chat (full conversation with tree modifications). Notification " +
19 "types filter which outbound events a channel receives (dream summaries, dream thoughts, " +
20 "or custom types). Up to ten channels per tree root." +
21 "\n\n" +
22 "The dispatch system finds all enabled output channels for a root, filters by " +
23 "notification type, decrypts each channel's secrets, delegates to the registered " +
24 "platform handler's send function, and logs success or failure with timestamps and " +
25 "error messages. The input processor is a complete message pipeline: it validates the " +
26 "channel, enforces queue depth limits (max 2 concurrent per channel), resolves the " +
27 "channel owner's LLM access, creates an OrchestratorRuntime session, routes through " +
28 "the tree orchestrator with mode-appropriate flags, handles abort/cancel commands, and " +
29 "returns the AI's reply for the platform handler to deliver. Secrets (API keys, bot " +
30 "tokens, webhook URLs) are encrypted at rest using AES-256-CBC. The type registry " +
31 "enforces a strict handler contract: validateConfig, buildEncryptedConfig, send, and " +
32 "optional registerInput/unregisterInput for managing persistent connections like bots.",
33
34 needs: {
35 services: ["session"],
36 models: ["Node", "User"],
37 },
38
39 optional: {
40 services: ["energy"],
41 extensions: ["html-rendering", "treeos-base"],
42 },
43
44 provides: {
45 models: { GatewayChannel: "./model.js" },
46 routes: "./routes.js",
47 tools: false,
48 sessionTypes: {
49 GATEWAY_INPUT: "gateway-input",
50 },
51
52 cli: [
53 {
54 command: "gateway [action] [args...]", scope: ["land"],
55 description: "Gateway channels. Actions: add, update, delete, test. No action lists channels.",
56 method: "GET",
57 endpoint: "/root/:rootId/gateway",
58 subcommands: {
59 "add": {
60 method: "POST",
61 endpoint: "/root/:rootId/gateway",
62 args: ["name", "type", "direction", "mode"],
63 description: "Add a channel. Usage: gateway add <name> <type> <direction> <mode>",
64 },
65 "update": {
66 method: "PUT",
67 endpoint: "/gateway/channel/:channelId",
68 args: ["channelId"],
69 description: "Update a channel. Usage: gateway update <channelId> (pass fields in body)",
70 },
71 "delete": {
72 method: "DELETE",
73 endpoint: "/gateway/channel/:channelId",
74 args: ["channelId"],
75 description: "Delete a channel. Usage: gateway delete <channelId>",
76 },
77 "test": {
78 method: "POST",
79 endpoint: "/gateway/channel/:channelId/test",
80 args: ["channelId"],
81 description: "Send a test notification. Usage: gateway test <channelId>",
82 },
83 },
84 },
85 ],
86 },
87};
88
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const GatewayChannelSchema = new mongoose.Schema(
5 {
6 _id: {
7 type: String,
8 default: uuidv4,
9 },
10
11 userId: {
12 type: String,
13 ref: "User",
14 required: true,
15 index: true,
16 },
17
18 rootId: {
19 type: String,
20 ref: "Node",
21 required: true,
22 index: true,
23 },
24
25 name: {
26 type: String,
27 required: true,
28 trim: true,
29 maxlength: 100,
30 },
31
32 type: {
33 type: String,
34 enum: ["telegram", "discord", "webapp", "email", "sms", "slack", "matrix", "x", "reddit", "tree"],
35 required: true,
36 },
37
38 direction: {
39 type: String,
40 enum: ["input", "input-output", "output"],
41 default: "output",
42 },
43
44 mode: {
45 type: String,
46 enum: ["read", "read-write", "write"],
47 default: "write",
48 },
49
50 enabled: {
51 type: Boolean,
52 default: true,
53 },
54
55 config: {
56 encryptedPayload: { type: String, default: null },
57 displayIdentifier: { type: String, default: null },
58 metadata: { type: mongoose.Schema.Types.Mixed, default: {} },
59 },
60
61 notificationTypes: {
62 type: [String],
63 default: ["dream-summary", "dream-thought"],
64 },
65
66 queueBehavior: {
67 type: String,
68 enum: ["respond", "silent"],
69 default: "respond",
70 },
71
72 lastDispatchAt: {
73 type: Date,
74 default: null,
75 },
76
77 lastError: {
78 type: String,
79 default: null,
80 },
81 },
82 { timestamps: { createdAt: true, updatedAt: true } },
83);
84
85GatewayChannelSchema.index({ rootId: 1, type: 1 });
86
87const GatewayChannel = mongoose.model("GatewayChannel", GatewayChannelSchema);
88export default GatewayChannel;
89
1/* ------------------------------------------------- */
2/* Gateway page (extracted from root.js) */
3/* ------------------------------------------------- */
4
5import { page } from "../../html-rendering/html/layout.js";
6import { escapeHtml } from "../../html-rendering/html/utils.js";
7
8export function renderGateway({ rootId, rootName, queryString, channels }) {
9 const channelRows = channels.length === 0
10 ? '<p style="color:rgba(255,255,255,0.5);font-size:0.9rem;">No channels configured yet. Add one below.</p>'
11 : channels.map(function(ch) {
12 var typeBadge = ch.type === "telegram" ? "TG"
13 : ch.type === "discord" ? "DC"
14 : "WEB";
15 var typeColor = ch.type === "telegram" ? "rgba(0,136,204,0.8)"
16 : ch.type === "discord" ? "rgba(88,101,242,0.8)"
17 : "rgba(72,187,120,0.8)";
18 var statusDot = ch.enabled
19 ? '<span style="color:rgba(72,187,120,0.9);">●</span>'
20 : '<span style="color:rgba(255,107,107,0.9);">●</span>';
21 var notifList = (ch.notificationTypes || []).join(", ");
22 var lastDispatch = ch.lastDispatchAt
23 ? new Date(ch.lastDispatchAt).toLocaleString()
24 : "Never";
25 var lastErr = ch.lastError
26 ? '<span style="color:rgba(255,107,107,0.8);font-size:0.75rem;">' + escapeHtml(ch.lastError) + '</span>'
27 : '';
28
29 var dirLabel = ch.direction === "input-output" ? "I/O"
30 : ch.direction === "input" ? "IN"
31 : "OUT";
32 var modeLabel = ch.mode === "read-write" ? "CHAT"
33 : ch.mode === "read" ? "QUERY"
34 : "PLACE";
35
36 return `
37<div class="channel-row" data-id="${ch._id}" style="
38 background:rgba(255,255,255,0.06);border-radius:12px;padding:16px;margin-bottom:12px;
39 border:1px solid rgba(255,255,255,0.1);position:relative;">
40 <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
41 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
42 ${statusDot}
43 <span style="font-weight:600;color:#fff;">${escapeHtml(ch.name)}</span>
44 <span style="background:${typeColor};color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:4px;font-weight:600;">${typeBadge}</span>
45 <span style="background:rgba(255,255,255,0.12);color:rgba(255,255,255,0.7);font-size:0.65rem;padding:2px 6px;border-radius:4px;">${dirLabel}</span>
46 <span style="background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.5);font-size:0.65rem;padding:2px 6px;border-radius:4px;">${modeLabel}</span>
47 </div>
48 <div style="display:flex;gap:8px;">
49 <button onclick="testChannel('${ch._id}')" style="
50 padding:4px 12px;border-radius:6px;border:1px solid rgba(115,111,230,0.4);
51 background:rgba(115,111,230,0.15);color:rgba(200,200,255,0.9);font-size:0.8rem;cursor:pointer;">
52 Test</button>
53 <button onclick="toggleChannel('${ch._id}', ${!ch.enabled})" style="
54 padding:4px 12px;border-radius:6px;border:1px solid rgba(255,179,71,0.4);
55 background:rgba(255,179,71,0.1);color:rgba(255,179,71,0.9);font-size:0.8rem;cursor:pointer;">
56 ${ch.enabled ? "Disable" : "Enable"}</button>
57 <button onclick="deleteChannel('${ch._id}')" style="
58 padding:4px 12px;border-radius:6px;border:1px solid rgba(255,107,107,0.4);
59 background:rgba(255,107,107,0.1);color:rgba(255,107,107,0.8);font-size:0.8rem;cursor:pointer;">
60 Delete</button>
61 </div>
62 </div>
63 <div style="margin-top:8px;font-size:0.8rem;color:rgba(255,255,255,0.5);">
64 ${ch.config?.displayIdentifier ? escapeHtml(ch.config.displayIdentifier) + ' · ' : ''}
65 ${notifList} · Last sent: ${lastDispatch}
66 </div>
67 ${lastErr ? '<div style="margin-top:4px;">' + lastErr + '</div>' : ''}
68</div>`;
69 }).join('\n');
70
71 const css = `
72 body { color: #fff; }
73 .content-card {
74 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
75 backdrop-filter: blur(22px) saturate(140%);
76 border-radius: 16px; padding: 28px;
77 box-shadow: 0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 rgba(255,255,255,0.25);
78 border: 1px solid rgba(255,255,255,0.28);
79 margin-bottom: 24px; animation: fadeInUp 0.6s ease-out both;
80 }
81 .section-header h2 { color: #fff; font-size: 1.3rem; font-weight: 700; margin-bottom: 16px; }
82 .back-nav {
83 display: flex; gap: 12px; margin-bottom: 20px; animation: fadeInUp 0.5s ease-out;
84 }
85 .back-nav a {
86 background: rgba(var(--glass-water-rgb), 0.25);
87 backdrop-filter: blur(12px); border-radius: 10px; padding: 8px 16px;
88 color: rgba(255,255,255,0.9); text-decoration: none; font-size: 0.85rem;
89 border: 1px solid rgba(255,255,255,0.15); font-weight: 500;
90 }
91 .back-nav a:hover { background: rgba(var(--glass-water-rgb), 0.35); }
92 label { display: block; font-size: 0.85rem; color: rgba(255,255,255,0.7); margin-bottom: 4px; margin-top: 12px; }
93 input, select {
94 width: 100%; padding: 10px 14px; border-radius: 8px;
95 border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.08);
96 color: #fff; font-size: 0.9rem; outline: none;
97 }
98 input::placeholder { color: rgba(255,255,255,0.7); }
99 input:focus, select:focus { border-color: rgba(115,111,230,0.6); }
100 select option { background: #3a3a6e; color: #fff; }
101 .btn-primary {
102 padding: 10px 20px; border-radius: 8px; border: 1px solid rgba(72,187,120,0.4);
103 background: rgba(72,187,120,0.15); color: rgba(72,187,120,0.95);
104 font-weight: 600; cursor: pointer; font-size: 0.9rem; margin-top: 16px;
105 }
106 .btn-primary:hover { background: rgba(72,187,120,0.25); }
107 .checkbox-row {
108 display: flex; align-items: center; gap: 8px; margin-top: 6px;
109 }
110 .checkbox-row input[type="checkbox"] { width: auto; }
111 #gatewayStatus {
112 display: none; font-size: 0.85rem; margin-top: 12px; padding: 8px 12px;
113 border-radius: 8px;
114 }
115`;
116
117 const body = `
118<div class="container">
119
120 <div class="back-nav">
121 <a href="/api/v1/root/${rootId}${queryString}">Back to Tree</a>
122 </div>
123
124 <div class="content-card">
125 <div class="section-header">
126 <h2>Gateway Channels</h2>
127 </div>
128 <p style="color:rgba(255,255,255,0.6);font-size:0.85rem;margin-bottom:16px;">
129 Output channels push notifications from this tree to external services.
130 </p>
131 <div id="channelList">
132 ${channelRows}
133 </div>
134 </div>
135
136 <div class="content-card" style="animation-delay:0.1s;">
137 <div class="section-header">
138 <h2>Add Channel</h2>
139 </div>
140
141 <label for="channelName">Channel Name</label>
142 <input type="text" id="channelName" placeholder="e.g. My Discord Updates" maxlength="100" />
143
144 <label for="channelType">Type</label>
145 <select id="channelType" onchange="updateFormFields()">
146 <option value="telegram">Telegram</option>
147 <option value="discord">Discord</option>
148 <option value="webapp">Web Push (this browser)</option>
149 </select>
150
151 <label for="channelDirection">Direction</label>
152 <select id="channelDirection" onchange="updateFormFields()">
153 <option value="output">Output (send notifications out)</option>
154 <option value="input">Input (receive messages in)</option>
155 <option value="input-output">Input/Output (bidirectional chat)</option>
156 </select>
157
158 <label for="channelMode">Mode</label>
159 <select id="channelMode">
160 <option value="write">Place (scans tree, makes edits, no response)</option>
161 <option value="read">Query (reads tree, responds, no edits)</option>
162 <option value="read-write">Chat (reads tree, makes edits, responds)</option>
163 </select>
164
165 <div id="telegramFields" style="margin-top:8px;">
166 <label for="tgBotToken">Bot Token</label>
167 <input type="password" id="tgBotToken" placeholder="123456:ABC-DEF..." />
168 <label for="tgChatId">Chat ID</label>
169 <input type="text" id="tgChatId" placeholder="-1001234567890" />
170 </div>
171
172 <div id="discordOutputFields" style="display:none;">
173 <label for="dcWebhookUrl">Webhook URL</label>
174 <input type="password" id="dcWebhookUrl" placeholder="https://discord.com/api/webhooks/..." />
175 </div>
176
177 <div id="discordInputFields" style="display:none;">
178 <div style="background:rgba(255,255,255,0.05);border-radius:8px;padding:12px;margin-top:8px;margin-bottom:12px;border:1px solid rgba(255,255,255,0.1);">
179 <div style="color:rgba(255,255,255,0.8);font-size:0.82rem;font-weight:600;margin-bottom:8px;">How to get your Discord bot details:</div>
180 <ol style="color:rgba(255,255,255,0.6);font-size:0.8rem;margin:0;padding-left:18px;line-height:1.6;">
181 <li>Go to <a href="https://discord.com/developers/applications" target="_blank" style="color:#1a1a1a;">Discord Developer Portal</a></li>
182 <li>Create a New Application, then go to the <strong>Bot</strong> tab</li>
183 <li>Click "Reset Token" to get your bot token and copy it</li>
184 <li>Enable <strong>Message Content Intent</strong> under Privileged Gateway Intents</li>
185 <li>Go to <strong>Installation</strong> tab, set integration type to <strong>Guild Install</strong></li>
186 <li>Go to <strong>OAuth2</strong> tab, check <em>bot</em> scope, then under Bot Permissions check <strong>Read Message History</strong> and <strong>Send Messages</strong></li>
187 <li>Copy the generated URL and open it to invite the bot to your server</li>
188 <li>In Discord, right-click the channel you want, click "Copy Channel ID"<br/>(Enable Developer Mode in Discord Settings > Advanced if you don't see it)</li>
189 </ol>
190 </div>
191 <label for="dcBotToken">Bot Token</label>
192 <input type="password" id="dcBotToken" placeholder="Discord bot token..." />
193 <label for="dcChannelId">Discord Channel ID</label>
194 <input type="text" id="dcChannelId" placeholder="1234567890123456789" />
195 <p style="color:rgba(255,179,71,0.7);font-size:0.8rem;margin-top:6px;">
196 Discord input requires Standard, Premium, or God tier.
197 </p>
198 </div>
199
200 <div id="webappFields" style="display:none;">
201 <p style="color:rgba(255,255,255,0.6);font-size:0.85rem;margin-top:12px;">
202 Your browser will ask for notification permission when you add this channel.
203 </p>
204 </div>
205
206 <div id="outputNotifSection" style="display:none;">
207 <label style="margin-top:16px;">Notification Types</label>
208 <div class="checkbox-row">
209 <input type="checkbox" id="notifSummary" checked /> <label for="notifSummary" style="margin:0;">Dream Summary</label>
210 </div>
211 <div class="checkbox-row">
212 <input type="checkbox" id="notifThought" checked /> <label for="notifThought" style="margin:0;">Dream Thought</label>
213 </div>
214 </div>
215
216 <div id="inputConfigSection" style="display:none;">
217 <label for="queueBehavior" style="margin-top:16px;">When Busy (2+ messages processing)</label>
218 <select id="queueBehavior">
219 <option value="respond">Respond with busy message</option>
220 <option value="silent">Stay silent</option>
221 </select>
222 </div>
223
224 <button class="btn-primary" onclick="addChannel()">Add Channel</button>
225 <div id="gatewayStatus"></div>
226 </div>
227
228</div>
229`;
230
231 const js = `
232var ROOT_ID = "${rootId}";
233
234function updateFormFields() {
235 var type = document.getElementById("channelType").value;
236 var direction = document.getElementById("channelDirection").value;
237 var hasOutput = direction === "output" || direction === "input-output";
238
239 // Webapp can only be output
240 var dirSelect = document.getElementById("channelDirection");
241 var modeSelect = document.getElementById("channelMode");
242 var modeLabel = document.querySelector('label[for="channelMode"]');
243 if (type === "webapp") {
244 dirSelect.value = "output";
245 dirSelect.disabled = true;
246 hasOutput = true;
247 } else {
248 dirSelect.disabled = false;
249 }
250
251 // Mode only relevant for channels with input capability
252 var hasInput = direction === "input" || direction === "input-output";
253 modeSelect.style.display = hasInput ? "block" : "none";
254 modeLabel.style.display = hasInput ? "block" : "none";
255
256 // Smart defaults per direction
257 if (direction === "input") {
258 modeSelect.value = "write";
259 } else if (direction === "input-output") {
260 modeSelect.value = "read-write";
261 }
262
263 // Telegram: always show (same bot token + chat ID for input and output)
264 document.getElementById("telegramFields").style.display = type === "telegram" ? "block" : "none";
265
266 // Discord: show different fields based on direction
267 document.getElementById("discordOutputFields").style.display = (type === "discord" && !hasInput) ? "block" : "none";
268 document.getElementById("discordInputFields").style.display = (type === "discord" && hasInput) ? "block" : "none";
269
270 // Webapp: only on output
271 document.getElementById("webappFields").style.display = (type === "webapp" && hasOutput) ? "block" : "none";
272
273 // Notification types: only for output channels
274 document.getElementById("outputNotifSection").style.display = hasOutput ? "block" : "none";
275
276 // Queue behavior: only for input channels
277 document.getElementById("inputConfigSection").style.display = hasInput ? "block" : "none";
278}
279
280function showStatus(msg, isError) {
281 var el = document.getElementById("gatewayStatus");
282 el.style.display = "block";
283 el.style.background = isError ? "rgba(255,107,107,0.15)" : "rgba(72,187,120,0.15)";
284 el.style.color = isError ? "rgba(255,107,107,0.95)" : "rgba(72,187,120,0.95)";
285 el.textContent = msg;
286 if (!isError) setTimeout(function() { el.style.display = "none"; }, 4000);
287}
288
289async function getWebPushSubscription() {
290 if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
291 throw new Error("Push notifications are not supported in this browser");
292 }
293
294 var permission = await Notification.requestPermission();
295 if (permission !== "granted") {
296 throw new Error("Notification permission denied");
297 }
298
299 var reg = await navigator.serviceWorker.register("/sw.js");
300 await navigator.serviceWorker.ready;
301
302 var vapidRes = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/vapid-key")
303 .then(function(r) { return r.json(); });
304 var vapidKey = vapidRes.data || vapidRes;
305
306 if (!vapidKey.key) throw new Error("VAPID key not configured on server");
307
308 var sub = await reg.pushManager.subscribe({
309 userVisibleOnly: true,
310 applicationServerKey: urlBase64ToUint8Array(vapidKey.key),
311 });
312
313 return sub.toJSON();
314}
315
316function urlBase64ToUint8Array(base64String) {
317 var padding = "=".repeat((4 - (base64String.length % 4)) % 4);
318 var base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
319 var rawData = atob(base64);
320 var outputArray = new Uint8Array(rawData.length);
321 for (var i = 0; i < rawData.length; i++) {
322 outputArray[i] = rawData.charCodeAt(i);
323 }
324 return outputArray;
325}
326
327async function addChannel() {
328 var name = document.getElementById("channelName").value.trim();
329 var type = document.getElementById("channelType").value;
330 var direction = document.getElementById("channelDirection").value;
331 var mode = document.getElementById("channelMode").value;
332 var hasOutput = direction === "output" || direction === "input-output";
333 var hasInput = direction === "input" || direction === "input-output";
334
335 if (!name) { showStatus("Please enter a channel name", true); return; }
336
337 var config = {};
338
339 try {
340 if (type === "telegram") {
341 // Telegram always needs bot token + chat ID
342 var botToken = document.getElementById("tgBotToken").value.trim();
343 var chatId = document.getElementById("tgChatId").value.trim();
344 if (!botToken || !chatId) { showStatus("Bot token and chat ID are required", true); return; }
345 config = { botToken: botToken, chatId: chatId };
346 } else if (type === "discord") {
347 if (hasInput) {
348 // Discord input: bot token + channel ID
349 var dcBotToken = document.getElementById("dcBotToken").value.trim();
350 var dcChannelId = document.getElementById("dcChannelId").value.trim();
351 if (!dcBotToken || !dcChannelId) { showStatus("Bot token and channel ID are required for Discord input", true); return; }
352 config = { botToken: dcBotToken, discordChannelId: dcChannelId };
353 // For input-output, optionally add webhook URL for output side
354 if (hasOutput) {
355 var webhookUrl = document.getElementById("dcWebhookUrl").value.trim();
356 if (webhookUrl) config.webhookUrl = webhookUrl;
357 }
358 } else {
359 // Discord output-only: webhook URL
360 var webhookUrl = document.getElementById("dcWebhookUrl").value.trim();
361 if (!webhookUrl) { showStatus("Webhook URL is required", true); return; }
362 config = { webhookUrl: webhookUrl };
363 }
364 } else if (type === "webapp") {
365 var subscription = await getWebPushSubscription();
366 config = { subscription: subscription, displayIdentifier: navigator.userAgent.split(" ").pop() || "Browser" };
367 }
368 } catch (err) {
369 showStatus(err.message, true);
370 return;
371 }
372
373 var notificationTypes = [];
374 if (hasOutput) {
375 if (document.getElementById("notifSummary").checked) notificationTypes.push("dream-summary");
376 if (document.getElementById("notifThought").checked) notificationTypes.push("dream-thought");
377 if (notificationTypes.length === 0 && direction === "output") { showStatus("Select at least one notification type", true); return; }
378 }
379
380 var queueBehavior = hasInput ? document.getElementById("queueBehavior").value : "respond";
381
382 try {
383 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels", {
384 method: "POST",
385 credentials: "include",
386 headers: { "Content-Type": "application/json" },
387 body: JSON.stringify({ name: name, type: type, direction: direction, mode: mode, config: config, notificationTypes: notificationTypes, queueBehavior: queueBehavior }),
388 });
389 var data = await res.json();
390 if (!res.ok) { showStatus((data.error && data.error.message) || data.error || "Failed to add channel", true); return; }
391 showStatus("Channel added successfully");
392 setTimeout(function() { location.reload(); }, 1000);
393 } catch (err) {
394 showStatus("Network error: " + err.message, true);
395 }
396}
397
398async function testChannel(channelId) {
399 try {
400 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId + "/test", {
401 method: "POST",
402 credentials: "include",
403 headers: { "Content-Type": "application/json" },
404 });
405 var data = await res.json();
406 if (!res.ok) { alert((data.error && data.error.message) || data.error || "Test failed"); return; }
407 alert("Test notification sent!");
408 } catch (err) { alert("Network error"); }
409}
410
411async function toggleChannel(channelId, enabled) {
412 try {
413 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId, {
414 method: "PUT",
415 credentials: "include",
416 headers: { "Content-Type": "application/json" },
417 body: JSON.stringify({ enabled: enabled }),
418 });
419 if (res.ok) location.reload();
420 else { var data = await res.json(); alert((data.error && data.error.message) || data.error || "Failed"); }
421 } catch (err) { alert("Network error"); }
422}
423
424async function deleteChannel(channelId) {
425 if (!confirm("Delete this channel?")) return;
426 try {
427 var res = await fetch("/api/v1/root/" + ROOT_ID + "/gateway/channels/" + channelId, {
428 method: "DELETE",
429 credentials: "include",
430 headers: { "Content-Type": "application/json" },
431 });
432 if (res.ok) location.reload();
433 else { var data = await res.json(); alert((data.error && data.error.message) || data.error || "Failed"); }
434 } catch (err) { alert("Network error"); }
435}
436`;
437
438 return page({
439 title: `Gateway -- ${escapeHtml(rootName)}`,
440 css,
441 body,
442 js,
443 });
444}
445
1// Gateway channel type registry.
2// Channel extensions (gateway-discord, gateway-telegram, gateway-webhook)
3// register their handlers here during init(). The gateway core delegates
4// validation, encryption, dispatch, and lifecycle to the registered handler.
5//
6// Handler shape:
7// {
8// allowedDirections: ["input", "output", "input-output"],
9// validateConfig(config, direction),
10// buildEncryptedConfig(config, direction) -> { secrets, metadata, displayIdentifier? },
11// send(secrets, metadata, notification),
12// registerInput?(channel, secrets),
13// unregisterInput?(channel, secrets),
14// requiredTiers?: ["standard", "premium"], // for input channels, null = no restriction
15// }
16
17const types = new Map();
18
19export function registerChannelType(typeName, handler) {
20 if (!typeName || typeof typeName !== "string") {
21 throw new Error("Channel type name must be a non-empty string");
22 }
23 if (!handler || typeof handler !== "object") {
24 throw new Error("Channel type handler must be an object");
25 }
26 if (!handler.validateConfig || typeof handler.validateConfig !== "function") {
27 throw new Error(`Handler for "${typeName}" must provide validateConfig(config, direction)`);
28 }
29 if (!handler.buildEncryptedConfig || typeof handler.buildEncryptedConfig !== "function") {
30 throw new Error(`Handler for "${typeName}" must provide buildEncryptedConfig(config, direction)`);
31 }
32 if (!handler.send || typeof handler.send !== "function") {
33 throw new Error(`Handler for "${typeName}" must provide send(secrets, metadata, notification)`);
34 }
35 if (!Array.isArray(handler.allowedDirections) || handler.allowedDirections.length === 0) {
36 throw new Error(`Handler for "${typeName}" must provide allowedDirections array`);
37 }
38 types.set(typeName, handler);
39}
40
41export function getChannelType(typeName) {
42 return types.get(typeName);
43}
44
45export function getRegisteredTypes() {
46 return [...types.keys()];
47}
48
49export function hasChannelType(typeName) {
50 return types.has(typeName);
51}
52
1import log from "../../seed/log.js";
2import express from "express";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import {
6 addGatewayChannel,
7 updateGatewayChannel,
8 deleteGatewayChannel,
9 getChannelsForRoot,
10 getChannelWithSecrets,
11} from "./core.js";
12import { dispatchTestNotification } from "./dispatch.js";
13import { getExtension } from "../loader.js";
14
15let htmlAuth = authenticate;
16export function resolveHtmlAuth() {
17 const htmlExt = getExtension("html-rendering");
18 if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
19}
20
21const router = express.Router();
22
23// ─────────────────────────────────────────────────────────────────────────
24// AUTHENTICATED: Channel CRUD
25// ─────────────────────────────────────────────────────────────────────────
26
27// List channels for a tree
28router.get("/root/:rootId/gateway", htmlAuth, async (req, res) => {
29 try {
30 const channels = await getChannelsForRoot(req.params.rootId);
31 if ("html" in req.query) {
32 try {
33 const { renderGateway } = await import("./pages/gateway.js");
34 const Node = (await import("../../seed/models/node.js")).default;
35 const root = await Node.findById(req.params.rootId).select("name").lean();
36 return res.send(renderGateway({ rootId: req.params.rootId, rootName: root?.name || "", queryString: `?token=${req.query.token || ""}&html`, channels }));
37 } catch (err) { log.debug("Gateway", "HTML rendering failed:", err.message); }
38 }
39 sendOk(res, { channels });
40 } catch (err) {
41 log.error("Gateway", "List channels error:", err.message);
42 sendError(res, 400, ERR.INVALID_INPUT, err.message);
43 }
44});
45
46// Add a channel
47router.post("/root/:rootId/gateway", authenticate, async (req, res) => {
48 try {
49 const channel = await addGatewayChannel(req.userId, req.params.rootId, req.body);
50 sendOk(res, { channel }, 201);
51 } catch (err) {
52 log.error("Gateway", "Add channel error:", err.message);
53 sendError(res, 400, ERR.INVALID_INPUT, err.message);
54 }
55});
56
57// Update a channel
58router.put("/gateway/channel/:channelId", authenticate, async (req, res) => {
59 try {
60 const channel = await updateGatewayChannel(req.userId, req.params.channelId, req.body);
61 sendOk(res, { channel });
62 } catch (err) {
63 log.error("Gateway", "Update channel error:", err.message);
64 sendError(res, 400, ERR.INVALID_INPUT, err.message);
65 }
66});
67
68// Delete a channel
69router.delete("/gateway/channel/:channelId", authenticate, async (req, res) => {
70 try {
71 const result = await deleteGatewayChannel(req.userId, req.params.channelId);
72 sendOk(res, result);
73 } catch (err) {
74 log.error("Gateway", "Delete channel error:", err.message);
75 sendError(res, 400, ERR.INVALID_INPUT, err.message);
76 }
77});
78
79// Test a channel (send test notification)
80router.post("/gateway/channel/:channelId/test", authenticate, async (req, res) => {
81 try {
82 const result = await dispatchTestNotification(req.params.channelId);
83 sendOk(res, result);
84 } catch (err) {
85 log.error("Gateway", "Test channel error:", err.message);
86 sendError(res, 400, ERR.INVALID_INPUT, err.message);
87 }
88});
89
90export default router;
91
Loading comments...