EXTENSION for treeos-connect
gateway
A tree is not limited to the TreeOS interface. The gateway extension is the core abstraction that connects trees to external platforms: Discord, Slack, Telegram, email, Matrix, Reddit, X, and any future channel type. It provides the channel model, the CRUD API, the type registry, the dispatch system, and the input processor. Platform-specific extensions (gateway-discord, gateway-slack, gateway-email, etc.) register their handlers with this core and inherit all shared infrastructure. Each channel binds a tree root to an external destination with three configuration dimensions. Direction controls data flow: output (tree pushes notifications outward), input (external messages flow into the tree), or input-output (bidirectional). Mode controls AI behavior on inbound messages: write maps to place (content goes onto the tree silently), read maps to query (AI answers without modifying the tree), and read-write maps to chat (full conversation with tree modifications). Notification types filter which outbound events a channel receives (dream summaries, dream thoughts, or custom types). Up to ten channels per tree root. The dispatch system finds all enabled output channels for a root, filters by notification type, decrypts each channel's secrets, delegates to the registered platform handler's send function, and logs success or failure with timestamps and error messages. The input processor is a complete message pipeline: it validates the channel, enforces queue depth limits (max 2 concurrent per channel), resolves the channel owner's LLM access, creates an OrchestratorRuntime session, routes through the tree orchestrator with mode-appropriate flags, handles abort/cancel commands, and returns the AI's reply for the platform handler to deliver. Secrets (API keys, bot tokens, webhook URLs) are encrypted at rest using AES-256-CBC. The type registry enforces a strict handler contract: validateConfig, buildEncryptedConfig, send, and optional registerInput/unregisterInput for managing persistent connections like bots.
v1.0.1 by TreeOS Site 0 downloads 10 files 1,661 lines 58.6 KB published 38d ago
treeos ext install gateway
View changelog

Manifest

Provides

  • 1 models
  • routes
  • 1 CLI commands

Requires

  • services: session
  • models: Node, User

Optional

  • services: energy
  • extensions: html-rendering, treeos-base
SHA256: 66ce4f5deeb6a78acda259546f993522255e973f6e46292a90ca775aab0915ba

Dependents

11 packages depend on this

PackageTypeRelationship
gateway-x v1.0.1extensionneeds
gateway-matrix v1.0.1extensionneeds
gateway-reddit v1.0.1extensionneeds
gateway-sms v1.0.1extensionneeds
gateway-telegram v1.0.1extensionneeds
gateway-webhook v1.0.1extensionneeds
gateway-tree v1.0.1extensionneeds
treeos-connect v1.0.3bundleincludes
gateway-discord v1.0.1extensionneeds
gateway-email v1.0.1extensionneeds
gateway-slack v1.0.1extensionneeds

CLI Commands

CommandMethodDescription
gatewayGETGateway channels. Actions: add, update, delete, test. No action lists channels.
gateway addPOSTAdd a channel. Usage: gateway add <name> <type> <direction> <mode>
gateway updatePUTUpdate a channel. Usage: gateway update <channelId> (pass fields in body)
gateway deleteDELETEDelete a channel. Usage: gateway delete <channelId>
gateway testPOSTSend a test notification. Usage: gateway test <channelId>

Source Code

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);">&#9679;</span>'
20          : '<span style="color:rgba(255,107,107,0.9);">&#9679;</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) + ' &middot; ' : ''}
65    ${notifList} &middot; 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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star gateway

Comments

Loading comments...

Post comments from the CLI: treeos ext comment gateway "your comment"
Max 3 comments per extension. One star and one flag per user.