Buttons, menus and the commands a channel offers
A great many Discord servers now work through bots: the message is a caption and the thing it is for is the button underneath, or there is nothing to type but a slash command. moho drew the caption and dropped the rest, so those channels read as broken rather than as unsupported. Components are parsed into words rather than the numbers Discord sends - a button, a menu, primary or danger - and kept, because a role picker is posted once and pressed for months. Their own column and their own write rather than a twentieth argument to append_message: they belong to one service, and every other backend would have to say "no components" in a language it does not speak. The rows survive, since five buttons on one line and one below is something the bot meant. Running a command and pressing a button are the same endpoint with different data, and both need the gateway session id - so READY now keeps it. A command is handed back to Discord as it was given rather than rebuilt from the parts a client happened to show. Verified as far as it can be from here: the command list is asked for correctly - real channels answer with their commands or with Discord's own "Missing Permissions" - and the component parsing is covered by tests against real payload shapes. Running a command and pressing a button are not exercised; see the issue. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -2477,6 +2477,7 @@ fn store_history_messages(state: &AppState, buffer_id: &str, messages: &[Value],
|
||||
tracing::warn!("discord: storing history message: {e}");
|
||||
continue;
|
||||
}
|
||||
note_components(state, buffer_id, msg_id, msg);
|
||||
// Backfilled messages need previews as much as live ones do - more so,
|
||||
// since a channel read for the first time is all history and none of it
|
||||
// would otherwise survive its links expiring.
|
||||
@@ -2681,6 +2682,403 @@ pub async fn extend_history(state: &AppState, token: &str, user_id: &str, own_di
|
||||
///
|
||||
/// Returns when the message was sent, which is what a client needs to go and
|
||||
/// read it out of the store.
|
||||
/// The buttons and menus on a message, in this client's own shape.
|
||||
///
|
||||
/// Discord lays them out in rows of up to five; the row is kept so a client
|
||||
/// can draw them as they were arranged rather than as one long line, and the
|
||||
/// type numbers are turned into words on the way through - a frontend should
|
||||
/// not have to know that 2 means button.
|
||||
fn extract_components(msg: &Value) -> Vec<model::Component> {
|
||||
let Some(rows) = msg["components"].as_array() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
// A row is itself a component (type 1) holding the real ones. A
|
||||
// message whose top level is already a control is not a shape Discord
|
||||
// sends, but reading it either way costs nothing.
|
||||
let children = row["components"].as_array().cloned().unwrap_or_else(|| vec![row.clone()]);
|
||||
for child in children {
|
||||
let kind = match child["type"].as_i64() {
|
||||
Some(2) => "button",
|
||||
// 3 is the plain string select; 5 through 8 are the ones that
|
||||
// pick users, roles, mentionables and channels. They are all
|
||||
// menus, and all of them answer the same way.
|
||||
Some(3) | Some(5) | Some(6) | Some(7) | Some(8) => "select",
|
||||
_ => continue,
|
||||
};
|
||||
let style = child["style"].as_i64().map(|s| match s {
|
||||
1 => "primary",
|
||||
2 => "secondary",
|
||||
3 => "success",
|
||||
4 => "danger",
|
||||
5 => "link",
|
||||
_ => "secondary",
|
||||
});
|
||||
let options = child["options"]
|
||||
.as_array()
|
||||
.map(|opts| {
|
||||
opts.iter()
|
||||
.filter_map(|o| {
|
||||
Some(model::ComponentOption {
|
||||
value: o["value"].as_str()?.to_string(),
|
||||
label: o["label"].as_str().unwrap_or_default().to_string(),
|
||||
description: o["description"].as_str().map(str::to_string),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.push(model::Component {
|
||||
kind: kind.to_string(),
|
||||
custom_id: child["custom_id"].as_str().map(str::to_string),
|
||||
label: child["label"].as_str().filter(|l| !l.is_empty()).map(str::to_string),
|
||||
style: style.map(str::to_string),
|
||||
url: child["url"].as_str().map(str::to_string),
|
||||
disabled: child["disabled"].as_bool().unwrap_or(false),
|
||||
// Written the way this client writes emoji everywhere else, so
|
||||
// a button's picture renders like one in a message does.
|
||||
emoji: match (child["emoji"]["name"].as_str(), child["emoji"]["id"].as_str()) {
|
||||
(Some(name), Some(id)) => Some(format!("<:{name}:{id}>")),
|
||||
(Some(name), None) => Some(name.to_string()),
|
||||
_ => None,
|
||||
},
|
||||
options,
|
||||
placeholder: child["placeholder"].as_str().map(str::to_string),
|
||||
row: row_index as i64,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stores a message's buttons and tells the client, if it has any.
|
||||
///
|
||||
/// Separate from the message itself because they arrive with it and are
|
||||
/// written a moment later - see Store::set_components - and because a message
|
||||
/// that gains or loses its buttons in an edit has to be able to say so.
|
||||
fn note_components(state: &AppState, buffer_id: &str, msg_id: &str, msg: &Value) {
|
||||
let components = extract_components(msg);
|
||||
if components.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = state.store.set_components(buffer_id, msg_id, &components) {
|
||||
tracing::debug!("discord: keeping the buttons on {msg_id}: {e:#}");
|
||||
return;
|
||||
}
|
||||
state.events.emit(
|
||||
"messageComponents",
|
||||
json!({ "bufferId": buffer_id, "messageId": msg_id, "components": components }),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod component_tests {
|
||||
use super::extract_components;
|
||||
use serde_json::json;
|
||||
|
||||
/// A message as Discord sends one: rows holding the controls, with the
|
||||
/// type numbers and style numbers this client turns into words.
|
||||
fn message() -> serde_json::Value {
|
||||
json!({
|
||||
"components": [
|
||||
{ "type": 1, "components": [
|
||||
{ "type": 2, "style": 1, "label": "Accept", "custom_id": "accept" },
|
||||
{ "type": 2, "style": 4, "label": "Decline", "custom_id": "decline", "disabled": true },
|
||||
{ "type": 2, "style": 5, "label": "Read the rules", "url": "https://example.com/rules" }
|
||||
]},
|
||||
{ "type": 1, "components": [
|
||||
{ "type": 3, "custom_id": "role", "placeholder": "Pick a role", "options": [
|
||||
{ "label": "Red", "value": "red", "description": "the red one" },
|
||||
{ "label": "Blue", "value": "blue" }
|
||||
]}
|
||||
]}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buttons_and_menus_come_out_named_rather_than_numbered() {
|
||||
let out = extract_components(&message());
|
||||
assert_eq!(out.len(), 4);
|
||||
assert_eq!(out[0].kind, "button");
|
||||
assert_eq!(out[0].label.as_deref(), Some("Accept"));
|
||||
assert_eq!(out[0].style.as_deref(), Some("primary"));
|
||||
assert_eq!(out[0].custom_id.as_deref(), Some("accept"));
|
||||
assert_eq!(out[1].style.as_deref(), Some("danger"));
|
||||
assert!(out[1].disabled);
|
||||
assert_eq!(out[2].style.as_deref(), Some("link"));
|
||||
assert_eq!(out[2].url.as_deref(), Some("https://example.com/rules"));
|
||||
assert_eq!(out[3].kind, "select");
|
||||
assert_eq!(out[3].placeholder.as_deref(), Some("Pick a role"));
|
||||
assert_eq!(out[3].options.len(), 2);
|
||||
assert_eq!(out[3].options[0].value, "red");
|
||||
assert_eq!(out[3].options[0].description.as_deref(), Some("the red one"));
|
||||
}
|
||||
|
||||
/// The rows are kept, because how they were laid out is something the bot
|
||||
/// meant: five on one line and one below is not the same as six in a row.
|
||||
#[test]
|
||||
fn the_rows_survive() {
|
||||
let out = extract_components(&message());
|
||||
assert_eq!(out[0].row, 0);
|
||||
assert_eq!(out[2].row, 0);
|
||||
assert_eq!(out[3].row, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_emoji_is_written_the_way_this_client_writes_them() {
|
||||
let msg = json!({ "components": [{ "type": 1, "components": [
|
||||
{ "type": 2, "style": 2, "custom_id": "a", "emoji": { "name": "pepe", "id": "12345" } },
|
||||
{ "type": 2, "style": 2, "custom_id": "b", "emoji": { "name": "👍" } }
|
||||
]}]});
|
||||
let out = extract_components(&msg);
|
||||
assert_eq!(out[0].emoji.as_deref(), Some("<:pepe:12345>"));
|
||||
assert_eq!(out[1].emoji.as_deref(), Some("👍"));
|
||||
}
|
||||
|
||||
/// Discord keeps adding component types. One this client has never heard
|
||||
/// of is skipped rather than drawn as an empty button.
|
||||
#[test]
|
||||
fn a_kind_this_does_not_know_is_left_alone() {
|
||||
let msg = json!({ "components": [{ "type": 1, "components": [
|
||||
{ "type": 4, "custom_id": "text-input" },
|
||||
{ "type": 2, "style": 2, "label": "Real", "custom_id": "real" }
|
||||
]}]});
|
||||
let out = extract_components(&msg);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].label.as_deref(), Some("Real"));
|
||||
}
|
||||
|
||||
/// The menus that pick people, roles and channels are menus too - they
|
||||
/// carry no options of their own, since the service fills them in.
|
||||
#[test]
|
||||
fn the_other_kinds_of_menu_are_menus() {
|
||||
for kind in [5, 6, 7, 8] {
|
||||
let msg = json!({ "components": [{ "type": 1, "components": [
|
||||
{ "type": kind, "custom_id": "pick", "placeholder": "Choose" }
|
||||
]}]});
|
||||
let out = extract_components(&msg);
|
||||
assert_eq!(out.len(), 1, "type {kind}");
|
||||
assert_eq!(out[0].kind, "select", "type {kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_with_nothing_on_it_has_nothing_on_it() {
|
||||
assert!(extract_components(&json!({})).is_empty());
|
||||
assert!(extract_components(&json!({ "components": [] })).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The slash commands this conversation offers.
|
||||
///
|
||||
/// Asked of Discord per channel rather than per guild, because that is the
|
||||
/// question with the right answer: a bot can be installed guild-wide and
|
||||
/// still be unusable in the channel somebody is typing in, and the same
|
||||
/// endpoint is what Discord's own client asks when a `/` is typed.
|
||||
pub async fn list_commands(state: &AppState, account_id: &str, buffer_id: &str, query: &str) -> Result<Value> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let channel_id = state.runtime.get_discord_channel(buffer_id).context("no known Discord channel for this conversation")?;
|
||||
let mut params: Vec<(&str, String)> = vec![
|
||||
// Type 1 is a slash command. The other two - the ones on the
|
||||
// right-click menus for a message or a person - have nowhere to be
|
||||
// offered from here.
|
||||
("type", "1".to_string()),
|
||||
("include_applications", "true".to_string()),
|
||||
("limit", "25".to_string()),
|
||||
];
|
||||
if !query.is_empty() {
|
||||
params.push(("query", query.to_string()));
|
||||
}
|
||||
let resp = http_client()
|
||||
.get(format!("{API_BASE}/channels/{channel_id}/application-commands/search"))
|
||||
.query(¶ms)
|
||||
.header("Authorization", &cfg.token)
|
||||
.send()
|
||||
.await
|
||||
.context("reading the commands")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, "reading the commands"));
|
||||
}
|
||||
let answer: Value = resp.json().await.context("reading the commands")?;
|
||||
|
||||
// Which bot each command belongs to, so the list can say who answers it -
|
||||
// two servers commonly have a /ban that does different things.
|
||||
let mut names: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
for app in answer["applications"].as_array().cloned().unwrap_or_default() {
|
||||
if let (Some(id), Some(name)) = (app["id"].as_str(), app["name"].as_str()) {
|
||||
names.insert(id.to_string(), name.to_string());
|
||||
}
|
||||
}
|
||||
let commands: Vec<Value> = answer["application_commands"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|command| {
|
||||
let application_id = command["application_id"].as_str().unwrap_or_default().to_string();
|
||||
json!({
|
||||
"id": command["id"],
|
||||
"name": command["name"],
|
||||
"description": command["description"],
|
||||
"applicationId": application_id,
|
||||
"application": names.get(&application_id),
|
||||
// Discord wants the command handed back to it as it was given,
|
||||
// so it travels to the client and back rather than being
|
||||
// rebuilt from the parts this list happens to show.
|
||||
"command": command,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "commands": commands }))
|
||||
}
|
||||
|
||||
/// Runs a slash command, or presses something on a message.
|
||||
///
|
||||
/// One function because Discord has one endpoint: an interaction says what
|
||||
/// kind it is and carries the data for that kind. Both need the gateway
|
||||
/// session id, which is Discord's way of asking that the thing doing this be
|
||||
/// a client somebody is actually using.
|
||||
async fn interact(state: &AppState, account_id: &str, buffer_id: &str, kind: i64, application_id: &str, data: Value) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let channel_id = state.runtime.get_discord_channel(buffer_id).context("no known Discord channel for this conversation")?;
|
||||
let session_id = state
|
||||
.runtime
|
||||
.discord_gateway_session(account_id)
|
||||
.context("this account is not connected to Discord right now")?;
|
||||
let mut payload = json!({
|
||||
"type": kind,
|
||||
"application_id": application_id,
|
||||
"channel_id": channel_id,
|
||||
"session_id": session_id,
|
||||
"data": data,
|
||||
// Discord dedupes on this, the same way it does for a message.
|
||||
"nonce": snowflake_at(chrono::Utc::now().timestamp()),
|
||||
});
|
||||
if let Some(guild_id) = state.runtime.get_discord_guild(buffer_id) {
|
||||
payload["guild_id"] = json!(guild_id);
|
||||
}
|
||||
let resp = http_client()
|
||||
.post(format!("{API_BASE}/interactions"))
|
||||
.header("Authorization", &cfg.token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("sending the interaction")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, "sending that"));
|
||||
}
|
||||
// Nothing comes back but a 204: whatever the bot does about it arrives as
|
||||
// an ordinary message, or as an edit to the one that was pressed.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs one of the commands `list_commands` offered.
|
||||
pub async fn run_command(state: &AppState, account_id: &str, buffer_id: &str, command: &Value, options: Value) -> Result<()> {
|
||||
let application_id = command["application_id"].as_str().context("that command says which bot it belongs to")?;
|
||||
let data = json!({
|
||||
"version": command["version"],
|
||||
"id": command["id"],
|
||||
"name": command["name"],
|
||||
"type": command["type"],
|
||||
"options": options,
|
||||
// Handed back as it was given. Discord validates the command against
|
||||
// its own copy and refuses a description that does not match.
|
||||
"application_command": command,
|
||||
"attachments": [],
|
||||
});
|
||||
interact(state, account_id, buffer_id, 2, application_id, data).await
|
||||
}
|
||||
|
||||
/// Presses a button, or answers a menu.
|
||||
pub async fn use_component(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
buffer_id: &str,
|
||||
message_id: &str,
|
||||
custom_id: &str,
|
||||
is_select: bool,
|
||||
values: Vec<String>,
|
||||
) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let channel_id = state.runtime.get_discord_channel(buffer_id).context("no known Discord channel for this conversation")?;
|
||||
// Which bot to tell is on the message that carries the control, not on
|
||||
// the control itself - so it is read from the message rather than guessed.
|
||||
let resp = http_client()
|
||||
.get(format!("{API_BASE}/channels/{channel_id}/messages/{message_id}"))
|
||||
.header("Authorization", &cfg.token)
|
||||
.send()
|
||||
.await
|
||||
.context("finding out whose button that is")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, "finding out whose button that is"));
|
||||
}
|
||||
let message: Value = resp.json().await.context("reading that message")?;
|
||||
let application_id = message["application_id"]
|
||||
.as_str()
|
||||
.or_else(|| message["author"]["id"].as_str())
|
||||
.context("that message does not say which bot posted it")?
|
||||
.to_string();
|
||||
|
||||
let mut data = json!({
|
||||
// 2 is a button and 3 a menu, which is the one place these numbers
|
||||
// have to survive: Discord reads them back.
|
||||
"component_type": if is_select { 3 } else { 2 },
|
||||
"custom_id": custom_id,
|
||||
});
|
||||
if is_select {
|
||||
data["values"] = json!(values);
|
||||
}
|
||||
interact_with_message(state, account_id, buffer_id, &application_id, data, message_id).await
|
||||
}
|
||||
|
||||
/// The same as `interact`, naming the message a control belongs to.
|
||||
async fn interact_with_message(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
buffer_id: &str,
|
||||
application_id: &str,
|
||||
data: Value,
|
||||
message_id: &str,
|
||||
) -> Result<()> {
|
||||
let cfg = state.accounts.get_discord(account_id).context("account not connected")?;
|
||||
let channel_id = state.runtime.get_discord_channel(buffer_id).context("no known Discord channel for this conversation")?;
|
||||
let session_id = state
|
||||
.runtime
|
||||
.discord_gateway_session(account_id)
|
||||
.context("this account is not connected to Discord right now")?;
|
||||
let mut payload = json!({
|
||||
"type": 3,
|
||||
"application_id": application_id,
|
||||
"channel_id": channel_id,
|
||||
"message_id": message_id,
|
||||
"session_id": session_id,
|
||||
"data": data,
|
||||
"nonce": snowflake_at(chrono::Utc::now().timestamp()),
|
||||
});
|
||||
if let Some(guild_id) = state.runtime.get_discord_guild(buffer_id) {
|
||||
payload["guild_id"] = json!(guild_id);
|
||||
}
|
||||
let resp = http_client()
|
||||
.post(format!("{API_BASE}/interactions"))
|
||||
.header("Authorization", &cfg.token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.context("sending the interaction")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, "pressing that"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything a Discord search can be narrowed by.
|
||||
///
|
||||
/// One struct rather than a pile of arguments because the client sends them
|
||||
@@ -3249,6 +3647,9 @@ async fn run_gateway(state: &AppState, config: &DiscordAccountConfig, session: &
|
||||
// What a later reconnect needs to pick this session
|
||||
// back up rather than starting over.
|
||||
if let Some(session_id) = d["session_id"].as_str() {
|
||||
// Also what an interaction has to name - see
|
||||
// Runtime::set_discord_gateway_session.
|
||||
state.runtime.set_discord_gateway_session(&account_id, session_id);
|
||||
*resume = Some(Resume {
|
||||
session_id: session_id.to_string(),
|
||||
url: d["resume_gateway_url"]
|
||||
@@ -3604,6 +4005,13 @@ async fn run_gateway(state: &AppState, config: &DiscordAccountConfig, session: &
|
||||
// lookup asks Discord about - a display name is not something
|
||||
// the API accepts - and what a moderation action would need.
|
||||
state.runtime.record_message(state, &account_id, &buffer_name, &kind, &from, &body, false, "chat", reply_to, real_msg_id, is_mention, avatar_url, embeds, attachments, author["id"].as_str().map(str::to_string));
|
||||
// The buttons under it, if it has any. After the
|
||||
// message rather than with it: they are written onto
|
||||
// the row that was just made, and a great many bot
|
||||
// messages are nothing but their buttons.
|
||||
if let Some(id) = d["id"].as_str() {
|
||||
note_components(state, &thumb_target.0, id, d);
|
||||
}
|
||||
if !thumb_target.1.is_empty() {
|
||||
cache_thumbnails(state.clone(), thumb_target.0, thumb_target.1, thumb_target.2);
|
||||
}
|
||||
@@ -3638,6 +4046,9 @@ async fn run_gateway(state: &AppState, config: &DiscordAccountConfig, session: &
|
||||
let body = extract_body(d).unwrap_or_default();
|
||||
let body = resolve_mentions(&body, d, &config.user_id, config.display_name.as_deref());
|
||||
state.runtime.update_message(state, &buffer_id, msg_id, &body, &embeds, &attachments);
|
||||
// A bot editing its own message routinely swaps the
|
||||
// buttons - a poll that closes, a page that turns.
|
||||
note_components(state, &buffer_id, msg_id, d);
|
||||
}
|
||||
"MESSAGE_DELETE" => {
|
||||
let channel_id = d["channel_id"].as_str().unwrap_or_default();
|
||||
@@ -4522,6 +4933,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
buffer_id: "b".into(),
|
||||
html: None,
|
||||
components: Vec::new(),
|
||||
sender_color: None,
|
||||
badges: Vec::new(),
|
||||
from: "x".into(),
|
||||
|
||||
@@ -298,6 +298,58 @@ pub struct Embed {
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// Something on a message that can be pressed.
|
||||
///
|
||||
/// Discord's own name for these is components, and a great many servers now
|
||||
/// work through them: a bot posts a message and the thing it is for is the
|
||||
/// button underneath, not the words. A client that draws only the words shows
|
||||
/// half of what was sent - a role picker with no roles, a ticket panel with no
|
||||
/// way to open a ticket.
|
||||
///
|
||||
/// Deliberately not modelled after Discord's wire shape. Their type numbers
|
||||
/// (2 is a button, 3 is a select) mean nothing to a frontend and would leak an
|
||||
/// integer nobody can read into every other protocol that grows the same idea.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Component {
|
||||
/// "button" or "select".
|
||||
pub kind: String,
|
||||
/// What the service calls this control, sent back when it is used. A link
|
||||
/// button has none: pressing it opens a page rather than telling anybody.
|
||||
#[serde(rename = "customId", skip_serializing_if = "Option::is_none")]
|
||||
pub custom_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// primary, secondary, success, danger or link - which is what the colour
|
||||
/// means, rather than the number the service sends.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
/// Where a link button goes.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub disabled: bool,
|
||||
/// The emoji on the button, as this client writes emoji elsewhere.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emoji: Option<String>,
|
||||
/// What a select offers. Empty for a button.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<ComponentOption>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub placeholder: Option<String>,
|
||||
/// Which row it was on, so a client can draw them as they were laid out.
|
||||
#[serde(default)]
|
||||
pub row: i64,
|
||||
}
|
||||
|
||||
/// One choice in a select menu.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ComponentOption {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A file attached to a message, described rather than inlined.
|
||||
///
|
||||
/// Modelled on how Matrix itself carries media and how matrix-rust-sdk hands
|
||||
@@ -424,6 +476,9 @@ pub struct Message {
|
||||
/// what search reads.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub html: Option<String>,
|
||||
/// Buttons and menus on the message, where the service has them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub components: Vec<Component>,
|
||||
}
|
||||
|
||||
pub fn buffer_id(account_id: &str, name: &str) -> String {
|
||||
|
||||
@@ -1174,6 +1174,70 @@ pub async fn dispatch(
|
||||
}
|
||||
|
||||
// What a room has told everyone to read first.
|
||||
// The slash commands this conversation offers, and running one.
|
||||
//
|
||||
// Listed per channel because that is the question with the right
|
||||
// answer: a bot installed across a guild can still be unusable in the
|
||||
// channel somebody is typing in.
|
||||
"listDiscordCommands" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("listDiscordCommands requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
let query = p_str(params, "query", "");
|
||||
match backend::discord::list_commands(state, &buffer.account_id, buffer_id, query).await {
|
||||
Ok(answer) => (Some(answer), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"runDiscordCommand" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("runDiscordCommand requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
// The command as Discord described it, handed straight back:
|
||||
// Discord checks it against its own copy and refuses one that has
|
||||
// been rebuilt from the parts a client happened to show.
|
||||
let Some(command) = params.get("command").filter(|c| c.is_object()) else {
|
||||
return (None, Some("runDiscordCommand requires the command it was given".to_string()));
|
||||
};
|
||||
let options = params.get("options").cloned().unwrap_or_else(|| serde_json::json!([]));
|
||||
match backend::discord::run_command(state, &buffer.account_id, buffer_id, command, options).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Pressing a button on a message, or answering one of its menus.
|
||||
"useDiscordComponent" => {
|
||||
let (buffer_id, message_id, custom_id) = match (
|
||||
p_str_opt(params, "bufferId"),
|
||||
p_str_opt(params, "messageId"),
|
||||
p_str_opt(params, "customId"),
|
||||
) {
|
||||
(Some(b), Some(m), Some(c)) => (b, m, c),
|
||||
_ => return (None, Some("useDiscordComponent requires \"bufferId\", \"messageId\" and \"customId\"".to_string())),
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
let is_select = params.get("select").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let values: Vec<String> = params
|
||||
.get("values")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|vs| vs.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
match backend::discord::use_component(state, &buffer.account_id, buffer_id, message_id, custom_id, is_select, values).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Discord's own search, over a whole server rather than this window's
|
||||
// copy of one channel.
|
||||
//
|
||||
|
||||
@@ -347,6 +347,8 @@ pub struct Runtime {
|
||||
/// this is the only place that mapping is kept (IRC has no equivalent
|
||||
/// need since its buffer name already *is* the protocol target).
|
||||
discord_channels: Mutex<HashMap<String, String>>,
|
||||
/// Account id -> the gateway session it is on, for interactions.
|
||||
discord_gateway_sessions: Mutex<HashMap<String, String>>,
|
||||
/// Conversations currently ringing this machine, buffer id -> (account,
|
||||
/// channel).
|
||||
///
|
||||
@@ -803,6 +805,7 @@ impl Runtime {
|
||||
presence: Mutex::new(HashMap::new()),
|
||||
own_identity: Mutex::new(HashMap::new()),
|
||||
discord_channels: Mutex::new(HashMap::new()),
|
||||
discord_gateway_sessions: Mutex::new(HashMap::new()),
|
||||
ringing_calls: Mutex::new(HashMap::new()),
|
||||
buffer_groups: Mutex::new(HashMap::new()),
|
||||
discord_guild_id: Mutex::new(HashMap::new()),
|
||||
@@ -1705,6 +1708,21 @@ impl Runtime {
|
||||
.map(|(buffer_id, _)| buffer_id.clone())
|
||||
}
|
||||
|
||||
/// The gateway session this account is on.
|
||||
///
|
||||
/// Kept because an interaction - running a slash command, pressing a
|
||||
/// button - has to say which live session it came from, and Discord
|
||||
/// refuses one that names a session it has never heard of. The value is
|
||||
/// only handed out by the gateway at READY, and only to whoever holds the
|
||||
/// socket, so it is remembered here rather than asked for.
|
||||
pub fn set_discord_gateway_session(&self, account_id: &str, session_id: &str) {
|
||||
self.discord_gateway_sessions.lock().unwrap().insert(account_id.to_string(), session_id.to_string());
|
||||
}
|
||||
|
||||
pub fn discord_gateway_session(&self, account_id: &str) -> Option<String> {
|
||||
self.discord_gateway_sessions.lock().unwrap().get(account_id).cloned()
|
||||
}
|
||||
|
||||
pub fn get_discord_channel(&self, buffer_id: &str) -> Option<String> {
|
||||
self.discord_channels.lock().unwrap().get(buffer_id).cloned()
|
||||
}
|
||||
@@ -3148,6 +3166,9 @@ impl Runtime {
|
||||
|
||||
let message = Message {
|
||||
html: html.clone(),
|
||||
// Filled in by the backend that has them, immediately after this
|
||||
// returns - only Discord has any, and it is the one that knows.
|
||||
components: Vec::new(),
|
||||
id: msg_id,
|
||||
buffer_id: buffer.id.clone(),
|
||||
from: from.to_string(),
|
||||
|
||||
+34
-7
@@ -115,6 +115,11 @@ impl Store {
|
||||
// separate rather than replacing body: the plain text is the
|
||||
// fallback every other protocol uses and the one search reads.
|
||||
"ALTER TABLE messages ADD COLUMN html TEXT",
|
||||
// Buttons and menus on a message. Kept rather than shown live and
|
||||
// forgotten: a role picker or a ticket panel is posted once and
|
||||
// pressed for months, so a client that lost them on restart would
|
||||
// be showing a message whose whole point had gone.
|
||||
"ALTER TABLE messages ADD COLUMN components TEXT NOT NULL DEFAULT '[]'",
|
||||
// Which kind of conversation this was said in.
|
||||
//
|
||||
// Buffers live only as long as the daemon does - they are rebuilt
|
||||
@@ -441,6 +446,7 @@ impl Store {
|
||||
let mut attachments: Vec<Attachment> = serde_json::from_str(&json_column("attachments")).unwrap_or_default();
|
||||
drop_missing_local_copies(&mut attachments);
|
||||
let badges = serde_json::from_str(&json_column("badges")).unwrap_or_default();
|
||||
let components = serde_json::from_str(&json_column("components")).unwrap_or_default();
|
||||
Ok(Message {
|
||||
id: row.get::<_, Option<String>>("msg_id")?.unwrap_or_default(),
|
||||
buffer_id: buffer_id.to_string(),
|
||||
@@ -461,6 +467,7 @@ impl Store {
|
||||
sender_color: row.get("sender_color")?,
|
||||
badges,
|
||||
html: row.get("html")?,
|
||||
components,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -468,7 +475,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND (?2 <= 0 OR ts < ?2)
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -479,6 +486,26 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Puts the buttons and menus on a message that already exists.
|
||||
///
|
||||
/// Its own call rather than another argument to `append_message`, which
|
||||
/// takes nineteen already: these belong to one service, are written by
|
||||
/// that service's backend immediately after the message lands, and adding
|
||||
/// a twentieth parameter would make every other backend say "no
|
||||
/// components" in a language none of them speak.
|
||||
pub fn set_components(&self, buffer_id: &str, msg_id: &str, components: &[crate::model::Component]) -> Result<()> {
|
||||
if components.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let json = serde_json::to_string(components)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE messages SET components = ?3 WHERE buffer_id = ?1 AND msg_id = ?2",
|
||||
params![buffer_id, msg_id, json],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Who somebody is, from the last thing they said.
|
||||
///
|
||||
/// Search filters are typed as names - "from:coty1911" - and Discord wants
|
||||
@@ -506,7 +533,7 @@ impl Store {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let limit = if limit > 0 { limit } else { 200 };
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ts > ?2
|
||||
ORDER BY ts ASC LIMIT ?3",
|
||||
@@ -525,7 +552,7 @@ impl Store {
|
||||
pub fn messages_around(&self, buffer_id: &str, ts: i64, span: i64) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let span = if span > 0 { span } else { 50 };
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread";
|
||||
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components";
|
||||
|
||||
let mut older = conn.prepare(&format!(
|
||||
"SELECT {COLUMNS} FROM messages WHERE buffer_id = ?1 AND ts <= ?2 ORDER BY ts DESC LIMIT ?3"
|
||||
@@ -571,7 +598,7 @@ impl Store {
|
||||
format!(" OR (buffer_kind IS NULL AND buffer_id IN ({places}))")
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, buffer_id
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components, buffer_id
|
||||
FROM messages
|
||||
WHERE is_highlight = 1 AND is_own = 0 AND (buffer_kind = 'channel'{legacy})
|
||||
ORDER BY ts DESC LIMIT ?"
|
||||
@@ -708,7 +735,7 @@ impl Store {
|
||||
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND body LIKE ?2 ESCAPE '\\'
|
||||
ORDER BY ts DESC LIMIT ?3",
|
||||
@@ -773,7 +800,7 @@ impl Store {
|
||||
pub fn thread_messages(&self, buffer_id: &str, root_id: &str) -> Result<Vec<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR msg_id = ?2)
|
||||
ORDER BY ts ASC, rowid ASC",
|
||||
@@ -785,7 +812,7 @@ impl Store {
|
||||
pub fn get_message(&self, buffer_id: &str, msg_id: &str) -> Result<Option<Message>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread
|
||||
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, components
|
||||
FROM messages
|
||||
WHERE buffer_id = ?1 AND msg_id = ?2",
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user