diff --git a/integrations/slack/manifest.yaml b/integrations/slack/manifest.yaml new file mode 100644 index 0000000..8c07d1f --- /dev/null +++ b/integrations/slack/manifest.yaml @@ -0,0 +1,97 @@ +id: slack +name: Slack +version: 1.0.0 +description: "Slack (Web API) — SOC notification and ChatOps: post messages (text or Block Kit), list/create channels, invite users, set channel topics, look up users, and add reactions. Bot-token authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: send message, list/create channel, invite to channel, set topic, get user, add reaction." +category: notification + +# Per-instance configuration. The bot token (xoxb-...) is sent as +# 'Authorization: Bearer '. Needs scopes such as chat:write, +# channels:read, channels:manage, users:read, users:read.email, reactions:write. +config_schema: + properties: + bot_token: + type: string + description: "Slack bot token (xoxb-...)" + x-soar-sensitive: true + required: + - bot_token + +commands: + - id: send_message + name: slack-send-message + description: "Post a message to a channel or user (text and/or Block Kit blocks)." + inputs_schema: + properties: + channel: { type: string, description: "Channel ID, channel name (#alerts), or user ID for a DM" } + text: { type: string, description: "Message text (fallback text when blocks are used)" } + blocks: { type: string, description: "Optional Block Kit blocks as a JSON array string" } + thread_ts: { type: string, description: "Optional parent message ts to reply in a thread" } + required: [channel] + outputs_schema: { properties: {} } + - id: list_channels + name: slack-list-channels + description: "List channels (public and private the bot can see)." + risk: read + inputs_schema: + properties: + types: { type: string, description: "Comma-separated channel types (default 'public_channel,private_channel')" } + limit: { type: number, description: "Max channels (default 200)" } + required: [] + outputs_schema: { properties: {} } + - id: create_channel + name: slack-create-channel + description: "Create a new channel." + inputs_schema: + properties: + name: { type: string, description: "Channel name (lowercase, no spaces)" } + is_private: { type: boolean, description: "Create a private channel (default false)" } + required: [name] + outputs_schema: { properties: {} } + - id: invite_to_channel + name: slack-invite-to-channel + description: "Invite one or more users to a channel." + inputs_schema: + properties: + channel: { type: string, description: "Channel ID" } + users: { type: string, description: "Comma-separated user IDs to invite" } + required: [channel, users] + outputs_schema: { properties: {} } + - id: set_channel_topic + name: slack-set-channel-topic + description: "Set a channel's topic." + inputs_schema: + properties: + channel: { type: string, description: "Channel ID" } + topic: { type: string, description: "New topic text" } + required: [channel, topic] + outputs_schema: { properties: {} } + - id: get_user + name: slack-get-user + description: "Look up a user by ID or email address." + risk: read + inputs_schema: + properties: + user_id: { type: string, description: "User ID (provide this or email)" } + email: { type: string, description: "Email address (provide this or user_id)" } + required: [] + outputs_schema: { properties: {} } + - id: add_reaction + name: slack-add-reaction + description: "Add an emoji reaction to a message." + inputs_schema: + properties: + channel: { type: string, description: "Channel ID of the message" } + timestamp: { type: string, description: "Message ts" } + emoji: { type: string, description: "Emoji name without colons (e.g. eyes)" } + required: [channel, timestamp, emoji] + outputs_schema: { properties: {} } + + - id: test_connection + name: slack-test-connection + description: "Verify connectivity and the bot token (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/slack/scripts/add_reaction.py b/integrations/slack/scripts/add_reaction.py new file mode 100644 index 0000000..4496c45 --- /dev/null +++ b/integrations/slack/scripts/add_reaction.py @@ -0,0 +1,57 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + channel = str(inputs.get("channel") or "").strip() + if not channel: + raise Exception("channel is required") + + timestamp = str(inputs.get("timestamp") or "").strip() + if not timestamp: + raise Exception("timestamp is required") + + emoji = str(inputs.get("emoji") or "").strip() + if not emoji: + raise Exception("emoji is required") + + return call("reactions.add", cfg, body={"channel": channel, "timestamp": timestamp, "name": emoji}) + + +_run(main) diff --git a/integrations/slack/scripts/create_channel.py b/integrations/slack/scripts/create_channel.py new file mode 100644 index 0000000..766aae4 --- /dev/null +++ b/integrations/slack/scripts/create_channel.py @@ -0,0 +1,51 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + name = str(inputs.get("name") or "").strip() + if not name: + raise Exception("name is required") + + is_private = bool(inputs.get("is_private") or False) + + return call("conversations.create", cfg, body={"name": name, "is_private": is_private}) + + +_run(main) diff --git a/integrations/slack/scripts/get_user.py b/integrations/slack/scripts/get_user.py new file mode 100644 index 0000000..fe9f03f --- /dev/null +++ b/integrations/slack/scripts/get_user.py @@ -0,0 +1,53 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + user_id = str(inputs.get("user_id") or "").strip() + email = str(inputs.get("email") or "").strip() + + if email: + return call("users.lookupByEmail", cfg, params={"email": email}, http="GET") + elif user_id: + return call("users.info", cfg, params={"user": user_id}, http="GET") + else: + raise Exception("user_id or email is required") + + +_run(main) diff --git a/integrations/slack/scripts/invite_to_channel.py b/integrations/slack/scripts/invite_to_channel.py new file mode 100644 index 0000000..d30ca0c --- /dev/null +++ b/integrations/slack/scripts/invite_to_channel.py @@ -0,0 +1,54 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + channel = str(inputs.get("channel") or "").strip() + if not channel: + raise Exception("channel is required") + + users_raw = inputs.get("users") + users_list = [s.strip() for s in str(users_raw or "").split(",") if s.strip()] + if not users_list: + raise Exception("users is required") + + return call("conversations.invite", cfg, body={"channel": channel, "users": ",".join(users_list)}) + + +_run(main) diff --git a/integrations/slack/scripts/list_channels.py b/integrations/slack/scripts/list_channels.py new file mode 100644 index 0000000..cc272cc --- /dev/null +++ b/integrations/slack/scripts/list_channels.py @@ -0,0 +1,48 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + types = str(inputs.get("types") or "public_channel,private_channel").strip() + limit = inputs.get("limit") or 200 + + return call("conversations.list", cfg, params={"types": types, "limit": int(limit)}, http="GET") + + +_run(main) diff --git a/integrations/slack/scripts/send_message.py b/integrations/slack/scripts/send_message.py new file mode 100644 index 0000000..5d89fd2 --- /dev/null +++ b/integrations/slack/scripts/send_message.py @@ -0,0 +1,67 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + channel = str(inputs.get("channel") or "").strip() + if not channel: + raise Exception("channel is required") + + text = str(inputs.get("text") or "").strip() + blocks_raw = str(inputs.get("blocks") or "").strip() + thread_ts = str(inputs.get("thread_ts") or "").strip() + + if not text and not blocks_raw: + raise Exception("text or blocks is required") + + body = {"channel": channel} + if text: + body["text"] = text + if blocks_raw: + try: + body["blocks"] = json.loads(blocks_raw) + except Exception: + raise Exception("blocks must be a valid JSON array") + if thread_ts: + body["thread_ts"] = thread_ts + + return call("chat.postMessage", cfg, body=body) + + +_run(main) diff --git a/integrations/slack/scripts/set_channel_topic.py b/integrations/slack/scripts/set_channel_topic.py new file mode 100644 index 0000000..e1e1709 --- /dev/null +++ b/integrations/slack/scripts/set_channel_topic.py @@ -0,0 +1,53 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + channel = str(inputs.get("channel") or "").strip() + if not channel: + raise Exception("channel is required") + + topic = str(inputs.get("topic") or "").strip() + if not topic: + raise Exception("topic is required") + + return call("conversations.setTopic", cfg, body={"channel": channel, "topic": topic}) + + +_run(main) diff --git a/integrations/slack/scripts/test_connection.py b/integrations/slack/scripts/test_connection.py new file mode 100644 index 0000000..5a995c5 --- /dev/null +++ b/integrations/slack/scripts/test_connection.py @@ -0,0 +1,46 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +BASE = "https://slack.com/api" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def call(method_name, cfg, body=None, params=None, http="POST"): + url = BASE + "/" + method_name + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")}) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bearer " + str(cfg.get("bot_token", "")), "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json; charset=utf-8" + req = urllib.request.Request(url, data=data, headers=headers, method=http) + with urllib.request.urlopen(req, timeout=60) as r: + resp = json.loads(r.read() or "{}") + if not resp.get("ok"): + raise Exception("Slack error: " + str(resp.get("error") or resp)) + return resp + + +def _run(fn): + try: + print(json.dumps(fn(_cfg(), _inputs()))) + except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) + except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + + +def main(cfg, inputs): + resp = call("auth.test", cfg) + return {"ok": True, "team": resp.get("team"), "user": resp.get("user")} + + +_run(main)