Show that somebody is writing
Missing on both protocols that carry it, in both directions - nothing listened and nothing was ever sent. The two differ in shape and the daemon evens them out. Discord names one person per event, never says anybody stopped, and expects a client to forget after about ten seconds; Matrix sends the whole set each time, with an empty one meaning everybody stopped, and takes a timeout and an explicit cancel. Both arrive as one "typing" event carrying how long to believe it, so no frontend has to know which service it is watching. Sending is rate-limited to once every four seconds rather than per keystroke: one notice covers ten, and a request per character would be a lot of traffic to say one thing. Sending a message says so immediately - Matrix can cancel, Discord ignores it and expires. The indicator expires on a timer as well as on the next event, because the last word somebody types produces no further notice at all. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -40,6 +40,14 @@ use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
const REMOTE_AUTH_URL: &str = "wss://remote-auth-gateway.discord.gg/?v=2";
|
||||
const GATEWAY_URL: &str = "wss://gateway.discord.gg/?v=10&encoding=json";
|
||||
const API_BASE: &str = "https://discord.com/api/v10";
|
||||
|
||||
/// How long a "typing" notice stands before it should be forgotten.
|
||||
///
|
||||
/// Discord sends no "stopped typing" event and its own client expires the
|
||||
/// indicator after ten seconds, so this is that convention rather than a
|
||||
/// number from the protocol. Matrix does carry a timeout of its own, and
|
||||
/// uses this as what to ask for.
|
||||
pub const TYPING_TTL_MS: u64 = 10_000;
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Says who is calling, honestly.
|
||||
@@ -2430,6 +2438,36 @@ async fn run_gateway(state: &AppState, config: &DiscordAccountConfig) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
// Somebody started typing. Discord sends no matching
|
||||
// "stopped" event - a client is expected to forget after
|
||||
// about ten seconds, or when a message from that person
|
||||
// arrives - so the expiry is carried rather than left for
|
||||
// each frontend to invent its own.
|
||||
"TYPING_START" => {
|
||||
let Some(channel_id) = d["channel_id"].as_str() else { continue };
|
||||
let Some(user_id) = d["user_id"].as_str() else { continue };
|
||||
if user_id == config.user_id {
|
||||
continue;
|
||||
}
|
||||
let Some((name, _)) = channel_map.get(channel_id) else { continue };
|
||||
let nick = d["member"]["nick"]
|
||||
.as_str()
|
||||
.or_else(|| d["member"]["user"]["global_name"].as_str())
|
||||
.or_else(|| d["member"]["user"]["username"].as_str())
|
||||
.map(str::to_string)
|
||||
.or_else(|| state.runtime.discord_known_name(&account_id, user_id))
|
||||
.unwrap_or_else(|| "Someone".to_string());
|
||||
state.events.emit(
|
||||
"typing",
|
||||
json!({
|
||||
"accountId": account_id,
|
||||
"bufferId": crate::model::buffer_id(&account_id, name),
|
||||
"nick": nick,
|
||||
"expiresInMs": TYPING_TTL_MS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Read somewhere else. Discord sends this to every one
|
||||
// of an account's sessions, including the one that did
|
||||
// the acking, so a client acting on it also settles its
|
||||
@@ -2787,6 +2825,27 @@ pub async fn edit_message(state: &AppState, buffer_id: &str, token: &str, msg_id
|
||||
/// DELETE .../messages/{id} - deleting your own message. Same
|
||||
/// author-only enforcement as edit; also works for a moderator with
|
||||
/// MANAGE_MESSAGES, which Discord itself decides, not this code.
|
||||
/// Says that this account is composing something.
|
||||
///
|
||||
/// One POST covers about ten seconds, so a caller repeats it while somebody
|
||||
/// keeps typing rather than sending one per keystroke. Failure is not worth
|
||||
/// reporting: a missing typing indicator is not something to interrupt
|
||||
/// somebody mid-sentence about.
|
||||
pub async fn send_typing(state: &AppState, buffer_id: &str, token: &str) -> Result<()> {
|
||||
let channel_id = state
|
||||
.runtime
|
||||
.get_discord_channel(buffer_id)
|
||||
.ok_or_else(|| anyhow!("no known Discord channel for this buffer"))?;
|
||||
http_client()
|
||||
.post(format!("{API_BASE}/channels/{channel_id}/typing"))
|
||||
.header("Authorization", token)
|
||||
.header("Content-Length", "0")
|
||||
.send()
|
||||
.await
|
||||
.context("sending a Discord typing notice")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tells Discord this conversation has been read up to its newest message.
|
||||
///
|
||||
/// Unread was purely local before this, so reading a channel here did not
|
||||
|
||||
@@ -404,6 +404,36 @@ async fn process_sync_response(state: &AppState, account_id: &str, own_user_id:
|
||||
|
||||
let Some(joined) = resp["rooms"]["join"].as_object() else { return };
|
||||
for (room_id, room) in joined {
|
||||
// Who is composing in this room, if anybody. Matrix sends the whole
|
||||
// set each time rather than one event per person, and an empty list
|
||||
// is how it says everybody stopped - so this is a replace, and the
|
||||
// empty case has to be emitted rather than skipped or the last
|
||||
// indicator would never clear.
|
||||
if let Some(events) = room["ephemeral"]["events"].as_array() {
|
||||
for event in events.iter().filter(|e| e["type"].as_str() == Some("m.typing")) {
|
||||
let members = state.runtime.get_matrix_room_members(account_id, room_id);
|
||||
let nicks: Vec<String> = event["content"]["user_ids"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|u| u.as_str())
|
||||
.filter(|u| *u != own_user_id)
|
||||
.map(|u| members.get(u).cloned().unwrap_or_else(|| u.to_string()))
|
||||
.collect();
|
||||
if let Some((buffer_name, _)) = state.runtime.get_matrix_room_name(account_id, room_id) {
|
||||
state.events.emit(
|
||||
"typing",
|
||||
serde_json::json!({
|
||||
"accountId": account_id,
|
||||
"bufferId": crate::model::buffer_id(account_id, &buffer_name),
|
||||
"nicks": nicks,
|
||||
"expiresInMs": crate::backend::discord::TYPING_TTL_MS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timeline_events: Vec<&Value> = room["timeline"]["events"].as_array().into_iter().flatten().collect();
|
||||
|
||||
let (buffer_name, buffer_kind) = match state.runtime.get_matrix_room_name(account_id, room_id) {
|
||||
@@ -983,6 +1013,25 @@ pub async fn join_room(state: &AppState, account_id: &str, room_id_or_alias: &st
|
||||
/// account's `rooms.leave` forever, which every client shows as a room you
|
||||
/// have left rather than one that is gone. Forgetting is best-effort, since a
|
||||
/// server may refuse it and the leave is the part that matters.
|
||||
/// Says that this account is composing something in a room.
|
||||
///
|
||||
/// Matrix wants a timeout with the notice and cancels with `typing: false`,
|
||||
/// unlike Discord's single fire-and-expire - so a caller that stops typing
|
||||
/// can actually say so rather than waiting the notice out.
|
||||
pub async fn send_typing(state: &AppState, account_id: &str, room_id: &str, typing: bool) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let room = url::form_urlencoded::byte_serialize(room_id.trim().as_bytes()).collect::<String>();
|
||||
let user = url::form_urlencoded::byte_serialize(account.user_id.trim().as_bytes()).collect::<String>();
|
||||
let body = if typing {
|
||||
serde_json::json!({ "typing": true, "timeout": crate::backend::discord::TYPING_TTL_MS })
|
||||
} else {
|
||||
serde_json::json!({ "typing": false })
|
||||
};
|
||||
http::put_json(&format!("{base}/_matrix/client/v3/rooms/{room}/typing/{user}"), &account.access_token, body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn leave_room(state: &AppState, account_id: &str, room_id: &str) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
|
||||
@@ -307,6 +307,42 @@ pub async fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
// "I am writing something." Discord and Matrix both carry it; IRC
|
||||
// and Sneedchat have no such notion, so this is quietly a no-op
|
||||
// there for the same reason markBufferRead is.
|
||||
//
|
||||
// Failure is swallowed rather than reported. A typing indicator that
|
||||
// did not arrive is not worth interrupting somebody mid-sentence
|
||||
// over, and this is called repeatedly while they write.
|
||||
"sendTyping" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("sendTyping requires \"bufferId\"".to_string()));
|
||||
};
|
||||
// Matrix can say "stopped", Discord cannot - it only expires.
|
||||
let typing = params.get("typing").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
match state.runtime.get_buffer(buffer_id) {
|
||||
Some(buffer) if buffer.account_id.starts_with("discord:") => {
|
||||
if typing {
|
||||
if let Some(cfg) = state.accounts.get_discord(&buffer.account_id) {
|
||||
if let Err(e) = backend::discord::send_typing(state, buffer_id, &cfg.token).await {
|
||||
tracing::debug!("sendTyping: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
Some(buffer) if buffer.account_id.starts_with("matrix:") => {
|
||||
if let Some(room_id) = state.runtime.get_matrix_room(&buffer.id) {
|
||||
if let Err(e) = backend::matrix::send_typing(state, &buffer.account_id, &room_id, typing).await {
|
||||
tracing::debug!("sendTyping: {e}");
|
||||
}
|
||||
}
|
||||
(Some(ok_node()), None)
|
||||
}
|
||||
_ => (Some(ok_node()), None),
|
||||
}
|
||||
}
|
||||
|
||||
// "I have read this." Only Discord has anywhere to put it - IRC and
|
||||
// Sneedchat have no read state at all, and Matrix's receipts are
|
||||
// their own piece of work - so this is quietly a no-op elsewhere
|
||||
|
||||
@@ -890,6 +890,19 @@ impl Runtime {
|
||||
.insert((account_id.to_string(), user_id.to_string()), name.to_string());
|
||||
}
|
||||
|
||||
/// What this account has previously learned to call somebody.
|
||||
///
|
||||
/// A typing notice from a DM carries no member object to read a name
|
||||
/// out of, and "Someone is typing" is worse than the name we already
|
||||
/// saw on their last message.
|
||||
pub fn discord_known_name(&self, account_id: &str, user_id: &str) -> Option<String> {
|
||||
self.discord_voice_names
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(account_id.to_string(), user_id.to_string()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Everyone in a voice channel, as (user id, what to call them).
|
||||
///
|
||||
/// Nobody is excluded here: a channel list has to show you your own
|
||||
|
||||
Reference in New Issue
Block a user