EXTENSION for treeos-connect
gateway-slack
Registers the Slack channel type with the gateway core, enabling trees to send and receive messages in Slack workspaces. Output channels post notifications to a Slack channel using the chat.postMessage API with a Bot User OAuth Token (xoxb-). Messages are formatted with Slack's bold markdown (*title*) and link/media unfurling is disabled for clean notification display. Input channels receive messages via Slack's Events API. The extension exposes a webhook endpoint at POST /api/v1/gateway/slack/:channelId that handles both the one-time url_verification challenge (sent during Slack app setup) and ongoing event_callback payloads. Request authenticity is verified using HMAC-SHA256 signature validation against the SLACK_SIGNING_SECRET, with a 5-minute replay attack window. Only plain message events are processed: bot messages, subtypes (edits, deletions), and messages from other bots are ignored to prevent loops. Input-output channels close the loop. When a Slack message is processed through the tree orchestrator and produces a reply, the bot posts the response back as a threaded reply (using thread_ts) in the same Slack channel. This means the tree's responses appear as thread replies to the original message, keeping the main channel clean. Team members interact with the tree by posting in the configured Slack channel. They do not need to install TreeOS or create accounts. The bot token and Slack channel ID can be configured globally via environment variables or per channel. The Slack app needs the channels:history (or groups:history for private channels) and chat:write OAuth scopes, plus the message.channels (or message.groups) event subscription.
v1.0.1 by TreeOS Site 0 downloads 4 files 292 lines 10.1 KB published 38d ago
treeos ext install gateway-slack
View changelog

Manifest

Requires

  • extensions: gateway
SHA256: edef395c1cfd81fd372d864477638f0aeea7ebf4013a5b4a476168c0cdac0561

Dependents

1 package depend on this

PackageTypeRelationship
treeos-connect v1.0.3bundleincludes

Environment Variables

KeyRequiredDescription
SLACK_BOT_TOKEN secret No Slack Bot User OAuth Token (xoxb-...)
SLACK_SIGNING_SECRET secret No Slack app signing secret for webhook verification

Source Code

