Discord channels have pins, and now moho can read and set them
A channel keeps its rules, its links and the thing being worked on in its
pins, and none of it was reachable here: /channels/{id}/pins was never
called. Reading is asked of Discord every time rather than cached, because
a pin is set by whoever runs the channel while nobody here is looking.
listPinned and setPinned replace the Matrix-only pair and dispatch on the
account, since the panel that shows them is the same panel and the only
difference is on this side. Discord tells a user client nothing when a pin
changes, so a successful pin re-reads the list and says so.
Verified live against a real channel: read a guild's existing pin, pinned
and unpinned a message and saw the list change both ways, and saw both
refusals - missing permission, and a message that no longer exists -
arrive in Discord's own words.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -858,6 +858,107 @@ pub async fn open_dm_with(state: &AppState, account_id: &str, user_ids: &[String
|
||||
Ok(buffer.id)
|
||||
}
|
||||
|
||||
/// The messages a channel has pinned, as this client's own message shape.
|
||||
///
|
||||
/// Asked of Discord every time rather than kept: a pin is set by whoever is
|
||||
/// running the channel, often while nobody here is looking, and a cached list
|
||||
/// would be a list of what was pinned when this window last happened to ask.
|
||||
/// The same messages are usually already in scrollback, but not always - a
|
||||
/// channel's rules are typically pinned years before anybody reads them.
|
||||
pub async fn list_pinned(state: &AppState, account_id: &str, buffer_id: &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 resp = http_client()
|
||||
.get(format!("{API_BASE}/channels/{channel_id}/pins"))
|
||||
.header("Authorization", &cfg.token)
|
||||
.send()
|
||||
.await
|
||||
.context("reading the pins")?;
|
||||
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 pins"));
|
||||
}
|
||||
let messages: Vec<Value> = resp.json().await.context("reading the pins")?;
|
||||
|
||||
// Discord returns newest first, which is the order a pin list is read in:
|
||||
// the thing pinned most recently is the thing being talked about now.
|
||||
let own_display_name = cfg.display_name.clone();
|
||||
let pinned: Vec<Value> = messages
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
let id = msg["id"].as_str()?;
|
||||
let author = &msg["author"];
|
||||
let from = author["global_name"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| author["username"].as_str())
|
||||
.unwrap_or("unknown");
|
||||
let body = resolve_mentions(&extract_body(msg).unwrap_or_default(), msg, &cfg.user_id, own_display_name.as_deref());
|
||||
let ts = msg["timestamp"]
|
||||
.as_str()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"bufferId": buffer_id,
|
||||
"from": from,
|
||||
// An attachment or an embed with no words is a real pin and a
|
||||
// common one - a picture everybody is meant to have seen - so
|
||||
// it is listed saying what it is rather than as a blank row.
|
||||
"body": if body.trim().is_empty() { describe_wordless(msg) } else { body },
|
||||
"ts": ts,
|
||||
"kind": "chat",
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
let ids: Vec<&str> = pinned.iter().filter_map(|m| m["id"].as_str()).collect();
|
||||
// Told as well as answered, so the header's count is right for every
|
||||
// window watching this conversation rather than only the one that asked.
|
||||
state.events.emit("pinnedMessages", json!({ "bufferId": buffer_id, "pinned": ids }));
|
||||
Ok(json!({ "bufferId": buffer_id, "pinned": pinned }))
|
||||
}
|
||||
|
||||
/// What to call a message that has no words in it.
|
||||
fn describe_wordless(msg: &Value) -> String {
|
||||
let attachments = msg["attachments"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
let embeds = msg["embeds"].as_array().map(|a| a.len()).unwrap_or(0);
|
||||
match (attachments, embeds) {
|
||||
(0, 0) => "(no text)".to_string(),
|
||||
(1, _) => "(an attachment)".to_string(),
|
||||
(n, _) if n > 1 => format!("({n} attachments)"),
|
||||
_ => "(a link)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins a message, or takes the pin off.
|
||||
///
|
||||
/// Whether this account may is Discord's decision - MANAGE_MESSAGES in a
|
||||
/// guild, and anybody in a DM - so it is asked rather than worked out here,
|
||||
/// and a refusal is passed through in Discord's own words.
|
||||
pub async fn set_pinned(state: &AppState, account_id: &str, buffer_id: &str, message_id: &str, pinned: bool) -> 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 url = format!("{API_BASE}/channels/{channel_id}/pins/{message_id}");
|
||||
let http = http_client();
|
||||
let request = if pinned { http.put(url) } else { http.delete(url) };
|
||||
let doing = if pinned { "pinning the message" } else { "unpinning the message" };
|
||||
let resp = request.header("Authorization", &cfg.token).send().await.context(doing)?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!("{}", discord_error_text(status, &text, doing));
|
||||
}
|
||||
// Read back, so the header's count and the menu's wording agree with what
|
||||
// just happened. Discord sends no pin event to a user client, so nothing
|
||||
// else would tell this window - or any other one - that the list changed.
|
||||
if let Err(e) = list_pinned(state, account_id, buffer_id).await {
|
||||
tracing::debug!("discord: re-reading the pins: {e:#}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many other people a Discord group message holds - ten including you.
|
||||
const GROUP_DM_MAX_OTHERS: usize = 9;
|
||||
|
||||
|
||||
@@ -2681,7 +2681,7 @@ pub async fn set_pinned(state: &AppState, account_id: &str, buffer_id: &str, eve
|
||||
set_room_state(state, account_id, buffer_id, "m.room.pinned_events", serde_json::json!({ "pinned": next })).await?;
|
||||
// Locally too, so the list is right before the next sync arrives.
|
||||
state.runtime.set_matrix_pinned(account_id, &room_id, next.clone());
|
||||
state.events.emit("matrixPinned", serde_json::json!({ "bufferId": buffer_id, "pinned": next }));
|
||||
state.events.emit("pinnedMessages", serde_json::json!({ "bufferId": buffer_id, "pinned": next }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ pub async fn process_state_events(state: &AppState, account_id: &str, room_id: &
|
||||
state.runtime.set_matrix_pinned(account_id, room_id, pinned.clone());
|
||||
if let Some(buffer_id) = state.runtime.matrix_buffer_for_room(account_id, room_id) {
|
||||
state.events.emit(
|
||||
"matrixPinned",
|
||||
"pinnedMessages",
|
||||
serde_json::json!({ "bufferId": buffer_id, "pinned": pinned }),
|
||||
);
|
||||
}
|
||||
|
||||
+26
-7
@@ -1125,14 +1125,26 @@ pub async fn dispatch(
|
||||
}
|
||||
|
||||
// What a room has told everyone to read first.
|
||||
"listMatrixPinned" => {
|
||||
// What a conversation has told everyone to read first.
|
||||
//
|
||||
// One method for every service that has pins rather than one each:
|
||||
// the panel that shows them is the same panel, and the difference
|
||||
// between a Matrix pin and a Discord one is entirely on this side.
|
||||
"listPinned" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("listMatrixPinned requires \"bufferId\"".to_string()));
|
||||
return (None, Some("listPinned requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
match backend::matrix::list_pinned(state, &buffer.account_id, buffer_id).await {
|
||||
let answer = if buffer.account_id.starts_with("discord:") {
|
||||
backend::discord::list_pinned(state, &buffer.account_id, buffer_id).await
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
backend::matrix::list_pinned(state, &buffer.account_id, buffer_id).await
|
||||
} else {
|
||||
Err(anyhow::anyhow!("this service has no pinned messages"))
|
||||
};
|
||||
match answer {
|
||||
Ok(answer) => (Some(answer), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
@@ -1140,16 +1152,23 @@ pub async fn dispatch(
|
||||
|
||||
// Pinning one, or taking the pin off. Whether this account may is the
|
||||
// server's decision, and its refusal is passed through in its words.
|
||||
"setMatrixPinned" => {
|
||||
let (buffer_id, event_id) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "eventId")) {
|
||||
"setPinned" => {
|
||||
let (buffer_id, message_id) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "messageId").or_else(|| p_str_opt(params, "eventId"))) {
|
||||
(Some(b), Some(e)) => (b, e),
|
||||
_ => return (None, Some("setMatrixPinned requires \"bufferId\" and \"eventId\"".to_string())),
|
||||
_ => return (None, Some("setPinned requires \"bufferId\" and \"messageId\"".to_string())),
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such conversation".to_string()));
|
||||
};
|
||||
let pinned = params.get("pinned").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
match backend::matrix::set_pinned(state, &buffer.account_id, buffer_id, event_id, pinned).await {
|
||||
let done = if buffer.account_id.starts_with("discord:") {
|
||||
backend::discord::set_pinned(state, &buffer.account_id, buffer_id, message_id, pinned).await
|
||||
} else if buffer.account_id.starts_with("matrix:") {
|
||||
backend::matrix::set_pinned(state, &buffer.account_id, buffer_id, message_id, pinned).await
|
||||
} else {
|
||||
Err(anyhow::anyhow!("this service has no pinned messages"))
|
||||
};
|
||||
match done {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user