Somewhere to make an invite, not only accept one

`join_guild` has been here on its own since the beginning: this client could
take an invite somebody else made and had no way to make one, so inviting
anybody meant opening the official client to fetch a link.

`POST /channels/{id}/invites`, through `send_write` like every other thing
this account does rather than reads. The three options are Discord's own and
so are the defaults - a day, unlimited uses, ordinary membership - and zero
keeps Discord's meaning at both ends: never expires, and no limit. Passed
through rather than translated, so what the official client offers is what is
offered here.

The link is built here rather than by the caller, in the short `discord.gg`
form the official client copies - the code alone is not a thing anybody can
paste.

Works for a group message as well as a guild channel: Discord treats both as
a channel somebody can be invited to.

Part of #121.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 02:05:08 -04:00
co-authored by Claude Opus 5
parent 02e08f8ff6
commit 2c024de938
2 changed files with 78 additions and 0 deletions
+53
View File
@@ -26,6 +26,59 @@ pub async fn join_guild(state: &AppState, account_id: &str, invite: &str) -> Res
Ok(())
}
/// Makes an invite to a conversation, and returns the link.
///
/// The other half of `join_guild`, which has been here on its own: this
/// client could accept an invite somebody else made but not make one, so
/// inviting anybody meant opening the official client for a link.
///
/// The three options are Discord's own, and the defaults are its defaults: a
/// day, unlimited uses, and full membership. Zero means "never" for the
/// expiry and "no limit" for the uses, which is Discord's convention and not
/// this client's - it is passed through rather than translated, so what the
/// official client offers is what is offered here.
///
/// Works for a group DM as well as a guild channel. Discord treats both as a
/// channel somebody can be invited to, and so does this.
pub async fn create_invite(
state: &AppState,
account_id: &str,
buffer_id: &str,
max_age: i64,
max_uses: i64,
temporary: bool,
) -> Result<String> {
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 = send_write(
http_client()
.post(format!("{API_BASE}/channels/{channel_id}/invites"))
.header("Authorization", &cfg.token)
.json(&json!({
"max_age": max_age,
"max_uses": max_uses,
"temporary": temporary,
})),
)
.await
.context("making an invite")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
bail!("{}", discord_error_text(status, &text, "making an invite"));
}
let answer: Value = resp.json().await.context("reading the invite back")?;
// The code is what Discord returns; the link is what a person pastes,
// and building it here means every caller does not have to know the
// domain. `discord.gg` rather than `discord.com/invite` because that is
// the short form the official client copies.
let code = answer["code"].as_str().context("Discord made an invite with no code in it")?;
Ok(format!("https://discord.gg/{code}"))
}
/// Creates a brand-new guild owned by this account - Discord's own "Create
/// My Own" server flow, same endpoint real clients use. Discord auto-
/// creates a default #general channel; the gateway's own GUILD_CREATE
+25
View File
@@ -969,6 +969,31 @@ pub async fn dispatch(
}
}
// An invite to the conversation on screen, as a link to paste.
//
// The options are Discord's own and so are the defaults: a day,
// unlimited uses, and full membership. Zero means "never" for the
// expiry and "no limit" for the uses, which is Discord's convention -
// passed through rather than translated.
"createDiscordInvite" => {
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
return (None, Some("createDiscordInvite requires \"bufferId\"".to_string()));
};
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
return (None, Some("no such conversation".to_string()));
};
if !buffer.account_id.starts_with("discord:") {
return (None, Some("that is not a Discord conversation".to_string()));
}
let max_age = params.get("maxAge").and_then(|v| v.as_i64()).unwrap_or(86_400);
let max_uses = params.get("maxUses").and_then(|v| v.as_i64()).unwrap_or(0);
let temporary = params.get("temporary").and_then(|v| v.as_bool()).unwrap_or(false);
match backend::discord::create_invite(state, &buffer.account_id, buffer_id, max_age, max_uses, temporary).await {
Ok(link) => (Some(serde_json::json!({ "invite": link })), None),
Err(e) => (None, Some(format!("{e:#}"))),
}
}
"createDiscordGuild" => {
let (account_id, name) = match (p_str_opt(params, "accountId"), p_str_opt(params, "name")) {
(Some(a), Some(n)) => (a, n),