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
Loading comments...