From 2b5e7f37b402f9e83b8445a8c471c61f993454e6 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sun, 12 Jul 2026 14:22:04 +0200 Subject: [PATCH] feat(discord): new Discord notification/ChatOps integration Discord webhook + bot API, 5 commands: webhook message, bot channel message, list channel messages, get channel. Webhook + bot-token auth, stdlib-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/discord/manifest.yaml | 68 +++++++++++++++++ integrations/discord/scripts/get_channel.py | 67 +++++++++++++++++ .../discord/scripts/list_channel_messages.py | 68 +++++++++++++++++ .../discord/scripts/send_channel_message.py | 70 ++++++++++++++++++ .../discord/scripts/send_webhook_message.py | 73 +++++++++++++++++++ .../discord/scripts/test_connection.py | 60 +++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 integrations/discord/manifest.yaml create mode 100644 integrations/discord/scripts/get_channel.py create mode 100644 integrations/discord/scripts/list_channel_messages.py create mode 100644 integrations/discord/scripts/send_channel_message.py create mode 100644 integrations/discord/scripts/send_webhook_message.py create mode 100644 integrations/discord/scripts/test_connection.py diff --git a/integrations/discord/manifest.yaml b/integrations/discord/manifest.yaml new file mode 100644 index 0000000..9bc5e56 --- /dev/null +++ b/integrations/discord/manifest.yaml @@ -0,0 +1,68 @@ +id: discord +name: Discord +version: 1.0.0 +description: "Discord — SOC alerting and ChatOps: post messages via an incoming webhook, and (with a bot token) send channel messages, read recent channel messages, and get channel info. Webhook + bot-token authentication; stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: webhook message, bot channel message, list channel messages, get channel." +category: notification + +# Per-instance configuration. webhook_url is used by the simple message command; +# bot_token (sent as 'Authorization: Bot ') is used by the channel commands. +config_schema: + properties: + webhook_url: + type: string + description: "Discord channel webhook URL (for webhook-message)" + x-soar-sensitive: true + bot_token: + type: string + description: "Bot token (for channel API commands)" + x-soar-sensitive: true + required: [] + +commands: + - id: send_webhook_message + name: discord-send-webhook-message + description: "Post a message via the configured incoming webhook." + inputs_schema: + properties: + content: { type: string, description: "Message text" } + username: { type: string, description: "Override the webhook display name (optional)" } + required: [content] + outputs_schema: { properties: {} } + - id: send_channel_message + name: discord-send-channel-message + description: "Send a message to a channel using the bot token." + inputs_schema: + properties: + channel_id: { type: string, description: "Channel ID" } + content: { type: string, description: "Message text" } + required: [channel_id, content] + outputs_schema: { properties: {} } + - id: list_channel_messages + name: discord-list-channel-messages + description: "List recent messages in a channel (bot token)." + risk: read + inputs_schema: + properties: + channel_id: { type: string, description: "Channel ID" } + limit: { type: number, description: "Max messages (default 20)" } + required: [channel_id] + outputs_schema: { properties: {} } + - id: get_channel + name: discord-get-channel + description: "Get a channel's info (bot token)." + risk: read + inputs_schema: + properties: + channel_id: { type: string, description: "Channel ID" } + required: [channel_id] + outputs_schema: { properties: {} } + + - id: test_connection + name: discord-test-connection + description: "Verify the bot token (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } diff --git a/integrations/discord/scripts/get_channel.py b/integrations/discord/scripts/get_channel.py new file mode 100644 index 0000000..d5cb08c --- /dev/null +++ b/integrations/discord/scripts/get_channel.py @@ -0,0 +1,67 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://discord.com/api/v10" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def webhook_post(url, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def bot(method, path, cfg, body=None, params=None): + token = str(cfg.get("bot_token", "")) + if not token: + raise Exception("bot_token is required for this command") + url = API + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bot " + token, "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +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) + + +q = lambda v: urllib.parse.quote(str(v), safe="") + + +def main(cfg, inputs): + channel_id = inputs.get("channel_id") + if not channel_id: + raise Exception("channel_id is required") + + resp = bot("GET", "/channels/" + q(channel_id), cfg) + return resp + + +_run(main) diff --git a/integrations/discord/scripts/list_channel_messages.py b/integrations/discord/scripts/list_channel_messages.py new file mode 100644 index 0000000..05ca323 --- /dev/null +++ b/integrations/discord/scripts/list_channel_messages.py @@ -0,0 +1,68 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://discord.com/api/v10" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def webhook_post(url, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def bot(method, path, cfg, body=None, params=None): + token = str(cfg.get("bot_token", "")) + if not token: + raise Exception("bot_token is required for this command") + url = API + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bot " + token, "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +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) + + +q = lambda v: urllib.parse.quote(str(v), safe="") + + +def main(cfg, inputs): + channel_id = inputs.get("channel_id") + if not channel_id: + raise Exception("channel_id is required") + limit = inputs.get("limit") + + resp = bot("GET", "/channels/" + q(channel_id) + "/messages", cfg, params={"limit": int(limit or 20)}) + return resp + + +_run(main) diff --git a/integrations/discord/scripts/send_channel_message.py b/integrations/discord/scripts/send_channel_message.py new file mode 100644 index 0000000..65fb2cd --- /dev/null +++ b/integrations/discord/scripts/send_channel_message.py @@ -0,0 +1,70 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://discord.com/api/v10" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def webhook_post(url, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def bot(method, path, cfg, body=None, params=None): + token = str(cfg.get("bot_token", "")) + if not token: + raise Exception("bot_token is required for this command") + url = API + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bot " + token, "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +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) + + +q = lambda v: urllib.parse.quote(str(v), safe="") + + +def main(cfg, inputs): + channel_id = inputs.get("channel_id") + if not channel_id: + raise Exception("channel_id is required") + content = inputs.get("content") + if not content: + raise Exception("content is required") + + resp = bot("POST", "/channels/" + q(channel_id) + "/messages", cfg, body={"content": content}) + return resp + + +_run(main) diff --git a/integrations/discord/scripts/send_webhook_message.py b/integrations/discord/scripts/send_webhook_message.py new file mode 100644 index 0000000..d9e8ee0 --- /dev/null +++ b/integrations/discord/scripts/send_webhook_message.py @@ -0,0 +1,73 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://discord.com/api/v10" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def webhook_post(url, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def bot(method, path, cfg, body=None, params=None): + token = str(cfg.get("bot_token", "")) + if not token: + raise Exception("bot_token is required for this command") + url = API + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bot " + token, "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +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): + content = inputs.get("content") + if not content: + raise Exception("content is required") + username = inputs.get("username") + + wh = cfg.get("webhook_url") + if not wh: + raise Exception("webhook_url is not configured") + + payload = {"content": content} + if username: + payload["username"] = username + + webhook_post(wh, payload) + return {"ok": True} + + +_run(main) diff --git a/integrations/discord/scripts/test_connection.py b/integrations/discord/scripts/test_connection.py new file mode 100644 index 0000000..2acf5ec --- /dev/null +++ b/integrations/discord/scripts/test_connection.py @@ -0,0 +1,60 @@ +import json, os, sys, urllib.parse, urllib.request, urllib.error + +API = "https://discord.com/api/v10" + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _inputs(): + return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + + +def webhook_post(url, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + try: + return json.loads(raw) if raw else {} + except Exception: + return {} + + +def bot(method, path, cfg, body=None, params=None): + token = str(cfg.get("bot_token", "")) + if not token: + raise Exception("bot_token is required for this command") + url = API + path + if params: + clean = {k: v for k, v in params.items() if v not in (None, "")} + if clean: + url += "?" + urllib.parse.urlencode(clean) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Authorization": "Bot " + token, "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return json.loads(raw) if raw else {} + + +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 = bot("GET", "/users/@me", cfg) + return {"ok": True, "bot": resp.get("username")} + + +_run(main)