// Discord REST adapter (`discord.com/api/v10`).
//
// Mirrors Hermes `plugins/platforms/discord/adapter.py`: text goes through
// `POST /channels/{id}/messages`, chunked at 2000 chars, with an optional
// `message_reference` for replies. Inbound traffic is either a Discord
// *interaction* webhook (PING / slash command) or a normalized MESSAGE_CREATE
// payload forwarded by a gateway relay.
///|
/// API root, overridable with `[discord.extra] api_base`.
pub fn discord_api_base(cfg : PlatformConfig) -> String {
let base = cfg.extra_str("api_base", "https://discord.com/api/v10")
if base.has_suffix("/") {
base[:base.length() - 1].to_owned()
} else {
base
}
}
///|
/// `POST /channels/{id}/messages` payload.
pub fn discord_send_payload(content : String, reply_to : String?) -> Json {
let payload = json_obj([("content", Json::string(content))])
match reply_to {
Some(mid) =>
if payload is Object(m) {
m.set(
"message_reference",
json_obj([
("message_id", Json::string(mid)),
("fail_if_not_exists", Json::boolean(false)),
]),
)
}
None => ()
}
payload
}
///|
/// Embed payload for a media URL (avoids multipart uploads).
pub fn discord_media_payload(
kind : String,
url : String,
caption : String?,
) -> Json {
let key = match kind {
"photo" | "image" => "image"
"video" => "image"
_ => "image"
}
let embed = json_obj([(key, json_obj([("url", Json::string(url))]))])
let payload = json_obj([("embeds", Json::array([embed]))])
obj_with_opt(payload, "content", caption)
}
///|
/// Map a Discord REST error response onto a `SendResult`.
fn discord_failure(prefix : String, e : ApiError) -> SendResult {
let retry_after = discord_retry_after(e.body)
SendResult::failed(
"\{prefix}: \{api_error_message(e)}",
retryable=e.is_transient() || retry_after is Some(_),
retry_after?,
kind=classify_send_error(e.body),
)
}
///|
/// Extract `retry_after` seconds from a Discord 429 body.
pub fn discord_retry_after(body : String) -> Double? {
let j = @json.parse(body) catch { _ => return None }
match j {
Object(m) =>
match m.get("retry_after") {
Some(Number(n, ..)) => Some(n)
_ => None
}
_ => None
}
}
///|
/// Message id from a Discord message object.
fn discord_message_id(j : Json) -> String? {
json_field_string(j, "id")
}
///|
/// One REST call. `meth` is one of "GET"/"POST"/"PATCH"/"PUT"/"DELETE".
async fn discord_call(
cfg : PlatformConfig,
token : String,
meth : String,
path : String,
payload : Json,
) -> SendResult {
let url = "\{discord_api_base(cfg)}\{path}"
let (m, body) = match meth {
"GET" => (@http.Get, "")
"POST" => (@http.Post, payload.stringify())
"PATCH" => (@http.Patch, payload.stringify())
"PUT" => (@http.Put, payload.stringify())
"DELETE" => (@http.Delete, "")
_ => (@http.Get, "")
}
match request_json(m, url, body, bot_headers(token)) {
Err(e) => discord_failure("discord \{meth} \{path}", e)
Ok(j) => send_ok(discord_message_id(j), j)
}
}
///|
/// Send ``content``, chunked at Discord's 2000-char limit.
pub async fn discord_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Discord, "DISCORD_BOT_TOKEN")
Some(token) =>
if content.is_empty() {
return SendResult::ok()
} else {
let mut first = true
let mut last = SendResult::ok()
let mut first_id : String? = None
for chunk in chunk_text(content, Platform::max_message_length(Discord)) {
let reply = if first { reply_to } else { None }
let r = send_with_retry(() => {
discord_call(
cfg,
token,
"POST",
"/channels/\{chat_id}/messages",
discord_send_payload(chunk, reply),
)
})
first = false
if !r.success {
return r
}
if first_id is None {
first_id = r.message_id
}
last = r
}
{ ..last, message_id: first_id, }
}
}
}
///|
/// Typing indicator (`POST /channels/{id}/typing`, no content).
pub async fn discord_typing(cfg : PlatformConfig, chat_id : String) -> Unit {
match cfg.token_of() {
None => ()
Some(token) =>
ignore(
discord_call(
cfg,
token,
"POST",
"/channels/\{chat_id}/typing",
Json::null(),
),
)
}
}
///|
/// Send a media URL inside an embed.
pub async fn discord_send_media(
cfg : PlatformConfig,
chat_id : String,
kind : String,
url : String,
caption? : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Discord, "DISCORD_BOT_TOKEN")
Some(token) =>
discord_call(
cfg,
token,
"POST",
"/channels/\{chat_id}/messages",
discord_media_payload(kind, url, caption),
)
}
}
///|
/// Edit a message (`PATCH /channels/{id}/messages/{mid}`).
pub async fn discord_edit(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
content : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Discord, "DISCORD_BOT_TOKEN")
Some(token) =>
discord_call(
cfg,
token,
"PATCH",
"/channels/\{chat_id}/messages/\{message_id}",
json_obj([("content", Json::string(content))]),
)
}
}
///|
/// Delete a message.
pub async fn discord_delete(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Discord, "DISCORD_BOT_TOKEN")
Some(token) =>
discord_call(
cfg,
token,
"DELETE",
"/channels/\{chat_id}/messages/\{message_id}",
Json::null(),
)
}
}
///|
/// Add a reaction (`PUT …/reactions/{emoji}/@me`).
pub async fn discord_react(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
emoji : String,
) -> SendResult {
match cfg.token_of() {
None => missing_config(Discord, "DISCORD_BOT_TOKEN")
Some(token) =>
discord_call(
cfg,
token,
"PUT",
"/channels/\{chat_id}/messages/\{message_id}/reactions/\{url_quote(emoji)}/@me",
Json::null(),
)
}
}
///|
/// Channel metadata (`GET /channels/{id}`).
pub async fn discord_get_chat(
cfg : PlatformConfig,
chat_id : String,
) -> ChatInfo? {
match cfg.token_of() {
None => None
Some(token) => {
let url = "\{discord_api_base(cfg)}/channels/\{chat_id}"
match get_json(url, bot_headers(token)) {
Err(_) => None
Ok(j) =>
match json_field_string(j, "name") {
Some(name) =>
Some({
name,
chat_type: discord_channel_type(
json_field_int(j, "type").unwrap_or(0),
),
chat_id,
})
None => None
}
}
}
}
}
///|
/// Discord `channel.type` → a readable chat type.
pub fn discord_channel_type(t : Int32) -> String {
match t {
0 => "text"
1 => "dm"
2 => "voice"
3 => "group_dm"
4 => "category"
5 => "announcement"
10 | 11 | 12 => "thread"
_ => "channel"
}
}
///|
/// Parse a Discord interaction webhook: `PING` (type 1) / application command
/// (type 2). Returns the events plus the `PONG` body when a handshake arrived.
pub fn discord_parse_interaction(body : String) -> Array[MessageEvent] {
let j = @json.parse(body) catch { _ => return [] }
let out : Array[MessageEvent] = []
match json_field_int(j, "type") {
Some(2) => {
let data = json_field_obj(j, "data").unwrap_or(Json::null())
let name = json_field_string(data, "name").unwrap_or("")
let text = discord_command_text(name, data)
let chat_id = json_id(j, "channel_id").unwrap_or("")
let member_obj = json_field_obj(j, "member").unwrap_or(Json::null())
let user = json_field_obj(member_obj, "user").unwrap_or(Json::null())
if !chat_id.is_empty() {
out.push(
MessageEvent::new(
text,
Command,
Discord,
chat_id,
user_id?=json_id(user, "id"),
user_name?=json_field_string(user, "username"),
message_id?=json_field_string(j, "id"),
media=[],
timestamp=now_ms(),
),
)
}
}
_ => ()
}
out
}
///|
/// Render a slash command plus its options as `/name option:value …`.
pub fn discord_command_text(name : String, data : Json) -> String {
let sb = StringBuilder()
sb.write_string("/")
sb.write_string(name)
match json_field_array(data, "options") {
Some(opts) =>
for o in opts {
sb.write_string(" ")
sb.write_string(json_field_string(o, "name").unwrap_or(""))
sb.write_string(":")
match json_field_string(o, "value") {
Some(v) => sb.write_string(v)
None => sb.write_string("")
}
}
None => ()
}
sb.to_string()
}
///|
/// Parse a normalized `MESSAGE_CREATE` payload (Discord gateway relay):
/// `{"t":"MESSAGE_CREATE","d":{"channel_id":…,"content":…,"author":{…},"id":…}}`
/// or a bare `d` object.
pub fn discord_parse_message_event(body : String) -> Array[MessageEvent] {
let j = @json.parse(body) catch { _ => return [] }
let d = match json_field_obj(j, "d") {
Some(d) => d
None => j
}
let author = json_field_obj(d, "author").unwrap_or(Json::null())
let content = json_field_string(d, "content").unwrap_or("")
let chat_id = json_id(d, "channel_id").unwrap_or("")
if chat_id.is_empty() || content.is_empty() {
return []
}
let mtype = if content.has_prefix("/") { Command } else { Text }
[
MessageEvent::new(
content,
mtype,
Discord,
chat_id,
user_id?=json_id(author, "id"),
user_name?=json_field_string(author, "username"),
message_id?=json_field_string(d, "id"),
media=[],
timestamp=now_ms(),
),
]
}
///|
/// Normalized outbound webhook send (Discord webhook URL with `?wait=true`).
pub async fn discord_webhook_send(
webhook_url : String,
content : String,
username? : String,
) -> SendResult {
let payload = json_obj([("content", Json::string(content))])
let payload = obj_with_opt(payload, "username", username)
match post_json(webhook_url, payload, json_headers()) {
Err(e) => discord_failure("discord webhook", e)
Ok(j) => send_ok(discord_message_id(j), j)
}
}