1// Slack channel type handler.
2// Output: posts messages to a Slack channel via chat.postMessage API.
3// Input: receives messages via Slack Events API webhook.
4// Config: botToken, slackChannelId. Signing secret from env for webhook verification.
5
6import log from "../../seed/log.js";
7import crypto from "crypto";
8
9function validateConfig(config, direction) {
10  const botToken = config.botToken || process.env.SLACK_BOT_TOKEN;
11  if (!botToken || !botToken.startsWith("xoxb-")) {
12    throw new Error("Slack requires a Bot User OAuth Token (xoxb-...) in config or SLACK_BOT_TOKEN env var");
13  }
14
15  if (!config.slackChannelId || typeof config.slackChannelId !== "string") {
16    throw new Error("Slack requires a slackChannelId (e.g., C01ABCDEF)");
17  }
18}
19
20function buildEncryptedConfig(config, direction) {
21  const secrets = {};
22  const metadata = {};
23
24  if (config.botToken) secrets.botToken = config.botToken;
25  metadata.slackChannelId = config.slackChannelId;
26
27  return {
28    secrets,
29    metadata,
30    displayIdentifier: `#${config.channelName || config.slackChannelId}`,
31  };
32}
33
34function getToken(secrets) {
35  return secrets.botToken || process.env.SLACK_BOT_TOKEN;
36}
37
38async function slackApi(method, token, body) {
39  const res = await fetch(`https://slack.com/api/${method}`, {
40    method: "POST",
41    headers: {
42      "Authorization": `Bearer ${token}`,
43      "Content-Type": "application/json; charset=utf-8",
44    },
45    body: JSON.stringify(body),
46  });
47
48  const data = await res.json();
49  if (!data.ok) {
50    throw new Error(`Slack API ${method}: ${data.error || "unknown error"}`);
51  }
52  return data;
53}
54
55async function send(secrets, metadata, notification) {
56  const token = getToken(secrets);
57  const text = notification.title
58    ? `*${notification.title}*\n\n${notification.content}`
59    : notification.content;
60
61  await slackApi("chat.postMessage", token, {
62    channel: metadata.slackChannelId,
63    text,
64    unfurl_links: false,
65    unfurl_media: false,
66  });
67}
68
69async function registerInput(channel, secrets) {
70  // Slack Events API uses a single webhook URL for all events.
71  // The URL is: POST /api/v1/gateway/slack/:channelId
72  // Configure in Slack App > Event Subscriptions > Request URL.
73  // Subscribe to: message.channels (public) or message.groups (private).
74  log.info("GatewaySlack",
75    `Slack input registered for channel ${channel._id}. ` +
76    `Events URL: POST /api/v1/gateway/slack/${channel._id}`,
77  );
78}
79
80async function unregisterInput(channel, secrets) {
81  log.verbose("GatewaySlack", `Slack input unregistered for channel ${channel._id}`);
82}
83
84// Exported for routes.js
85export { slackApi, getToken };
86
87export default {
88  allowedDirections: ["input", "output", "input-output"],
89  validateConfig,
90  buildEncryptedConfig,
91  send,
92  registerInput,
93  unregisterInput,
94};
95
1import log from "../../seed/log.js";
2import handler from "./handler.js";
3import { getExtension } from "../loader.js";
4
5export async function init(core) {
6  const gateway = getExtension("gateway");
7  if (!gateway?.exports?.registerChannelType) {
8    throw new Error("gateway-slack requires the gateway extension to be loaded first");
9  }
10
11  gateway.exports.registerChannelType("slack", handler);
12  log.verbose("GatewaySlack", "Registered Slack channel type");
13
14  const { default: router } = await import("./routes.js");
15
16  return { router };
17}
18
1export default {
2  name: "gateway-slack",
3  version: "1.0.1",
4  builtFor: "treeos-connect",
5  description:
6    "Registers the Slack channel type with the gateway core, enabling trees to send and " +
7    "receive messages in Slack workspaces. Output channels post notifications to a Slack " +
8    "channel using the chat.postMessage API with a Bot User OAuth Token (xoxb-). Messages " +
9    "are formatted with Slack's bold markdown (*title*) and link/media unfurling is " +
10    "disabled for clean notification display." +
11    "\n\n" +
12    "Input channels receive messages via Slack's Events API. The extension exposes a " +
13    "webhook endpoint at POST /api/v1/gateway/slack/:channelId that handles both the " +
14    "one-time url_verification challenge (sent during Slack app setup) and ongoing " +
15    "event_callback payloads. Request authenticity is verified using HMAC-SHA256 signature " +
16    "validation against the SLACK_SIGNING_SECRET, with a 5-minute replay attack window. " +
17    "Only plain message events are processed: bot messages, subtypes (edits, deletions), " +
18    "and messages from other bots are ignored to prevent loops." +
19    "\n\n" +
20    "Input-output channels close the loop. When a Slack message is processed through the " +
21    "tree orchestrator and produces a reply, the bot posts the response back as a threaded " +
22    "reply (using thread_ts) in the same Slack channel. This means the tree's responses " +
23    "appear as thread replies to the original message, keeping the main channel clean. " +
24    "Team members interact with the tree by posting in the configured Slack channel. They " +
25    "do not need to install TreeOS or create accounts. The bot token and Slack channel ID " +
26    "can be configured globally via environment variables or per channel. The Slack app " +
27    "needs the channels:history (or groups:history for private channels) and chat:write " +
28    "OAuth scopes, plus the message.channels (or message.groups) event subscription.",
29
30  needs: {
31    extensions: ["gateway"],
32  },
33
34  optional: {},
35
36  provides: {
37    models: {},
38    routes: false,
39    tools: false,
40    jobs: false,
41    orchestrator: false,
42    energyActions: {},
43    sessionTypes: {},
44    env: [
45      { key: "SLACK_BOT_TOKEN", required: false, secret: true, description: "Slack Bot User OAuth Token (xoxb-...)" },
46      { key: "SLACK_SIGNING_SECRET", required: false, secret: true, description: "Slack app signing secret for webhook verification" },
47    ],
48    cli: [],
49  },
50};
51
1// Slack Events API webhook receiver.
2// Handles: url_verification challenge, event_callback for messages.
3// Verifies request signature using SLACK_SIGNING_SECRET.
4// Webhook URL: POST /api/v1/gateway/slack/:channelId
5
6import log from "../../seed/log.js";
7import { sendOk } from "../../seed/protocol.js";
8import express from "express";
9import crypto from "crypto";
10
11const router = express.Router();
12
13// Slack sends JSON with signature verification via headers
14router.post("/gateway/slack/:channelId", express.json({ limit: "256kb" }), async (req, res) => {
15  const body = req.body;
16
17  // Slack URL verification challenge (sent once during setup)
18  if (body.type === "url_verification") {
19    return res.json({ challenge: body.challenge });
20  }
21
22  // Respond 200 immediately (Slack retries on 3s timeout)
23  sendOk(res, { ok: true });
24
25  // Only process event_callback
26  if (body.type !== "event_callback" || !body.event) return;
27
28  try {
29    const channelId = req.params.channelId;
30    const event = body.event;
31
32    // Only handle message events (not subtypes like bot_message, message_changed)
33    if (event.type !== "message" || event.subtype) return;
34    if (event.bot_id) return; // Ignore bot messages (prevents loops)
35    if (!event.text) return;
36
37    // Verify signing secret if configured
38    const signingSecret = process.env.SLACK_SIGNING_SECRET;
39    if (signingSecret) {
40      const timestamp = req.headers["x-slack-request-timestamp"];
41      const slackSig = req.headers["x-slack-signature"];
42
43      // Prevent replay attacks (5 min window)
44      if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
45        log.warn("GatewaySlack", `Request too old for channel ${channelId}`);
46        return;
47      }
48
49      const rawBody = JSON.stringify(body);
50      const baseString = `v0:${timestamp}:${rawBody}`;
51      const computed = "v0=" + crypto
52        .createHmac("sha256", signingSecret)
53        .update(baseString, "utf8")
54        .digest("hex");
55
56      if (!crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(slackSig || ""))) {
57        log.warn("GatewaySlack", `Signature mismatch for channel ${channelId}`);
58        return;
59      }
60    }
61
62    // Load channel
63    const { getExtension } = await import("../loader.js");
64    const GatewayChannel = getExtension("gateway")?.exports?.GatewayChannel;
65    const channel = await GatewayChannel.findById(channelId).lean();
66    if (!channel || !channel.enabled) return;
67    if (channel.type !== "slack") return;
68
69    const hasInput = channel.direction === "input" || channel.direction === "input-output";
70    if (!hasInput) return;
71
72    // Verify the event is from the configured Slack channel
73    const expectedChannel = channel.config?.metadata?.slackChannelId;
74    if (expectedChannel && event.channel !== expectedChannel) return;
75
76    const senderName = event.user || "unknown";
77    const senderPlatformId = event.user || "";
78    const messageText = event.text.trim();
79
80    if (!messageText) return;
81
82    log.verbose("GatewaySlack",
83      `Slack message on channel ${channelId} from ${senderName}: "${messageText.slice(0, 80)}"`,
84    );
85
86    // Process via gateway core
87    const gateway = getExtension("gateway");
88    if (!gateway?.exports?.processGatewayMessage) {
89      log.error("GatewaySlack", "Gateway core not loaded");
90      return;
91    }
92
93    const result = await gateway.exports.processGatewayMessage(channelId, {
94      senderName,
95      senderPlatformId,
96      messageText,
97    });
98
99    // Reply in the Slack channel if input-output
100    if (result.reply && channel.direction === "input-output") {
101      try {
102        const { slackApi, getToken } = await import("./handler.js");
103        const fullChannel = await gateway.exports.getChannelWithSecrets(channel._id);
104        const secrets = fullChannel?.config?.decryptedSecrets || {};
105        const token = getToken(secrets);
106        if (!token) throw new Error("No Slack bot token available for reply");
107
108        await slackApi("chat.postMessage", token, {
109          channel: event.channel,
110          text: result.reply,
111          thread_ts: event.ts,
112          unfurl_links: false,
113          unfurl_media: false,
114        });
115      } catch (replyErr) {
116        log.warn("GatewaySlack", `Reply failed on channel ${channelId}: ${replyErr.message}`);
117      }
118    }
119  } catch (err) {
120    log.error("GatewaySlack",
121      `Slack webhook error for channel ${req.params.channelId}:`,
122      err.message,
123    );
124  }
125});
126
127export default router;
128

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-slack

Comments

Loading comments...

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