Skip to content

Commit a4d5333

Browse files
hyperpolymathclaude
andcommitted
feat: add Discord and Reddit channels
Discord channel supports two modes: - Bot mode (DISCORD_BOT_TOKEN + DISCORD_CHANNEL_ID) — posts to channel with embed formatting, returns full message URL - Webhook mode (DISCORD_WEBHOOK_URL) — simpler, no bot required Reddit channel uses OAuth2 password grant: - Authenticates with client_id/secret + username/password - Submits self-posts to specified subreddit - Returns reddit.com permalink Both use SecureDNS resolution before HTTP calls, Req for requests, HTTPS-only transport. Channel registry and credentials updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d7ab2c9 commit a4d5333

4 files changed

Lines changed: 390 additions & 3 deletions

File tree

elixir-mcp/lib/feedback_a_tron/channel.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ defmodule FeedbackATron.Channel do
5555
mailman: FeedbackATron.Channels.Mailman,
5656
sourcehut: FeedbackATron.Channels.SourceHut,
5757
jira: FeedbackATron.Channels.Jira,
58-
matrix: FeedbackATron.Channels.Matrix
58+
matrix: FeedbackATron.Channels.Matrix,
59+
discord: FeedbackATron.Channels.Discord,
60+
reddit: FeedbackATron.Channels.Reddit
5961
}
6062
end
6163

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule FeedbackATron.Channels.Discord do
3+
@moduledoc """
4+
Discord channel — HTTPS only.
5+
6+
Submits feedback as Discord embeds via two modes:
7+
- **Bot token**: POST to /channels/{channel_id}/messages (requires bot in server)
8+
- **Webhook URL**: POST to webhook endpoint (simpler, no bot needed)
9+
10+
DNS resolution via DoH/DoT exclusively (see SecureDNS).
11+
12+
## Bot mode credentials
13+
14+
%{token: "Bot ...", channel_id: "123456..."}
15+
16+
## Webhook mode credentials
17+
18+
%{webhook_url: "https://discord.com/api/webhooks/..."}
19+
20+
Author: Jonathan D.A. Jewell
21+
"""
22+
23+
@behaviour FeedbackATron.Channel
24+
25+
require Logger
26+
alias FeedbackATron.SecureDNS
27+
28+
@discord_api_base "https://discord.com/api/v10"
29+
30+
# Embed colour: feedback-o-tron brand blue
31+
@embed_colour 0x5865F2
32+
33+
@impl true
34+
def platform, do: :discord
35+
36+
@impl true
37+
def transport, do: :https
38+
39+
@impl true
40+
def validate_creds(cred) do
41+
cond do
42+
# Webhook mode — only need a valid HTTPS webhook URL
43+
not is_nil(cred[:webhook_url]) ->
44+
if String.starts_with?(cred[:webhook_url], "https://discord.com/api/webhooks/") do
45+
:ok
46+
else
47+
{:error, "Discord webhook URL must start with https://discord.com/api/webhooks/"}
48+
end
49+
50+
# Bot mode — need token and channel_id
51+
not is_nil(cred[:token]) ->
52+
cond do
53+
not String.starts_with?(cred[:token] || "", "Bot ") ->
54+
{:error, "Discord bot token must start with 'Bot '"}
55+
is_nil(cred[:channel_id]) ->
56+
{:error, "Discord channel_id required for bot mode"}
57+
true ->
58+
:ok
59+
end
60+
61+
true ->
62+
{:error, "Discord credentials require either :webhook_url or :token + :channel_id"}
63+
end
64+
end
65+
66+
@impl true
67+
def submit(issue, cred, opts) do
68+
if cred[:webhook_url] do
69+
submit_webhook(issue, cred, opts)
70+
else
71+
submit_bot(issue, cred, opts)
72+
end
73+
end
74+
75+
# --- Bot mode: POST /channels/{channel_id}/messages ---
76+
77+
defp submit_bot(issue, cred, _opts) do
78+
channel_id = cred.channel_id
79+
80+
with {:ok, _ips} <- SecureDNS.resolve("discord.com") do
81+
embed = build_embed(issue)
82+
83+
headers = [
84+
{"Authorization", cred.token},
85+
{"Content-Type", "application/json"}
86+
]
87+
88+
url = "#{@discord_api_base}/channels/#{channel_id}/messages"
89+
90+
case Req.post(url, json: %{embeds: [embed]}, headers: headers, receive_timeout: 15_000) do
91+
{:ok, %{status: 200, body: resp}} ->
92+
message_id = resp["id"]
93+
guild_id = resp["guild_id"] || "unknown"
94+
msg_channel_id = resp["channel_id"] || channel_id
95+
{:ok, %{platform: :discord, url: "https://discord.com/channels/#{guild_id}/#{msg_channel_id}/#{message_id}"}}
96+
97+
{:ok, %{status: status, body: resp}} ->
98+
error_msg = extract_error(resp)
99+
Logger.error("Discord bot API error #{status}: #{error_msg}")
100+
{:error, %{platform: :discord, status: status, error: error_msg}}
101+
102+
{:error, reason} ->
103+
{:error, %{platform: :discord, error: reason}}
104+
end
105+
else
106+
{:error, reason} ->
107+
{:error, %{platform: :discord, error: {:dns_failed, reason}}}
108+
end
109+
end
110+
111+
# --- Webhook mode: POST to webhook URL ---
112+
113+
defp submit_webhook(issue, cred, _opts) do
114+
webhook_url = cred.webhook_url
115+
116+
# Extract hostname from webhook URL for secure DNS
117+
%URI{host: hostname} = URI.parse(webhook_url)
118+
119+
with {:ok, _ips} <- SecureDNS.resolve(hostname) do
120+
embed = build_embed(issue)
121+
122+
# Append ?wait=true so Discord returns the message object
123+
post_url = "#{webhook_url}?wait=true"
124+
125+
case Req.post(post_url, json: %{embeds: [embed]}, receive_timeout: 15_000) do
126+
{:ok, %{status: 200, body: _resp}} ->
127+
# Webhooks don't return enough info to construct a full message URL
128+
{:ok, %{platform: :discord, url: webhook_url}}
129+
130+
{:ok, %{status: status, body: resp}} ->
131+
error_msg = extract_error(resp)
132+
Logger.error("Discord webhook error #{status}: #{error_msg}")
133+
{:error, %{platform: :discord, status: status, error: error_msg}}
134+
135+
{:error, reason} ->
136+
{:error, %{platform: :discord, error: reason}}
137+
end
138+
else
139+
{:error, reason} ->
140+
{:error, %{platform: :discord, error: {:dns_failed, reason}}}
141+
end
142+
end
143+
144+
# --- Helpers ---
145+
146+
@doc false
147+
defp build_embed(issue) do
148+
embed = %{
149+
title: issue.title,
150+
description: issue.body,
151+
color: @embed_colour
152+
}
153+
154+
# Include repo info as a footer if available
155+
if issue[:repo] do
156+
Map.put(embed, :footer, %{text: "Repository: #{issue.repo}"})
157+
else
158+
embed
159+
end
160+
end
161+
162+
defp extract_error(%{"message" => msg}), do: msg
163+
defp extract_error(%{"code" => code, "message" => msg}), do: "#{code}: #{msg}"
164+
defp extract_error(other), do: inspect(other)
165+
end
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
defmodule FeedbackATron.Channels.Reddit do
3+
@moduledoc """
4+
Reddit channel — HTTPS only.
5+
6+
Submits feedback as self-posts to a subreddit via OAuth2.
7+
8+
Authentication flow:
9+
1. POST to https://www.reddit.com/api/v1/access_token with Basic auth
10+
(client_id:client_secret) and grant_type=password
11+
2. Use the returned bearer token against https://oauth.reddit.com
12+
13+
DNS resolution via DoH/DoT exclusively (see SecureDNS).
14+
15+
## Credentials
16+
17+
%{client_id: "...", client_secret: "...", username: "...", password: "..."}
18+
19+
## Options
20+
21+
[subreddit: "somesub"]
22+
23+
Author: Jonathan D.A. Jewell
24+
"""
25+
26+
@behaviour FeedbackATron.Channel
27+
28+
require Logger
29+
alias FeedbackATron.SecureDNS
30+
31+
# Reddit API requirement: identify the app in User-Agent
32+
@user_agent "feedback-o-tron/1.0 (by /u/hyperpolymath)"
33+
34+
@impl true
35+
def platform, do: :reddit
36+
37+
@impl true
38+
def transport, do: :https
39+
40+
@impl true
41+
def validate_creds(cred) do
42+
cond do
43+
is_nil(cred[:client_id]) -> {:error, "Reddit client_id required"}
44+
is_nil(cred[:client_secret]) -> {:error, "Reddit client_secret required"}
45+
is_nil(cred[:username]) -> {:error, "Reddit username required"}
46+
is_nil(cred[:password]) -> {:error, "Reddit password required"}
47+
true -> :ok
48+
end
49+
end
50+
51+
@impl true
52+
def submit(issue, cred, opts) do
53+
subreddit = opts[:subreddit]
54+
55+
if is_nil(subreddit) do
56+
{:error, %{platform: :reddit, error: "subreddit option is required (e.g. [subreddit: \"somesub\"])"}}
57+
else
58+
with {:ok, _ips} <- SecureDNS.resolve("www.reddit.com"),
59+
{:ok, _ips} <- SecureDNS.resolve("oauth.reddit.com"),
60+
{:ok, access_token} <- obtain_access_token(cred) do
61+
submit_post(issue, access_token, subreddit, cred)
62+
else
63+
{:error, %{platform: :reddit} = err} ->
64+
{:error, err}
65+
66+
{:error, reason} ->
67+
{:error, %{platform: :reddit, error: {:dns_failed, reason}}}
68+
end
69+
end
70+
end
71+
72+
# --- OAuth2 token exchange ---
73+
74+
defp obtain_access_token(cred) do
75+
auth_url = "https://www.reddit.com/api/v1/access_token"
76+
77+
# Basic auth header: base64(client_id:client_secret)
78+
basic_auth = Base.encode64("#{cred.client_id}:#{cred.client_secret}")
79+
80+
headers = [
81+
{"Authorization", "Basic #{basic_auth}"},
82+
{"User-Agent", @user_agent},
83+
{"Content-Type", "application/x-www-form-urlencoded"}
84+
]
85+
86+
form_body = URI.encode_query(%{
87+
grant_type: "password",
88+
username: cred.username,
89+
password: cred.password
90+
})
91+
92+
case Req.post(auth_url, body: form_body, headers: headers, receive_timeout: 15_000) do
93+
{:ok, %{status: 200, body: %{"access_token" => token}}} ->
94+
{:ok, token}
95+
96+
{:ok, %{status: status, body: resp}} ->
97+
error_msg = extract_error(resp)
98+
Logger.error("Reddit OAuth2 error #{status}: #{error_msg}")
99+
{:error, %{platform: :reddit, status: status, error: "OAuth2 failed: #{error_msg}"}}
100+
101+
{:error, reason} ->
102+
{:error, %{platform: :reddit, error: reason}}
103+
end
104+
end
105+
106+
# --- Submit self-post ---
107+
108+
defp submit_post(issue, access_token, subreddit, cred) do
109+
submit_url = "https://oauth.reddit.com/api/submit"
110+
111+
# Build the post body with repo context if available
112+
post_body = if issue[:repo] do
113+
"#{issue.body}\n\n---\n*Repository: #{issue.repo}*"
114+
else
115+
issue.body
116+
end
117+
118+
headers = [
119+
{"Authorization", "bearer #{access_token}"},
120+
{"User-Agent", build_user_agent(cred)},
121+
{"Content-Type", "application/x-www-form-urlencoded"}
122+
]
123+
124+
form_body = URI.encode_query(%{
125+
api_type: "json",
126+
kind: "self",
127+
sr: subreddit,
128+
title: issue.title,
129+
text: post_body
130+
})
131+
132+
case Req.post(submit_url, body: form_body, headers: headers, receive_timeout: 15_000) do
133+
{:ok, %{status: 200, body: %{"json" => %{"data" => %{"permalink" => permalink}}}}} ->
134+
{:ok, %{platform: :reddit, url: "https://reddit.com#{permalink}"}}
135+
136+
{:ok, %{status: 200, body: %{"json" => %{"errors" => errors}}}} when errors != [] ->
137+
error_str = errors |> Enum.map(fn [_code, msg | _] -> msg end) |> Enum.join("; ")
138+
Logger.error("Reddit submit error: #{error_str}")
139+
{:error, %{platform: :reddit, error: error_str}}
140+
141+
{:ok, %{status: status, body: resp}} ->
142+
error_msg = extract_error(resp)
143+
Logger.error("Reddit submit API error #{status}: #{error_msg}")
144+
{:error, %{platform: :reddit, status: status, error: error_msg}}
145+
146+
{:error, reason} ->
147+
{:error, %{platform: :reddit, error: reason}}
148+
end
149+
end
150+
151+
# --- Helpers ---
152+
153+
defp build_user_agent(cred) do
154+
username = cred[:username] || "unknown"
155+
"feedback-o-tron/1.0 (by /u/#{username})"
156+
end
157+
158+
defp extract_error(%{"error" => error}) when is_binary(error), do: error
159+
defp extract_error(%{"message" => msg}), do: msg
160+
defp extract_error(other), do: inspect(other)
161+
end

0 commit comments

Comments
 (0)