", strings.Join(items, "")), false, true)
- }
-}
-
-func fnBridgeGuild(ce *WrappedCommandEvent) {
- if len(ce.Args) == 0 || len(ce.Args) > 2 {
- ce.Reply("**Usage**: `$cmdprefix guilds bridge [--entire]")
- } else if err := ce.User.bridgeGuild(ce.Args[0], len(ce.Args) == 2 && strings.ToLower(ce.Args[1]) == "--entire"); err != nil {
- ce.Reply("Error bridging guild: %v", err)
- } else {
- ce.Reply("Successfully bridged guild")
- }
-}
-
-func fnUnbridgeGuild(ce *WrappedCommandEvent) {
- if len(ce.Args) != 1 {
- ce.Reply("**Usage**: `$cmdprefix guilds unbridge ")
- } else if err := ce.User.unbridgeGuild(ce.Args[0]); err != nil {
- ce.Reply("Error unbridging guild: %v", err)
- } else {
- ce.Reply("Successfully unbridged guild")
- }
-}
-
-const availableModes = "Available modes:\n" +
- "* `nothing` to never bridge any messages (default when unbridged)\n" +
- "* `if-portal-exists` to bridge messages in existing portals, but drop messages in unbridged channels\n" +
- "* `create-on-message` to bridge all messages and create portals if necessary on incoming messages (default after bridging)\n" +
- "* `everything` to bridge all messages and create portals proactively on bridge startup (default if bridged with `--entire`)\n"
-
-func fnGuildBridgingMode(ce *WrappedCommandEvent) {
- if len(ce.Args) == 0 || len(ce.Args) > 2 {
- ce.Reply("**Usage**: `$cmdprefix guilds bridging-mode [mode]`\n\n" + availableModes)
- return
- }
- guild := ce.Bridge.GetGuildByID(ce.Args[0], false)
- if guild == nil {
- ce.Reply("Guild not found")
- return
- }
- if len(ce.Args) == 1 {
- ce.Reply("%s (%s) is currently set to %s (`%s`)\n\n%s", guild.PlainName, guild.ID, guild.BridgingMode.Description(), guild.BridgingMode.String(), availableModes)
- return
- }
- mode := database.ParseGuildBridgingMode(ce.Args[1])
- if mode == database.GuildBridgeInvalid {
- ce.Reply("Invalid guild bridging mode `%s`", ce.Args[1])
- return
- }
- guild.BridgingMode = mode
- guild.Update()
- ce.Reply("Set guild bridging mode to %s", mode.Description())
-}
-
-var cmdBridge = &commands.FullHandler{
- Func: wrapCommand(fnBridge),
- Name: "bridge",
- Help: commands.HelpMeta{
- Section: HelpSectionPortalManagement,
- Description: "Bridge this room to a specific Discord channel",
- Args: "[--replace[=delete]] <_channel ID_>",
- },
- RequiresEventLevel: roomModerator,
-}
-
-func isNumber(str string) bool {
- for _, chr := range str {
- if chr < '0' || chr > '9' {
- return false
- }
- }
- return true
-}
-
-func fnBridge(ce *WrappedCommandEvent) {
- if ce.Portal != nil {
- ce.Reply("This is already a portal room. Unbridge with `$cmdprefix unbridge` first if you want to link it to a different channel.")
- return
- }
- var channelID string
- var unbridgeOld, deleteOld bool
- fail := true
- for _, arg := range ce.Args {
- arg = strings.ToLower(arg)
- if arg == "--replace" {
- unbridgeOld = true
- } else if arg == "--replace=delete" {
- unbridgeOld = true
- deleteOld = true
- } else if channelID == "" && isNumber(arg) {
- channelID = arg
- fail = false
- } else {
- fail = true
- break
- }
- }
- if fail {
- ce.Reply("**Usage**: `$cmdprefix bridge [--replace[=delete]] `")
- return
- }
- portal := ce.User.GetExistingPortalByID(channelID)
- if portal == nil {
- ce.Reply("Channel not found")
- return
- }
- portal.roomCreateLock.Lock()
- defer portal.roomCreateLock.Unlock()
- if portal.MXID != "" {
- hasUnbridgePermission := ce.User.PermissionLevel >= bridgeconfig.PermissionLevelAdmin
- if !hasUnbridgePermission {
- levels, err := portal.MainIntent().PowerLevels(portal.MXID)
- if errors.Is(err, mautrix.MNotFound) {
- ce.ZLog.Debug().Err(err).Msg("Got M_NOT_FOUND trying to get power levels to check if user can unbridge it, assuming the room is gone")
- hasUnbridgePermission = true
- } else if err != nil {
- ce.ZLog.Warn().Err(err).Msg("Failed to check room power levels")
- ce.Reply("Failed to get power levels in old room to see if you're allowed to unbridge it")
- return
- } else {
- hasUnbridgePermission = levels.GetUserLevel(ce.User.GetMXID()) >= levels.GetEventLevel(roomModerator)
- }
- }
- if !unbridgeOld || !hasUnbridgePermission {
- extraHelp := "Rerun the command with `--replace` or `--replace=delete` to unbridge the old room."
- if !hasUnbridgePermission {
- extraHelp = "Additionally, you do not have the permissions to unbridge the old room."
- }
- ce.Reply("That channel is already bridged to [%s](https://matrix.to/#/%s). %s", portal.Name, portal.MXID, extraHelp)
- return
- }
- ce.ZLog.Debug().
- Str("old_room_id", portal.MXID.String()).
- Bool("delete", deleteOld).
- Msg("Unbridging old room")
- portal.removeFromSpace()
- portal.cleanup(!deleteOld)
- portal.RemoveMXID()
- ce.ZLog.Info().
- Str("old_room_id", portal.MXID.String()).
- Bool("delete", deleteOld).
- Msg("Unbridged old room to make space for new bridge")
- }
- if portal.Guild != nil && portal.Guild.BridgingMode < database.GuildBridgeIfPortalExists {
- ce.ZLog.Debug().Str("guild_id", portal.Guild.ID).Msg("Bumping bridging mode of portal guild to if-portal-exists")
- portal.Guild.BridgingMode = database.GuildBridgeIfPortalExists
- portal.Guild.Update()
- }
- ce.ZLog.Debug().Str("channel_id", portal.Key.ChannelID).Msg("Bridging room")
- portal.MXID = ce.RoomID
- portal.bridge.portalsLock.Lock()
- portal.bridge.portalsByMXID[portal.MXID] = portal
- portal.bridge.portalsLock.Unlock()
- portal.updateRoomName()
- portal.updateRoomAvatar()
- portal.updateRoomTopic()
- portal.updateSpace(ce.User)
- portal.UpdateBridgeInfo()
- state, err := portal.MainIntent().State(portal.MXID)
- if err != nil {
- ce.ZLog.Error().Err(err).Msg("Failed to update state cache for room")
- } else {
- encryptionEvent, isEncrypted := state[event.StateEncryption][""]
- portal.Encrypted = isEncrypted && encryptionEvent.Content.AsEncryption().Algorithm == id.AlgorithmMegolmV1
- }
- portal.Update()
- ce.Reply("Room successfully bridged")
- ce.ZLog.Info().
- Str("channel_id", portal.Key.ChannelID).
- Bool("encrypted", portal.Encrypted).
- Msg("Manual bridging complete")
-}
-
-var cmdUnbridge = &commands.FullHandler{
- Func: wrapCommand(fnUnbridge),
- Name: "unbridge",
- Help: commands.HelpMeta{
- Section: HelpSectionPortalManagement,
- Description: "Unbridge this room from the linked Discord channel",
- },
- RequiresPortal: true,
- RequiresEventLevel: roomModerator,
-}
-
-var cmdCreatePortal = &commands.FullHandler{
- Func: wrapCommand(fnCreatePortal),
- Name: "create-portal",
- Help: commands.HelpMeta{
- Section: HelpSectionPortalManagement,
- Description: "Create a portal for a specific channel",
- Args: "<_channel ID_>",
- },
- RequiresLogin: true,
-}
-
-func fnCreatePortal(ce *WrappedCommandEvent) {
- meta, err := ce.User.Session.Channel(ce.Args[0])
- if err != nil {
- ce.Reply("Failed to get channel info: %v", err)
- return
- } else if meta == nil {
- ce.Reply("Channel not found")
- return
- } else if !ce.User.channelIsBridgeable(meta) {
- ce.Reply("That channel can't be bridged")
- return
- }
- portal := ce.User.GetPortalByMeta(meta)
- if portal.Guild != nil && portal.Guild.BridgingMode == database.GuildBridgeNothing {
- ce.Reply("That guild is set to not bridge any messages. Bridge the guild with `$cmdprefix guilds bridge %s` first", portal.Guild.ID)
- return
- } else if portal.MXID != "" {
- ce.Reply("That channel is already bridged: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL())
- return
- }
- err = portal.CreateMatrixRoom(ce.User, meta)
- if err != nil {
- ce.Reply("Failed to create portal: %v", err)
- } else {
- ce.Reply("Portal created: [%s](%s)", portal.Name, portal.MXID.URI(portal.bridge.Config.Homeserver.Domain).MatrixToURL())
- }
-}
-
-var cmdDeletePortal = &commands.FullHandler{
- Func: wrapCommand(fnUnbridge),
- Name: "delete-portal",
- Help: commands.HelpMeta{
- Section: HelpSectionPortalManagement,
- Description: "Unbridge this room and kick all Matrix users",
- },
- RequiresPortal: true,
- RequiresEventLevel: roomModerator,
-}
-
-func fnUnbridge(ce *WrappedCommandEvent) {
- ce.Portal.roomCreateLock.Lock()
- defer ce.Portal.roomCreateLock.Unlock()
- ce.Portal.removeFromSpace()
- ce.Portal.cleanup(ce.Command == "unbridge")
- ce.Portal.RemoveMXID()
-}
-
-var cmdDeleteAllPortals = &commands.FullHandler{
- Func: wrapCommand(fnDeleteAllPortals),
- Name: "delete-all-portals",
- Help: commands.HelpMeta{
- Section: commands.HelpSectionAdmin,
- Description: "Delete all portals.",
- },
- RequiresAdmin: true,
-}
-
-func fnDeleteAllPortals(ce *WrappedCommandEvent) {
- portals := ce.Bridge.GetAllPortals()
- guilds := ce.Bridge.GetAllGuilds()
- if len(portals) == 0 && len(guilds) == 0 {
- ce.Reply("Didn't find any portals")
- return
- }
-
- leave := func(mxid id.RoomID, intent *appservice.IntentAPI) {
- if len(mxid) > 0 {
- _, _ = intent.KickUser(mxid, &mautrix.ReqKickUser{
- Reason: "Deleting portal",
- UserID: ce.User.MXID,
- })
- }
- }
- customPuppet := ce.Bridge.GetPuppetByCustomMXID(ce.User.MXID)
- if customPuppet != nil && customPuppet.CustomIntent() != nil {
- intent := customPuppet.CustomIntent()
- leave = func(mxid id.RoomID, _ *appservice.IntentAPI) {
- if len(mxid) > 0 {
- _, _ = intent.LeaveRoom(mxid)
- _, _ = intent.ForgetRoom(mxid)
- }
- }
- }
- ce.Reply("Found %d channel portals and %d guild portals, deleting...", len(portals), len(guilds))
- for _, portal := range portals {
- portal.Delete()
- leave(portal.MXID, portal.MainIntent())
- }
- for _, guild := range guilds {
- guild.Delete()
- leave(guild.MXID, ce.Bot)
- }
- ce.Reply("Finished deleting portal info. Now cleaning up rooms in background. You'll have to restart the bridge or relogin before rooms can be bridged again.")
-
- go func() {
- for _, portal := range portals {
- portal.cleanup(false)
- }
- ce.Reply("Finished background cleanup of deleted portal rooms.")
- }()
-}
diff --git a/commands_botinteraction.go b/commands_botinteraction.go
deleted file mode 100644
index 8dd585a..0000000
--- a/commands_botinteraction.go
+++ /dev/null
@@ -1,318 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "fmt"
- "strconv"
- "strings"
- "time"
-
- "github.com/bwmarrin/discordgo"
- "github.com/google/shlex"
-
- "maunium.net/go/mautrix/bridge/commands"
-)
-
-var HelpSectionDiscordBots = commands.HelpSection{Name: "Discord bot interaction", Order: 30}
-
-var cmdCommands = &commands.FullHandler{
- Func: wrapCommand(fnCommands),
- Name: "commands",
- Aliases: []string{"cmds", "cs"},
- Help: commands.HelpMeta{
- Section: HelpSectionDiscordBots,
- Description: "View parameters of bot interaction commands on Discord",
- Args: "search <_query_> OR help <_command_>",
- },
- RequiresPortal: true,
- RequiresLogin: true,
-}
-
-var cmdExec = &commands.FullHandler{
- Func: wrapCommand(fnExec),
- Name: "exec",
- Aliases: []string{"command", "cmd", "c", "exec", "e"},
- Help: commands.HelpMeta{
- Section: HelpSectionDiscordBots,
- Description: "Run bot interaction commands on Discord",
- Args: "<_command_> [_arg=value ..._]",
- },
- RequiresLogin: true,
- RequiresPortal: true,
-}
-
-func (portal *Portal) getCommand(user *User, command string) (*discordgo.ApplicationCommand, error) {
- portal.commandsLock.Lock()
- defer portal.commandsLock.Unlock()
- cmd, ok := portal.commands[command]
- if !ok {
- results, err := user.Session.ApplicationCommandsSearch(portal.Key.ChannelID, command, portal.RefererOpt(""))
- if err != nil {
- return nil, err
- }
- for _, result := range results {
- if result.Name == command {
- portal.commands[result.Name] = result
- cmd = result
- break
- }
- }
- if cmd == nil {
- return nil, nil
- }
- }
- return cmd, nil
-}
-
-func getCommandOptionTypeName(optType discordgo.ApplicationCommandOptionType) string {
- switch optType {
- case discordgo.ApplicationCommandOptionSubCommand:
- return "subcommand"
- case discordgo.ApplicationCommandOptionSubCommandGroup:
- return "subcommand group (unsupported)"
- case discordgo.ApplicationCommandOptionString:
- return "string"
- case discordgo.ApplicationCommandOptionInteger:
- return "integer"
- case discordgo.ApplicationCommandOptionBoolean:
- return "boolean"
- case discordgo.ApplicationCommandOptionUser:
- return "user (unsupported)"
- case discordgo.ApplicationCommandOptionChannel:
- return "channel (unsupported)"
- case discordgo.ApplicationCommandOptionRole:
- return "role (unsupported)"
- case discordgo.ApplicationCommandOptionMentionable:
- return "mentionable (unsupported)"
- case discordgo.ApplicationCommandOptionNumber:
- return "number"
- case discordgo.ApplicationCommandOptionAttachment:
- return "attachment (unsupported)"
- default:
- return fmt.Sprintf("unknown type %d", optType)
- }
-}
-
-func parseCommandOptionValue(optType discordgo.ApplicationCommandOptionType, value string) (any, error) {
- switch optType {
- case discordgo.ApplicationCommandOptionSubCommandGroup:
- return nil, fmt.Errorf("subcommand groups aren't supported")
- case discordgo.ApplicationCommandOptionString:
- return value, nil
- case discordgo.ApplicationCommandOptionInteger:
- return strconv.ParseInt(value, 10, 64)
- case discordgo.ApplicationCommandOptionBoolean:
- return strconv.ParseBool(value)
- case discordgo.ApplicationCommandOptionUser:
- return nil, fmt.Errorf("user options aren't supported")
- case discordgo.ApplicationCommandOptionChannel:
- return nil, fmt.Errorf("channel options aren't supported")
- case discordgo.ApplicationCommandOptionRole:
- return nil, fmt.Errorf("role options aren't supported")
- case discordgo.ApplicationCommandOptionMentionable:
- return nil, fmt.Errorf("mentionable options aren't supported")
- case discordgo.ApplicationCommandOptionNumber:
- return strconv.ParseFloat(value, 64)
- case discordgo.ApplicationCommandOptionAttachment:
- return nil, fmt.Errorf("attachment options aren't supported")
- default:
- return nil, fmt.Errorf("unknown option type %d", optType)
- }
-}
-
-func indent(text, with string) string {
- split := strings.Split(text, "\n")
- for i, part := range split {
- split[i] = with + part
- }
- return strings.Join(split, "\n")
-}
-
-func formatOption(opt *discordgo.ApplicationCommandOption) string {
- argText := fmt.Sprintf("* `%s`: %s", opt.Name, getCommandOptionTypeName(opt.Type))
- if strings.ToLower(opt.Description) != opt.Name {
- argText += fmt.Sprintf(" - %s", opt.Description)
- }
- if opt.Required {
- argText += " (required)"
- }
- if len(opt.Options) > 0 {
- subopts := make([]string, len(opt.Options))
- for i, subopt := range opt.Options {
- subopts[i] = indent(formatOption(subopt), " ")
- }
- argText += "\n" + strings.Join(subopts, "\n")
- }
- return argText
-}
-
-func formatCommand(cmd *discordgo.ApplicationCommand) string {
- baseText := fmt.Sprintf("$cmdprefix exec %s", cmd.Name)
- if len(cmd.Options) > 0 {
- args := make([]string, len(cmd.Options))
- argPlaceholder := "[arg=value ...]"
- for i, opt := range cmd.Options {
- args[i] = formatOption(opt)
- if opt.Required {
- argPlaceholder = ""
- }
- }
- baseText = fmt.Sprintf("`%s %s` - %s\n%s", baseText, argPlaceholder, cmd.Description, strings.Join(args, "\n"))
- } else {
- baseText = fmt.Sprintf("`%s` - %s", baseText, cmd.Description)
- }
- return baseText
-}
-
-func parseCommandOptions(opts []*discordgo.ApplicationCommandOption, subcommands []string, namedArgs map[string]string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
- subcommandDone := false
- for _, opt := range opts {
- optRes := &discordgo.ApplicationCommandOptionInput{
- Type: opt.Type,
- Name: opt.Name,
- }
- if opt.Type == discordgo.ApplicationCommandOptionSubCommand {
- if !subcommandDone && len(subcommands) > 0 && subcommands[0] == opt.Name {
- subcommandDone = true
- optRes.Options, err = parseCommandOptions(opt.Options, subcommands[1:], namedArgs)
- if err != nil {
- err = fmt.Errorf("error parsing subcommand %s: %v", opt.Name, err)
- break
- }
- subcommands = subcommands[1:]
- } else {
- continue
- }
- } else if argVal, ok := namedArgs[opt.Name]; ok {
- optRes.Value, err = parseCommandOptionValue(opt.Type, argVal)
- if err != nil {
- err = fmt.Errorf("error parsing parameter %s: %v", opt.Name, err)
- break
- }
- } else if opt.Required {
- switch opt.Type {
- case discordgo.ApplicationCommandOptionSubCommandGroup, discordgo.ApplicationCommandOptionUser,
- discordgo.ApplicationCommandOptionChannel, discordgo.ApplicationCommandOptionRole,
- discordgo.ApplicationCommandOptionMentionable, discordgo.ApplicationCommandOptionAttachment:
- err = fmt.Errorf("missing required parameter %s (which is not supported by the bridge)", opt.Name)
- default:
- err = fmt.Errorf("missing required parameter %s", opt.Name)
- }
- break
- } else {
- continue
- }
- res = append(res, optRes)
- }
- if len(subcommands) > 0 {
- err = fmt.Errorf("unparsed subcommands left over (did you forget quoting for parameters with spaces?)")
- }
- return
-}
-
-func executeCommand(cmd *discordgo.ApplicationCommand, args []string) (res []*discordgo.ApplicationCommandOptionInput, err error) {
- namedArgs := map[string]string{}
- n := 0
- for _, arg := range args {
- name, value, isNamed := strings.Cut(arg, "=")
- if isNamed {
- namedArgs[name] = value
- } else {
- args[n] = arg
- n++
- }
- }
- return parseCommandOptions(cmd.Options, args[:n], namedArgs)
-}
-
-func fnCommands(ce *WrappedCommandEvent) {
- if len(ce.Args) < 2 {
- ce.Reply("**Usage**: `$cmdprefix commands search <_query_>` OR `$cmdprefix commands help <_command_>`")
- return
- }
- subcmd := strings.ToLower(ce.Args[0])
- if subcmd == "search" {
- results, err := ce.User.Session.ApplicationCommandsSearch(ce.Portal.Key.ChannelID, ce.Args[1], ce.Portal.RefererOpt(""))
- if err != nil {
- ce.Reply("Error searching for commands: %v", err)
- return
- }
- formatted := make([]string, len(results))
- ce.Portal.commandsLock.Lock()
- for i, result := range results {
- ce.Portal.commands[result.Name] = result
- formatted[i] = indent(formatCommand(result), " ")
- formatted[i] = "*" + formatted[i][1:]
- }
- ce.Portal.commandsLock.Unlock()
- ce.Reply("Found results:\n" + strings.Join(formatted, "\n"))
- } else if subcmd == "help" {
- command := strings.ToLower(ce.Args[1])
- cmd, err := ce.Portal.getCommand(ce.User, command)
- if err != nil {
- ce.Reply("Error searching for commands: %v", err)
- } else if cmd == nil {
- ce.Reply("Command %q not found", command)
- } else {
- ce.Reply(formatCommand(cmd))
- }
- }
-}
-
-func fnExec(ce *WrappedCommandEvent) {
- if len(ce.Args) == 0 {
- ce.Reply("**Usage**: `$cmdprefix exec [arg=value ...]`")
- return
- }
- args, err := shlex.Split(ce.RawArgs)
- if err != nil {
- ce.Reply("Error parsing args with shlex: %v", err)
- return
- }
- command := strings.ToLower(args[0])
- cmd, err := ce.Portal.getCommand(ce.User, command)
- if err != nil {
- ce.Reply("Error searching for commands: %v", err)
- } else if cmd == nil {
- ce.Reply("Command %q not found", command)
- } else if options, err := executeCommand(cmd, args[1:]); err != nil {
- ce.Reply("Error parsing arguments: %v\n\n**Usage:** "+formatCommand(cmd), err)
- } else {
- nonce := generateNonce()
- ce.User.pendingInteractionsLock.Lock()
- ce.User.pendingInteractions[nonce] = ce
- ce.User.pendingInteractionsLock.Unlock()
- err = ce.User.Session.SendInteractions(ce.Portal.GuildID, ce.Portal.Key.ChannelID, cmd, options, nonce, ce.Portal.RefererOpt(""))
- if err != nil {
- ce.Reply("Error sending interaction: %v", err)
- ce.User.pendingInteractionsLock.Lock()
- delete(ce.User.pendingInteractions, nonce)
- ce.User.pendingInteractionsLock.Unlock()
- } else {
- go func() {
- time.Sleep(10 * time.Second)
- ce.User.pendingInteractionsLock.Lock()
- if _, stillWaiting := ce.User.pendingInteractions[nonce]; stillWaiting {
- delete(ce.User.pendingInteractions, nonce)
- ce.Reply("Timed out waiting for interaction success")
- }
- ce.User.pendingInteractionsLock.Unlock()
- }()
- }
- }
-}
diff --git a/config/bridge.go b/config/bridge.go
deleted file mode 100644
index c546aa8..0000000
--- a/config/bridge.go
+++ /dev/null
@@ -1,240 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package config
-
-import (
- "errors"
- "fmt"
- "strings"
- "text/template"
-
- "github.com/bwmarrin/discordgo"
-
- "maunium.net/go/mautrix/bridge/bridgeconfig"
-)
-
-type BridgeConfig struct {
- UsernameTemplate string `yaml:"username_template"`
- DisplaynameTemplate string `yaml:"displayname_template"`
- ChannelNameTemplate string `yaml:"channel_name_template"`
- GuildNameTemplate string `yaml:"guild_name_template"`
- PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"`
- PrivateChannelCreateLimit int `yaml:"startup_private_channel_create_limit"`
-
- PortalMessageBuffer int `yaml:"portal_message_buffer"`
-
- PublicAddress string `yaml:"public_address"`
- AvatarProxyKey string `yaml:"avatar_proxy_key"`
-
- DeliveryReceipts bool `yaml:"delivery_receipts"`
- MessageStatusEvents bool `yaml:"message_status_events"`
- MessageErrorNotices bool `yaml:"message_error_notices"`
- RestrictedRooms bool `yaml:"restricted_rooms"`
- AutojoinThreadOnOpen bool `yaml:"autojoin_thread_on_open"`
- EmbedFieldsAsTables bool `yaml:"embed_fields_as_tables"`
- MuteChannelsOnCreate bool `yaml:"mute_channels_on_create"`
- SyncDirectChatList bool `yaml:"sync_direct_chat_list"`
- ResendBridgeInfo bool `yaml:"resend_bridge_info"`
- CustomEmojiReactions bool `yaml:"custom_emoji_reactions"`
- DeletePortalOnChannelDelete bool `yaml:"delete_portal_on_channel_delete"`
- DeleteGuildOnLeave bool `yaml:"delete_guild_on_leave"`
- FederateRooms bool `yaml:"federate_rooms"`
- PrefixWebhookMessages bool `yaml:"prefix_webhook_messages"`
- EnableWebhookAvatars bool `yaml:"enable_webhook_avatars"`
- UseDiscordCDNUpload bool `yaml:"use_discord_cdn_upload"`
- ForbidDMingStrangers bool `yaml:"forbid_dming_strangers"`
-
- Proxy string `yaml:"proxy"`
-
- CacheMedia string `yaml:"cache_media"`
- DirectMedia DirectMedia `yaml:"direct_media"`
-
- AnimatedSticker struct {
- Target string `yaml:"target"`
- Args struct {
- Width int `yaml:"width"`
- Height int `yaml:"height"`
- FPS int `yaml:"fps"`
- } `yaml:"args"`
- } `yaml:"animated_sticker"`
-
- DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"`
-
- CommandPrefix string `yaml:"command_prefix"`
- ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"`
-
- Backfill struct {
- Limits struct {
- Initial BackfillLimitPart `yaml:"initial"`
- Missed BackfillLimitPart `yaml:"missed"`
- } `yaml:"forward_limits"`
- MaxGuildMembers int `yaml:"max_guild_members"`
- } `yaml:"backfill"`
-
- Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"`
-
- Provisioning struct {
- Prefix string `yaml:"prefix"`
- SharedSecret string `yaml:"shared_secret"`
- DebugEndpoints bool `yaml:"debug_endpoints"`
- } `yaml:"provisioning"`
-
- Permissions bridgeconfig.PermissionConfig `yaml:"permissions"`
-
- usernameTemplate *template.Template `yaml:"-"`
- displaynameTemplate *template.Template `yaml:"-"`
- channelNameTemplate *template.Template `yaml:"-"`
- guildNameTemplate *template.Template `yaml:"-"`
-}
-
-type DirectMedia struct {
- Enabled bool `yaml:"enabled"`
- ServerName string `yaml:"server_name"`
- WellKnownResponse string `yaml:"well_known_response"`
- AllowProxy bool `yaml:"allow_proxy"`
- ServerKey string `yaml:"server_key"`
-}
-
-type BackfillLimitPart struct {
- DM int `yaml:"dm"`
- Channel int `yaml:"channel"`
- Thread int `yaml:"thread"`
-}
-
-func (bc *BridgeConfig) GetResendBridgeInfo() bool {
- return bc.ResendBridgeInfo
-}
-
-func (bc *BridgeConfig) EnableMessageStatusEvents() bool {
- return bc.MessageStatusEvents
-}
-
-func (bc *BridgeConfig) EnableMessageErrorNotices() bool {
- return bc.MessageErrorNotices
-}
-
-func boolToInt(val bool) int {
- if val {
- return 1
- }
- return 0
-}
-
-func (bc *BridgeConfig) Validate() error {
- _, hasWildcard := bc.Permissions["*"]
- _, hasExampleDomain := bc.Permissions["example.com"]
- _, hasExampleUser := bc.Permissions["@admin:example.com"]
- exampleLen := boolToInt(hasWildcard) + boolToInt(hasExampleUser) + boolToInt(hasExampleDomain)
- if len(bc.Permissions) <= exampleLen {
- return errors.New("bridge.permissions not configured")
- }
- return nil
-}
-
-type umBridgeConfig BridgeConfig
-
-func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
- err := unmarshal((*umBridgeConfig)(bc))
- if err != nil {
- return err
- }
-
- bc.usernameTemplate, err = template.New("username").Parse(bc.UsernameTemplate)
- if err != nil {
- return err
- } else if !strings.Contains(bc.FormatUsername("1234567890"), "1234567890") {
- return fmt.Errorf("username template is missing user ID placeholder")
- }
- bc.displaynameTemplate, err = template.New("displayname").Parse(bc.DisplaynameTemplate)
- if err != nil {
- return err
- }
- bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate)
- if err != nil {
- return err
- }
- bc.guildNameTemplate, err = template.New("guild_name").Parse(bc.GuildNameTemplate)
- if err != nil {
- return err
- }
-
- return nil
-}
-
-var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil)
-
-func (bc BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig {
- return bc.DoublePuppetConfig
-}
-
-func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig {
- return bc.Encryption
-}
-
-func (bc BridgeConfig) GetCommandPrefix() string {
- return bc.CommandPrefix
-}
-
-func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts {
- return bc.ManagementRoomText
-}
-
-func (bc BridgeConfig) FormatUsername(userID string) string {
- var buffer strings.Builder
- _ = bc.usernameTemplate.Execute(&buffer, userID)
- return buffer.String()
-}
-
-type DisplaynameParams struct {
- *discordgo.User
- Webhook bool
- Application bool
-}
-
-func (bc BridgeConfig) FormatDisplayname(user *discordgo.User, webhook, application bool) string {
- var buffer strings.Builder
- _ = bc.displaynameTemplate.Execute(&buffer, &DisplaynameParams{
- User: user,
- Webhook: webhook,
- Application: application,
- })
- return buffer.String()
-}
-
-type ChannelNameParams struct {
- Name string
- ParentName string
- GuildName string
- NSFW bool
- Type discordgo.ChannelType
-}
-
-func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string {
- var buffer strings.Builder
- _ = bc.channelNameTemplate.Execute(&buffer, params)
- return buffer.String()
-}
-
-type GuildNameParams struct {
- Name string
-}
-
-func (bc BridgeConfig) FormatGuildName(params GuildNameParams) string {
- var buffer strings.Builder
- _ = bc.guildNameTemplate.Execute(&buffer, params)
- return buffer.String()
-}
diff --git a/config/upgrade.go b/config/upgrade.go
deleted file mode 100644
index 3d7a9fa..0000000
--- a/config/upgrade.go
+++ /dev/null
@@ -1,152 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package config
-
-import (
- up "go.mau.fi/util/configupgrade"
- "go.mau.fi/util/random"
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/federation"
-)
-
-func DoUpgrade(helper *up.Helper) {
- bridgeconfig.Upgrader.DoUpgrade(helper)
-
- helper.Copy(up.Str, "bridge", "username_template")
- helper.Copy(up.Str, "bridge", "displayname_template")
- helper.Copy(up.Str, "bridge", "channel_name_template")
- helper.Copy(up.Str, "bridge", "guild_name_template")
- if legacyPrivateChatPortalMeta, ok := helper.Get(up.Bool, "bridge", "private_chat_portal_meta"); ok {
- updatedPrivateChatPortalMeta := "default"
- if legacyPrivateChatPortalMeta == "true" {
- updatedPrivateChatPortalMeta = "always"
- }
- helper.Set(up.Str, updatedPrivateChatPortalMeta, "bridge", "private_chat_portal_meta")
- } else {
- helper.Copy(up.Str, "bridge", "private_chat_portal_meta")
- }
- helper.Copy(up.Int, "bridge", "startup_private_channel_create_limit")
- helper.Copy(up.Str|up.Null, "bridge", "public_address")
- if apkey, ok := helper.Get(up.Str, "bridge", "avatar_proxy_key"); !ok || apkey == "generate" {
- helper.Set(up.Str, random.String(32), "bridge", "avatar_proxy_key")
- } else {
- helper.Copy(up.Str, "bridge", "avatar_proxy_key")
- }
- helper.Copy(up.Int, "bridge", "portal_message_buffer")
- helper.Copy(up.Bool, "bridge", "delivery_receipts")
- helper.Copy(up.Bool, "bridge", "message_status_events")
- helper.Copy(up.Bool, "bridge", "message_error_notices")
- helper.Copy(up.Bool, "bridge", "restricted_rooms")
- helper.Copy(up.Bool, "bridge", "autojoin_thread_on_open")
- helper.Copy(up.Bool, "bridge", "embed_fields_as_tables")
- helper.Copy(up.Bool, "bridge", "mute_channels_on_create")
- helper.Copy(up.Bool, "bridge", "sync_direct_chat_list")
- helper.Copy(up.Bool, "bridge", "resend_bridge_info")
- helper.Copy(up.Bool, "bridge", "custom_emoji_reactions")
- helper.Copy(up.Bool, "bridge", "delete_portal_on_channel_delete")
- helper.Copy(up.Bool, "bridge", "delete_guild_on_leave")
- helper.Copy(up.Bool, "bridge", "federate_rooms")
- helper.Copy(up.Bool, "bridge", "prefix_webhook_messages")
- helper.Copy(up.Bool, "bridge", "enable_webhook_avatars")
- helper.Copy(up.Bool, "bridge", "use_discord_cdn_upload")
- helper.Copy(up.Bool, "bridge", "forbid_dming_strangers")
- helper.Copy(up.Str|up.Null, "bridge", "proxy")
- helper.Copy(up.Str, "bridge", "cache_media")
- helper.Copy(up.Bool, "bridge", "direct_media", "enabled")
- helper.Copy(up.Str, "bridge", "direct_media", "server_name")
- helper.Copy(up.Str|up.Null, "bridge", "direct_media", "well_known_response")
- helper.Copy(up.Bool, "bridge", "direct_media", "allow_proxy")
- if serverKey, ok := helper.Get(up.Str, "bridge", "direct_media", "server_key"); !ok || serverKey == "generate" {
- serverKey = federation.GenerateSigningKey().SynapseString()
- helper.Set(up.Str, serverKey, "bridge", "direct_media", "server_key")
- } else {
- helper.Copy(up.Str, "bridge", "direct_media", "server_key")
- }
- helper.Copy(up.Str, "bridge", "animated_sticker", "target")
- helper.Copy(up.Int, "bridge", "animated_sticker", "args", "width")
- helper.Copy(up.Int, "bridge", "animated_sticker", "args", "height")
- helper.Copy(up.Int, "bridge", "animated_sticker", "args", "fps")
- helper.Copy(up.Map, "bridge", "double_puppet_server_map")
- helper.Copy(up.Bool, "bridge", "double_puppet_allow_discovery")
- helper.Copy(up.Map, "bridge", "login_shared_secret_map")
- helper.Copy(up.Str, "bridge", "command_prefix")
- helper.Copy(up.Str, "bridge", "management_room_text", "welcome")
- helper.Copy(up.Str, "bridge", "management_room_text", "welcome_connected")
- helper.Copy(up.Str, "bridge", "management_room_text", "welcome_unconnected")
- helper.Copy(up.Str|up.Null, "bridge", "management_room_text", "additional_help")
- helper.Copy(up.Bool, "bridge", "backfill", "enabled")
- helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "dm")
- helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "channel")
- helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "initial", "thread")
- helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "dm")
- helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "channel")
- helper.Copy(up.Int, "bridge", "backfill", "forward_limits", "missed", "thread")
- helper.Copy(up.Int, "bridge", "backfill", "max_guild_members")
- helper.Copy(up.Bool, "bridge", "encryption", "allow")
- helper.Copy(up.Bool, "bridge", "encryption", "default")
- helper.Copy(up.Bool, "bridge", "encryption", "require")
- helper.Copy(up.Bool, "bridge", "encryption", "appservice")
- helper.Copy(up.Bool, "bridge", "encryption", "msc4190")
- helper.Copy(up.Bool, "bridge", "encryption", "allow_key_sharing")
- helper.Copy(up.Bool, "bridge", "encryption", "plaintext_mentions")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outbound_on_ack")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "dont_store_outbound")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "ratchet_on_decrypt")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_fully_used_on_decrypt")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_prev_on_new_session")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_on_device_delete")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "periodically_delete_expired")
- helper.Copy(up.Bool, "bridge", "encryption", "delete_keys", "delete_outdated_inbound")
- helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "receive")
- helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "send")
- helper.Copy(up.Str, "bridge", "encryption", "verification_levels", "share")
- helper.Copy(up.Bool, "bridge", "encryption", "rotation", "enable_custom")
- helper.Copy(up.Int, "bridge", "encryption", "rotation", "milliseconds")
- helper.Copy(up.Int, "bridge", "encryption", "rotation", "messages")
- helper.Copy(up.Bool, "bridge", "encryption", "rotation", "disable_device_change_key_rotation")
-
- helper.Copy(up.Str, "bridge", "provisioning", "prefix")
- if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" {
- sharedSecret := random.String(64)
- helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret")
- } else {
- helper.Copy(up.Str, "bridge", "provisioning", "shared_secret")
- }
- helper.Copy(up.Bool, "bridge", "provisioning", "debug_endpoints")
-
- helper.Copy(up.Map, "bridge", "permissions")
- //helper.Copy(up.Bool, "bridge", "relay", "enabled")
- //helper.Copy(up.Bool, "bridge", "relay", "admin_only")
- //helper.Copy(up.Map, "bridge", "relay", "message_formats")
-}
-
-var SpacedBlocks = [][]string{
- {"homeserver", "software"},
- {"appservice"},
- {"appservice", "hostname"},
- {"appservice", "database"},
- {"appservice", "id"},
- {"appservice", "as_token"},
- {"bridge"},
- {"bridge", "command_prefix"},
- {"bridge", "management_room_text"},
- {"bridge", "encryption"},
- {"bridge", "provisioning"},
- {"bridge", "permissions"},
- //{"bridge", "relay"},
- {"logging"},
-}
diff --git a/custompuppet.go b/custompuppet.go
deleted file mode 100644
index f1c1f05..0000000
--- a/custompuppet.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package main
-
-import (
- "maunium.net/go/mautrix/id"
-)
-
-func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error {
- puppet.CustomMXID = mxid
- puppet.AccessToken = accessToken
- puppet.Update()
- err := puppet.StartCustomMXID(false)
- if err != nil {
- return err
- }
- // TODO leave rooms with default puppet
- return nil
-}
-
-func (puppet *Puppet) ClearCustomMXID() {
- save := puppet.CustomMXID != "" || puppet.AccessToken != ""
- puppet.bridge.puppetsLock.Lock()
- if puppet.CustomMXID != "" && puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] == puppet {
- delete(puppet.bridge.puppetsByCustomMXID, puppet.CustomMXID)
- }
- puppet.bridge.puppetsLock.Unlock()
- puppet.CustomMXID = ""
- puppet.AccessToken = ""
- puppet.customIntent = nil
- puppet.customUser = nil
- if save {
- puppet.Update()
- }
-}
-
-func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error {
- newIntent, newAccessToken, err := puppet.bridge.DoublePuppet.Setup(puppet.CustomMXID, puppet.AccessToken, reloginOnFail)
- if err != nil {
- puppet.ClearCustomMXID()
- return err
- }
- puppet.bridge.puppetsLock.Lock()
- puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet
- puppet.bridge.puppetsLock.Unlock()
- if puppet.AccessToken != newAccessToken {
- puppet.AccessToken = newAccessToken
- puppet.Update()
- }
- puppet.customIntent = newIntent
- puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID)
- return nil
-}
-
-func (user *User) tryAutomaticDoublePuppeting() {
- if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) {
- return
- }
- user.log.Debug().Msg("Checking if double puppeting needs to be enabled")
- puppet := user.bridge.GetPuppetByID(user.DiscordID)
- if len(puppet.CustomMXID) > 0 {
- user.log.Debug().Msg("User already has double-puppeting enabled")
- // Custom puppet already enabled
- return
- }
- puppet.CustomMXID = user.MXID
- err := puppet.StartCustomMXID(true)
- if err != nil {
- user.log.Warn().Err(err).Msg("Failed to login with shared secret")
- } else {
- // TODO leave rooms with default puppet
- user.log.Debug().Msg("Successfully automatically enabled custom puppet")
- }
-}
diff --git a/database/database.go b/database/database.go
deleted file mode 100644
index a12bab6..0000000
--- a/database/database.go
+++ /dev/null
@@ -1,76 +0,0 @@
-package database
-
-import (
- _ "embed"
-
- _ "github.com/lib/pq"
- _ "github.com/mattn/go-sqlite3"
- "go.mau.fi/util/dbutil"
- "maunium.net/go/maulogger/v2"
-
- "go.mau.fi/mautrix-discord/database/upgrades"
-)
-
-type Database struct {
- *dbutil.Database
-
- User *UserQuery
- Portal *PortalQuery
- Puppet *PuppetQuery
- Message *MessageQuery
- Thread *ThreadQuery
- Reaction *ReactionQuery
- Guild *GuildQuery
- Role *RoleQuery
- File *FileQuery
-}
-
-func New(baseDB *dbutil.Database, log maulogger.Logger) *Database {
- db := &Database{Database: baseDB}
- db.UpgradeTable = upgrades.Table
- db.User = &UserQuery{
- db: db,
- log: log.Sub("User"),
- }
- db.Portal = &PortalQuery{
- db: db,
- log: log.Sub("Portal"),
- }
- db.Puppet = &PuppetQuery{
- db: db,
- log: log.Sub("Puppet"),
- }
- db.Message = &MessageQuery{
- db: db,
- log: log.Sub("Message"),
- }
- db.Thread = &ThreadQuery{
- db: db,
- log: log.Sub("Thread"),
- }
- db.Reaction = &ReactionQuery{
- db: db,
- log: log.Sub("Reaction"),
- }
- db.Guild = &GuildQuery{
- db: db,
- log: log.Sub("Guild"),
- }
- db.Role = &RoleQuery{
- db: db,
- log: log.Sub("Role"),
- }
- db.File = &FileQuery{
- db: db,
- log: log.Sub("File"),
- }
- return db
-}
-
-func strPtr[T ~string](val T) *string {
- if val == "" {
- return nil
- }
- valStr := string(val)
- return &valStr
-}
diff --git a/database/file.go b/database/file.go
deleted file mode 100644
index 2ee926f..0000000
--- a/database/file.go
+++ /dev/null
@@ -1,138 +0,0 @@
-package database
-
-import (
- "database/sql"
- "encoding/json"
- "errors"
- "time"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/crypto/attachment"
- "maunium.net/go/mautrix/id"
-)
-
-type FileQuery struct {
- db *Database
- log log.Logger
-}
-
-// language=postgresql
-const (
- fileSelect = "SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file"
- fileInsert = `
- INSERT INTO discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
- `
-)
-
-func (fq *FileQuery) New() *File {
- return &File{
- db: fq.db,
- log: fq.log,
- }
-}
-
-func (fq *FileQuery) Get(url string, encrypted bool) *File {
- query := fileSelect + " WHERE url=$1 AND encrypted=$2"
- return fq.New().Scan(fq.db.QueryRow(query, url, encrypted))
-}
-
-func (fq *FileQuery) GetEmojiByMXC(mxc id.ContentURI) *File {
- query := fileSelect + " WHERE mxc=$1 AND emoji_name<>'' LIMIT 1"
- return fq.New().Scan(fq.db.QueryRow(query, mxc.String()))
-}
-
-type File struct {
- db *Database
- log log.Logger
-
- URL string
- Encrypted bool
- MXC id.ContentURI
-
- ID string
- EmojiName string
-
- Size int
- Width int
- Height int
- MimeType string
-
- DecryptionInfo *attachment.EncryptedFile
- Timestamp time.Time
-}
-
-func (f *File) Scan(row dbutil.Scannable) *File {
- var fileID, emojiName, decryptionInfo sql.NullString
- var width, height sql.NullInt32
- var timestamp int64
- var mxc string
- err := row.Scan(&f.URL, &f.Encrypted, &mxc, &fileID, &emojiName, &f.Size, &width, &height, &f.MimeType, &decryptionInfo, ×tamp)
- if err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- f.log.Errorln("Database scan failed:", err)
- panic(err)
- }
- return nil
- }
- f.ID = fileID.String
- f.EmojiName = emojiName.String
- f.Timestamp = time.UnixMilli(timestamp).UTC()
- f.Width = int(width.Int32)
- f.Height = int(height.Int32)
- f.MXC, err = id.ParseContentURI(mxc)
- if err != nil {
- f.log.Errorfln("Failed to parse content URI %s: %v", mxc, err)
- panic(err)
- }
- if decryptionInfo.Valid {
- err = json.Unmarshal([]byte(decryptionInfo.String), &f.DecryptionInfo)
- if err != nil {
- f.log.Errorfln("Failed to unmarshal decryption info of %v: %v", f.MXC, err)
- panic(err)
- }
- }
- return f
-}
-
-func positiveIntToNullInt32(val int) (ptr sql.NullInt32) {
- if val > 0 {
- ptr.Valid = true
- ptr.Int32 = int32(val)
- }
- return
-}
-
-func (f *File) Insert(txn dbutil.Execable) {
- if txn == nil {
- txn = f.db
- }
- var decryptionInfoStr sql.NullString
- if f.DecryptionInfo != nil {
- decryptionInfo, err := json.Marshal(f.DecryptionInfo)
- if err != nil {
- f.log.Warnfln("Failed to marshal decryption info of %v: %v", f.MXC, err)
- panic(err)
- }
- decryptionInfoStr.Valid = true
- decryptionInfoStr.String = string(decryptionInfo)
- }
- _, err := txn.Exec(fileInsert,
- f.URL, f.Encrypted, f.MXC.String(), strPtr(f.ID), strPtr(f.EmojiName), f.Size,
- positiveIntToNullInt32(f.Width), positiveIntToNullInt32(f.Height), f.MimeType,
- decryptionInfoStr, f.Timestamp.UnixMilli(),
- )
- if err != nil {
- f.log.Warnfln("Failed to insert copied file %v: %v", f.MXC, err)
- panic(err)
- }
-}
-
-func (f *File) Delete() {
- _, err := f.db.Exec("DELETE FROM discord_file WHERE url=$1 AND encrypted=$2", f.URL, f.Encrypted)
- if err != nil {
- f.log.Warnfln("Failed to delete copied file %v: %v", f.MXC, err)
- panic(err)
- }
-}
diff --git a/database/guild.go b/database/guild.go
deleted file mode 100644
index 70976a5..0000000
--- a/database/guild.go
+++ /dev/null
@@ -1,194 +0,0 @@
-package database
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "strings"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-type GuildBridgingMode int
-
-const (
- // GuildBridgeNothing tells the bridge to never bridge messages, not even checking if a portal exists.
- GuildBridgeNothing GuildBridgingMode = iota
- // GuildBridgeIfPortalExists tells the bridge to bridge messages in channels that already have portals.
- GuildBridgeIfPortalExists
- // GuildBridgeCreateOnMessage tells the bridge to create portals as soon as a message is received.
- GuildBridgeCreateOnMessage
- // GuildBridgeEverything tells the bridge to proactively create portals on startup and when receiving channel create notifications.
- GuildBridgeEverything
-
- GuildBridgeInvalid GuildBridgingMode = -1
-)
-
-func ParseGuildBridgingMode(str string) GuildBridgingMode {
- str = strings.ToLower(str)
- str = strings.ReplaceAll(str, "-", "")
- str = strings.ReplaceAll(str, "_", "")
- switch str {
- case "nothing", "0":
- return GuildBridgeNothing
- case "ifportalexists", "1":
- return GuildBridgeIfPortalExists
- case "createonmessage", "2":
- return GuildBridgeCreateOnMessage
- case "everything", "3":
- return GuildBridgeEverything
- default:
- return GuildBridgeInvalid
- }
-}
-
-func (gbm GuildBridgingMode) String() string {
- switch gbm {
- case GuildBridgeNothing:
- return "nothing"
- case GuildBridgeIfPortalExists:
- return "if-portal-exists"
- case GuildBridgeCreateOnMessage:
- return "create-on-message"
- case GuildBridgeEverything:
- return "everything"
- default:
- return ""
- }
-}
-
-func (gbm GuildBridgingMode) Description() string {
- switch gbm {
- case GuildBridgeNothing:
- return "never bridge messages"
- case GuildBridgeIfPortalExists:
- return "bridge messages in existing portals"
- case GuildBridgeCreateOnMessage:
- return "bridge all messages and create portals on first message"
- case GuildBridgeEverything:
- return "bridge all messages and create portals proactively"
- default:
- return ""
- }
-}
-
-type GuildQuery struct {
- db *Database
- log log.Logger
-}
-
-const (
- guildSelect = "SELECT dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode FROM guild"
-)
-
-func (gq *GuildQuery) New() *Guild {
- return &Guild{
- db: gq.db,
- log: gq.log,
- }
-}
-
-func (gq *GuildQuery) GetByID(dcid string) *Guild {
- query := guildSelect + " WHERE dcid=$1"
- return gq.New().Scan(gq.db.QueryRow(query, dcid))
-}
-
-func (gq *GuildQuery) GetByMXID(mxid id.RoomID) *Guild {
- query := guildSelect + " WHERE mxid=$1"
- return gq.New().Scan(gq.db.QueryRow(query, mxid))
-}
-
-func (gq *GuildQuery) GetAll() []*Guild {
- rows, err := gq.db.Query(guildSelect)
- if err != nil {
- gq.log.Errorln("Failed to query guilds:", err)
- return nil
- }
-
- var guilds []*Guild
- for rows.Next() {
- guild := gq.New().Scan(rows)
- if guild != nil {
- guilds = append(guilds, guild)
- }
- }
-
- return guilds
-}
-
-type Guild struct {
- db *Database
- log log.Logger
-
- ID string
- MXID id.RoomID
- PlainName string
- Name string
- NameSet bool
- Avatar string
- AvatarURL id.ContentURI
- AvatarSet bool
-
- BridgingMode GuildBridgingMode
-}
-
-func (g *Guild) Scan(row dbutil.Scannable) *Guild {
- var mxid sql.NullString
- var avatarURL string
- err := row.Scan(&g.ID, &mxid, &g.PlainName, &g.Name, &g.NameSet, &g.Avatar, &avatarURL, &g.AvatarSet, &g.BridgingMode)
- if err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- g.log.Errorln("Database scan failed:", err)
- panic(err)
- }
-
- return nil
- }
- if g.BridgingMode < GuildBridgeNothing || g.BridgingMode > GuildBridgeEverything {
- panic(fmt.Errorf("invalid guild bridging mode %d in guild %s", g.BridgingMode, g.ID))
- }
- g.MXID = id.RoomID(mxid.String)
- g.AvatarURL, _ = id.ParseContentURI(avatarURL)
- return g
-}
-
-func (g *Guild) mxidPtr() *id.RoomID {
- if g.MXID != "" {
- return &g.MXID
- }
- return nil
-}
-
-func (g *Guild) Insert() {
- query := `
- INSERT INTO guild (dcid, mxid, plain_name, name, name_set, avatar, avatar_url, avatar_set, bridging_mode)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
- `
- _, err := g.db.Exec(query, g.ID, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode)
- if err != nil {
- g.log.Warnfln("Failed to insert %s: %v", g.ID, err)
- panic(err)
- }
-}
-
-func (g *Guild) Update() {
- query := `
- UPDATE guild SET mxid=$1, plain_name=$2, name=$3, name_set=$4, avatar=$5, avatar_url=$6, avatar_set=$7, bridging_mode=$8
- WHERE dcid=$9
- `
- _, err := g.db.Exec(query, g.mxidPtr(), g.PlainName, g.Name, g.NameSet, g.Avatar, g.AvatarURL.String(), g.AvatarSet, g.BridgingMode, g.ID)
- if err != nil {
- g.log.Warnfln("Failed to update %s: %v", g.ID, err)
- panic(err)
- }
-}
-
-func (g *Guild) Delete() {
- _, err := g.db.Exec("DELETE FROM guild WHERE dcid=$1", g.ID)
- if err != nil {
- g.log.Warnfln("Failed to delete %s: %v", g.ID, err)
- panic(err)
- }
-}
diff --git a/database/json.go b/database/json.go
deleted file mode 100644
index 566a6c4..0000000
--- a/database/json.go
+++ /dev/null
@@ -1,20 +0,0 @@
-package database
-
-import (
- "go.mau.fi/util/dbutil"
-)
-
-// Backported from mautrix/go-util@e5cb5e96d15cb87ffe6e5970c2f90ee47980e715.
-
-// JSONPtr is a convenience function for wrapping a pointer to a value in the JSON utility, but removing typed nils
-// (i.e. preventing nils from turning into the string "null" in the database).
-func JSONPtr[T any](val *T) dbutil.JSON {
- return dbutil.JSON{Data: UntypedNil(val)}
-}
-
-func UntypedNil[T any](val *T) any {
- if val == nil {
- return nil
- }
- return val
-}
diff --git a/database/message.go b/database/message.go
deleted file mode 100644
index c38483c..0000000
--- a/database/message.go
+++ /dev/null
@@ -1,258 +0,0 @@
-package database
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-type MessageQuery struct {
- db *Database
- log log.Logger
-}
-
-const (
- messageSelect = "SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid FROM message"
-)
-
-func (mq *MessageQuery) New() *Message {
- return &Message{
- db: mq.db,
- log: mq.log,
- }
-}
-
-func (mq *MessageQuery) scanAll(rows dbutil.Rows, err error) []*Message {
- if err != nil {
- mq.log.Warnfln("Failed to query many messages: %v", err)
- panic(err)
- } else if rows == nil {
- return nil
- }
-
- var messages []*Message
- for rows.Next() {
- messages = append(messages, mq.New().Scan(rows))
- }
-
- return messages
-}
-
-func (mq *MessageQuery) GetByDiscordID(key PortalKey, discordID string) []*Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC"
- return mq.scanAll(mq.db.Query(query, key.ChannelID, key.Receiver, discordID))
-}
-
-func (mq *MessageQuery) GetFirstByDiscordID(key PortalKey, discordID string) *Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id ASC LIMIT 1"
- return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
-}
-
-func (mq *MessageQuery) GetLastByDiscordID(key PortalKey, discordID string) *Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dcid=$3 ORDER BY dc_attachment_id DESC LIMIT 1"
- return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, discordID))
-}
-
-func (mq *MessageQuery) GetClosestBefore(key PortalKey, threadID string, ts time.Time) *Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 AND timestamp<=$4 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
- return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID, ts.UnixMilli()))
-}
-
-func (mq *MessageQuery) GetLastInThread(key PortalKey, threadID string) *Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_thread_id=$3 ORDER BY timestamp DESC, dc_attachment_id DESC LIMIT 1"
- return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver, threadID))
-}
-
-func (mq *MessageQuery) GetLast(key PortalKey) *Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 ORDER BY timestamp DESC LIMIT 1"
- return mq.New().Scan(mq.db.QueryRow(query, key.ChannelID, key.Receiver))
-}
-
-func (mq *MessageQuery) DeleteAll(key PortalKey) {
- query := "DELETE FROM message WHERE dc_chan_id=$1 AND dc_chan_receiver=$2"
- _, err := mq.db.Exec(query, key.ChannelID, key.Receiver)
- if err != nil {
- mq.log.Warnfln("Failed to delete messages of %s: %v", key, err)
- panic(err)
- }
-}
-
-func (mq *MessageQuery) GetByMXID(key PortalKey, mxid id.EventID) *Message {
- query := messageSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND mxid=$3"
-
- row := mq.db.QueryRow(query, key.ChannelID, key.Receiver, mxid)
- if row == nil {
- return nil
- }
-
- return mq.New().Scan(row)
-}
-
-func (mq *MessageQuery) MassInsert(key PortalKey, msgs []Message) {
- if len(msgs) == 0 {
- return
- }
- valueStringFormat := "($%d, $%d, $1, $2, $%d, $%d, $%d, $%d, $%d, $%d)"
- if mq.db.Dialect == dbutil.SQLite {
- valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
- }
- params := make([]interface{}, 2+len(msgs)*8)
- placeholders := make([]string, len(msgs))
- params[0] = key.ChannelID
- params[1] = key.Receiver
- for i, msg := range msgs {
- baseIndex := 2 + i*8
- params[baseIndex] = msg.DiscordID
- params[baseIndex+1] = msg.AttachmentID
- params[baseIndex+2] = msg.SenderID
- params[baseIndex+3] = msg.Timestamp.UnixMilli()
- params[baseIndex+4] = msg.editTimestampVal()
- params[baseIndex+5] = msg.ThreadID
- params[baseIndex+6] = msg.MXID
- params[baseIndex+7] = msg.SenderMXID.String()
- placeholders[i] = fmt.Sprintf(valueStringFormat, baseIndex+1, baseIndex+2, baseIndex+3, baseIndex+4, baseIndex+5, baseIndex+6, baseIndex+7, baseIndex+8)
- }
- _, err := mq.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
- if err != nil {
- mq.log.Warnfln("Failed to insert %d messages: %v", len(msgs), err)
- panic(err)
- }
-}
-
-type Message struct {
- db *Database
- log log.Logger
-
- DiscordID string
- AttachmentID string
- Channel PortalKey
- SenderID string
- Timestamp time.Time
- EditTimestamp time.Time
- ThreadID string
-
- MXID id.EventID
- SenderMXID id.UserID
-}
-
-func (m *Message) DiscordProtoChannelID() string {
- if m.ThreadID != "" {
- return m.ThreadID
- } else {
- return m.Channel.ChannelID
- }
-}
-
-func (m *Message) Scan(row dbutil.Scannable) *Message {
- var ts, editTS int64
-
- err := row.Scan(&m.DiscordID, &m.AttachmentID, &m.Channel.ChannelID, &m.Channel.Receiver, &m.SenderID, &ts, &editTS, &m.ThreadID, &m.MXID, &m.SenderMXID)
- if err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- m.log.Errorln("Database scan failed:", err)
- panic(err)
- }
-
- return nil
- }
-
- if ts != 0 {
- m.Timestamp = time.UnixMilli(ts).UTC()
- }
- if editTS != 0 {
- m.EditTimestamp = time.Unix(0, editTS).UTC()
- }
-
- return m
-}
-
-const messageInsertQuery = `
- INSERT INTO message (
- dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid, sender_mxid
- )
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
-`
-
-var messageMassInsertTemplate = strings.Replace(messageInsertQuery, "($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", "%s", 1)
-
-type MessagePart struct {
- AttachmentID string
- MXID id.EventID
-}
-
-func (m *Message) editTimestampVal() int64 {
- if m.EditTimestamp.IsZero() {
- return 0
- }
- return m.EditTimestamp.UnixNano()
-}
-
-func (m *Message) MassInsertParts(msgs []MessagePart) {
- if len(msgs) == 0 {
- return
- }
- valueStringFormat := "($1, $%d, $2, $3, $4, $5, $6, $7, $%d, $8)"
- if m.db.Dialect == dbutil.SQLite {
- valueStringFormat = strings.ReplaceAll(valueStringFormat, "$", "?")
- }
- params := make([]interface{}, 8+len(msgs)*2)
- placeholders := make([]string, len(msgs))
- params[0] = m.DiscordID
- params[1] = m.Channel.ChannelID
- params[2] = m.Channel.Receiver
- params[3] = m.SenderID
- params[4] = m.Timestamp.UnixMilli()
- params[5] = m.editTimestampVal()
- params[6] = m.ThreadID
- params[7] = m.SenderMXID.String()
- for i, msg := range msgs {
- params[8+i*2] = msg.AttachmentID
- params[8+i*2+1] = msg.MXID
- placeholders[i] = fmt.Sprintf(valueStringFormat, 8+i*2+1, 8+i*2+2)
- }
- _, err := m.db.Exec(fmt.Sprintf(messageMassInsertTemplate, strings.Join(placeholders, ", ")), params...)
- if err != nil {
- m.log.Warnfln("Failed to insert %d parts of %s@%s: %v", len(msgs), m.DiscordID, m.Channel, err)
- panic(err)
- }
-}
-
-func (m *Message) Insert() {
- _, err := m.db.Exec(messageInsertQuery,
- m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver, m.SenderID,
- m.Timestamp.UnixMilli(), m.editTimestampVal(), m.ThreadID, m.MXID, m.SenderMXID.String())
-
- if err != nil {
- m.log.Warnfln("Failed to insert %s@%s: %v", m.DiscordID, m.Channel, err)
- panic(err)
- }
-}
-
-const editUpdateQuery = `
- UPDATE message
- SET dc_edit_timestamp=$1
- WHERE dcid=$2 AND dc_attachment_id=$3 AND dc_chan_id=$4 AND dc_chan_receiver=$5 AND dc_edit_timestamp<$1
-`
-
-func (m *Message) UpdateEditTimestamp(ts time.Time) {
- _, err := m.db.Exec(editUpdateQuery, ts.UnixNano(), m.DiscordID, m.AttachmentID, m.Channel.ChannelID, m.Channel.Receiver)
- if err != nil {
- m.log.Warnfln("Failed to update edit timestamp of %s@%s: %v", m.DiscordID, m.Channel, err)
- panic(err)
- }
-}
-
-func (m *Message) Delete() {
- query := "DELETE FROM message WHERE dcid=$1 AND dc_chan_id=$2 AND dc_chan_receiver=$3 AND dc_attachment_id=$4"
- _, err := m.db.Exec(query, m.DiscordID, m.Channel.ChannelID, m.Channel.Receiver, m.AttachmentID)
- if err != nil {
- m.log.Warnfln("Failed to delete %q of %s@%s: %v", m.AttachmentID, m.DiscordID, m.Channel, err)
- panic(err)
- }
-}
diff --git a/database/portal.go b/database/portal.go
deleted file mode 100644
index 3c6a8da..0000000
--- a/database/portal.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package database
-
-import (
- "database/sql"
-
- "github.com/bwmarrin/discordgo"
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-// language=postgresql
-const (
- portalSelect = `
- SELECT dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
- plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
- encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret
- FROM portal
- `
-)
-
-type PortalKey struct {
- ChannelID string
- Receiver string
-}
-
-func NewPortalKey(channelID, receiver string) PortalKey {
- return PortalKey{
- ChannelID: channelID,
- Receiver: receiver,
- }
-}
-
-func (key PortalKey) String() string {
- if key.Receiver == "" {
- return key.ChannelID
- }
- return key.ChannelID + "-" + key.Receiver
-}
-
-type PortalQuery struct {
- db *Database
- log log.Logger
-}
-
-func (pq *PortalQuery) New() *Portal {
- return &Portal{
- db: pq.db,
- log: pq.log,
- }
-}
-
-func (pq *PortalQuery) GetAll() []*Portal {
- return pq.getAll(portalSelect)
-}
-
-func (pq *PortalQuery) GetAllInGuild(guildID string) []*Portal {
- return pq.getAll(portalSelect+" WHERE dc_guild_id=$1", guildID)
-}
-
-func (pq *PortalQuery) GetByID(key PortalKey) *Portal {
- return pq.get(portalSelect+" WHERE dcid=$1 AND (receiver=$2 OR receiver='')", key.ChannelID, key.Receiver)
-}
-
-func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal {
- return pq.get(portalSelect+" WHERE mxid=$1", mxid)
-}
-
-func (pq *PortalQuery) FindPrivateChatBetween(id, receiver string) *Portal {
- return pq.get(portalSelect+" WHERE other_user_id=$1 AND receiver=$2 AND type=$3", id, receiver, discordgo.ChannelTypeDM)
-}
-
-func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal {
- return pq.getAll(portalSelect+" WHERE other_user_id=$1 AND type=$2", id, discordgo.ChannelTypeDM)
-}
-
-func (pq *PortalQuery) FindPrivateChatsOf(receiver string) []*Portal {
- query := portalSelect + " portal WHERE receiver=$1 AND type=$2;"
-
- return pq.getAll(query, receiver, discordgo.ChannelTypeDM)
-}
-
-func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal {
- rows, err := pq.db.Query(query, args...)
- if err != nil || rows == nil {
- return nil
- }
- defer rows.Close()
-
- var portals []*Portal
- for rows.Next() {
- portals = append(portals, pq.New().Scan(rows))
- }
-
- return portals
-}
-
-func (pq *PortalQuery) get(query string, args ...interface{}) *Portal {
- return pq.New().Scan(pq.db.QueryRow(query, args...))
-}
-
-type Portal struct {
- db *Database
- log log.Logger
-
- Key PortalKey
- Type discordgo.ChannelType
- OtherUserID string
- ParentID string
- GuildID string
-
- MXID id.RoomID
-
- PlainName string
- Name string
- NameSet bool
- FriendNick bool
- Topic string
- TopicSet bool
- Avatar string
- AvatarURL id.ContentURI
- AvatarSet bool
- Encrypted bool
- InSpace id.RoomID
-
- FirstEventID id.EventID
-
- RelayWebhookID string
- RelayWebhookSecret string
-}
-
-func (p *Portal) Scan(row dbutil.Scannable) *Portal {
- var otherUserID, guildID, parentID, mxid, firstEventID, relayWebhookID, relayWebhookSecret sql.NullString
- var chanType int32
- var avatarURL string
-
- err := row.Scan(&p.Key.ChannelID, &p.Key.Receiver, &chanType, &otherUserID, &guildID, &parentID,
- &mxid, &p.PlainName, &p.Name, &p.NameSet, &p.FriendNick, &p.Topic, &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet,
- &p.Encrypted, &p.InSpace, &firstEventID, &relayWebhookID, &relayWebhookSecret)
-
- if err != nil {
- if err != sql.ErrNoRows {
- p.log.Errorln("Database scan failed:", err)
- panic(err)
- }
-
- return nil
- }
-
- p.MXID = id.RoomID(mxid.String)
- p.OtherUserID = otherUserID.String
- p.GuildID = guildID.String
- p.ParentID = parentID.String
- p.Type = discordgo.ChannelType(chanType)
- p.FirstEventID = id.EventID(firstEventID.String)
- p.AvatarURL, _ = id.ParseContentURI(avatarURL)
- p.RelayWebhookID = relayWebhookID.String
- p.RelayWebhookSecret = relayWebhookSecret.String
-
- return p
-}
-
-func (p *Portal) Insert() {
- query := `
- INSERT INTO portal (dcid, receiver, type, other_user_id, dc_guild_id, dc_parent_id, mxid,
- plain_name, name, name_set, friend_nick, topic, topic_set, avatar, avatar_url, avatar_set,
- encrypted, in_space, first_event_id, relay_webhook_id, relay_webhook_secret)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
- `
- _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver, p.Type,
- strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
- p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet,
- p.Encrypted, p.InSpace, p.FirstEventID.String(), strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret))
-
- if err != nil {
- p.log.Warnfln("Failed to insert %s: %v", p.Key, err)
- panic(err)
- }
-}
-
-func (p *Portal) Update() {
- query := `
- UPDATE portal
- SET type=$1, other_user_id=$2, dc_guild_id=$3, dc_parent_id=$4, mxid=$5,
- plain_name=$6, name=$7, name_set=$8, friend_nick=$9, topic=$10, topic_set=$11,
- avatar=$12, avatar_url=$13, avatar_set=$14, encrypted=$15, in_space=$16, first_event_id=$17,
- relay_webhook_id=$18, relay_webhook_secret=$19
- WHERE dcid=$20 AND receiver=$21
- `
- _, err := p.db.Exec(query,
- p.Type, strPtr(p.OtherUserID), strPtr(p.GuildID), strPtr(p.ParentID), strPtr(string(p.MXID)),
- p.PlainName, p.Name, p.NameSet, p.FriendNick, p.Topic, p.TopicSet,
- p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.Encrypted, p.InSpace, p.FirstEventID.String(),
- strPtr(p.RelayWebhookID), strPtr(p.RelayWebhookSecret),
- p.Key.ChannelID, p.Key.Receiver)
-
- if err != nil {
- p.log.Warnfln("Failed to update %s: %v", p.Key, err)
- panic(err)
- }
-}
-
-func (p *Portal) Delete() {
- query := "DELETE FROM portal WHERE dcid=$1 AND receiver=$2"
- _, err := p.db.Exec(query, p.Key.ChannelID, p.Key.Receiver)
- if err != nil {
- p.log.Warnfln("Failed to delete %s: %v", p.Key, err)
- panic(err)
- }
-}
diff --git a/database/puppet.go b/database/puppet.go
deleted file mode 100644
index d6080c7..0000000
--- a/database/puppet.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package database
-
-import (
- "database/sql"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-const (
- puppetSelect = "SELECT id, name, name_set, avatar, avatar_url, avatar_set," +
- " contact_info_set, global_name, username, discriminator, is_bot, is_webhook, is_application, custom_mxid, access_token, next_batch" +
- " FROM puppet "
-)
-
-type PuppetQuery struct {
- db *Database
- log log.Logger
-}
-
-func (pq *PuppetQuery) New() *Puppet {
- return &Puppet{
- db: pq.db,
- log: pq.log,
- }
-}
-
-func (pq *PuppetQuery) Get(id string) *Puppet {
- return pq.get(puppetSelect+" WHERE id=$1", id)
-}
-
-func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet {
- return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid)
-}
-
-func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet {
- return pq.New().Scan(pq.db.QueryRow(query, args...))
-}
-
-func (pq *PuppetQuery) GetAll() []*Puppet {
- return pq.getAll(puppetSelect)
-}
-
-func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet {
- return pq.getAll(puppetSelect + " WHERE custom_mxid<>''")
-}
-
-func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet {
- rows, err := pq.db.Query(query, args...)
- if err != nil || rows == nil {
- return nil
- }
- defer rows.Close()
-
- var puppets []*Puppet
- for rows.Next() {
- puppets = append(puppets, pq.New().Scan(rows))
- }
-
- return puppets
-}
-
-type Puppet struct {
- db *Database
- log log.Logger
-
- ID string
- Name string
- NameSet bool
- Avatar string
- AvatarURL id.ContentURI
- AvatarSet bool
-
- ContactInfoSet bool
-
- GlobalName string
- Username string
- Discriminator string
- IsBot bool
- IsWebhook bool
- IsApplication bool
-
- CustomMXID id.UserID
- AccessToken string
- NextBatch string
-}
-
-func (p *Puppet) Scan(row dbutil.Scannable) *Puppet {
- var avatarURL string
- var customMXID, accessToken, nextBatch sql.NullString
-
- err := row.Scan(&p.ID, &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.AvatarSet, &p.ContactInfoSet,
- &p.GlobalName, &p.Username, &p.Discriminator, &p.IsBot, &p.IsWebhook, &p.IsApplication, &customMXID, &accessToken, &nextBatch)
-
- if err != nil {
- if err != sql.ErrNoRows {
- p.log.Errorln("Database scan failed:", err)
- panic(err)
- }
-
- return nil
- }
-
- p.AvatarURL, _ = id.ParseContentURI(avatarURL)
- p.CustomMXID = id.UserID(customMXID.String)
- p.AccessToken = accessToken.String
- p.NextBatch = nextBatch.String
-
- return p
-}
-
-func (p *Puppet) Insert() {
- query := `
- INSERT INTO puppet (
- id, name, name_set, avatar, avatar_url, avatar_set, contact_info_set,
- global_name, username, discriminator, is_bot, is_webhook, is_application,
- custom_mxid, access_token, next_batch
- )
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
- `
- _, err := p.db.Exec(query, p.ID, p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
- p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
- strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch))
-
- if err != nil {
- p.log.Warnfln("Failed to insert %s: %v", p.ID, err)
- panic(err)
- }
-}
-
-func (p *Puppet) Update() {
- query := `
- UPDATE puppet SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5, contact_info_set=$6,
- global_name=$7, username=$8, discriminator=$9, is_bot=$10, is_webhook=$11, is_application=$12,
- custom_mxid=$13, access_token=$14, next_batch=$15
- WHERE id=$16
- `
- _, err := p.db.Exec(
- query,
- p.Name, p.NameSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, p.ContactInfoSet,
- p.GlobalName, p.Username, p.Discriminator, p.IsBot, p.IsWebhook, p.IsApplication,
- strPtr(p.CustomMXID), strPtr(p.AccessToken), strPtr(p.NextBatch),
- p.ID,
- )
-
- if err != nil {
- p.log.Warnfln("Failed to update %s: %v", p.ID, err)
- panic(err)
- }
-}
diff --git a/database/reaction.go b/database/reaction.go
deleted file mode 100644
index 8727bb5..0000000
--- a/database/reaction.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package database
-
-import (
- "database/sql"
- "errors"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-type ReactionQuery struct {
- db *Database
- log log.Logger
-}
-
-const (
- reactionSelect = "SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, mxid FROM reaction"
-)
-
-func (rq *ReactionQuery) New() *Reaction {
- return &Reaction{
- db: rq.db,
- log: rq.log,
- }
-}
-
-func (rq *ReactionQuery) GetAllForMessage(key PortalKey, discordMessageID string) []*Reaction {
- query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3"
-
- return rq.getAll(query, key.ChannelID, key.Receiver, discordMessageID)
-}
-
-func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction {
- rows, err := rq.db.Query(query, args...)
- if err != nil || rows == nil {
- return nil
- }
-
- var reactions []*Reaction
- for rows.Next() {
- reactions = append(reactions, rq.New().Scan(rows))
- }
-
- return reactions
-}
-
-func (rq *ReactionQuery) GetByDiscordID(key PortalKey, msgID, sender, emojiName string) *Reaction {
- query := reactionSelect + " WHERE dc_chan_id=$1 AND dc_chan_receiver=$2 AND dc_msg_id=$3 AND dc_sender=$4 AND dc_emoji_name=$5"
-
- return rq.get(query, key.ChannelID, key.Receiver, msgID, sender, emojiName)
-}
-
-func (rq *ReactionQuery) GetByMXID(mxid id.EventID) *Reaction {
- query := reactionSelect + " WHERE mxid=$1"
-
- return rq.get(query, mxid)
-}
-
-func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction {
- row := rq.db.QueryRow(query, args...)
- if row == nil {
- return nil
- }
-
- return rq.New().Scan(row)
-}
-
-type Reaction struct {
- db *Database
- log log.Logger
-
- Channel PortalKey
- MessageID string
- Sender string
- EmojiName string
- ThreadID string
-
- MXID id.EventID
-
- FirstAttachmentID string
-}
-
-func (r *Reaction) Scan(row dbutil.Scannable) *Reaction {
- err := row.Scan(&r.Channel.ChannelID, &r.Channel.Receiver, &r.MessageID, &r.Sender, &r.EmojiName, &r.ThreadID, &r.MXID)
- if err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- r.log.Errorln("Database scan failed:", err)
- panic(err)
- }
- return nil
- }
-
- return r
-}
-
-func (r *Reaction) DiscordProtoChannelID() string {
- if r.ThreadID != "" {
- return r.ThreadID
- } else {
- return r.Channel.ChannelID
- }
-}
-
-func (r *Reaction) Insert() {
- query := `
- INSERT INTO reaction (dc_msg_id, dc_first_attachment_id, dc_sender, dc_emoji_name, dc_chan_id, dc_chan_receiver, dc_thread_id, mxid)
- VALUES($1, $2, $3, $4, $5, $6, $7, $8)
- `
- _, err := r.db.Exec(query, r.MessageID, r.FirstAttachmentID, r.Sender, r.EmojiName, r.Channel.ChannelID, r.Channel.Receiver, r.ThreadID, r.MXID)
- if err != nil {
- r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.MessageID, r.Channel, err)
- panic(err)
- }
-}
-
-func (r *Reaction) Delete() {
- query := "DELETE FROM reaction WHERE dc_msg_id=$1 AND dc_sender=$2 AND dc_emoji_name=$3"
- _, err := r.db.Exec(query, r.MessageID, r.Sender, r.EmojiName)
- if err != nil {
- r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.MessageID, r.Channel, err)
- panic(err)
- }
-}
diff --git a/database/role.go b/database/role.go
deleted file mode 100644
index 3696b51..0000000
--- a/database/role.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package database
-
-import (
- "database/sql"
- "errors"
-
- "github.com/bwmarrin/discordgo"
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
-)
-
-type RoleQuery struct {
- db *Database
- log log.Logger
-}
-
-// language=postgresql
-const (
- roleSelect = "SELECT dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions FROM role"
- roleUpsert = `
- INSERT INTO role (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
- ON CONFLICT (dc_guild_id, dcid) DO UPDATE
- SET name=excluded.name, icon=excluded.icon, mentionable=excluded.mentionable, managed=excluded.managed,
- hoist=excluded.hoist, color=excluded.color, position=excluded.position, permissions=excluded.permissions
- `
- roleDelete = "DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2"
-)
-
-func (rq *RoleQuery) New() *Role {
- return &Role{
- db: rq.db,
- log: rq.log,
- }
-}
-
-func (rq *RoleQuery) GetByID(guildID, dcid string) *Role {
- query := roleSelect + " WHERE dc_guild_id=$1 AND dcid=$2"
- return rq.New().Scan(rq.db.QueryRow(query, guildID, dcid))
-}
-
-func (rq *RoleQuery) DeleteByID(guildID, dcid string) {
- _, err := rq.db.Exec("DELETE FROM role WHERE dc_guild_id=$1 AND dcid=$2", guildID, dcid)
- if err != nil {
- rq.log.Warnfln("Failed to delete %s/%s: %v", guildID, dcid, err)
- panic(err)
- }
-}
-
-func (rq *RoleQuery) GetAll(guildID string) []*Role {
- rows, err := rq.db.Query(roleSelect+" WHERE dc_guild_id=$1", guildID)
- if err != nil {
- rq.log.Errorfln("Failed to query roles of %s: %v", guildID, err)
- return nil
- }
-
- var roles []*Role
- for rows.Next() {
- role := rq.New().Scan(rows)
- if role != nil {
- roles = append(roles, role)
- }
- }
-
- return roles
-}
-
-type Role struct {
- db *Database
- log log.Logger
-
- GuildID string
-
- discordgo.Role
-}
-
-func (r *Role) Scan(row dbutil.Scannable) *Role {
- var icon sql.NullString
- err := row.Scan(&r.GuildID, &r.ID, &r.Name, &icon, &r.Mentionable, &r.Managed, &r.Hoist, &r.Color, &r.Position, &r.Permissions)
- if err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- r.log.Errorln("Database scan failed:", err)
- panic(err)
- }
-
- return nil
- }
- r.Icon = icon.String
- return r
-}
-
-func (r *Role) Upsert(txn dbutil.Execable) {
- if txn == nil {
- txn = r.db
- }
- _, err := txn.Exec(roleUpsert, r.GuildID, r.ID, r.Name, strPtr(r.Icon), r.Mentionable, r.Managed, r.Hoist, r.Color, r.Position, r.Permissions)
- if err != nil {
- r.log.Warnfln("Failed to insert %s/%s: %v", r.GuildID, r.ID, err)
- panic(err)
- }
-}
-
-func (r *Role) Delete(txn dbutil.Execable) {
- if txn == nil {
- txn = r.db
- }
- _, err := txn.Exec(roleDelete, r.GuildID, r.Icon)
- if err != nil {
- r.log.Warnfln("Failed to delete %s/%s: %v", r.GuildID, r.ID, err)
- panic(err)
- }
-}
diff --git a/database/thread.go b/database/thread.go
deleted file mode 100644
index 87f4127..0000000
--- a/database/thread.go
+++ /dev/null
@@ -1,111 +0,0 @@
-package database
-
-import (
- "database/sql"
- "errors"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-type ThreadQuery struct {
- db *Database
- log log.Logger
-}
-
-const (
- threadSelect = "SELECT dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid FROM thread"
-)
-
-func (tq *ThreadQuery) New() *Thread {
- return &Thread{
- db: tq.db,
- log: tq.log,
- }
-}
-
-func (tq *ThreadQuery) GetByDiscordID(discordID string) *Thread {
- query := threadSelect + " WHERE dcid=$1"
-
- row := tq.db.QueryRow(query, discordID)
- if row == nil {
- return nil
- }
-
- return tq.New().Scan(row)
-}
-
-func (tq *ThreadQuery) GetByMatrixRootMsg(mxid id.EventID) *Thread {
- query := threadSelect + " WHERE root_msg_mxid=$1"
-
- row := tq.db.QueryRow(query, mxid)
- if row == nil {
- return nil
- }
-
- return tq.New().Scan(row)
-}
-
-func (tq *ThreadQuery) GetByMatrixRootOrCreationNoticeMsg(mxid id.EventID) *Thread {
- query := threadSelect + " WHERE root_msg_mxid=$1 OR creation_notice_mxid=$1"
-
- row := tq.db.QueryRow(query, mxid)
- if row == nil {
- return nil
- }
-
- return tq.New().Scan(row)
-}
-
-type Thread struct {
- db *Database
- log log.Logger
-
- ID string
- ParentID string
-
- RootDiscordID string
- RootMXID id.EventID
-
- CreationNoticeMXID id.EventID
-}
-
-func (t *Thread) Scan(row dbutil.Scannable) *Thread {
- err := row.Scan(&t.ID, &t.ParentID, &t.RootDiscordID, &t.RootMXID, &t.CreationNoticeMXID)
- if err != nil {
- if !errors.Is(err, sql.ErrNoRows) {
- t.log.Errorln("Database scan failed:", err)
- panic(err)
- }
- return nil
- }
- return t
-}
-
-func (t *Thread) Insert() {
- query := "INSERT INTO thread (dcid, parent_chan_id, root_msg_dcid, root_msg_mxid, creation_notice_mxid) VALUES ($1, $2, $3, $4, $5)"
- _, err := t.db.Exec(query, t.ID, t.ParentID, t.RootDiscordID, t.RootMXID, t.CreationNoticeMXID)
- if err != nil {
- t.log.Warnfln("Failed to insert %s@%s: %v", t.ID, t.ParentID, err)
- panic(err)
- }
-}
-
-func (t *Thread) Update() {
- query := "UPDATE thread SET creation_notice_mxid=$2 WHERE dcid=$1"
- _, err := t.db.Exec(query, t.ID, t.CreationNoticeMXID)
- if err != nil {
- t.log.Warnfln("Failed to update %s@%s: %v", t.ID, t.ParentID, err)
- panic(err)
- }
-}
-
-func (t *Thread) Delete() {
- query := "DELETE FROM thread WHERE dcid=$1 AND parent_chan_id=$2"
- _, err := t.db.Exec(query, t.ID, t.ParentID)
- if err != nil {
- t.log.Warnfln("Failed to delete %s@%s: %v", t.ID, t.ParentID, err)
- panic(err)
- }
-}
diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql
deleted file mode 100644
index d794530..0000000
--- a/database/upgrades/00-latest-revision.sql
+++ /dev/null
@@ -1,180 +0,0 @@
--- v0 -> v24 (compatible with v19+): Latest revision
-
-CREATE TABLE guild (
- dcid TEXT PRIMARY KEY,
- mxid TEXT UNIQUE,
- plain_name TEXT NOT NULL,
- name TEXT NOT NULL,
- name_set BOOLEAN NOT NULL,
- avatar TEXT NOT NULL,
- avatar_url TEXT NOT NULL,
- avatar_set BOOLEAN NOT NULL,
-
- bridging_mode INTEGER NOT NULL
-);
-
-CREATE TABLE portal (
- dcid TEXT,
- receiver TEXT,
- other_user_id TEXT,
- type INTEGER NOT NULL,
-
- dc_guild_id TEXT,
- dc_parent_id TEXT,
- -- This is not accessed by the bridge, it's only used for the portal parent foreign key.
- -- Only guild channels have parents, but only DMs have a receiver field.
- dc_parent_receiver TEXT NOT NULL DEFAULT '',
-
- mxid TEXT UNIQUE,
- plain_name TEXT NOT NULL,
- name TEXT NOT NULL,
- name_set BOOLEAN NOT NULL,
- friend_nick BOOLEAN NOT NULL,
- topic TEXT NOT NULL,
- topic_set BOOLEAN NOT NULL,
- avatar TEXT NOT NULL,
- avatar_url TEXT NOT NULL,
- avatar_set BOOLEAN NOT NULL,
- encrypted BOOLEAN NOT NULL,
- in_space TEXT NOT NULL,
-
- first_event_id TEXT NOT NULL,
-
- relay_webhook_id TEXT,
- relay_webhook_secret TEXT,
-
- PRIMARY KEY (dcid, receiver),
- CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE,
- CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE
-);
-
-CREATE TABLE thread (
- dcid TEXT PRIMARY KEY,
- parent_chan_id TEXT NOT NULL,
- root_msg_dcid TEXT NOT NULL,
- root_msg_mxid TEXT NOT NULL,
- creation_notice_mxid TEXT NOT NULL,
- -- This is also not accessed by the bridge.
- receiver TEXT NOT NULL DEFAULT '',
-
- CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
-);
-
-CREATE TABLE puppet (
- id TEXT PRIMARY KEY,
-
- name TEXT NOT NULL,
- name_set BOOLEAN NOT NULL DEFAULT false,
- avatar TEXT NOT NULL,
- avatar_url TEXT NOT NULL,
- avatar_set BOOLEAN NOT NULL DEFAULT false,
-
- contact_info_set BOOLEAN NOT NULL DEFAULT false,
-
- global_name TEXT NOT NULL DEFAULT '',
- username TEXT NOT NULL DEFAULT '',
- discriminator TEXT NOT NULL DEFAULT '',
- is_bot BOOLEAN NOT NULL DEFAULT false,
- is_webhook BOOLEAN NOT NULL DEFAULT false,
- is_application BOOLEAN NOT NULL DEFAULT false,
-
- custom_mxid TEXT,
- access_token TEXT,
- next_batch TEXT
-);
-
-CREATE TABLE "user" (
- mxid TEXT PRIMARY KEY,
- dcid TEXT UNIQUE,
-
- discord_token TEXT,
- management_room TEXT,
- space_room TEXT,
- dm_space_room TEXT,
-
- read_state_version INTEGER NOT NULL DEFAULT 0,
- heartbeat_session jsonb
-);
-
-CREATE TABLE user_portal (
- discord_id TEXT,
- user_mxid TEXT,
- type TEXT NOT NULL,
- in_space BOOLEAN NOT NULL,
- timestamp BIGINT NOT NULL,
-
- PRIMARY KEY (discord_id, user_mxid),
- CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
-);
-
-CREATE TABLE message (
- dcid TEXT,
- dc_attachment_id TEXT,
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_sender TEXT NOT NULL,
- timestamp BIGINT NOT NULL,
- dc_edit_timestamp BIGINT NOT NULL,
- dc_thread_id TEXT NOT NULL,
-
- mxid TEXT NOT NULL UNIQUE,
- sender_mxid TEXT NOT NULL DEFAULT '',
-
- PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
- CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
-);
-
-CREATE TABLE reaction (
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_msg_id TEXT,
- dc_sender TEXT,
- dc_emoji_name TEXT,
- dc_thread_id TEXT NOT NULL,
-
- dc_first_attachment_id TEXT NOT NULL,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
- CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
-);
-
-CREATE TABLE role (
- dc_guild_id TEXT,
- dcid TEXT,
-
- name TEXT NOT NULL,
- icon TEXT,
-
- mentionable BOOLEAN NOT NULL,
- managed BOOLEAN NOT NULL,
- hoist BOOLEAN NOT NULL,
-
- color INTEGER NOT NULL,
- position INTEGER NOT NULL,
- permissions BIGINT NOT NULL,
-
- PRIMARY KEY (dc_guild_id, dcid),
- CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
-);
-
-CREATE TABLE discord_file (
- url TEXT,
- encrypted BOOLEAN,
- mxc TEXT NOT NULL,
-
- id TEXT,
- emoji_name TEXT,
-
- size BIGINT NOT NULL,
- width INTEGER,
- height INTEGER,
- mime_type TEXT NOT NULL,
- decryption_info jsonb,
- timestamp BIGINT NOT NULL,
-
- PRIMARY KEY (url, encrypted)
-);
-
-CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);
diff --git a/database/upgrades/02-column-renames.sql b/database/upgrades/02-column-renames.sql
deleted file mode 100644
index 86b0cb0..0000000
--- a/database/upgrades/02-column-renames.sql
+++ /dev/null
@@ -1,53 +0,0 @@
--- v2: Rename columns in message-related tables
-
-ALTER TABLE portal RENAME COLUMN dmuser TO other_user_id;
-ALTER TABLE portal RENAME COLUMN channel_id TO dcid;
-
-ALTER TABLE "user" RENAME COLUMN id TO dcid;
-
-ALTER TABLE puppet DROP COLUMN enable_presence;
-ALTER TABLE puppet DROP COLUMN enable_receipts;
-
-DROP TABLE message;
-DROP TABLE reaction;
-DROP TABLE attachment;
-
-CREATE TABLE message (
- dcid TEXT,
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_sender TEXT NOT NULL,
- timestamp BIGINT NOT NULL,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dcid, dc_chan_id, dc_chan_receiver),
- CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
-);
-
-CREATE TABLE reaction (
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_msg_id TEXT,
- dc_sender TEXT,
- dc_emoji_name TEXT,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
- CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
-);
-
-CREATE TABLE attachment (
- dcid TEXT,
- dc_msg_id TEXT,
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dcid, dc_msg_id, dc_chan_id, dc_chan_receiver),
- CONSTRAINT attachment_message_fkey FOREIGN KEY (dc_msg_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
-);
-
-UPDATE portal SET receiver='' WHERE type<>1;
diff --git a/database/upgrades/03-spaces.sql b/database/upgrades/03-spaces.sql
deleted file mode 100644
index 79bc3c5..0000000
--- a/database/upgrades/03-spaces.sql
+++ /dev/null
@@ -1,73 +0,0 @@
--- v3: Store portal parent metadata for spaces
-DROP TABLE guild;
-
-CREATE TABLE guild (
- dcid TEXT PRIMARY KEY,
- mxid TEXT UNIQUE,
- name TEXT NOT NULL,
- name_set BOOLEAN NOT NULL,
- avatar TEXT NOT NULL,
- avatar_url TEXT NOT NULL,
- avatar_set BOOLEAN NOT NULL,
-
- auto_bridge_channels BOOLEAN NOT NULL
-);
-
-CREATE TABLE user_portal (
- discord_id TEXT,
- user_mxid TEXT,
- type TEXT NOT NULL,
- in_space BOOLEAN NOT NULL,
- timestamp BIGINT NOT NULL,
-
- PRIMARY KEY (discord_id, user_mxid),
- CONSTRAINT up_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) ON DELETE CASCADE
-);
-
-ALTER TABLE portal ADD COLUMN dc_guild_id TEXT;
-ALTER TABLE portal ADD COLUMN dc_parent_id TEXT;
-ALTER TABLE portal ADD COLUMN dc_parent_receiver TEXT NOT NULL DEFAULT '';
-ALTER TABLE portal ADD CONSTRAINT portal_parent_fkey FOREIGN KEY (dc_parent_id, dc_parent_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE;
-ALTER TABLE portal ADD CONSTRAINT portal_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild(dcid) ON DELETE CASCADE;
-DELETE FROM portal WHERE type IS NULL;
--- only: postgres
-ALTER TABLE portal ALTER COLUMN type SET NOT NULL;
-
-ALTER TABLE portal ADD COLUMN in_space TEXT NOT NULL DEFAULT '';
-ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
-ALTER TABLE portal ADD COLUMN topic_set BOOLEAN NOT NULL DEFAULT false;
-ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
--- only: postgres for next 5 lines
-ALTER TABLE portal ALTER COLUMN in_space DROP DEFAULT;
-ALTER TABLE portal ALTER COLUMN name_set DROP DEFAULT;
-ALTER TABLE portal ALTER COLUMN topic_set DROP DEFAULT;
-ALTER TABLE portal ALTER COLUMN avatar_set DROP DEFAULT;
-ALTER TABLE portal ALTER COLUMN encrypted DROP DEFAULT;
-
-ALTER TABLE puppet RENAME COLUMN display_name TO name;
-ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false;
-ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false;
--- only: postgres for next 2 lines
-ALTER TABLE puppet ALTER COLUMN name_set DROP DEFAULT;
-ALTER TABLE puppet ALTER COLUMN avatar_set DROP DEFAULT;
-
-ALTER TABLE "user" ADD COLUMN space_room TEXT;
-ALTER TABLE "user" ADD COLUMN dm_space_room TEXT;
-ALTER TABLE "user" RENAME COLUMN token TO discord_token;
-
-UPDATE message SET timestamp=timestamp*1000;
-
-CREATE TABLE thread (
- dcid TEXT PRIMARY KEY,
- parent_chan_id TEXT NOT NULL,
- root_msg_dcid TEXT NOT NULL,
- root_msg_mxid TEXT NOT NULL,
- -- This is also not accessed by the bridge.
- receiver TEXT NOT NULL DEFAULT '',
-
- CONSTRAINT thread_parent_fkey FOREIGN KEY (parent_chan_id, receiver) REFERENCES portal(dcid, receiver) ON DELETE CASCADE ON UPDATE CASCADE
-);
-
-ALTER TABLE message ADD COLUMN dc_thread_id TEXT;
-ALTER TABLE attachment ADD COLUMN dc_thread_id TEXT;
-ALTER TABLE reaction ADD COLUMN dc_thread_id TEXT;
diff --git a/database/upgrades/04-attachment-fix.postgres.sql b/database/upgrades/04-attachment-fix.postgres.sql
deleted file mode 100644
index c476afd..0000000
--- a/database/upgrades/04-attachment-fix.postgres.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- v4: Fix storing attachments
-ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
-ALTER TABLE attachment DROP CONSTRAINT attachment_message_fkey;
-ALTER TABLE message DROP CONSTRAINT message_pkey;
-ALTER TABLE message ADD COLUMN dc_attachment_id TEXT NOT NULL DEFAULT '';
-ALTER TABLE message ADD COLUMN dc_edit_index INTEGER NOT NULL DEFAULT 0;
-ALTER TABLE message ALTER COLUMN dc_attachment_id DROP DEFAULT;
-ALTER TABLE message ALTER COLUMN dc_edit_index DROP DEFAULT;
-ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);
-INSERT INTO message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
- SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
- FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
-DROP TABLE attachment;
-
-ALTER TABLE reaction ADD COLUMN dc_first_attachment_id TEXT NOT NULL DEFAULT '';
-ALTER TABLE reaction ALTER COLUMN dc_first_attachment_id DROP DEFAULT;
-ALTER TABLE reaction ADD COLUMN _dc_first_edit_index INTEGER DEFAULT 0;
-ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey
- FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver)
- REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver);
diff --git a/database/upgrades/04-attachment-fix.sqlite.sql b/database/upgrades/04-attachment-fix.sqlite.sql
deleted file mode 100644
index 88c4386..0000000
--- a/database/upgrades/04-attachment-fix.sqlite.sql
+++ /dev/null
@@ -1,45 +0,0 @@
--- v4: Fix storing attachments
-CREATE TABLE new_message (
- dcid TEXT,
- dc_attachment_id TEXT,
- dc_edit_index INTEGER,
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_sender TEXT NOT NULL,
- timestamp BIGINT NOT NULL,
- dc_thread_id TEXT,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver),
- CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
-);
-INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
- SELECT dcid, '', 0, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid FROM message;
-INSERT INTO new_message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_thread_id, mxid)
- SELECT message.dcid, attachment.dcid, 0, attachment.dc_chan_id, attachment.dc_chan_receiver, message.dc_sender, message.timestamp, attachment.dc_thread_id, attachment.mxid
- FROM attachment LEFT JOIN message ON attachment.dc_msg_id = message.dcid;
-DROP TABLE attachment;
-DROP TABLE message;
-ALTER TABLE new_message RENAME TO message;
-
-CREATE TABLE new_reaction (
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_msg_id TEXT,
- dc_sender TEXT,
- dc_emoji_name TEXT,
- dc_thread_id TEXT,
-
- dc_first_attachment_id TEXT NOT NULL,
- _dc_first_edit_index INTEGER NOT NULL DEFAULT 0,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
- CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
-);
-INSERT INTO new_reaction (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
-SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, '', mxid FROM reaction;
-DROP TABLE reaction;
-ALTER TABLE new_reaction RENAME TO reaction;
diff --git a/database/upgrades/05-reaction-fkey-fix.sql b/database/upgrades/05-reaction-fkey-fix.sql
deleted file mode 100644
index 1a02a5e..0000000
--- a/database/upgrades/05-reaction-fkey-fix.sql
+++ /dev/null
@@ -1,8 +0,0 @@
--- v5: Fix foreign key broken in v4
--- only: postgres
-
-ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
-ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey
- FOREIGN KEY (dc_msg_id, dc_first_attachment_id, _dc_first_edit_index, dc_chan_id, dc_chan_receiver)
- REFERENCES message(dcid, dc_attachment_id, dc_edit_index, dc_chan_id, dc_chan_receiver)
- ON DELETE CASCADE;
diff --git a/database/upgrades/06-user-read-state-version.sql b/database/upgrades/06-user-read-state-version.sql
deleted file mode 100644
index 612a777..0000000
--- a/database/upgrades/06-user-read-state-version.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- v6: Store user read state version
-ALTER TABLE "user" ADD COLUMN read_state_version INTEGER NOT NULL DEFAULT 0;
diff --git a/database/upgrades/07-store-role-info.sql b/database/upgrades/07-store-role-info.sql
deleted file mode 100644
index 21f6a57..0000000
--- a/database/upgrades/07-store-role-info.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- v7: Store role info
-CREATE TABLE role (
- dc_guild_id TEXT,
- dcid TEXT,
-
- name TEXT NOT NULL,
- icon TEXT,
-
- mentionable BOOLEAN NOT NULL,
- managed BOOLEAN NOT NULL,
- hoist BOOLEAN NOT NULL,
-
- color INTEGER NOT NULL,
- position INTEGER NOT NULL,
- permissions BIGINT NOT NULL,
-
- PRIMARY KEY (dc_guild_id, dcid),
- CONSTRAINT role_guild_fkey FOREIGN KEY (dc_guild_id) REFERENCES guild (dcid) ON DELETE CASCADE
-);
diff --git a/database/upgrades/08-channel-plain-name.sql b/database/upgrades/08-channel-plain-name.sql
deleted file mode 100644
index 22237b6..0000000
--- a/database/upgrades/08-channel-plain-name.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- v8: Store plain name of channels and guilds
-ALTER TABLE guild ADD COLUMN plain_name TEXT;
-ALTER TABLE portal ADD COLUMN plain_name TEXT;
-UPDATE guild SET plain_name=name;
-UPDATE portal SET plain_name=name;
-UPDATE portal SET plain_name='' WHERE type=1;
--- only: postgres for next 2 lines
-ALTER TABLE guild ALTER COLUMN plain_name SET NOT NULL;
-ALTER TABLE portal ALTER COLUMN plain_name SET NOT NULL;
diff --git a/database/upgrades/09-more-thread-data.sql b/database/upgrades/09-more-thread-data.sql
deleted file mode 100644
index 461a1d4..0000000
--- a/database/upgrades/09-more-thread-data.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- v9: Store more info for proper thread support
-ALTER TABLE thread ADD COLUMN creation_notice_mxid TEXT NOT NULL DEFAULT '';
-UPDATE message SET dc_thread_id='' WHERE dc_thread_id IS NULL;
-UPDATE reaction SET dc_thread_id='' WHERE dc_thread_id IS NULL;
-
--- only: postgres for next 3 lines
-ALTER TABLE thread ALTER COLUMN creation_notice_mxid DROP DEFAULT;
-ALTER TABLE message ALTER COLUMN dc_thread_id SET NOT NULL;
-ALTER TABLE reaction ALTER COLUMN dc_thread_id SET NOT NULL;
diff --git a/database/upgrades/10-remove-broken-double-puppets.sql b/database/upgrades/10-remove-broken-double-puppets.sql
deleted file mode 100644
index 862c917..0000000
--- a/database/upgrades/10-remove-broken-double-puppets.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- v10: Remove double puppet ghosts added while there was a bug in the bridge
-DELETE FROM puppet WHERE id='';
diff --git a/database/upgrades/11-cache-reuploaded-files.sql b/database/upgrades/11-cache-reuploaded-files.sql
deleted file mode 100644
index c32c2bc..0000000
--- a/database/upgrades/11-cache-reuploaded-files.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- v11: Cache files copied from Discord to Matrix
-CREATE TABLE discord_file (
- url TEXT,
- encrypted BOOLEAN,
-
- id TEXT,
- mxc TEXT NOT NULL,
-
- size BIGINT NOT NULL,
- width INTEGER,
- height INTEGER,
-
- decryption_info jsonb,
-
- timestamp BIGINT NOT NULL,
-
- PRIMARY KEY (url, encrypted)
-);
diff --git a/database/upgrades/12-file-cache-mime-type.sql b/database/upgrades/12-file-cache-mime-type.sql
deleted file mode 100644
index 1bdb960..0000000
--- a/database/upgrades/12-file-cache-mime-type.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- v12: Cache mime type for reuploaded files
-ALTER TABLE discord_file ADD COLUMN mime_type TEXT NOT NULL DEFAULT '';
--- only: postgres
-ALTER TABLE discord_file ALTER COLUMN mime_type DROP DEFAULT;
diff --git a/database/upgrades/13-merge-emoji-and-file.postgres.sql b/database/upgrades/13-merge-emoji-and-file.postgres.sql
deleted file mode 100644
index 18ef607..0000000
--- a/database/upgrades/13-merge-emoji-and-file.postgres.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- v13: Merge tables used for cached custom emojis and attachments
-ALTER TABLE discord_file ADD CONSTRAINT mxc_unique UNIQUE (mxc);
-ALTER TABLE discord_file ADD COLUMN emoji_name TEXT;
-DROP TABLE emoji;
diff --git a/database/upgrades/13-merge-emoji-and-file.sqlite.sql b/database/upgrades/13-merge-emoji-and-file.sqlite.sql
deleted file mode 100644
index ffe1b25..0000000
--- a/database/upgrades/13-merge-emoji-and-file.sqlite.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- v13: Merge tables used for cached custom emojis and attachments
-CREATE TABLE new_discord_file (
- url TEXT,
- encrypted BOOLEAN,
- mxc TEXT NOT NULL UNIQUE,
-
- id TEXT,
- emoji_name TEXT,
-
- size BIGINT NOT NULL,
- width INTEGER,
- height INTEGER,
- mime_type TEXT NOT NULL,
- decryption_info jsonb,
- timestamp BIGINT NOT NULL,
-
- PRIMARY KEY (url, encrypted)
-);
-
-INSERT INTO new_discord_file (url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp)
-SELECT url, encrypted, id, mxc, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
-
-DROP TABLE discord_file;
-ALTER TABLE new_discord_file RENAME TO discord_file;
diff --git a/database/upgrades/14-guild-bridging-mode.sql b/database/upgrades/14-guild-bridging-mode.sql
deleted file mode 100644
index 854d1c0..0000000
--- a/database/upgrades/14-guild-bridging-mode.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- v14: Add more modes of bridging guilds
-ALTER TABLE guild ADD COLUMN bridging_mode INTEGER NOT NULL DEFAULT 0;
-UPDATE guild SET bridging_mode=2 WHERE mxid<>'';
-UPDATE guild SET bridging_mode=3 WHERE auto_bridge_channels=true;
-ALTER TABLE guild DROP COLUMN auto_bridge_channels;
--- only: postgres
-ALTER TABLE guild ALTER COLUMN bridging_mode DROP DEFAULT;
diff --git a/database/upgrades/15-portal-relay-webhook.sql b/database/upgrades/15-portal-relay-webhook.sql
deleted file mode 100644
index 0035d00..0000000
--- a/database/upgrades/15-portal-relay-webhook.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- v15: Store relay webhook URL for portals
-ALTER TABLE portal ADD COLUMN relay_webhook_id TEXT;
-ALTER TABLE portal ADD COLUMN relay_webhook_secret TEXT;
diff --git a/database/upgrades/16-add-contact-info.sql b/database/upgrades/16-add-contact-info.sql
deleted file mode 100644
index 8595ae3..0000000
--- a/database/upgrades/16-add-contact-info.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- v16: Store whether custom contact info has been set for the puppet
-
-ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false;
diff --git a/database/upgrades/17-dm-portal-friend-nick.sql b/database/upgrades/17-dm-portal-friend-nick.sql
deleted file mode 100644
index 2c2b43c..0000000
--- a/database/upgrades/17-dm-portal-friend-nick.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- v17: Store whether DM portal name is a friend nickname
-ALTER TABLE portal ADD COLUMN friend_nick BOOLEAN NOT NULL DEFAULT false;
diff --git a/database/upgrades/18-extra-ghost-metadata.sql b/database/upgrades/18-extra-ghost-metadata.sql
deleted file mode 100644
index 92677dc..0000000
--- a/database/upgrades/18-extra-ghost-metadata.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- v18 (compatible with v15+): Store additional metadata for ghosts
-ALTER TABLE puppet ADD COLUMN username TEXT NOT NULL DEFAULT '';
-ALTER TABLE puppet ADD COLUMN discriminator TEXT NOT NULL DEFAULT '';
-ALTER TABLE puppet ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;
diff --git a/database/upgrades/19-message-edit-ts.postgres.sql b/database/upgrades/19-message-edit-ts.postgres.sql
deleted file mode 100644
index 231afa1..0000000
--- a/database/upgrades/19-message-edit-ts.postgres.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- v19: Replace dc_edit_index with dc_edit_timestamp
--- transaction: off
-BEGIN;
-
-ALTER TABLE reaction DROP CONSTRAINT reaction_message_fkey;
-ALTER TABLE message DROP CONSTRAINT message_pkey;
-ALTER TABLE message DROP COLUMN dc_edit_index;
-ALTER TABLE reaction DROP COLUMN _dc_first_edit_index;
-ALTER TABLE message ADD PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver);
-ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE;
-
-ALTER TABLE message ADD COLUMN dc_edit_timestamp BIGINT NOT NULL DEFAULT 0;
-ALTER TABLE message ALTER COLUMN dc_edit_timestamp DROP DEFAULT;
-
-COMMIT;
diff --git a/database/upgrades/19-message-edit-ts.sqlite.sql b/database/upgrades/19-message-edit-ts.sqlite.sql
deleted file mode 100644
index a25f317..0000000
--- a/database/upgrades/19-message-edit-ts.sqlite.sql
+++ /dev/null
@@ -1,48 +0,0 @@
--- v19: Replace dc_edit_index with dc_edit_timestamp
--- transaction: off
-PRAGMA foreign_keys = OFF;
-BEGIN;
-
-CREATE TABLE message_new (
- dcid TEXT,
- dc_attachment_id TEXT,
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_sender TEXT NOT NULL,
- timestamp BIGINT NOT NULL,
- dc_edit_timestamp BIGINT NOT NULL,
- dc_thread_id TEXT NOT NULL,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver),
- CONSTRAINT message_portal_fkey FOREIGN KEY (dc_chan_id, dc_chan_receiver) REFERENCES portal (dcid, receiver) ON DELETE CASCADE
-);
-INSERT INTO message_new (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, dc_edit_timestamp, dc_thread_id, mxid)
- SELECT dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver, dc_sender, timestamp, 0, dc_thread_id, mxid FROM message;
-DROP TABLE message;
-ALTER TABLE message_new RENAME TO message;
-
-CREATE TABLE reaction_new (
- dc_chan_id TEXT,
- dc_chan_receiver TEXT,
- dc_msg_id TEXT,
- dc_sender TEXT,
- dc_emoji_name TEXT,
- dc_thread_id TEXT NOT NULL,
-
- dc_first_attachment_id TEXT NOT NULL,
-
- mxid TEXT NOT NULL UNIQUE,
-
- PRIMARY KEY (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name),
- CONSTRAINT reaction_message_fkey FOREIGN KEY (dc_msg_id, dc_first_attachment_id, dc_chan_id, dc_chan_receiver) REFERENCES message (dcid, dc_attachment_id, dc_chan_id, dc_chan_receiver) ON DELETE CASCADE
-);
-INSERT INTO reaction_new (dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, dc_thread_id, dc_first_attachment_id, mxid)
- SELECT dc_chan_id, dc_chan_receiver, dc_msg_id, dc_sender, dc_emoji_name, COALESCE(dc_thread_id, ''), dc_first_attachment_id, mxid FROM reaction;
-DROP TABLE reaction;
-ALTER TABLE reaction_new RENAME TO reaction;
-
-PRAGMA foreign_key_check;
-COMMIT;
-PRAGMA foreign_keys = ON;
diff --git a/database/upgrades/20-message-sender-mxid.sql b/database/upgrades/20-message-sender-mxid.sql
deleted file mode 100644
index aa2bd65..0000000
--- a/database/upgrades/20-message-sender-mxid.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- v20 (compatible with v19+): Store message sender Matrix user ID
-ALTER TABLE message ADD COLUMN sender_mxid TEXT NOT NULL DEFAULT '';
diff --git a/database/upgrades/21-more-puppet-info.sql b/database/upgrades/21-more-puppet-info.sql
deleted file mode 100644
index 3bc374a..0000000
--- a/database/upgrades/21-more-puppet-info.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- v21 (compatible with v19+): Store global displayname and is webhook status for puppets
-ALTER TABLE puppet ADD COLUMN global_name TEXT NOT NULL DEFAULT '';
-ALTER TABLE puppet ADD COLUMN is_webhook BOOLEAN NOT NULL DEFAULT false;
diff --git a/database/upgrades/22-file-cache-duplicate-mxc.sql b/database/upgrades/22-file-cache-duplicate-mxc.sql
deleted file mode 100644
index b0bac3b..0000000
--- a/database/upgrades/22-file-cache-duplicate-mxc.sql
+++ /dev/null
@@ -1,26 +0,0 @@
--- v22 (compatible with v19+): Allow non-unique mxc URIs in file cache
-CREATE TABLE new_discord_file (
- url TEXT,
- encrypted BOOLEAN,
- mxc TEXT NOT NULL,
-
- id TEXT,
- emoji_name TEXT,
-
- size BIGINT NOT NULL,
- width INTEGER,
- height INTEGER,
- mime_type TEXT NOT NULL,
- decryption_info jsonb,
- timestamp BIGINT NOT NULL,
-
- PRIMARY KEY (url, encrypted)
-);
-
-INSERT INTO new_discord_file (url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp)
-SELECT url, encrypted, mxc, id, emoji_name, size, width, height, mime_type, decryption_info, timestamp FROM discord_file;
-
-DROP TABLE discord_file;
-ALTER TABLE new_discord_file RENAME TO discord_file;
-
-CREATE INDEX discord_file_mxc_idx ON discord_file (mxc);
diff --git a/database/upgrades/23-puppet-is-application.sql b/database/upgrades/23-puppet-is-application.sql
deleted file mode 100644
index 6279c88..0000000
--- a/database/upgrades/23-puppet-is-application.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- v23 (compatible with v19+): Store is application status for puppets
-ALTER TABLE puppet ADD COLUMN is_application BOOLEAN NOT NULL DEFAULT false;
diff --git a/database/upgrades/24-user-heartbeat-session.sql b/database/upgrades/24-user-heartbeat-session.sql
deleted file mode 100644
index ccb44f9..0000000
--- a/database/upgrades/24-user-heartbeat-session.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- v24 (compatible with v19+): Add persisted heartbeat sessions
-ALTER TABLE "user" ADD COLUMN heartbeat_session jsonb;
diff --git a/database/user.go b/database/user.go
deleted file mode 100644
index eff661b..0000000
--- a/database/user.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package database
-
-import (
- "database/sql"
-
- "github.com/bwmarrin/discordgo"
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-type UserQuery struct {
- db *Database
- log log.Logger
-}
-
-func (uq *UserQuery) New() *User {
- return &User{
- db: uq.db,
- log: uq.log,
- }
-}
-
-func (uq *UserQuery) GetByMXID(userID id.UserID) *User {
- query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE mxid=$1`
- return uq.New().Scan(uq.db.QueryRow(query, userID))
-}
-
-func (uq *UserQuery) GetByID(id string) *User {
- query := `SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session FROM "user" WHERE dcid=$1`
- return uq.New().Scan(uq.db.QueryRow(query, id))
-}
-
-func (uq *UserQuery) GetAllWithToken() []*User {
- query := `
- SELECT mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session
- FROM "user" WHERE discord_token IS NOT NULL
- `
- rows, err := uq.db.Query(query)
- if err != nil || rows == nil {
- return nil
- }
-
- var users []*User
- for rows.Next() {
- user := uq.New().Scan(rows)
- if user != nil {
- users = append(users, user)
- }
- }
- return users
-}
-
-type User struct {
- db *Database
- log log.Logger
-
- MXID id.UserID
- DiscordID string
- DiscordToken string
- ManagementRoom id.RoomID
- SpaceRoom id.RoomID
- DMSpaceRoom id.RoomID
- HeartbeatSession *discordgo.HeartbeatSession
-
- ReadStateVersion int
-}
-
-func (u *User) Scan(row dbutil.Scannable) *User {
- var discordID, managementRoom, spaceRoom, dmSpaceRoom, discordToken sql.NullString
- err := row.Scan(&u.MXID, &discordID, &discordToken, &managementRoom, &spaceRoom, &dmSpaceRoom, &u.ReadStateVersion, dbutil.JSON{Data: &u.HeartbeatSession})
- if err != nil {
- if err != sql.ErrNoRows {
- u.log.Errorln("Database scan failed:", err)
- panic(err)
- }
- return nil
- }
- u.DiscordID = discordID.String
- u.DiscordToken = discordToken.String
- u.ManagementRoom = id.RoomID(managementRoom.String)
- u.SpaceRoom = id.RoomID(spaceRoom.String)
- u.DMSpaceRoom = id.RoomID(dmSpaceRoom.String)
- return u
-}
-
-func (u *User) Insert() {
- query := `INSERT INTO "user" (mxid, dcid, discord_token, management_room, space_room, dm_space_room, read_state_version, heartbeat_session) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`
- _, err := u.db.Exec(query, u.MXID, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession))
- if err != nil {
- u.log.Warnfln("Failed to insert %s: %v", u.MXID, err)
- panic(err)
- }
-}
-
-func (u *User) Update() {
- query := `UPDATE "user" SET dcid=$1, discord_token=$2, management_room=$3, space_room=$4, dm_space_room=$5, read_state_version=$6, heartbeat_session=$7 WHERE mxid=$8`
- _, err := u.db.Exec(query, strPtr(u.DiscordID), strPtr(u.DiscordToken), strPtr(string(u.ManagementRoom)), strPtr(string(u.SpaceRoom)), strPtr(string(u.DMSpaceRoom)), u.ReadStateVersion, JSONPtr(u.HeartbeatSession), u.MXID)
- if err != nil {
- u.log.Warnfln("Failed to update %q: %v", u.MXID, err)
- panic(err)
- }
-}
diff --git a/database/userportal.go b/database/userportal.go
deleted file mode 100644
index 783b83d..0000000
--- a/database/userportal.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package database
-
-import (
- "database/sql"
- "errors"
- "time"
-
- "go.mau.fi/util/dbutil"
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/mautrix/id"
-)
-
-const (
- UserPortalTypeDM = "dm"
- UserPortalTypeGuild = "guild"
- UserPortalTypeThread = "thread"
-)
-
-type UserPortal struct {
- DiscordID string
- Type string
- Timestamp time.Time
- InSpace bool
-}
-
-func (up UserPortal) Scan(l log.Logger, row dbutil.Scannable) *UserPortal {
- var ts int64
- err := row.Scan(&up.DiscordID, &up.Type, &ts, &up.InSpace)
- if err != nil {
- l.Errorln("Error scanning user portal:", err)
- panic(err)
- }
- up.Timestamp = time.UnixMilli(ts).UTC()
- return &up
-}
-
-func (u *User) scanUserPortals(rows dbutil.Rows) []UserPortal {
- var ups []UserPortal
- for rows.Next() {
- up := UserPortal{}.Scan(u.log, rows)
- if up != nil {
- ups = append(ups, *up)
- }
- }
- return ups
-}
-
-func (db *Database) GetUsersInPortal(channelID string) []id.UserID {
- rows, err := db.Query("SELECT user_mxid FROM user_portal WHERE discord_id=$1", channelID)
- if err != nil {
- db.Portal.log.Errorln("Failed to get users in portal:", err)
- }
- var users []id.UserID
- for rows.Next() {
- var mxid id.UserID
- err = rows.Scan(&mxid)
- if err != nil {
- db.Portal.log.Errorln("Failed to scan user in portal:", err)
- } else {
- users = append(users, mxid)
- }
- }
- return users
-}
-
-func (u *User) GetPortals() []UserPortal {
- rows, err := u.db.Query("SELECT discord_id, type, timestamp, in_space FROM user_portal WHERE user_mxid=$1", u.MXID)
- if err != nil {
- u.log.Errorln("Failed to get portals:", err)
- panic(err)
- }
- return u.scanUserPortals(rows)
-}
-
-func (u *User) IsInSpace(discordID string) (isIn bool) {
- query := `SELECT in_space FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
- err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
- u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
- panic(err)
- }
- return
-}
-
-func (u *User) IsInPortal(discordID string) (isIn bool) {
- query := `SELECT EXISTS(SELECT 1 FROM user_portal WHERE user_mxid=$1 AND discord_id=$2)`
- err := u.db.QueryRow(query, u.MXID, discordID).Scan(&isIn)
- if err != nil && !errors.Is(err, sql.ErrNoRows) {
- u.log.Warnfln("Failed to scan in_space for %s/%s: %v", u.MXID, discordID, err)
- panic(err)
- }
- return
-}
-
-func (u *User) MarkInPortal(portal UserPortal) {
- query := `
- INSERT INTO user_portal (discord_id, type, user_mxid, timestamp, in_space)
- VALUES ($1, $2, $3, $4, $5)
- ON CONFLICT (discord_id, user_mxid) DO UPDATE
- SET timestamp=excluded.timestamp, in_space=excluded.in_space
- `
- _, err := u.db.Exec(query, portal.DiscordID, portal.Type, u.MXID, portal.Timestamp.UnixMilli(), portal.InSpace)
- if err != nil {
- u.log.Errorfln("Failed to insert user portal %s/%s: %v", u.MXID, portal.DiscordID, err)
- panic(err)
- }
-}
-
-func (u *User) MarkNotInPortal(discordID string) {
- query := `DELETE FROM user_portal WHERE user_mxid=$1 AND discord_id=$2`
- _, err := u.db.Exec(query, u.MXID, discordID)
- if err != nil {
- u.log.Errorfln("Failed to remove user portal %s/%s: %v", u.MXID, discordID, err)
- panic(err)
- }
-}
-
-func (u *User) PortalHasOtherUsers(discordID string) (hasOtherUsers bool) {
- query := `SELECT COUNT(*) > 0 FROM user_portal WHERE user_mxid<>$1 AND discord_id=$2`
- err := u.db.QueryRow(query, u.MXID, discordID).Scan(&hasOtherUsers)
- if err != nil {
- u.log.Errorfln("Failed to check if %s has users other than %s: %v", discordID, u.MXID, err)
- panic(err)
- }
- return
-}
-
-func (u *User) PrunePortalList(beforeTS time.Time) []UserPortal {
- query := `
- DELETE FROM user_portal
- WHERE user_mxid=$1 AND timestamp<$2 AND type IN ('dm', 'guild')
- RETURNING discord_id, type, timestamp, in_space
- `
- rows, err := u.db.Query(query, u.MXID, beforeTS.UnixMilli())
- if err != nil {
- u.log.Errorln("Failed to prune user guild list:", err)
- panic(err)
- }
- return u.scanUserPortals(rows)
-}
diff --git a/directmedia.go b/directmedia.go
deleted file mode 100644
index 6b683aa..0000000
--- a/directmedia.go
+++ /dev/null
@@ -1,663 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "crypto/sha256"
- "encoding/binary"
- "encoding/hex"
- "errors"
- "fmt"
- "io"
- "mime"
- "mime/multipart"
- "net"
- "net/http"
- "net/textproto"
- "net/url"
- "os"
- "strconv"
- "strings"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/bwmarrin/discordgo"
- "github.com/gorilla/mux"
- "github.com/rs/zerolog"
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/federation"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
-)
-
-type DirectMediaAPI struct {
- bridge *DiscordBridge
- ks *federation.KeyServer
- cfg config.DirectMedia
- log zerolog.Logger
- proxy http.Client
-
- signatureKey [32]byte
-
- attachmentCache map[AttachmentCacheKey]AttachmentCacheValue
- attachmentCacheLock sync.Mutex
-}
-
-type AttachmentCacheKey struct {
- ChannelID uint64
- AttachmentID uint64
-}
-
-type AttachmentCacheValue struct {
- URL string
- Expiry time.Time
-}
-
-func newDirectMediaAPI(br *DiscordBridge) *DirectMediaAPI {
- if !br.Config.Bridge.DirectMedia.Enabled {
- return nil
- }
- dma := &DirectMediaAPI{
- bridge: br,
- cfg: br.Config.Bridge.DirectMedia,
- log: br.ZLog.With().Str("component", "direct media").Logger(),
- proxy: http.Client{
- Transport: &http.Transport{
- DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
- TLSHandshakeTimeout: 10 * time.Second,
- ForceAttemptHTTP2: false,
- },
- Timeout: 60 * time.Second,
- },
- attachmentCache: make(map[AttachmentCacheKey]AttachmentCacheValue),
- }
- r := br.AS.Router
-
- parsed, err := federation.ParseSynapseKey(dma.cfg.ServerKey)
- if err != nil {
- dma.log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to parse server key")
- os.Exit(11)
- return nil
- }
- dma.signatureKey = sha256.Sum256(parsed.Priv.Seed())
- dma.ks = &federation.KeyServer{
- KeyProvider: &federation.StaticServerKey{
- ServerName: dma.cfg.ServerName,
- Key: parsed,
- },
- WellKnownTarget: dma.cfg.WellKnownResponse,
- Version: federation.ServerVersion{
- Name: br.Name,
- Version: br.Version,
- },
- }
- if dma.ks.WellKnownTarget == "" {
- dma.ks.WellKnownTarget = fmt.Sprintf("%s:443", dma.cfg.ServerName)
- }
- federationRouter := r.PathPrefix("/_matrix/federation").Subrouter()
- mediaRouter := r.PathPrefix("/_matrix/media").Subrouter()
- clientMediaRouter := r.PathPrefix("/_matrix/client/v1/media").Subrouter()
- var reqIDCounter atomic.Uint64
- middleware := func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Access-Control-Allow-Origin", "*")
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
- w.Header().Set("Access-Control-Allow-Headers", "X-Requested-With, Content-Type, Authorization")
- log := dma.log.With().
- Str("remote_addr", r.RemoteAddr).
- Str("request_path", r.URL.Path).
- Uint64("req_id", reqIDCounter.Add(1)).
- Logger()
- next.ServeHTTP(w, r.WithContext(log.WithContext(r.Context())))
- })
- }
- mediaRouter.Use(middleware)
- federationRouter.Use(middleware)
- clientMediaRouter.Use(middleware)
- addRoutes := func(version string) {
- mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
- mediaRouter.HandleFunc("/"+version+"/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet)
- mediaRouter.HandleFunc("/"+version+"/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
- mediaRouter.HandleFunc("/"+version+"/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut)
- mediaRouter.HandleFunc("/"+version+"/upload", dma.UploadNotSupported).Methods(http.MethodPost)
- mediaRouter.HandleFunc("/"+version+"/create", dma.UploadNotSupported).Methods(http.MethodPost)
- mediaRouter.HandleFunc("/"+version+"/config", dma.UploadNotSupported).Methods(http.MethodGet)
- mediaRouter.HandleFunc("/"+version+"/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet)
- }
- clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
- clientMediaRouter.HandleFunc("/download/{serverName}/{mediaID}/{fileName}", dma.DownloadMedia).Methods(http.MethodGet)
- clientMediaRouter.HandleFunc("/thumbnail/{serverName}/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
- clientMediaRouter.HandleFunc("/upload/{serverName}/{mediaID}", dma.UploadNotSupported).Methods(http.MethodPut)
- clientMediaRouter.HandleFunc("/upload", dma.UploadNotSupported).Methods(http.MethodPost)
- clientMediaRouter.HandleFunc("/create", dma.UploadNotSupported).Methods(http.MethodPost)
- clientMediaRouter.HandleFunc("/config", dma.UploadNotSupported).Methods(http.MethodGet)
- clientMediaRouter.HandleFunc("/preview_url", dma.PreviewURLNotSupported).Methods(http.MethodGet)
- addRoutes("v3")
- addRoutes("r0")
- addRoutes("v1")
- federationRouter.HandleFunc("/v1/media/download/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
- federationRouter.HandleFunc("/v1/media/thumbnail/{mediaID}", dma.DownloadMedia).Methods(http.MethodGet)
- federationRouter.HandleFunc("/v1/version", dma.ks.GetServerVersion).Methods(http.MethodGet)
- mediaRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
- mediaRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
- federationRouter.NotFoundHandler = http.HandlerFunc(dma.UnknownEndpoint)
- federationRouter.MethodNotAllowedHandler = http.HandlerFunc(dma.UnsupportedMethod)
- dma.ks.Register(r)
-
- return dma
-}
-
-func (dma *DirectMediaAPI) makeMXC(data MediaIDData) id.ContentURI {
- return id.ContentURI{
- Homeserver: dma.cfg.ServerName,
- FileID: data.Wrap().SignedString(dma.signatureKey),
- }
-}
-
-func parseExpiryTS(addr string) time.Time {
- parsedURL, err := url.Parse(addr)
- if err != nil {
- return time.Time{}
- }
- tsBytes, err := hex.DecodeString(parsedURL.Query().Get("ex"))
- if err != nil || len(tsBytes) != 4 {
- return time.Time{}
- }
- parsedTS := int64(binary.BigEndian.Uint32(tsBytes))
- if parsedTS > time.Now().Unix() && parsedTS < time.Now().Add(365*24*time.Hour).Unix() {
- return time.Unix(parsedTS, 0)
- }
- return time.Time{}
-}
-
-func (dma *DirectMediaAPI) addAttachmentToCache(channelID uint64, att *discordgo.MessageAttachment) time.Time {
- attachmentID, err := strconv.ParseUint(att.ID, 10, 64)
- if err != nil {
- return time.Time{}
- }
- expiry := parseExpiryTS(att.URL)
- if expiry.IsZero() {
- expiry = time.Now().Add(24 * time.Hour)
- }
- dma.attachmentCache[AttachmentCacheKey{
- ChannelID: channelID,
- AttachmentID: attachmentID,
- }] = AttachmentCacheValue{
- URL: att.URL,
- Expiry: expiry,
- }
- return expiry
-}
-
-func (dma *DirectMediaAPI) AttachmentMXC(channelID, messageID string, att *discordgo.MessageAttachment) (mxc id.ContentURI) {
- if dma == nil {
- return
- }
- channelIDInt, err := strconv.ParseUint(channelID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("channel_id", channelID).Msg("Got non-integer channel ID")
- return
- }
- messageIDInt, err := strconv.ParseUint(messageID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("message_id", messageID).Msg("Got non-integer message ID")
- return
- }
- attachmentIDInt, err := strconv.ParseUint(att.ID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("attachment_id", att.ID).Msg("Got non-integer attachment ID")
- return
- }
- dma.attachmentCacheLock.Lock()
- dma.addAttachmentToCache(channelIDInt, att)
- dma.attachmentCacheLock.Unlock()
- return dma.makeMXC(&AttachmentMediaData{
- ChannelID: channelIDInt,
- MessageID: messageIDInt,
- AttachmentID: attachmentIDInt,
- })
-}
-
-func (dma *DirectMediaAPI) EmojiMXC(emojiID, name string, animated bool) (mxc id.ContentURI) {
- if dma == nil {
- return
- }
- emojiIDInt, err := strconv.ParseUint(emojiID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("emoji_id", emojiID).Msg("Got non-integer emoji ID")
- return
- }
- return dma.makeMXC(&EmojiMediaData{
- EmojiMediaDataInner: EmojiMediaDataInner{
- EmojiID: emojiIDInt,
- Animated: animated,
- },
- Name: name,
- })
-}
-
-func (dma *DirectMediaAPI) StickerMXC(stickerID string, format discordgo.StickerFormat) (mxc id.ContentURI) {
- if dma == nil {
- return
- }
- stickerIDInt, err := strconv.ParseUint(stickerID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("sticker_id", stickerID).Msg("Got non-integer sticker ID")
- return
- } else if format > 255 || format < 0 {
- dma.log.Warn().Int("format", int(format)).Msg("Got invalid sticker format")
- return
- }
- return dma.makeMXC(&StickerMediaData{
- StickerID: stickerIDInt,
- Format: byte(format),
- })
-}
-
-func (dma *DirectMediaAPI) AvatarMXC(guildID, userID, avatarID string) (mxc id.ContentURI) {
- if dma == nil {
- return
- }
- animated := strings.HasPrefix(avatarID, "a_")
- avatarIDBytes, err := hex.DecodeString(strings.TrimPrefix(avatarID, "a_"))
- if err != nil {
- dma.log.Warn().Str("avatar_id", avatarID).Msg("Got non-hex avatar ID")
- return
- } else if len(avatarIDBytes) != 16 {
- dma.log.Warn().Str("avatar_id", avatarID).Msg("Got invalid avatar ID length")
- return
- }
- avatarIDArray := [16]byte(avatarIDBytes)
- userIDInt, err := strconv.ParseUint(userID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("user_id", userID).Msg("Got non-integer user ID")
- return
- }
- if guildID != "" {
- guildIDInt, err := strconv.ParseUint(guildID, 10, 64)
- if err != nil {
- dma.log.Warn().Str("guild_id", guildID).Msg("Got non-integer guild ID")
- return
- }
- return dma.makeMXC(&GuildMemberAvatarMediaData{
- GuildID: guildIDInt,
- UserID: userIDInt,
- AvatarID: avatarIDArray,
- Animated: animated,
- })
- } else {
- return dma.makeMXC(&UserAvatarMediaData{
- UserID: userIDInt,
- AvatarID: avatarIDArray,
- Animated: animated,
- })
- }
-}
-
-type RespError struct {
- Code string
- Message string
- Status int
-}
-
-func (re *RespError) Error() string {
- return re.Message
-}
-
-var ErrNoUsersWithAccessFound = errors.New("no users found to fetch message")
-var ErrAttachmentNotFound = errors.New("attachment not found")
-
-func (dma *DirectMediaAPI) fetchNewAttachmentURL(ctx context.Context, meta *AttachmentMediaData) (string, time.Time, error) {
- var client *discordgo.Session
- channelIDStr := strconv.FormatUint(meta.ChannelID, 10)
- portal := dma.bridge.GetExistingPortalByID(database.PortalKey{ChannelID: channelIDStr})
- var users []id.UserID
- if portal != nil && portal.GuildID != "" {
- users = dma.bridge.DB.GetUsersInPortal(portal.GuildID)
- } else {
- users = dma.bridge.DB.GetUsersInPortal(channelIDStr)
- }
- for _, userID := range users {
- user := dma.bridge.GetCachedUserByMXID(userID)
- if user == nil || user.Session == nil {
- continue
- }
- perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channelIDStr)
- if err == nil && perms&discordgo.PermissionViewChannel == 0 {
- continue
- }
- if client == nil || err == nil {
- client = user.Session
- if !client.IsUser {
- break
- }
- }
- }
- if client == nil {
- return "", time.Time{}, ErrNoUsersWithAccessFound
- }
- var msgs []*discordgo.Message
- var err error
- messageIDStr := strconv.FormatUint(meta.MessageID, 10)
- if client.IsUser {
- var refs []discordgo.RequestOption
- if portal != nil {
- refs = append(refs, discordgo.WithChannelReferer(portal.GuildID, channelIDStr))
- }
- msgs, err = client.ChannelMessages(channelIDStr, 5, "", "", messageIDStr, refs...)
- } else {
- var msg *discordgo.Message
- msg, err = client.ChannelMessage(channelIDStr, messageIDStr)
- msgs = []*discordgo.Message{msg}
- }
- if err != nil {
- return "", time.Time{}, fmt.Errorf("failed to fetch message: %w", err)
- }
- attachmentIDStr := strconv.FormatUint(meta.AttachmentID, 10)
- var url string
- var expiry time.Time
- for _, item := range msgs {
- for _, att := range item.Attachments {
- thisExpiry := dma.addAttachmentToCache(meta.ChannelID, att)
- if att.ID == attachmentIDStr {
- url = att.URL
- expiry = thisExpiry
- }
- }
- }
- if url == "" {
- return "", time.Time{}, ErrAttachmentNotFound
- }
- return url, expiry, nil
-}
-
-func (dma *DirectMediaAPI) GetEmojiInfo(contentURI id.ContentURI) *EmojiMediaData {
- if dma == nil || contentURI.IsEmpty() || contentURI.Homeserver != dma.cfg.ServerName {
- return nil
- }
- mediaID, err := ParseMediaID(contentURI.FileID, dma.signatureKey)
- if err != nil {
- return nil
- }
- emojiData, ok := mediaID.Data.(*EmojiMediaData)
- if !ok {
- return nil
- }
- return emojiData
-
-}
-
-func (dma *DirectMediaAPI) getMediaURL(ctx context.Context, encodedMediaID string) (url string, expiry time.Time, err error) {
- var mediaID *MediaID
- mediaID, err = ParseMediaID(encodedMediaID, dma.signatureKey)
- if err != nil {
- err = &RespError{
- Code: mautrix.MNotFound.ErrCode,
- Message: err.Error(),
- Status: http.StatusNotFound,
- }
- return
- }
- switch mediaData := mediaID.Data.(type) {
- case *AttachmentMediaData:
- dma.attachmentCacheLock.Lock()
- defer dma.attachmentCacheLock.Unlock()
- cached, ok := dma.attachmentCache[mediaData.CacheKey()]
- if ok && time.Until(cached.Expiry) > 5*time.Minute {
- return cached.URL, cached.Expiry, nil
- }
- zerolog.Ctx(ctx).Debug().
- Uint64("channel_id", mediaData.ChannelID).
- Uint64("message_id", mediaData.MessageID).
- Uint64("attachment_id", mediaData.AttachmentID).
- Msg("Refreshing attachment URL")
- url, expiry, err = dma.fetchNewAttachmentURL(ctx, mediaData)
- if err != nil {
- zerolog.Ctx(ctx).Err(err).Msg("Failed to refresh attachment URL")
- msg := "Failed to refresh attachment URL"
- if errors.Is(err, ErrNoUsersWithAccessFound) {
- msg = "No users found with access to the channel"
- } else if errors.Is(err, ErrAttachmentNotFound) {
- msg = "Attachment not found in message. Perhaps it was deleted?"
- }
- err = &RespError{
- Code: mautrix.MNotFound.ErrCode,
- Message: msg,
- Status: http.StatusNotFound,
- }
- } else {
- zerolog.Ctx(ctx).Debug().Time("expiry", expiry).Msg("Successfully refreshed attachment URL")
- }
- case *EmojiMediaData:
- if mediaData.Animated {
- url = discordgo.EndpointEmojiAnimated(strconv.FormatUint(mediaData.EmojiID, 10))
- } else {
- url = discordgo.EndpointEmoji(strconv.FormatUint(mediaData.EmojiID, 10))
- }
- case *StickerMediaData:
- url = discordgo.EndpointStickerImage(
- strconv.FormatUint(mediaData.StickerID, 10),
- discordgo.StickerFormat(mediaData.Format),
- )
- case *UserAvatarMediaData:
- if mediaData.Animated {
- url = discordgo.EndpointUserAvatarAnimated(
- strconv.FormatUint(mediaData.UserID, 10),
- fmt.Sprintf("a_%x", mediaData.AvatarID),
- )
- } else {
- url = discordgo.EndpointUserAvatar(
- strconv.FormatUint(mediaData.UserID, 10),
- fmt.Sprintf("%x", mediaData.AvatarID),
- )
- }
- case *GuildMemberAvatarMediaData:
- if mediaData.Animated {
- url = discordgo.EndpointGuildMemberAvatarAnimated(
- strconv.FormatUint(mediaData.GuildID, 10),
- strconv.FormatUint(mediaData.UserID, 10),
- fmt.Sprintf("a_%x", mediaData.AvatarID),
- )
- } else {
- url = discordgo.EndpointGuildMemberAvatar(
- strconv.FormatUint(mediaData.GuildID, 10),
- strconv.FormatUint(mediaData.UserID, 10),
- fmt.Sprintf("%x", mediaData.AvatarID),
- )
- }
- default:
- zerolog.Ctx(ctx).Error().Type("media_data_type", mediaData).Msg("Unrecognized media data struct")
- err = &RespError{
- Code: "M_UNKNOWN",
- Message: "Unrecognized media data struct",
- Status: http.StatusInternalServerError,
- }
- }
- return
-}
-
-func (dma *DirectMediaAPI) proxyDownload(ctx context.Context, w http.ResponseWriter, url, fileName string) {
- log := zerolog.Ctx(ctx)
- req, err := http.NewRequest(http.MethodGet, url, nil)
- if err != nil {
- log.Err(err).Str("url", url).Msg("Failed to create proxy request")
- jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{
- ErrCode: "M_UNKNOWN",
- Err: "Failed to create proxy request",
- })
- return
- }
- for key, val := range discordgo.DroidDownloadHeaders {
- req.Header.Set(key, val)
- }
- resp, err := dma.proxy.Do(req)
- defer func() {
- if resp != nil && resp.Body != nil {
- _ = resp.Body.Close()
- }
- }()
- if err != nil {
- log.Err(err).Str("url", url).Msg("Failed to proxy download")
- jsonResponse(w, http.StatusServiceUnavailable, &mautrix.RespError{
- ErrCode: "M_UNKNOWN",
- Err: "Failed to proxy download",
- })
- return
- } else if resp.StatusCode != http.StatusOK {
- log.Warn().Str("url", url).Int("status", resp.StatusCode).Msg("Unexpected status code proxying download")
- jsonResponse(w, resp.StatusCode, &mautrix.RespError{
- ErrCode: "M_UNKNOWN",
- Err: "Unexpected status code proxying download",
- })
- return
- }
- w.Header()["Content-Type"] = resp.Header["Content-Type"]
- w.Header()["Content-Length"] = resp.Header["Content-Length"]
- w.Header()["Last-Modified"] = resp.Header["Last-Modified"]
- w.Header()["Cache-Control"] = resp.Header["Cache-Control"]
- contentDisposition := "attachment"
- switch resp.Header.Get("Content-Type") {
- case "text/css", "text/plain", "text/csv", "application/json", "application/ld+json", "image/jpeg", "image/gif",
- "image/png", "image/apng", "image/webp", "image/avif", "video/mp4", "video/webm", "video/ogg", "video/quicktime",
- "audio/mp4", "audio/webm", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wave", "audio/wav", "audio/x-wav",
- "audio/x-pn-wav", "audio/flac", "audio/x-flac", "application/pdf":
- contentDisposition = "inline"
- }
- if fileName != "" {
- contentDisposition = mime.FormatMediaType(contentDisposition, map[string]string{
- "filename": fileName,
- })
- }
- w.Header().Set("Content-Disposition", contentDisposition)
- w.WriteHeader(http.StatusOK)
- _, err = io.Copy(w, resp.Body)
- if err != nil {
- log.Debug().Err(err).Msg("Failed to write proxy response")
- }
-}
-
-func (dma *DirectMediaAPI) DownloadMedia(w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- log := zerolog.Ctx(ctx)
- isNewFederation := strings.HasPrefix(r.URL.Path, "/_matrix/federation/v1/media/")
- vars := mux.Vars(r)
- if !isNewFederation && vars["serverName"] != dma.cfg.ServerName {
- jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
- ErrCode: mautrix.MNotFound.ErrCode,
- Err: fmt.Sprintf("This is a Discord media proxy for %q, other media downloads are not available here", dma.cfg.ServerName),
- })
- return
- }
- // TODO check destination header in X-Matrix auth when isNewFederation
-
- url, expiresAt, err := dma.getMediaURL(ctx, vars["mediaID"])
- if err != nil {
- var respError *RespError
- if errors.As(err, &respError) {
- jsonResponse(w, respError.Status, &mautrix.RespError{
- ErrCode: respError.Code,
- Err: respError.Message,
- })
- } else {
- log.Err(err).Str("media_id", vars["mediaID"]).Msg("Failed to get media URL")
- jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
- ErrCode: mautrix.MNotFound.ErrCode,
- Err: "Media not found",
- })
- }
- return
- }
- if isNewFederation {
- mp := multipart.NewWriter(w)
- w.Header().Set("Content-Type", strings.Replace(mp.FormDataContentType(), "form-data", "mixed", 1))
- var metaPart io.Writer
- metaPart, err = mp.CreatePart(textproto.MIMEHeader{
- "Content-Type": {"application/json"},
- })
- if err != nil {
- log.Err(err).Msg("Failed to create multipart metadata field")
- return
- }
- _, err = metaPart.Write([]byte(`{}`))
- if err != nil {
- log.Err(err).Msg("Failed to write multipart metadata field")
- return
- }
- _, err = mp.CreatePart(textproto.MIMEHeader{
- "Location": {url},
- })
- if err != nil {
- log.Err(err).Msg("Failed to create multipart redirect field")
- return
- }
- err = mp.Close()
- if err != nil {
- log.Err(err).Msg("Failed to close multipart writer")
- return
- }
- return
- }
- // Proxy if the config allows proxying and the request doesn't allow redirects.
- // In any other case, redirect to the Discord CDN.
- if dma.cfg.AllowProxy && r.URL.Query().Get("allow_redirect") != "true" {
- dma.proxyDownload(ctx, w, url, vars["fileName"])
- return
- }
- w.Header().Set("Location", url)
- expirySeconds := (time.Until(expiresAt) - 5*time.Minute).Seconds()
- if expiresAt.IsZero() {
- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
- } else if expirySeconds > 0 {
- cacheControl := fmt.Sprintf("public, max-age=%d, immutable", int(expirySeconds))
- w.Header().Set("Cache-Control", cacheControl)
- } else {
- w.Header().Set("Cache-Control", "no-store")
- }
- w.WriteHeader(http.StatusTemporaryRedirect)
-}
-
-func (dma *DirectMediaAPI) UploadNotSupported(w http.ResponseWriter, r *http.Request) {
- jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
- ErrCode: mautrix.MUnrecognized.ErrCode,
- Err: "This bridge only supports proxying Discord media downloads and does not support media uploads.",
- })
-}
-
-func (dma *DirectMediaAPI) PreviewURLNotSupported(w http.ResponseWriter, r *http.Request) {
- jsonResponse(w, http.StatusNotImplemented, &mautrix.RespError{
- ErrCode: mautrix.MUnrecognized.ErrCode,
- Err: "This bridge only supports proxying Discord media downloads and does not support URL previews.",
- })
-}
-
-func (dma *DirectMediaAPI) UnknownEndpoint(w http.ResponseWriter, r *http.Request) {
- jsonResponse(w, http.StatusNotFound, &mautrix.RespError{
- ErrCode: mautrix.MUnrecognized.ErrCode,
- Err: "Unrecognized endpoint",
- })
-}
-
-func (dma *DirectMediaAPI) UnsupportedMethod(w http.ResponseWriter, r *http.Request) {
- jsonResponse(w, http.StatusMethodNotAllowed, &mautrix.RespError{
- ErrCode: mautrix.MUnrecognized.ErrCode,
- Err: "Invalid method for endpoint",
- })
-}
diff --git a/directmedia_id.go b/directmedia_id.go
deleted file mode 100644
index 92b935a..0000000
--- a/directmedia_id.go
+++ /dev/null
@@ -1,287 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2024 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "bytes"
- "crypto/hmac"
- "crypto/sha256"
- "encoding/base64"
- "encoding/binary"
- "errors"
- "fmt"
- "io"
-)
-
-const MediaIDPrefix = "\U0001F408DISCORD"
-const MediaIDVersion = 1
-
-type MediaIDClass uint8
-
-const (
- MediaIDClassAttachment MediaIDClass = 1
- MediaIDClassEmoji MediaIDClass = 2
- MediaIDClassSticker MediaIDClass = 3
- MediaIDClassUserAvatar MediaIDClass = 4
- MediaIDClassGuildMemberAvatar MediaIDClass = 5
-)
-
-type MediaIDData interface {
- Write(to io.Writer)
- Read(from io.Reader) error
- Size() int
- Wrap() *MediaID
-}
-
-type MediaID struct {
- Version uint8
- TypeClass MediaIDClass
- Data MediaIDData
-}
-
-func ParseMediaID(id string, key [32]byte) (*MediaID, error) {
- data, err := base64.RawURLEncoding.DecodeString(id)
- if err != nil {
- return nil, fmt.Errorf("failed to decode base64: %w", err)
- }
- hasher := hmac.New(sha256.New, key[:])
- checksum := data[len(data)-TruncatedHashLength:]
- data = data[:len(data)-TruncatedHashLength]
- hasher.Write(data)
- if !hmac.Equal(checksum, hasher.Sum(nil)[:TruncatedHashLength]) {
- return nil, ErrMediaIDChecksumMismatch
- }
- mid := &MediaID{}
- err = mid.Read(bytes.NewReader(data))
- if err != nil {
- return nil, fmt.Errorf("failed to parse media ID: %w", err)
- }
- return mid, nil
-}
-
-const TruncatedHashLength = 16
-
-func (mid *MediaID) SignedString(key [32]byte) string {
- buf := bytes.NewBuffer(make([]byte, 0, mid.Size()))
- mid.Write(buf)
- hasher := hmac.New(sha256.New, key[:])
- hasher.Write(buf.Bytes())
- buf.Write(hasher.Sum(nil)[:TruncatedHashLength])
- return base64.RawURLEncoding.EncodeToString(buf.Bytes())
-}
-
-func (mid *MediaID) Write(to io.Writer) {
- _, _ = to.Write([]byte(MediaIDPrefix))
- _ = binary.Write(to, binary.BigEndian, mid.Version)
- _ = binary.Write(to, binary.BigEndian, mid.TypeClass)
- mid.Data.Write(to)
-}
-
-func (mid *MediaID) Size() int {
- return len(MediaIDPrefix) + 2 + mid.Data.Size() + TruncatedHashLength
-}
-
-var (
- ErrInvalidMediaID = errors.New("invalid media ID")
- ErrMediaIDChecksumMismatch = errors.New("invalid checksum in media ID")
- ErrUnsupportedMediaID = errors.New("unsupported media ID")
-)
-
-func (mid *MediaID) Read(from io.Reader) error {
- prefix := make([]byte, len(MediaIDPrefix))
- _, err := io.ReadFull(from, prefix)
- if err != nil || !bytes.Equal(prefix, []byte(MediaIDPrefix)) {
- return fmt.Errorf("%w: prefix not found", ErrInvalidMediaID)
- }
- versionAndClass := make([]byte, 2)
- _, err = io.ReadFull(from, versionAndClass)
- if err != nil {
- return fmt.Errorf("%w: version and class not found", ErrInvalidMediaID)
- } else if versionAndClass[0] != MediaIDVersion {
- return fmt.Errorf("%w: unknown version %d", ErrUnsupportedMediaID, versionAndClass[0])
- }
- switch MediaIDClass(versionAndClass[1]) {
- case MediaIDClassAttachment:
- mid.Data = &AttachmentMediaData{}
- case MediaIDClassEmoji:
- mid.Data = &EmojiMediaData{}
- case MediaIDClassSticker:
- mid.Data = &StickerMediaData{}
- case MediaIDClassUserAvatar:
- mid.Data = &UserAvatarMediaData{}
- case MediaIDClassGuildMemberAvatar:
- mid.Data = &GuildMemberAvatarMediaData{}
- default:
- return fmt.Errorf("%w: unrecognized type class %d", ErrUnsupportedMediaID, versionAndClass[1])
- }
- err = mid.Data.Read(from)
- if err != nil {
- return fmt.Errorf("failed to parse media ID data: %w", err)
- }
- return nil
-}
-
-type AttachmentMediaData struct {
- ChannelID uint64
- MessageID uint64
- AttachmentID uint64
-}
-
-func (amd *AttachmentMediaData) Write(to io.Writer) {
- _ = binary.Write(to, binary.BigEndian, amd)
-}
-
-func (amd *AttachmentMediaData) Read(from io.Reader) (err error) {
- return binary.Read(from, binary.BigEndian, amd)
-}
-
-func (amd *AttachmentMediaData) Size() int {
- return binary.Size(amd)
-}
-
-func (amd *AttachmentMediaData) Wrap() *MediaID {
- return &MediaID{
- Version: MediaIDVersion,
- TypeClass: MediaIDClassAttachment,
- Data: amd,
- }
-}
-
-func (amd *AttachmentMediaData) CacheKey() AttachmentCacheKey {
- return AttachmentCacheKey{
- ChannelID: amd.ChannelID,
- AttachmentID: amd.AttachmentID,
- }
-}
-
-type StickerMediaData struct {
- StickerID uint64
- Format uint8
-}
-
-func (smd *StickerMediaData) Write(to io.Writer) {
- _ = binary.Write(to, binary.BigEndian, smd)
-}
-
-func (smd *StickerMediaData) Read(from io.Reader) error {
- return binary.Read(from, binary.BigEndian, smd)
-}
-
-func (smd *StickerMediaData) Size() int {
- return binary.Size(smd)
-}
-
-func (smd *StickerMediaData) Wrap() *MediaID {
- return &MediaID{
- Version: MediaIDVersion,
- TypeClass: MediaIDClassSticker,
- Data: smd,
- }
-}
-
-type EmojiMediaDataInner struct {
- EmojiID uint64
- Animated bool
-}
-
-type EmojiMediaData struct {
- EmojiMediaDataInner
- Name string
-}
-
-func (emd *EmojiMediaData) Write(to io.Writer) {
- _ = binary.Write(to, binary.BigEndian, &emd.EmojiMediaDataInner)
- _, _ = to.Write([]byte(emd.Name))
-}
-
-func (emd *EmojiMediaData) Read(from io.Reader) (err error) {
- err = binary.Read(from, binary.BigEndian, &emd.EmojiMediaDataInner)
- if err != nil {
- return
- }
- name, err := io.ReadAll(from)
- if err != nil {
- return
- }
- emd.Name = string(name)
- return
-}
-
-func (emd *EmojiMediaData) Size() int {
- return binary.Size(&emd.EmojiMediaDataInner) + len(emd.Name)
-}
-
-func (emd *EmojiMediaData) Wrap() *MediaID {
- return &MediaID{
- Version: MediaIDVersion,
- TypeClass: MediaIDClassEmoji,
- Data: emd,
- }
-}
-
-type UserAvatarMediaData struct {
- UserID uint64
- Animated bool
- AvatarID [16]byte
-}
-
-func (uamd *UserAvatarMediaData) Write(to io.Writer) {
- _ = binary.Write(to, binary.BigEndian, uamd)
-}
-
-func (uamd *UserAvatarMediaData) Read(from io.Reader) error {
- return binary.Read(from, binary.BigEndian, uamd)
-}
-
-func (uamd *UserAvatarMediaData) Size() int {
- return binary.Size(uamd)
-}
-
-func (uamd *UserAvatarMediaData) Wrap() *MediaID {
- return &MediaID{
- Version: MediaIDVersion,
- TypeClass: MediaIDClassUserAvatar,
- Data: uamd,
- }
-}
-
-type GuildMemberAvatarMediaData struct {
- GuildID uint64
- UserID uint64
- Animated bool
- AvatarID [16]byte
-}
-
-func (guamd *GuildMemberAvatarMediaData) Write(to io.Writer) {
- _ = binary.Write(to, binary.BigEndian, guamd)
-}
-
-func (guamd *GuildMemberAvatarMediaData) Read(from io.Reader) error {
- return binary.Read(from, binary.BigEndian, guamd)
-}
-
-func (guamd *GuildMemberAvatarMediaData) Size() int {
- return binary.Size(guamd)
-}
-
-func (guamd *GuildMemberAvatarMediaData) Wrap() *MediaID {
- return &MediaID{
- Version: MediaIDVersion,
- TypeClass: MediaIDClassGuildMemberAvatar,
- Data: guamd,
- }
-}
diff --git a/discord.go b/discord.go
deleted file mode 100644
index 37cddbc..0000000
--- a/discord.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package main
-
-import (
- "errors"
-
- "github.com/bwmarrin/discordgo"
-)
-
-func (user *User) channelIsBridgeable(channel *discordgo.Channel) bool {
- switch channel.Type {
- case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews:
- // allowed
- case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM:
- // DMs are always bridgeable, no need for permission checks
- return true
- default:
- // everything else is not allowed
- return false
- }
-
- log := user.log.With().Str("guild_id", channel.GuildID).Str("channel_id", channel.ID).Logger()
-
- member, err := user.Session.State.Member(channel.GuildID, user.DiscordID)
- if errors.Is(err, discordgo.ErrStateNotFound) {
- log.Debug().Msg("Fetching own membership in guild to check roles")
- member, err = user.Session.GuildMember(channel.GuildID, user.DiscordID)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to get own membership in guild from server")
- } else {
- err = user.Session.State.MemberAdd(member)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to add own membership in guild to cache")
- }
- }
- } else if err != nil {
- log.Warn().Err(err).Msg("Failed to get own membership in guild from cache")
- }
- err = user.Session.State.ChannelAdd(channel)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to add channel to cache")
- }
- perms, err := user.Session.State.UserChannelPermissions(user.DiscordID, channel.ID)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to get permissions in channel to determine if it's bridgeable")
- return true
- }
- log.Debug().
- Int64("permissions", perms).
- Bool("view_channel", perms&discordgo.PermissionViewChannel > 0).
- Msg("Computed permissions in channel")
- return perms&discordgo.PermissionViewChannel > 0
-}
diff --git a/docker-run.sh b/docker-run.sh
index 054a636..f4a5630 100755
--- a/docker-run.sh
+++ b/docker-run.sh
@@ -15,7 +15,7 @@ function fixperms {
}
if [[ ! -f /data/config.yaml ]]; then
- cp /opt/mautrix-discord/example-config.yaml /data/config.yaml
+ /usr/bin/mautrix-discord -c /data/config.yaml -e
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."
diff --git a/example-config.yaml b/example-config.yaml
deleted file mode 100644
index 0c1ab13..0000000
--- a/example-config.yaml
+++ /dev/null
@@ -1,385 +0,0 @@
-# Homeserver details.
-homeserver:
- # The address that this appservice can use to connect to the homeserver.
- address: https://matrix.example.com
- # The domain of the homeserver (also known as server_name, used for MXIDs, etc).
- domain: example.com
-
- # What software is the homeserver running?
- # Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
- software: standard
- # The URL to push real-time bridge status to.
- # If set, the bridge will make POST requests to this URL whenever a user's discord connection state changes.
- # The bridge will use the appservice as_token to authorize requests.
- status_endpoint: null
- # Endpoint for reporting per-message status.
- message_send_checkpoint_endpoint: null
- # Does the homeserver support https://github.com/matrix-org/matrix-spec-proposals/pull/2246?
- async_media: false
-
- # Should the bridge use a websocket for connecting to the homeserver?
- # The server side is currently not documented anywhere and is only implemented by mautrix-wsproxy,
- # mautrix-asmux (deprecated), and hungryserv (proprietary).
- websocket: false
- # How often should the websocket be pinged? Pinging will be disabled if this is zero.
- ping_interval_seconds: 0
-
-# Application service host/registration related details.
-# Changing these values requires regeneration of the registration.
-appservice:
- # The address that the homeserver can use to connect to this appservice.
- address: http://localhost:29334
-
- # The hostname and port where this appservice should listen.
- hostname: 0.0.0.0
- port: 29334
-
- # Database config.
- database:
- # The database type. "sqlite3-fk-wal" and "postgres" are supported.
- type: postgres
- # The database URI.
- # SQLite: A raw file path is supported, but `file:?_txlock=immediate` is recommended.
- # https://github.com/mattn/go-sqlite3#connection-string
- # Postgres: Connection string. For example, postgres://user:password@host/database?sslmode=disable
- # To connect via Unix socket, use something like postgres:///dbname?host=/var/run/postgresql
- uri: postgres://user:password@host/database?sslmode=disable
- # Maximum number of connections. Mostly relevant for Postgres.
- max_open_conns: 20
- max_idle_conns: 2
- # Maximum connection idle time and lifetime before they're closed. Disabled if null.
- # Parsed with https://pkg.go.dev/time#ParseDuration
- max_conn_idle_time: null
- max_conn_lifetime: null
-
- # The unique ID of this appservice.
- id: discord
- # Appservice bot details.
- bot:
- # Username of the appservice bot.
- username: discordbot
- # Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
- # to leave display name/avatar as-is.
- displayname: Discord bridge bot
- avatar: mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC
-
- # Whether or not to receive ephemeral events via appservice transactions.
- # Requires MSC2409 support (i.e. Synapse 1.22+).
- ephemeral_events: true
-
- # Should incoming events be handled asynchronously?
- # This may be necessary for large public instances with lots of messages going through.
- # However, messages will not be guaranteed to be bridged in the same order they were sent in.
- async_transactions: false
-
- # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
- as_token: "This value is generated when generating the registration"
- hs_token: "This value is generated when generating the registration"
-
-# Bridge config
-bridge:
- # Localpart template of MXIDs for Discord users.
- # {{.}} is replaced with the internal ID of the Discord user.
- username_template: discord_{{.}}
- # Displayname template for Discord users. This is also used as the room name in DMs if private_chat_portal_meta is enabled.
- # Available variables:
- # .ID - Internal user ID
- # .Username - Legacy display/username on Discord
- # .GlobalName - New displayname on Discord
- # .Discriminator - The 4 numbers after the name on Discord
- # .Bot - Whether the user is a bot
- # .System - Whether the user is an official system user
- # .Webhook - Whether the user is a webhook and is not an application
- # .Application - Whether the user is an application
- displayname_template: '{{if .Webhook}}Webhook{{else}}{{or .GlobalName .Username}}{{if .Bot}} (bot){{end}}{{end}}'
- # Displayname template for Discord channels (bridged as rooms, or spaces when type=4).
- # Available variables:
- # .Name - Channel name, or user displayname (pre-formatted with displayname_template) in DMs.
- # .ParentName - Parent channel name (used for categories).
- # .GuildName - Guild name.
- # .NSFW - Whether the channel is marked as NSFW.
- # .Type - Channel type (see values at https://github.com/bwmarrin/discordgo/blob/v0.25.0/structs.go#L251-L267)
- channel_name_template: '{{if or (eq .Type 3) (eq .Type 4)}}{{.Name}}{{else}}#{{.Name}}{{end}}'
- # Displayname template for Discord guilds (bridged as spaces).
- # Available variables:
- # .Name - Guild name
- guild_name_template: '{{.Name}}'
- # Whether to explicitly set the avatar and room name for private chat portal rooms.
- # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
- # If set to `always`, all DM rooms will have explicit names and avatars set.
- # If set to `never`, DM rooms will never have names and avatars set.
- private_chat_portal_meta: default
-
- # Publicly accessible base URL that Discord can use to reach the bridge, used for avatars in relay mode.
- # If not set, avatars will not be bridged. Only the /mautrix-discord/avatar/{server}/{id}/{hash} endpoint is used on this address.
- # This should not have a trailing slash, the endpoint above will be appended to the provided address.
- public_address: null
- # A random key used to sign the avatar URLs. The bridge will only accept requests with a valid signature.
- avatar_proxy_key: generate
-
- portal_message_buffer: 128
-
- # Number of private channel portals to create on bridge startup.
- # Other portals will be created when receiving messages.
- startup_private_channel_create_limit: 5
- # Should the bridge send a read receipt from the bridge bot when a message has been sent to Discord?
- delivery_receipts: false
- # Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
- message_status_events: false
- # Whether the bridge should send error notices via m.notice events when a message fails to bridge.
- message_error_notices: true
- # Should the bridge use space-restricted join rules instead of invite-only for guild rooms?
- # This can avoid unnecessary invite events in guild rooms when members are synced in.
- restricted_rooms: false
- # Should the bridge automatically join the user to threads on Discord when the thread is opened on Matrix?
- # This only works with clients that support thread read receipts (MSC3771 added in Matrix v1.4).
- autojoin_thread_on_open: true
- # Should inline fields in Discord embeds be bridged as HTML tables to Matrix?
- # Tables aren't supported in all clients, but are the only way to emulate the Discord inline field UI.
- embed_fields_as_tables: true
- # Should guild channels be muted when the portal is created? This only meant for single-user instances,
- # it won't mute it for all users if there are multiple Matrix users in the same Discord guild.
- mute_channels_on_create: false
- # Should the bridge update the m.direct account data event when double puppeting is enabled.
- # Note that updating the m.direct event is not atomic (except with mautrix-asmux)
- # and is therefore prone to race conditions.
- sync_direct_chat_list: false
- # Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
- # This field will automatically be changed back to false after it, except if the config file is not writable.
- resend_bridge_info: false
- # Should incoming custom emoji reactions be bridged as mxc:// URIs?
- # If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available.
- custom_emoji_reactions: true
- # Should the bridge attempt to completely delete portal rooms when a channel is deleted on Discord?
- # If true, the bridge will try to kick Matrix users from the room. Otherwise, the bridge only makes ghosts leave.
- delete_portal_on_channel_delete: false
- # Should the bridge delete all portal rooms when you leave a guild on Discord?
- # This only applies if the guild has no other Matrix users on this bridge instance.
- delete_guild_on_leave: true
- # Whether or not created rooms should have federation enabled.
- # If false, created portal rooms will never be federated.
- federate_rooms: true
- # Prefix messages from webhooks with the profile info? This can be used along with a custom displayname_template
- # to better handle webhooks that change their name all the time (like ones used by bridges).
- #
- # This will use the fallback mode in MSC4144, which means clients that support MSC4144 will not show the prefix
- # (and will instead show the name and avatar as the message sender).
- prefix_webhook_messages: true
- # Bridge webhook avatars?
- enable_webhook_avatars: false
- # Should the bridge upload media to the Discord CDN directly before sending the message when using a user token,
- # like the official client does? The other option is sending the media in the message send request as a form part
- # (which is always used by bots and webhooks).
- use_discord_cdn_upload: true
- # Should the bridge forbid direct messages from users to other users who they aren't friends with? Discord generally
- # considers this to be a "risky" action. Note that the bridge will conservatively reject all outgoing DMs from users
- # until it has synced that user's relationships from Discord.
- forbid_dming_strangers: true
- # Proxy for Discord connections
- proxy:
- # Should mxc uris copied from Discord be cached?
- # This can be `never` to never cache, `unencrypted` to only cache unencrypted mxc uris, or `always` to cache everything.
- # If you have a media repo that generates non-unique mxc uris, you should set this to never.
- cache_media: unencrypted
- # Settings for converting Discord media to custom mxc:// URIs instead of reuploading.
- # More details can be found at https://docs.mau.fi/bridges/go/discord/direct-media.html
- direct_media:
- # Should custom mxc:// URIs be used instead of reuploading media?
- enabled: false
- # The server name to use for the custom mxc:// URIs.
- # This server name will effectively be a real Matrix server, it just won't implement anything other than media.
- # You must either set up .well-known delegation from this domain to the bridge, or proxy the domain directly to the bridge.
- server_name: discord-media.example.com
- # Optionally a custom .well-known response. This defaults to `server_name:443`
- well_known_response:
- # The bridge supports MSC3860 media download redirects and will use them if the requester supports it.
- # Optionally, you can force redirects and not allow proxying at all by setting this to false.
- allow_proxy: true
- # Matrix server signing key to make the federation tester pass, same format as synapse's .signing.key file.
- # This key is also used to sign the mxc:// URIs to ensure only the bridge can generate them.
- server_key: generate
- # Settings for converting animated stickers.
- animated_sticker:
- # Format to which animated stickers should be converted.
- # disable - No conversion, send as-is (lottie JSON)
- # png - converts to non-animated png (fastest)
- # gif - converts to animated gif
- # webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
- # webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
- target: webp
- # Arguments for converter. All converters take width and height.
- args:
- width: 320
- height: 320
- fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
- # Servers to always allow double puppeting from
- double_puppet_server_map:
- example.com: https://example.com
- # Allow using double puppeting from any server with a valid client .well-known file.
- double_puppet_allow_discovery: false
- # Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
- #
- # If set, double puppeting will be enabled automatically for local users
- # instead of users having to find an access token and run `login-matrix`
- # manually.
- login_shared_secret_map:
- example.com: foobar
-
- # The prefix for commands. Only required in non-management rooms.
- command_prefix: '!discord'
- # Messages sent upon joining a management room.
- # Markdown is supported. The defaults are listed below.
- management_room_text:
- # Sent when joining a room.
- welcome: "Hello, I'm a Discord bridge bot."
- # Sent when joining a management room and the user is already logged in.
- welcome_connected: "Use `help` for help."
- # Sent when joining a management room and the user is not logged in.
- welcome_unconnected: "Use `help` for help or `login` to log in."
- # Optional extra text sent when joining a management room.
- additional_help: ""
-
- # Settings for backfilling messages.
- backfill:
- # Limits for forward backfilling.
- forward_limits:
- # Initial backfill (when creating portal). 0 means backfill is disabled.
- # A special unlimited value is not supported, you must set a limit. Initial backfill will
- # fetch all messages first before backfilling anything, so high limits can take a lot of time.
- initial:
- dm: 0
- channel: 0
- thread: 0
- # Missed message backfill (on startup).
- # 0 means backfill is disabled, -1 means fetch all messages since last bridged message.
- # When using unlimited backfill (-1), messages are backfilled as they are fetched.
- # With limits, all messages up to the limit are fetched first and backfilled afterwards.
- missed:
- dm: 0
- channel: 0
- thread: 0
- # Maximum members in a guild to enable backfilling. Set to -1 to disable limit.
- # This can be used as a rough heuristic to disable backfilling in channels that are too active.
- # Currently only applies to missed message backfill.
- max_guild_members: -1
-
- # End-to-bridge encryption support options.
- #
- # See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
- encryption:
- # Allow encryption, work in group chat rooms with e2ee enabled
- allow: false
- # Default to encryption, force-enable encryption in all portals the bridge creates
- # This will cause the bridge bot to be in private chats for the encryption to work properly.
- default: false
- # Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
- # Changing this option requires updating the appservice registration file.
- appservice: false
- # Whether to use MSC4190 instead of appservice login to create the bridge bot device.
- # Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
- # Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
- # Changing this option requires updating the appservice registration file.
- msc4190: false
- # Require encryption, drop any unencrypted messages.
- require: false
- # Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
- # You must use a client that supports requesting keys from other users to use this feature.
- allow_key_sharing: false
- # Should users mentions be in the event wire content to enable the server to send push notifications?
- plaintext_mentions: false
- # Options for deleting megolm sessions from the bridge.
- delete_keys:
- # Beeper-specific: delete outbound sessions when hungryserv confirms
- # that the user has uploaded the key to key backup.
- delete_outbound_on_ack: false
- # Don't store outbound sessions in the inbound table.
- dont_store_outbound: false
- # Ratchet megolm sessions forward after decrypting messages.
- ratchet_on_decrypt: false
- # Delete fully used keys (index >= max_messages) after decrypting messages.
- delete_fully_used_on_decrypt: false
- # Delete previous megolm sessions from same device when receiving a new one.
- delete_prev_on_new_session: false
- # Delete megolm sessions received from a device when the device is deleted.
- delete_on_device_delete: false
- # Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
- periodically_delete_expired: false
- # Delete inbound megolm sessions that don't have the received_at field used for
- # automatic ratcheting and expired session deletion. This is meant as a migration
- # to delete old keys prior to the bridge update.
- delete_outdated_inbound: false
- # What level of device verification should be required from users?
- #
- # Valid levels:
- # unverified - Send keys to all device in the room.
- # cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
- # cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
- # cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
- # Note that creating user signatures from the bridge bot is not currently possible.
- # verified - Require manual per-device verification
- # (currently only possible by modifying the `trust` column in the `crypto_device` database table).
- verification_levels:
- # Minimum level for which the bridge should send keys to when bridging messages from WhatsApp to Matrix.
- receive: unverified
- # Minimum level that the bridge should accept for incoming Matrix messages.
- send: unverified
- # Minimum level that the bridge should require for accepting key requests.
- share: cross-signed-tofu
- # Options for Megolm room key rotation. These options allow you to
- # configure the m.room.encryption event content. See:
- # https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
- # more information about that event.
- rotation:
- # Enable custom Megolm room key rotation settings. Note that these
- # settings will only apply to rooms created after this option is
- # set.
- enable_custom: false
- # The maximum number of milliseconds a session should be used
- # before changing it. The Matrix spec recommends 604800000 (a week)
- # as the default.
- milliseconds: 604800000
- # The maximum number of messages that should be sent with a given a
- # session before changing it. The Matrix spec recommends 100 as the
- # default.
- messages: 100
-
- # Disable rotating keys when a user's devices change?
- # You should not enable this option unless you understand all the implications.
- disable_device_change_key_rotation: false
-
- # Settings for provisioning API
- provisioning:
- # Prefix for the provisioning API paths.
- prefix: /_matrix/provision
- # Shared secret for authentication. If set to "generate", a random secret will be generated,
- # or if set to "disable", the provisioning API will be disabled.
- shared_secret: generate
- # Enable debug API at /debug with provisioning authentication.
- debug_endpoints: false
-
- # Permissions for using the bridge.
- # Permitted values:
- # relay - Talk through the relaybot (if enabled), no access otherwise
- # user - Access to use the bridge to chat with a Discord account.
- # admin - User level and some additional administration tools
- # Permitted keys:
- # * - All Matrix users
- # domain - All users on that homeserver
- # mxid - Specific user
- permissions:
- "*": relay
- "example.com": user
- "@admin:example.com": admin
-
-# Logging config. See https://github.com/tulir/zeroconfig for details.
-logging:
- min_level: debug
- writers:
- - type: stdout
- format: pretty-colored
- - type: file
- format: json
- filename: ./logs/mautrix-discord.log
- max_size: 100
- max_backups: 10
- compress: true
diff --git a/formatter.go b/formatter.go
deleted file mode 100644
index 2112b04..0000000
--- a/formatter.go
+++ /dev/null
@@ -1,260 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "fmt"
- "regexp"
- "strings"
-
- "github.com/bwmarrin/discordgo"
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/extension"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/util"
- "go.mau.fi/util/variationselector"
- "golang.org/x/exp/slices"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/format"
- "maunium.net/go/mautrix/format/mdext"
- "maunium.net/go/mautrix/id"
-)
-
-// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
-//
-// Discord allows escaping with just one backslash, e.g. \__a__,
-// but standard markdown requires both to be escaped (\_\_a__)
-var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
-
-func escapeReplacement(s string) string {
- return s[:2] + `\` + s[2:]
-}
-
-// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
-// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
-type indentableParagraphParser struct {
- parser.BlockParser
-}
-
-var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
-
-func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
- return true
-}
-
-var removeFeaturesExceptLinks = []any{
- parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
- parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
- parser.NewCodeBlockParser(),
-}
-var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
-var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500)))
-var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag)
-
-var discordRenderer = goldmark.New(
- goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)),
- fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
-)
-var discordRendererWithInlineLinks = goldmark.New(
- goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)),
- fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
-)
-
-func (portal *Portal) renderDiscordMarkdownOnlyHTMLNoUnwrap(text string, allowInlineLinks bool) string {
- text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
-
- var buf strings.Builder
- ctx := parser.NewContext()
- ctx.Set(parserContextPortal, portal)
- renderer := discordRenderer
- if allowInlineLinks {
- renderer = discordRendererWithInlineLinks
- }
- err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
- if err != nil {
- panic(fmt.Errorf("markdown parser errored: %w", err))
- }
- return buf.String()
-}
-
-func (portal *Portal) renderDiscordMarkdownOnlyHTML(text string, allowInlineLinks bool) string {
- return format.UnwrapSingleParagraph(portal.renderDiscordMarkdownOnlyHTMLNoUnwrap(text, allowInlineLinks))
-}
-
-const formatterContextPortalKey = "fi.mau.discord.portal"
-const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
-const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
-const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews"
-
-func appendIfNotContains(arr []string, newItem string) []string {
- for _, item := range arr {
- if item == newItem {
- return arr
- }
- }
- return append(arr, newItem)
-}
-
-func (br *DiscordBridge) pillConverter(displayname, mxid, eventID string, ctx format.Context) string {
- if len(mxid) == 0 {
- return displayname
- }
- if mxid[0] == '#' {
- alias, err := br.Bot.ResolveAlias(id.RoomAlias(mxid))
- if err != nil {
- return displayname
- }
- mxid = alias.RoomID.String()
- }
- if mxid[0] == '!' {
- portal := br.GetPortalByMXID(id.RoomID(mxid))
- if portal != nil {
- if eventID == "" {
- //currentPortal := ctx[formatterContextPortalKey].(*Portal)
- return fmt.Sprintf("<#%s>", portal.Key.ChannelID)
- //if currentPortal.GuildID == portal.GuildID {
- //} else if portal.GuildID != "" {
- // return fmt.Sprintf("<#%s:%s:%s>", portal.Key.ChannelID, portal.GuildID, portal.Name)
- //} else {
- // // TODO is mentioning private channels possible at all?
- //}
- } else if msg := br.DB.Message.GetByMXID(portal.Key, id.EventID(eventID)); msg != nil {
- guildID := portal.GuildID
- if guildID == "" {
- guildID = "@me"
- }
- return fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, msg.DiscordProtoChannelID(), msg.DiscordID)
- }
- }
- } else if mxid[0] == '@' {
- allowedMentions, _ := ctx.ReturnData[formatterContextInputAllowedMentionsKey].([]id.UserID)
- if allowedMentions != nil && !slices.Contains(allowedMentions, id.UserID(mxid)) {
- return displayname
- }
- mentions := ctx.ReturnData[formatterContextAllowedMentionsKey].(*discordgo.MessageAllowedMentions)
- parsedID, ok := br.ParsePuppetMXID(id.UserID(mxid))
- if ok {
- mentions.Users = appendIfNotContains(mentions.Users, parsedID)
- return fmt.Sprintf("<@%s>", parsedID)
- }
- mentionedUser := br.GetUserByMXID(id.UserID(mxid))
- if mentionedUser != nil && mentionedUser.DiscordID != "" {
- mentions.Users = appendIfNotContains(mentions.Users, mentionedUser.DiscordID)
- return fmt.Sprintf("<@%s>", mentionedUser.DiscordID)
- }
- }
- return displayname
-}
-
-const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
-
-// Discord links start with http:// or https://, contain at least two characters afterwards,
-// don't contain < or whitespace anywhere, and don't end with "'),.:;]
-//
-// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
-var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
-var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
-
-var discordMarkdownEscaper = strings.NewReplacer(
- `\`, `\\`,
- `_`, `\_`,
- `*`, `\*`,
- `~`, `\~`,
- "`", "\\`",
- `|`, `\|`,
- `<`, `\<`,
- `#`, `\#`,
-)
-
-func escapeDiscordMarkdown(s string) string {
- submatches := discordLinkRegex.FindAllStringIndex(s, -1)
- if submatches == nil {
- return discordMarkdownEscaper.Replace(s)
- }
- var builder strings.Builder
- offset := 0
- for _, match := range submatches {
- start := match[0]
- end := match[1]
- builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start]))
- builder.WriteString(s[start:end])
- offset = end
- }
- builder.WriteString(discordMarkdownEscaper.Replace(s[offset:]))
- return builder.String()
-}
-
-var matrixHTMLParser = &format.HTMLParser{
- TabsToSpaces: 4,
- Newline: "\n",
- HorizontalLine: "\n---\n",
- ItalicConverter: func(s string, ctx format.Context) string {
- return fmt.Sprintf("*%s*", s)
- },
- UnderlineConverter: func(s string, ctx format.Context) string {
- return fmt.Sprintf("__%s__", s)
- },
- TextConverter: func(s string, ctx format.Context) string {
- if ctx.TagStack.Has("pre") || ctx.TagStack.Has("code") {
- // If we're in a code block, don't escape markdown
- return s
- }
- return escapeDiscordMarkdown(s)
- },
- SpoilerConverter: func(text, reason string, ctx format.Context) string {
- if reason != "" {
- return fmt.Sprintf("(%s) ||%s||", reason, text)
- }
- return fmt.Sprintf("||%s||", text)
- },
- LinkConverter: func(text, href string, ctx format.Context) string {
- linkPreviews := ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey].([]string)
- allowPreview := linkPreviews == nil || slices.Contains(linkPreviews, href)
- if text == href {
- if !allowPreview {
- return fmt.Sprintf("<%s>", text)
- }
- return text
- } else if !discordLinkRegexFull.MatchString(href) {
- return fmt.Sprintf("%s (%s)", escapeDiscordMarkdown(text), escapeDiscordMarkdown(href))
- } else if !allowPreview {
- return fmt.Sprintf("[%s](<%s>)", escapeDiscordMarkdown(text), href)
- } else {
- return fmt.Sprintf("[%s](%s)", escapeDiscordMarkdown(text), href)
- }
- },
-}
-
-func (portal *Portal) parseMatrixHTML(content *event.MessageEventContent, allowedLinkPreviews []string) (string, *discordgo.MessageAllowedMentions) {
- allowedMentions := &discordgo.MessageAllowedMentions{
- Parse: []discordgo.AllowedMentionType{},
- Users: []string{},
- RepliedUser: true,
- }
- if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 {
- ctx := format.NewContext()
- ctx.ReturnData[formatterContextInputAllowedLinkPreviewsKey] = allowedLinkPreviews
- ctx.ReturnData[formatterContextPortalKey] = portal
- ctx.ReturnData[formatterContextAllowedMentionsKey] = allowedMentions
- if content.Mentions != nil {
- ctx.ReturnData[formatterContextInputAllowedMentionsKey] = content.Mentions.UserIDs
- }
- return variationselector.FullyQualify(matrixHTMLParser.Parse(content.FormattedBody, ctx)), allowedMentions
- } else {
- return variationselector.FullyQualify(escapeDiscordMarkdown(content.Body)), allowedMentions
- }
-}
diff --git a/formatter_test.go b/formatter_test.go
deleted file mode 100644
index c05f95b..0000000
--- a/formatter_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestEscapeDiscordMarkdown(t *testing.T) {
- type escapeTest struct {
- name string
- input string
- expected string
- }
-
- tests := []escapeTest{
- {"Simple text", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", "Lorem ipsum dolor sit amet, consectetuer adipiscing elit."},
- {"Backslash", `foo\bar`, `foo\\bar`},
- {"Underscore", `foo_bar`, `foo\_bar`},
- {"Asterisk", `foo*bar`, `foo\*bar`},
- {"Tilde", `foo~bar`, `foo\~bar`},
- {"Backtick", "foo`bar", "foo\\`bar"},
- {"Forward tick", `foo´bar`, `foo´bar`},
- {"Pipe", `foo|bar`, `foo\|bar`},
- {"Less than", `foobar`, `foo>bar`},
- {"Multiple things", `\_*~|`, `\\\_\*\~\|`},
- {"URL", `https://example.com/foo_bar`, `https://example.com/foo_bar`},
- {"Multiple URLs", `hello_world https://example.com/foo_bar *testing* https://a_b_c/*def*`, `hello\_world https://example.com/foo_bar \*testing\* https://a_b_c/*def*`},
- {"URL ends with no-break zero-width space", "https://example.com\ufefffoo_bar", "https://example.com\ufefffoo\\_bar"},
- {"URL ends with less than", `https://example.com github.com/beeper/discordgo v0.0.0-20260808090638-8051e14a4471
-replace github.com/imroc/req/v3 => github.com/beeper/req/v3 v3.0.0-20260808092221-1540c0bf3d1a
+replace github.com/imroc/req/v3 => github.com/beeper/req/v3 v3.0.0-20260703124114-47a4e2aa147e
diff --git a/go.sum b/go.sum
index 45e5a74..076a1df 100644
--- a/go.sum
+++ b/go.sum
@@ -1,61 +1,57 @@
-github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
-github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
-github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
-github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
+filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
+github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
+github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beeper/discordgo v0.0.0-20260808090638-8051e14a4471 h1:yeMnzGxjXjRNbUo5cwP5+XGNJUPHX+yFjvD/PbGvTJc=
github.com/beeper/discordgo v0.0.0-20260808090638-8051e14a4471/go.mod h1:ATQaN5n/cY4rxHFSsp4UOB1cxf4gdtreFt5NXmlVTmc=
-github.com/beeper/req/v3 v3.0.0-20260808092221-1540c0bf3d1a h1:LcaRVi2XyUC7LWnpMHFwDlC1jMs5GyLDSerZoB9aH4I=
-github.com/beeper/req/v3 v3.0.0-20260808092221-1540c0bf3d1a/go.mod h1:oQlr0iamhVs/GYPzSWYNaFZFgsHzIYj6ibQrdPwwy2A=
-github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
-github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/beeper/req/v3 v3.0.0-20260703124114-47a4e2aa147e h1:CpRFpusGLZVcc+R1Im50QYI9JeEQuwudVMkUkRvZpuU=
+github.com/beeper/req/v3 v3.0.0-20260703124114-47a4e2aa147e/go.mod h1:pH5qbsMNeaNpCOlozJoLRuLfSxEYGp9uWEG5j6zogNQ=
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
-github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
-github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
-github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
-github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
-github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
-github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
-github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/icholy/digest v1.2.0 h1:oTbG4IsNOmidJ+421ehG7Ty93yt1yotq13kFMG569yw=
-github.com/icholy/digest v1.2.0/go.mod h1:1P1+LzUv48ybX7bu8tVpZ2QWdd+xRuePNuGawHjwRUE=
-github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
-github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
+github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
+github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
+github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
-github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
+github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
+github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E=
+github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
-github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
-github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
-github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
-github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
+github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
+github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
+github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
+github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
@@ -63,38 +59,43 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
+github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
+github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
-github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
-github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
-go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb h1:Is+6vDKgINRy9KHodvi7NElxoDaWA8sc2S3cF3+QWjs=
-go.mau.fi/util v0.2.2-0.20231228160422-22fdd4bbddeb/go.mod h1:tiBX6nxVSOjU89jVQ7wBh3P8KjM26Lv1k7/I5QdSvBw=
-go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto=
-go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
+github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391 h1:lsvBEY8MJfYdV61YbwikiQvb0Al/onbmLW5wfl/0tag=
+go.mau.fi/util v0.9.12-0.20260719092501-f9c03d846391/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q=
+go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
+go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
-golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
-golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
-golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297 h1:YXnL44eJ77R+ji4/ooy8UsXIhz+lbi2Qgdlc8iRN0gY=
-golang.org/x/exp v0.0.0-20260813180055-c1d0aacb2297/go.mod h1:Mkmymgv+uMpSQ/XxJ/7GpdrdYoqm3u72jEbpCLiJmNk=
-golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
-golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g=
+golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
-golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -102,11 +103,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
-gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
+gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
-maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
-maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
-maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2 h1:8PdwIklPNHTL/tI9tG2S0Tf9UvAgRt8yZjJbjV0XIpA=
-maunium.net/go/mautrix v0.16.3-0.20250810202616-6bc5698125c2/go.mod h1:gCgLw/4c1a8QsiOWTdUdXlt5cYdE0rJ9wLeZQKPD58Q=
+maunium.net/go/mautrix v0.29.1-0.20260812095854-b840de46eb68 h1:dP6jF6NMynRcu/g7+MsR2JcELvXe1PWY5fiPg9/XxWY=
+maunium.net/go/mautrix v0.29.1-0.20260812095854-b840de46eb68/go.mod h1:PeLuIih5jnbwb2xKNNi0Te7AtINEQE2Uv3p6iG3RV80=
diff --git a/guildportal.go b/guildportal.go
deleted file mode 100644
index d7be670..0000000
--- a/guildportal.go
+++ /dev/null
@@ -1,335 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "errors"
- "fmt"
- "sync"
-
- log "maunium.net/go/maulogger/v2"
- "maunium.net/go/maulogger/v2/maulogadapt"
-
- "maunium.net/go/mautrix"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "github.com/bwmarrin/discordgo"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
-)
-
-type Guild struct {
- *database.Guild
-
- bridge *DiscordBridge
- log log.Logger
-
- roomCreateLock sync.Mutex
-}
-
-func (br *DiscordBridge) loadGuild(dbGuild *database.Guild, id string, createIfNotExist bool) *Guild {
- if dbGuild == nil {
- if id == "" || !createIfNotExist {
- return nil
- }
-
- dbGuild = br.DB.Guild.New()
- dbGuild.ID = id
- dbGuild.Insert()
- }
-
- guild := br.NewGuild(dbGuild)
-
- br.guildsByID[guild.ID] = guild
- if guild.MXID != "" {
- br.guildsByMXID[guild.MXID] = guild
- }
-
- return guild
-}
-
-func (br *DiscordBridge) GetGuildByMXID(mxid id.RoomID) *Guild {
- br.guildsLock.Lock()
- defer br.guildsLock.Unlock()
-
- portal, ok := br.guildsByMXID[mxid]
- if !ok {
- return br.loadGuild(br.DB.Guild.GetByMXID(mxid), "", false)
- }
-
- return portal
-}
-
-func (br *DiscordBridge) GetGuildByID(id string, createIfNotExist bool) *Guild {
- br.guildsLock.Lock()
- defer br.guildsLock.Unlock()
-
- guild, ok := br.guildsByID[id]
- if !ok {
- return br.loadGuild(br.DB.Guild.GetByID(id), id, createIfNotExist)
- }
-
- return guild
-}
-
-func (br *DiscordBridge) GetAllGuilds() []*Guild {
- return br.dbGuildsToGuilds(br.DB.Guild.GetAll())
-}
-
-func (br *DiscordBridge) dbGuildsToGuilds(dbGuilds []*database.Guild) []*Guild {
- br.guildsLock.Lock()
- defer br.guildsLock.Unlock()
-
- output := make([]*Guild, len(dbGuilds))
- for index, dbGuild := range dbGuilds {
- if dbGuild == nil {
- continue
- }
-
- guild, ok := br.guildsByID[dbGuild.ID]
- if !ok {
- guild = br.loadGuild(dbGuild, "", false)
- }
-
- output[index] = guild
- }
-
- return output
-}
-
-func (br *DiscordBridge) NewGuild(dbGuild *database.Guild) *Guild {
- guild := &Guild{
- Guild: dbGuild,
- bridge: br,
- log: br.Log.Sub(fmt.Sprintf("Guild/%s", dbGuild.ID)),
- }
-
- return guild
-}
-
-func (guild *Guild) getBridgeInfo() (string, event.BridgeEventContent) {
- bridgeInfo := event.BridgeEventContent{
- BridgeBot: guild.bridge.Bot.UserID,
- Creator: guild.bridge.Bot.UserID,
- Protocol: event.BridgeInfoSection{
- ID: "discordgo",
- DisplayName: "Discord",
- AvatarURL: guild.bridge.Config.AppService.Bot.ParsedAvatar.CUString(),
- ExternalURL: "https://discord.com/",
- },
- Channel: event.BridgeInfoSection{
- ID: guild.ID,
- DisplayName: guild.Name,
- AvatarURL: guild.AvatarURL.CUString(),
- },
- }
- bridgeInfoStateKey := fmt.Sprintf("fi.mau.discord://discord/%s", guild.ID)
- return bridgeInfoStateKey, bridgeInfo
-}
-
-func (guild *Guild) UpdateBridgeInfo() {
- if len(guild.MXID) == 0 {
- guild.log.Debugln("Not updating bridge info: no Matrix room created")
- return
- }
- guild.log.Debugln("Updating bridge info...")
- stateKey, content := guild.getBridgeInfo()
- _, err := guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateBridge, stateKey, content)
- if err != nil {
- guild.log.Warnln("Failed to update m.bridge:", err)
- }
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- _, err = guild.bridge.Bot.SendStateEvent(guild.MXID, event.StateHalfShotBridge, stateKey, content)
- if err != nil {
- guild.log.Warnln("Failed to update uk.half-shot.bridge:", err)
- }
-}
-
-func (guild *Guild) CreateMatrixRoom(user *User, meta *discordgo.Guild) error {
- guild.roomCreateLock.Lock()
- defer guild.roomCreateLock.Unlock()
- if guild.MXID != "" {
- return nil
- }
- guild.log.Infoln("Creating Matrix room for guild")
- guild.UpdateInfo(user, meta)
-
- bridgeInfoStateKey, bridgeInfo := guild.getBridgeInfo()
-
- initialState := []*event.Event{{
- Type: event.StateBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }, {
- // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
- Type: event.StateHalfShotBridge,
- Content: event.Content{Parsed: bridgeInfo},
- StateKey: &bridgeInfoStateKey,
- }}
-
- if !guild.AvatarURL.IsEmpty() {
- initialState = append(initialState, &event.Event{
- Type: event.StateRoomAvatar,
- Content: event.Content{Parsed: &event.RoomAvatarEventContent{
- URL: guild.AvatarURL,
- }},
- })
- }
-
- creationContent := map[string]interface{}{
- "type": event.RoomTypeSpace,
- }
- if !guild.bridge.Config.Bridge.FederateRooms {
- creationContent["m.federate"] = false
- }
-
- resp, err := guild.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{
- Visibility: "private",
- Name: guild.Name,
- Preset: "private_chat",
- InitialState: initialState,
- CreationContent: creationContent,
- RoomVersion: "11",
- })
- if err != nil {
- guild.log.Warnln("Failed to create room:", err)
- return err
- }
-
- guild.MXID = resp.RoomID
- guild.NameSet = true
- guild.AvatarSet = !guild.AvatarURL.IsEmpty()
- guild.Update()
- guild.bridge.guildsLock.Lock()
- guild.bridge.guildsByMXID[guild.MXID] = guild
- guild.bridge.guildsLock.Unlock()
- guild.log.Infoln("Matrix room created:", guild.MXID)
-
- user.ensureInvited(nil, guild.MXID, false, true)
-
- return nil
-}
-
-func (guild *Guild) UpdateInfo(source *User, meta *discordgo.Guild) *discordgo.Guild {
- if meta.Unavailable {
- guild.log.Debugfln("Ignoring unavailable guild update")
- return meta
- }
- changed := false
- changed = guild.UpdateName(meta) || changed
- changed = guild.UpdateAvatar(meta.Icon) || changed
- if changed {
- guild.UpdateBridgeInfo()
- guild.Update()
- }
- source.ensureInvited(nil, guild.MXID, false, false)
- return meta
-}
-
-func (guild *Guild) UpdateName(meta *discordgo.Guild) bool {
- name := guild.bridge.Config.Bridge.FormatGuildName(config.GuildNameParams{
- Name: meta.Name,
- })
- if guild.PlainName == meta.Name && guild.Name == name && (guild.NameSet || guild.MXID == "") {
- return false
- }
- guild.log.Debugfln("Updating name %q -> %q", guild.Name, name)
- guild.Name = name
- guild.PlainName = meta.Name
- guild.NameSet = false
- if guild.MXID != "" {
- _, err := guild.bridge.Bot.SetRoomName(guild.MXID, guild.Name)
- if err != nil {
- guild.log.Warnln("Failed to update room name: %s", err)
- } else {
- guild.NameSet = true
- }
- }
- return true
-}
-
-func (guild *Guild) UpdateAvatar(iconID string) bool {
- if guild.Avatar == iconID && (iconID == "") == guild.AvatarURL.IsEmpty() && (guild.AvatarSet || guild.MXID == "") {
- return false
- }
- guild.log.Debugfln("Updating avatar %q -> %q", guild.Avatar, iconID)
- guild.AvatarSet = false
- guild.Avatar = iconID
- guild.AvatarURL = id.ContentURI{}
- if guild.Avatar != "" {
- // TODO direct media support
- copied, err := guild.bridge.copyAttachmentToMatrix(guild.bridge.Bot, discordgo.EndpointGuildIcon(guild.ID, iconID), false, AttachmentMeta{
- AttachmentID: fmt.Sprintf("guild_avatar/%s/%s", guild.ID, iconID),
- })
- if err != nil {
- guild.log.Warnfln("Failed to reupload guild avatar %s: %v", iconID, err)
- return true
- }
- guild.AvatarURL = copied.MXC
- }
- if guild.MXID != "" {
- _, err := guild.bridge.Bot.SetRoomAvatar(guild.MXID, guild.AvatarURL)
- if err != nil {
- guild.log.Warnln("Failed to update room avatar:", err)
- } else {
- guild.AvatarSet = true
- }
- }
- return true
-}
-
-func (guild *Guild) cleanup() {
- if guild.MXID == "" {
- return
- }
- intent := guild.bridge.Bot
- if guild.bridge.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) {
- err := intent.BeeperDeleteRoom(guild.MXID)
- if err != nil && !errors.Is(err, mautrix.MNotFound) {
- guild.log.Errorfln("Failed to delete %s using hungryserv yeet endpoint: %v", guild.MXID, err)
- }
- return
- }
- guild.bridge.cleanupRoom(intent, guild.MXID, false, *maulogadapt.MauAsZero(guild.log))
-}
-
-func (guild *Guild) RemoveMXID() {
- guild.bridge.guildsLock.Lock()
- defer guild.bridge.guildsLock.Unlock()
- if guild.MXID == "" {
- return
- }
- delete(guild.bridge.guildsByMXID, guild.MXID)
- guild.MXID = ""
- guild.AvatarSet = false
- guild.NameSet = false
- guild.BridgingMode = database.GuildBridgeNothing
- guild.Update()
-}
-
-func (guild *Guild) Delete() {
- guild.Guild.Delete()
- guild.bridge.guildsLock.Lock()
- delete(guild.bridge.guildsByID, guild.ID)
- if guild.MXID != "" {
- delete(guild.bridge.guildsByMXID, guild.MXID)
- }
- guild.bridge.guildsLock.Unlock()
-
-}
diff --git a/http.go b/http.go
deleted file mode 100644
index c38d27d..0000000
--- a/http.go
+++ /dev/null
@@ -1,109 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2026 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- "context"
- "crypto/tls"
- "fmt"
- "net"
- "net/http"
- "net/url"
- "strings"
-
- "github.com/imroc/req/v3"
- utls "github.com/refraction-networking/utls"
-)
-
-func compileTransport(onlyAdvertiseHTTP1InALPN bool, proxy *url.URL) http.RoundTripper {
- reqClient := req.C().ImpersonateChrome()
- if proxy != nil {
- reqClient.SetProxy(http.ProxyURL(proxy))
- } else {
- reqClient.SetProxy(nil)
- }
- if onlyAdvertiseHTTP1InALPN {
- forceHTTP1ChromeFingerprint(reqClient)
- reqClient.EnableForceHTTP1()
- }
- return reqClient.Transport
-}
-
-// forceHTTP1ChromeFingerprint overrides the req client's TLS handshake so the
-// uTLS ClientHello keeps Chrome's full fingerprint but advertises _only_
-// http/1.1 in ALPN.
-func forceHTTP1ChromeFingerprint(c *req.Client) {
- // (This is adapted from uTLS's SetTLSFingerprint.)
- c.SetTLSHandshake(func(ctx context.Context, addr string, plainConn net.Conn) (net.Conn, *tls.ConnectionState, error) {
- hostname := addr
- if i := strings.LastIndex(addr, ":"); i != -1 {
- hostname = addr[:i]
- }
-
- // NOTE: The ClientHelloID here _must_ match what req's
- // ImpersonateChrome uses.
- spec, err := utls.UTLSIdToSpec(utls.HelloChrome_120)
- if err != nil {
- return nil, nil, fmt.Errorf("failed to build Chrome uTLS spec: %w", err)
- }
-
- // The actual changes we're making here:
- exts := spec.Extensions[:0]
- for _, ext := range spec.Extensions {
- switch e := ext.(type) {
- // Drop the ALPS (application_settings) extension. Modern Chrome
- // will stop offering h2 there when ALPN omits it. Match that
- // behavior.
- case *utls.ApplicationSettingsExtension:
- continue
-
- // Patch the ALPN extension to exclusively offer http/1.1.
- case *utls.ALPNExtension:
- e.AlpnProtocols = []string{"http/1.1"}
- }
- exts = append(exts, ext)
- }
- spec.Extensions = exts
-
- tlsConfig := c.GetTLSClientConfig()
- uconn := utls.UClient(plainConn, &utls.Config{
- ServerName: hostname,
- NextProtos: []string{"http/1.1"},
- RootCAs: tlsConfig.RootCAs,
- InsecureSkipVerify: tlsConfig.InsecureSkipVerify,
- KeyLogWriter: tlsConfig.KeyLogWriter,
- }, utls.HelloCustom)
- if err := uconn.ApplyPreset(&spec); err != nil {
- return nil, nil, fmt.Errorf("failed to apply Chrome uTLS spec: %w", err)
- }
- if err := uconn.HandshakeContext(ctx); err != nil {
- return nil, nil, err
- }
-
- cs := uconn.ConnectionState()
- return uconn, &tls.ConnectionState{
- Version: cs.Version,
- HandshakeComplete: cs.HandshakeComplete,
- DidResume: cs.DidResume,
- CipherSuite: cs.CipherSuite,
- NegotiatedProtocol: cs.NegotiatedProtocol,
- ServerName: cs.ServerName,
- PeerCertificates: cs.PeerCertificates,
- VerifiedChains: cs.VerifiedChains,
- }, nil
- })
-}
diff --git a/main.go b/main.go
deleted file mode 100644
index 2792443..0000000
--- a/main.go
+++ /dev/null
@@ -1,208 +0,0 @@
-// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-package main
-
-import (
- _ "embed"
- "net/http"
- "sync"
-
- "go.mau.fi/util/configupgrade"
- "go.mau.fi/util/exsync"
- "golang.org/x/sync/semaphore"
- "maunium.net/go/mautrix/bridge"
- "maunium.net/go/mautrix/bridge/commands"
- "maunium.net/go/mautrix/event"
- "maunium.net/go/mautrix/id"
-
- "go.mau.fi/mautrix-discord/config"
- "go.mau.fi/mautrix-discord/database"
-)
-
-// Information to find out exactly which commit the bridge was built from.
-// These are filled at build time with the -X linker flag.
-var (
- Tag = "unknown"
- Commit = "unknown"
- BuildTime = "unknown"
-)
-
-//go:embed example-config.yaml
-var ExampleConfig string
-
-type DiscordBridge struct {
- bridge.Bridge
-
- Config *config.Config
- DB *database.Database
-
- DMA *DirectMediaAPI
- provisioning *ProvisioningAPI
-
- usersByMXID map[id.UserID]*User
- usersByID map[string]*User
- usersLock sync.Mutex
-
- managementRooms map[id.RoomID]*User
- managementRoomsLock sync.Mutex
-
- portalsByMXID map[id.RoomID]*Portal
- portalsByID map[database.PortalKey]*Portal
- portalsLock sync.Mutex
-
- threadsByID map[string]*Thread
- threadsByRootMXID map[id.EventID]*Thread
- threadsByCreationNoticeMXID map[id.EventID]*Thread
- threadsLock sync.Mutex
-
- guildsByMXID map[id.RoomID]*Guild
- guildsByID map[string]*Guild
- guildsLock sync.Mutex
-
- puppets map[string]*Puppet
- puppetsByCustomMXID map[id.UserID]*Puppet
- puppetsLock sync.Mutex
-
- attachmentTransfers *exsync.Map[attachmentKey, *exsync.ReturnableOnce[*database.File]]
- parallelAttachmentSemaphore *semaphore.Weighted
-}
-
-func (br *DiscordBridge) GetExampleConfig() string {
- return ExampleConfig
-}
-
-func (br *DiscordBridge) GetConfigPtr() interface{} {
- br.Config = &config.Config{
- BaseConfig: &br.Bridge.Config,
- }
- br.Config.BaseConfig.Bridge = &br.Config.Bridge
- return br.Config
-}
-
-func (br *DiscordBridge) Init() {
- br.CommandProcessor = commands.NewProcessor(&br.Bridge)
- br.RegisterCommands()
- br.EventProcessor.On(event.StateTombstone, br.HandleTombstone)
-
- matrixHTMLParser.PillConverter = br.pillConverter
-
- br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database"))
- discordLog = br.ZLog.With().Str("component", "discordgo").Logger()
-}
-
-func (br *DiscordBridge) Start() {
- if br.Config.Bridge.Provisioning.SharedSecret != "disable" {
- br.provisioning = newProvisioningAPI(br)
- }
- if br.Config.Bridge.PublicAddress != "" {
- br.AS.Router.HandleFunc("/mautrix-discord/avatar/{server}/{mediaID}/{checksum}", br.serveMediaProxy).Methods(http.MethodGet)
- }
- br.DMA = newDirectMediaAPI(br)
- br.WaitWebsocketConnected()
- go br.startUsers()
-}
-
-func (br *DiscordBridge) Stop() {
- for _, user := range br.usersByMXID {
- if user.Session == nil {
- continue
- }
-
- br.Log.Debugln("Disconnecting", user.MXID)
- user.Session.Close()
- }
-}
-
-func (br *DiscordBridge) GetIPortal(mxid id.RoomID) bridge.Portal {
- p := br.GetPortalByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *DiscordBridge) GetIUser(mxid id.UserID, create bool) bridge.User {
- p := br.GetUserByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *DiscordBridge) IsGhost(mxid id.UserID) bool {
- _, isGhost := br.ParsePuppetMXID(mxid)
- return isGhost
-}
-
-func (br *DiscordBridge) GetIGhost(mxid id.UserID) bridge.Ghost {
- p := br.GetPuppetByMXID(mxid)
- if p == nil {
- return nil
- }
- return p
-}
-
-func (br *DiscordBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost bridge.Ghost) {
- //TODO implement
-}
-
-func main() {
- br := &DiscordBridge{
- usersByMXID: make(map[id.UserID]*User),
- usersByID: make(map[string]*User),
-
- managementRooms: make(map[id.RoomID]*User),
-
- portalsByMXID: make(map[id.RoomID]*Portal),
- portalsByID: make(map[database.PortalKey]*Portal),
-
- threadsByID: make(map[string]*Thread),
- threadsByRootMXID: make(map[id.EventID]*Thread),
- threadsByCreationNoticeMXID: make(map[id.EventID]*Thread),
-
- guildsByID: make(map[string]*Guild),
- guildsByMXID: make(map[id.RoomID]*Guild),
-
- puppets: make(map[string]*Puppet),
- puppetsByCustomMXID: make(map[id.UserID]*Puppet),
-
- attachmentTransfers: exsync.NewMap[attachmentKey, *exsync.ReturnableOnce[*database.File]](),
- parallelAttachmentSemaphore: semaphore.NewWeighted(3),
- }
- br.Bridge = bridge.Bridge{
- Name: "mautrix-discord",
- URL: "https://github.com/mautrix/discord",
- Description: "A Matrix-Discord puppeting bridge.",
- Version: "0.7.7",
- ProtocolName: "Discord",
- BeeperServiceName: "discordgo",
- BeeperNetworkName: "discord",
-
- CryptoPickleKey: "maunium.net/go/mautrix-whatsapp",
-
- ConfigUpgrader: &configupgrade.StructUpgrader{
- SimpleUpgrader: configupgrade.SimpleUpgrader(config.DoUpgrade),
- Blocks: config.SpacedBlocks,
- Base: ExampleConfig,
- },
-
- Child: br,
- }
- br.InitVersion(Tag, Commit, BuildTime)
-
- br.Main()
-}
diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go
new file mode 100644
index 0000000..95f1c2b
--- /dev/null
+++ b/pkg/connector/backfill.go
@@ -0,0 +1,227 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "slices"
+ "strconv"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+var (
+ _ bridgev2.BackfillingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.BackfillingNetworkAPIWithLimits = (*DiscordClient)(nil)
+)
+
+func (d *DiscordClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) {
+ if !d.IsLoggedIn() {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ parentChannelID := discordid.ParseChannelPortalID(fetchParams.Portal.ID)
+ channelID := parentChannelID
+ threadChannelID := ""
+ var knownThreadRootID *networkid.MessageID
+
+ if fetchParams.ThreadRoot != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(fetchParams.ThreadRoot))
+ if err != nil {
+ return nil, err
+ }
+ if thread == nil {
+ return &bridgev2.FetchMessagesResponse{
+ Messages: nil,
+ HasMore: false,
+ }, nil
+ }
+ threadChannelID = thread.ThreadChannelID
+ channelID = threadChannelID
+ threadRootID := fetchParams.ThreadRoot
+ knownThreadRootID = &threadRootID
+ }
+
+ guildID := fetchParams.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ refererOpt := makeDiscordReferer(guildID, parentChannelID, threadChannelID)
+
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "fetch messages").
+ Str("channel_id", channelID).
+ Str("thread_channel_id", threadChannelID).
+ Int("desired_count", fetchParams.Count).
+ Bool("forward", fetchParams.Forward).Logger()
+ ctx = log.WithContext(ctx)
+
+ var beforeID string
+ var afterID string
+
+ if fetchParams.AnchorMessage != nil {
+ anchorID := discordid.ParseMessageID(fetchParams.AnchorMessage.ID)
+
+ if fetchParams.Forward {
+ afterID = anchorID
+ } else {
+ beforeID = anchorID
+ }
+ }
+
+ // ChannelMessages returns messages ordered from newest to oldest.
+ count := min(fetchParams.Count, 100)
+ log.Debug().Msg("Fetching channel history for backfill")
+ msgs, err := d.Session.ChannelMessages(channelID, count, beforeID, afterID, "", refererOpt)
+ if err != nil {
+ return nil, d.tryWrappingError(ctx, err)
+ }
+
+ // Update our user cache with all of the users present in the response. This
+ // indirectly makes `GetUserInfo` on `DiscordClient` return the information
+ // we've fetched above.
+ cachedDiscordUserIDs := d.userCache.UpdateWithMessages(msgs)
+
+ {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "update ghosts via fetched messages").
+ Logger()
+ ctx := log.WithContext(ctx)
+
+ // Update/create all of the ghosts for the users involved. This lets us
+ // set a correct per-message profile on each message, even for users
+ // that we've never seen until now.
+ for _, discordUserID := range cachedDiscordUserIDs {
+
+ ghost, err := d.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(discordUserID))
+ if err != nil {
+ log.Err(err).Str("ghost_id", discordUserID).
+ Msg("Failed to get ghost associated with message")
+ continue
+ }
+ ghost.UpdateInfoIfNecessary(ctx, d.UserLogin, bridgev2.RemoteEventMessage)
+ }
+ }
+
+ converted := make([]*bridgev2.BackfillMessage, 0, len(msgs))
+ provablyReadMessageCount := 0
+ for _, msg := range msgs {
+ parsedMsgID, _ := strconv.ParseInt(msg.ID, 10, 64)
+ msgTs, _ := discordgo.SnowflakeTimestamp(msg.ID)
+
+ readState := d.readStateForID(msg.ChannelID)
+ if readState != nil {
+ lastAckedMsgID, _ := strconv.ParseInt(string(readState.LastMessageID), 10, 64)
+ if lastAckedMsgID >= parsedMsgID {
+ provablyReadMessageCount += 1
+ }
+ }
+
+ // NOTE: For now, we aren't backfilling reactions. This is because:
+ //
+ // - Discord does not provide enough historical reaction data in the
+ // response from the message history endpoint to construct valid
+ // BackfillReactions.
+ // - Fetching the reaction data would be prohibitively expensive for
+ // messages with many reactions. Messages in large guilds can have
+ // tens of thousands of reactions.
+ // - Indicating aggregated child events[1] from BackfillMessage doesn't
+ // seem possible due to how portal backfilling batching currently
+ // works.
+ //
+ // [1]: https://spec.matrix.org/v1.16/client-server-api/#reference-relations
+ //
+ // It might be worth fetching the reaction data anyways if we observe
+ // a small overall number of reactions.
+ sender := d.makeEventSender(msg.Author)
+
+ // Use the ghost's intent, falling back to the bridge's.
+ ghost, err := d.connector.Bridge.GetGhostByID(ctx, sender.Sender)
+ if err != nil {
+ log.Err(err).Msg("Failed to look up ghost while converting backfilled message")
+ }
+ var intent bridgev2.MatrixAPI
+ if ghost == nil {
+ intent = fetchParams.Portal.Bridge.Bot
+ } else {
+ intent = ghost.Intent
+ }
+
+ converted = append(converted, &bridgev2.BackfillMessage{
+ ID: discordid.MakeMessageID(msg.ID),
+ ConvertedMessage: d.connector.MsgConv.ToMatrix(ctx, fetchParams.Portal, intent, d.UserLogin, d.Session, msg, knownThreadRootID),
+ Sender: sender,
+ Timestamp: msgTs,
+ StreamOrder: parsedMsgID,
+ })
+
+ if fetchParams.ThreadRoot == "" && msg.Flags&discordgo.MessageFlagsHasThread != 0 {
+ latest := ""
+ if msg.Thread != nil {
+ latest = msg.Thread.LastMessageID
+ }
+ if latest == "" {
+ latest = msg.ID
+ }
+ converted[len(converted)-1].ShouldBackfillThread = true
+ converted[len(converted)-1].LastThreadMessage = discordid.MakeMessageID(latest)
+ if err := d.upsertThreadInfoFromMessage(ctx, msg); err != nil {
+ log.Err(err).Str("message_id", msg.ID).Msg("Failed to store thread info while backfilling")
+ }
+ }
+ }
+ // FetchMessagesResponse expects messages to always be ordered from oldest to newest.
+ slices.Reverse(converted)
+
+ log.Debug().
+ Int("converted_count", len(converted)).
+ Int("provably_read_message_count", provablyReadMessageCount).
+ Msg("Finished fetching and converting, returning backfill response")
+
+ // It doesn't seem like we can express unreadness for every message, so do
+ // it for the entire batch. A single unread message makes the entire batch
+ // unread, even if some messages were actually read.
+ entireBatchWasProvablyRead := len(msgs) == provablyReadMessageCount
+ return &bridgev2.FetchMessagesResponse{
+ Messages: converted,
+ Forward: fetchParams.Forward,
+ MarkRead: entireBatchWasProvablyRead,
+ // This might not actually be true if the channel's total number of messages is itself a multiple
+ // of `count`, but that's probably okay.
+ HasMore: len(msgs) == count,
+ }, nil
+}
+
+func (d *DiscordClient) GetBackfillMaxBatchCount(
+ _ context.Context,
+ portal *bridgev2.Portal,
+ _ *database.BackfillTask,
+) int {
+ backfillQueueConfig := d.connector.Bridge.Config.Backfill.Queue
+
+ switch portal.RoomType {
+ case database.RoomTypeDM:
+ return backfillQueueConfig.GetOverride("dm")
+ case database.RoomTypeGroupDM:
+ return backfillQueueConfig.GetOverride("group_dm")
+ default:
+ return backfillQueueConfig.GetOverride("channel")
+ }
+}
diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go
new file mode 100644
index 0000000..20d9efe
--- /dev/null
+++ b/pkg/connector/capabilities.go
@@ -0,0 +1,180 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+
+ "go.mau.fi/util/ffmpeg"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/event"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+var DiscordGeneralCaps = &bridgev2.NetworkGeneralCapabilities{
+ // Aggressive ghost info updates let us refresh ghost profiles during
+ // backfill and when handling incoming messages, edits, and reactions.
+ // Otherwise, mautrix skips updating ghosts that already have a name and
+ // avatar set[1].
+ //
+ // Also, generally, it is cheap for DiscordClient's GetUserInfo to be
+ // called as it should mostly hit the user cache. Otherwise, it asks
+ // Discord's REST API for the user, which the first-party client itself
+ // does fairly liberally. Regardless, no Matrix calls occur if the profile
+ // didn't actually change, as Mautrix diffs.
+ //
+ // [1]: https://github.com/mautrix/go/blob/2b8e6caf43bea108e7fccacfa793fe46069ce3a8/bridgev2/ghost.go#L295
+ AggressiveUpdateInfo: true,
+
+ Provisioning: bridgev2.ProvisioningCapabilities{
+ ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{},
+ GroupCreation: map[string]bridgev2.GroupTypeCapabilities{},
+ },
+}
+
+func (d *DiscordConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
+ return DiscordGeneralCaps
+}
+
+func (d *DiscordConnector) GetBridgeInfoVersion() (info, caps int) {
+ return 1, 4
+}
+
+/*func supportedIfFFmpeg() event.CapabilitySupportLevel {
+ if ffmpeg.Supported() {
+ return event.CapLevelPartialSupport
+ }
+ return event.CapLevelRejected
+}*/
+
+func capID() string {
+ base := "fi.mau.discord.capabilities.2026_03_18"
+ if ffmpeg.Supported() {
+ return base + "+ffmpeg"
+ }
+ return base
+}
+
+// TODO: This limit is increased depending on user subscription status (Discord Nitro).
+const MaxTextLength = 2000
+
+// TODO: This limit is increased depending on user subscription status (Discord Nitro).
+// TODO: Verify this figure (10 MiB).
+const MaxFileSize = 10485760
+
+var discordCaps = &event.RoomFeatures{
+ ID: capID(),
+ Reply: event.CapLevelFullySupported,
+ Reaction: event.CapLevelFullySupported,
+ Edit: event.CapLevelFullySupported,
+ Delete: event.CapLevelFullySupported,
+ Formatting: event.FormattingFeatureMap{
+ event.FmtBold: event.CapLevelFullySupported,
+ event.FmtItalic: event.CapLevelFullySupported,
+ event.FmtStrikethrough: event.CapLevelFullySupported,
+ event.FmtInlineCode: event.CapLevelFullySupported,
+ event.FmtCodeBlock: event.CapLevelFullySupported,
+ event.FmtSyntaxHighlighting: event.CapLevelFullySupported,
+ event.FmtBlockquote: event.CapLevelFullySupported,
+ event.FmtInlineLink: event.CapLevelFullySupported,
+ event.FmtUserLink: event.CapLevelFullySupported,
+ event.FmtRoomLink: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtEventLink: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtAtRoomMention: event.CapLevelUnsupported, // TODO: Support.
+ event.FmtUnorderedList: event.CapLevelFullySupported,
+ event.FmtOrderedList: event.CapLevelFullySupported,
+ event.FmtListStart: event.CapLevelFullySupported,
+ event.FmtListJumpValue: event.CapLevelUnsupported,
+ event.FmtCustomEmoji: event.CapLevelUnsupported, // TODO: Support.
+ },
+ File: event.FileFeatureMap{
+ event.MsgImage: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "image/jpeg": event.CapLevelFullySupported,
+ "image/png": event.CapLevelFullySupported,
+ "image/gif": event.CapLevelFullySupported,
+ "image/webp": event.CapLevelFullySupported,
+ "image/*": event.CapLevelPartialSupport,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.MsgVideo: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "video/mp4": event.CapLevelFullySupported,
+ "video/webm": event.CapLevelFullySupported,
+ "video/*": event.CapLevelPartialSupport,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.MsgAudio: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "audio/mpeg": event.CapLevelFullySupported,
+ "audio/webm": event.CapLevelFullySupported,
+ "audio/wav": event.CapLevelFullySupported,
+ "audio/*": event.CapLevelPartialSupport,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.CapMsgVoice: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "audio/ogg; codecs=opus": event.CapLevelFullySupported,
+ "audio/ogg": event.CapLevelFullySupported,
+ "audio/webm; codecs=opus": event.CapLevelFullySupported,
+ "audio/webm": event.CapLevelFullySupported,
+ "audio/*": event.CapLevelPartialSupport,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.MsgFile: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "*/*": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ event.CapMsgGIF: {
+ MimeTypes: map[string]event.CapabilitySupportLevel{
+ "image/gif": event.CapLevelFullySupported,
+ },
+ Caption: event.CapLevelFullySupported,
+ MaxCaptionLength: MaxTextLength,
+ MaxSize: MaxFileSize,
+ },
+ },
+ LocationMessage: event.CapLevelUnsupported,
+ MaxTextLength: MaxTextLength,
+ Thread: event.CapLevelPartialSupport,
+}
+
+func (d *DiscordClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures {
+ if portal.Metadata.(*discordid.PortalMetadata).GuildID == "" {
+ caps := discordCaps.Clone()
+ caps.Thread = event.CapLevelUnsupported
+ return caps
+ }
+ return discordCaps
+}
diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go
new file mode 100644
index 0000000..f9542f0
--- /dev/null
+++ b/pkg/connector/chatinfo.go
@@ -0,0 +1,273 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/ptr"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+// getGuildSpaceInfo computes the [bridgev2.ChatInfo] for a guild space.
+func (d *DiscordClient) getGuildSpaceInfo(_ctx context.Context, guild *discordgo.Guild) (*bridgev2.ChatInfo, error) {
+ selfEvtSender := d.selfEventSender()
+
+ return &bridgev2.ChatInfo{
+ Name: &guild.Name,
+ Topic: nil,
+ Members: &bridgev2.ChatMemberList{
+ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
+ selfEvtSender.Sender: {EventSender: selfEvtSender},
+ },
+ // As recommended by the spec, prohibit normal events by setting
+ // events_default to a suitably high number.
+ PowerLevels: &bridgev2.PowerLevelOverrides{EventsDefault: ptr.Ptr(100)},
+ },
+ Avatar: d.makeAvatarForGuild(guild),
+ Type: ptr.Ptr(database.RoomTypeSpace),
+ }, nil
+}
+
+func portalIsPrivate(p *bridgev2.Portal) bool {
+ return p.RoomType == database.RoomTypeDM || p.RoomType == database.RoomTypeGroupDM
+}
+
+func channelIsPrivate(ch *discordgo.Channel) bool {
+ return ch.Type == discordgo.ChannelTypeDM || ch.Type == discordgo.ChannelTypeGroupDM
+}
+
+func readableChannelType(typ discordgo.ChannelType) (desc string) {
+ desc = "other"
+
+ switch typ {
+ case discordgo.ChannelTypeGuildText:
+ desc = "guild text"
+ case discordgo.ChannelTypeGuildNews:
+ desc = "guild news"
+ case discordgo.ChannelTypeDM:
+ desc = "dm"
+ case discordgo.ChannelTypeGroupDM:
+ desc = "group dm"
+ case discordgo.ChannelTypeGuildPublicThread:
+ desc = "public thread"
+ case discordgo.ChannelTypeGuildPrivateThread:
+ desc = "private thread"
+ }
+
+ return
+}
+
+func (d *DiscordClient) makeAvatarForChannel(ctx context.Context, ch *discordgo.Channel) *bridgev2.Avatar {
+ if channelIsPrivate(ch) {
+ return &bridgev2.Avatar{
+ ID: discordid.MakeAvatarID(ch.Icon),
+ Get: func(ctx context.Context) ([]byte, error) {
+ url := discordgo.EndpointGroupIcon(ch.ID, ch.Icon)
+ return httpGet(ctx, d.httpClient, url, "channel/gdm icon")
+ },
+ Remove: ch.Icon == "",
+ }
+ } else {
+ if !d.connector.Config.GuildAvatarsInRoomsEnabled() {
+ return nil
+ }
+
+ guild, err := d.Session.State.Guild(ch.GuildID)
+
+ if err != nil || guild == nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Couldn't look up guild in cache in order to create room avatar")
+ return nil
+ }
+
+ return d.makeAvatarForGuild(guild)
+ }
+}
+
+func (d *DiscordClient) getPrivateChannelMemberList(ch *discordgo.Channel) bridgev2.ChatMemberList {
+ var members bridgev2.ChatMemberList
+ members.IsFull = true
+ members.MemberMap = make(bridgev2.ChatMemberMap, len(ch.Recipients))
+
+ if len(ch.Recipients) > 0 {
+ selfEventSender := d.selfEventSender()
+
+ // Private channels' array of participants doesn't include ourselves,
+ // so inject ourselves as a member.
+ members.MemberMap[selfEventSender.Sender] = bridgev2.ChatMember{EventSender: selfEventSender}
+
+ for _, recipient := range ch.Recipients {
+ sender := d.makeEventSender(recipient)
+ members.MemberMap[sender.Sender] = bridgev2.ChatMember{EventSender: sender}
+ }
+
+ members.TotalMemberCount = len(ch.Recipients)
+ }
+
+ return members
+}
+
+func (d *DiscordClient) getChannelNameParams(ch *discordgo.Channel) *ChannelNameParams {
+ params := &ChannelNameParams{
+ Name: ch.Name,
+ Type: ch.Type,
+ NSFW: ch.NSFW,
+ IsDM: ch.Type == discordgo.ChannelTypeDM,
+ IsGroupDM: ch.Type == discordgo.ChannelTypeGroupDM,
+ IsCategory: ch.Type == discordgo.ChannelTypeGuildCategory,
+ IsGuildChannel: ch.GuildID != "",
+ }
+
+ if ch.ParentID != "" {
+ parent, err := d.Session.State.Channel(ch.ParentID)
+ if err == nil && parent != nil {
+ params.ParentName = parent.Name
+ }
+ }
+
+ if ch.GuildID != "" {
+ guild, err := d.Session.State.Guild(ch.GuildID)
+ if err == nil && guild != nil {
+ params.GuildName = guild.Name
+ }
+ }
+
+ return params
+}
+
+func (d *DiscordClient) getChannelName(ch *discordgo.Channel) *string {
+ if ch.Type == discordgo.ChannelTypeDM {
+ // Respect friend nicknames.
+ if len(ch.Recipients) > 0 {
+ if rel := d.relationshipWithUserID(ch.Recipients[0].ID); rel != nil && rel.Nickname != "" {
+ return &rel.Nickname
+ }
+ } else {
+ // Impossible?
+ }
+
+ return nil
+ }
+
+ name := d.connector.Config.FormatChannelName(d.getChannelNameParams(ch))
+ return &name
+}
+
+// getChannelChatInfo computes [bridgev2.ChatInfo] for a guild channel or private (DM or group DM) channel.
+func (d *DiscordClient) getChannelChatInfo(ctx context.Context, ch *discordgo.Channel) (*bridgev2.ChatInfo, error) {
+ var roomType database.RoomType
+ switch ch.Type {
+ case discordgo.ChannelTypeGuildCategory:
+ roomType = database.RoomTypeSpace
+ case discordgo.ChannelTypeDM:
+ roomType = database.RoomTypeDM
+ case discordgo.ChannelTypeGroupDM:
+ roomType = database.RoomTypeGroupDM
+ default:
+ roomType = database.RoomTypeDefault
+ }
+
+ var parentPortalID *networkid.PortalID
+ if ch.Type == discordgo.ChannelTypeGuildCategory || (ch.ParentID == "" && ch.GuildID != "") {
+ // Categories and uncategorized guild channels always have the guild as their parent.
+ parentPortalID = ptr.Ptr(discordid.MakeGuildPortalIDWithID(ch.GuildID))
+ } else if ch.ParentID != "" {
+ // Categorized guild channels.
+ parentPortalID = ptr.Ptr(discordid.MakeChannelPortalIDWithID(ch.ParentID))
+ }
+
+ var memberList bridgev2.ChatMemberList
+ if channelIsPrivate(ch) {
+ memberList = d.getPrivateChannelMemberList(ch)
+ } else {
+ // TODO we're _always_ sending partial member lists for guilds; we can probably
+ // do better than that
+ selfEventSender := d.selfEventSender()
+
+ memberList = bridgev2.ChatMemberList{
+ IsFull: false,
+ MemberMap: map[networkid.UserID]bridgev2.ChatMember{
+ selfEventSender.Sender: {EventSender: selfEventSender},
+ },
+ }
+ }
+
+ return &bridgev2.ChatInfo{
+ Name: d.getChannelName(ch),
+ Topic: &ch.Topic,
+ Avatar: d.makeAvatarForChannel(ctx, ch),
+
+ Members: &memberList,
+
+ Type: &roomType,
+ ParentID: parentPortalID,
+
+ UserLocal: &bridgev2.UserLocalPortalInfo{
+ MutedUntil: ptr.Ptr(d.channelMutedUntil(ch.GuildID, ch.ID)),
+ },
+ CanBackfill: true,
+
+ ExtraUpdates: func(ctx context.Context, portal *bridgev2.Portal) (changed bool) {
+ meta := portal.Metadata.(*discordid.PortalMetadata)
+ if meta.GuildID != ch.GuildID {
+ meta.GuildID = ch.GuildID
+ changed = true
+ }
+ if meta.ChannelType == nil || *meta.ChannelType != ch.Type {
+ meta.ChannelType = ptr.Ptr(ch.Type)
+ changed = true
+ }
+
+ return
+ },
+ }, nil
+}
+
+func (d *DiscordClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
+ if d.Session == nil {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ guildID := discordid.ParseGuildPortalID(portal.ID)
+ if guildID != "" {
+ // Portal is a space representing a Discord guild.
+
+ guild, err := d.Session.State.Guild(guildID)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't get guild: %w", err)
+ }
+
+ return d.getGuildSpaceInfo(ctx, guild)
+ } else {
+ // Portal is to a channel of some kind (private or guild).
+ channelID := discordid.ParseChannelPortalID(portal.ID)
+
+ ch, err := d.Session.State.Channel(channelID)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't get channel: %w", err)
+ }
+
+ return d.getChannelChatInfo(ctx, ch)
+ }
+}
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
new file mode 100644
index 0000000..e1da843
--- /dev/null
+++ b/pkg/connector/client.go
@@ -0,0 +1,1399 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "iter"
+ "maps"
+ "net/http"
+ "regexp"
+ "slices"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/coder/websocket"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/bridgev2/simplevent"
+ "maunium.net/go/mautrix/bridgev2/status"
+ "maunium.net/go/mautrix/event"
+
+ "go.mau.fi/util/exmaps"
+
+ "go.mau.fi/mautrix-discord/pkg/discordauth"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+type DiscordClient struct {
+ connector *DiscordConnector
+ UserLogin *bridgev2.UserLogin
+ Session *discordgo.Session
+ httpClient *http.Client
+
+ stopConnecting atomic.Pointer[context.CancelFunc]
+ fullSyncDone atomic.Bool // inverted (i.e. not needsInitSync) so zero value is "correct"
+ // seenReady is used to discern the initial READY payload from ones
+ // received during reconnections (where resumption is not possible).
+ seenReady atomic.Bool
+
+ markedOpened map[string]time.Time
+ markedOpenedLock sync.Mutex
+
+ // A map of guild ID (or "" for the settings concerning private channels)
+ // to its corresponding UserGuildSettings.
+ guildSettings map[string]*discordgo.UserGuildSettings
+ guildSettingsLock sync.RWMutex
+
+ // A map of resource (e.g. channel) ID to its corresponding read state.
+ //
+ // Since there can be thousands of read state entries, the map is to help
+ // keep lookups by channel ID speedy by avoiding constant linear searching.
+ readStates map[string]*discordgo.ReadState
+ readStatesLock sync.RWMutex
+
+ relationshipLock sync.RWMutex
+ relationships map[string]*discordgo.Relationship
+
+ userCache *UserCache
+
+ lastSendAttemptMutex sync.Mutex
+ lastSendAttempt *SendAttempt
+
+ vitalsMu sync.Mutex // guards vitals and safetyHub
+ vitals *vitals
+ safetyHub *discordgo.SafetyHub // last fetched safety hub information when permitted
+}
+
+func (d *DiscordConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
+ meta := login.Metadata.(*discordid.UserLoginMetadata)
+
+ var session *discordgo.Session
+ if meta.Token == "" {
+ login.Log.Warn().Msg("Login has no token, not setting up a session")
+ // Session on the UserLogin will be nil.
+ } else {
+ var err error
+ session, err = NewDiscordSession(ctx, d.Bridge.GetHTTPClientSettings(), meta.Token)
+ if err != nil {
+ return err
+ }
+ }
+
+ cl := DiscordClient{
+ connector: d,
+ UserLogin: login,
+ Session: session,
+ // This HTTP client is quickly overridden by a proxied version (when
+ // one is configured).
+ httpClient: d.Bridge.GetHTTPClientSettings().Compile(),
+ userCache: NewUserCache(session),
+ guildSettings: make(map[string]*discordgo.UserGuildSettings),
+ readStates: make(map[string]*discordgo.ReadState),
+ relationships: make(map[string]*discordgo.Relationship),
+ }
+ login.Client = &cl
+
+ if session != nil {
+ session.RESTResponseHook = cl.tapDiscordRESTResponse
+ session.BeforeReconnect = func(*discordgo.Session) {
+ c := login.Client.(*DiscordClient)
+ if c.connector.proxyConfigured() && !c.updateProxy(c.connector.Bridge.BackgroundCtx, "reconnect") {
+ // Failed to update the proxy. Continue reconnecting via the
+ // last good proxy, but report the failure.
+ c.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateTransientDisconnect,
+ Error: DCProxyResolveFail,
+ })
+ }
+ }
+ }
+
+ return nil
+}
+
+var _ bridgev2.NetworkAPI = (*DiscordClient)(nil)
+
+func (d *DiscordClient) userLoginMetadata() *discordid.UserLoginMetadata {
+ return d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
+}
+
+func (d *DiscordClient) Connect(ctx context.Context) {
+ log := zerolog.Ctx(ctx)
+
+ lacksToken := !d.HasToken()
+ lacksSession := d.Session == nil
+ if lacksToken || lacksSession {
+ // (d.Session can be nil if we lacked credentials on startup.)
+ log.Warn().Bool("lacking_token", lacksToken).
+ Bool("lacking_session", lacksSession).
+ Msg("Refusing to connect")
+
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ Error: DCNotLoggedIn,
+ UserAction: status.UserActionRelogin,
+ })
+ return
+ }
+
+ meta := d.userLoginMetadata()
+ if meta.HeartbeatSession.IsExpired() {
+ log.Info().Msg("Heartbeat session expired, creating a new one")
+ meta.HeartbeatSession = discordgo.NewHeartbeatSession()
+ }
+ meta.HeartbeatSession.BumpLastUsed()
+ d.Session.HeartbeatSession = meta.HeartbeatSession
+
+ d.markedOpened = make(map[string]time.Time)
+
+ d.connectRetrying(ctx, 0)
+}
+
+func vitalsErrorCode(v *vitals) (code status.BridgeStateErrorCode) {
+ if v == nil {
+ return
+ }
+
+ // TODO: Arrange the code assignments based on what actually takes priority
+ // in Discord's UI.
+
+ // TODO: This is somewhat isomorphic to RequiresUserIntervention, so maybe
+ // we can just define a set of intervention codes (?) in discordgo?
+
+ switch v.RequiredAction {
+ case discordgo.RequireAgreements:
+ code = DCRequireAgreements
+ case discordgo.RequireVerifiedEmail:
+ code = DCRequireVerifiedEmail
+ case discordgo.RequireVerifiedPhone:
+ code = DCRequireVerifiedPhone
+ case discordgo.RequireReverifiedEmail:
+ code = DCRequireReverifiedEmail
+ case discordgo.RequireReverifiedPhone:
+ code = DCRequireReverifiedPhone
+ case discordgo.RequireVerifiedEmailOrVerifiedPhone:
+ code = DCRequireVerifiedEmailOrVerifiedPhone
+ case discordgo.RequireReverifiedEmailOrVerifiedPhone:
+ code = DCRequireReverifiedEmailOrVerifiedPhone
+ case discordgo.RequireVerifiedEmailOrReverifiedPhone:
+ code = DCRequireVerifiedEmailOrReverifiedPhone
+ case discordgo.RequireReverifiedEmailOrReverifiedPhone:
+ code = DCRequireReverifiedEmailOrReverifiedPhone
+ case discordgo.RequireSafetyFlows:
+ code = DCRequireSafetyFlows
+ }
+
+ if v.HasUnreadSystemMessages {
+ code = DCUnreadSystemMessages
+ }
+
+ return
+}
+
+func (d *DiscordClient) sendCurrentState(ctx context.Context) {
+ log := zerolog.Ctx(ctx)
+
+ vitals := d.peekVitals()
+
+ info := make(map[string]any)
+ if vi, err := vitals.infoMap(); err == nil {
+ info["vitals"] = vi
+ } else {
+ log.Err(err).Msg("Failed to compute vitals info map, omitting from bridge state")
+ }
+
+ if vitals.RequiresUserIntervention() {
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ Error: vitalsErrorCode(vitals),
+ UserAction: status.UserActionOpenNative,
+ Info: info,
+ })
+ return
+ }
+
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateConnected,
+ Info: info,
+ })
+}
+
+const maxGatewayConnectRetries = 5
+
+// tokenInvalidated responds to Discord invalidating our token.
+func (d *DiscordClient) tokenInvalidated(ctx context.Context, circumstance string) {
+ log := zerolog.Ctx(ctx)
+ log.Info().Msg("Invalidating user login")
+
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ Error: DCWebsocketDisconnect4004,
+ UserAction: status.UserActionRelogin,
+ })
+
+ props := d.baseAnalyticsProps(ctx)
+ props["circumstance"] = circumstance
+ d.UserLogin.TrackAnalytics("Discord auth invalidation", props)
+
+ // Empty out the token.
+ log.Debug().Msg("Emptying token")
+ meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
+ meta.Token = ""
+ if err := d.UserLogin.Save(ctx); err != nil {
+ log.Err(err).Msg("Failed to save user login in order to invalidate session")
+ }
+}
+
+func (d *DiscordClient) connectRetrying(ctx context.Context, retryCount int) {
+ retryCtx, cancel := context.WithCancel(ctx)
+ oldStop := d.stopConnecting.Swap(&cancel)
+ if oldStop != nil {
+ (*oldStop)()
+ }
+
+ log := zerolog.Ctx(ctx).With().Int("retry_count", retryCount).Logger()
+
+ log.Debug().Msg("Connecting to Discord")
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateConnecting,
+ })
+
+ if d.connector.proxyConfigured() && !d.updateProxy(ctx, "connect") {
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateUnknownError,
+ Error: DCProxyResolveFail,
+ })
+ return
+ }
+
+ err := d.connect(ctx)
+ if err != nil {
+ log.Err(err).Msg("Couldn't connect to Discord")
+
+ if websocket.CloseStatus(err) == 4004 {
+ // Effectively the same as *discordgo.InvalidAuth, but at connect
+ // time. (discordgo only dispatches the synthetic InvalidAuth event
+ // once you've already connected successfully.)
+ //
+ // Don't retry.
+ d.tokenInvalidated(ctx, "when connecting")
+ } else if retryCount <= maxGatewayConnectRetries {
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateTransientDisconnect,
+ Error: DCUnknownWebsocketError,
+ Message: err.Error(),
+ })
+
+ sleepDuration := time.Second * time.Duration(2< 0
+ log.Trace().
+ Int64("permissions", perms).
+ Bool("channel_visible", canView).
+ Msg("Computed visibility of guild channel")
+ return canView
+}
+
+func (d *DiscordClient) makeAvatarForGuild(guild *discordgo.Guild) *bridgev2.Avatar {
+ return &bridgev2.Avatar{
+ ID: discordid.MakeAvatarID(guild.Icon),
+ Get: func(ctx context.Context) ([]byte, error) {
+ url := discordgo.EndpointGuildIcon(guild.ID, guild.Icon)
+ return httpGet(ctx, d.httpClient, url, "guild icon")
+ },
+ Remove: guild.Icon == "",
+ }
+}
+
+// bridgedGuildIDs returns a set of guild IDs that should be bridged. Note that
+// presence in the returned set does not imply anything about the corresponding
+// portals and rooms.
+func (d *DiscordClient) bridgedGuildIDs() map[string]struct{} {
+ meta := d.UserLogin.Metadata.(*discordid.UserLoginMetadata)
+ bridgingGuildIDs := map[string]struct{}{}
+
+ // guilds that were bridged via the provisioning api
+ for guildID, bridged := range meta.BridgedGuildIDs {
+ if bridged {
+ bridgingGuildIDs[guildID] = struct{}{}
+ }
+ }
+
+ // guilds that were declared in the configuration file
+ for _, guildID := range d.connector.Config.Guilds.BridgingGuildIDs {
+ bridgingGuildIDs[guildID] = struct{}{}
+ }
+
+ return bridgingGuildIDs
+}
+
+func (d *DiscordClient) syncGuilds(ctx context.Context) {
+ guildIDs := slices.Sorted(maps.Keys(d.bridgedGuildIDs()))
+
+ for _, guildID := range guildIDs {
+ log := zerolog.Ctx(ctx).With().
+ Str("guild_id", guildID).
+ Str("action", "sync guild").
+ Logger()
+
+ err := d.syncGuild(log.WithContext(ctx), guildID)
+ if err != nil {
+ log.Err(err).Msg("Couldn't bridge guild during sync")
+ }
+ }
+}
+
+// ensurePortal _synchronously_ guarantees the existence of a portal's Matrix
+// room with up-to-date chat info.
+//
+// This is especially useful in situations where the ordering of room creation
+// is important.
+//
+// If info is nil, then the chat info is fetched from the NetworkAPI.
+//
+// If the portal already has a room, then we merely ensure that the portal's
+// info is up-to-date.
+func (d *DiscordClient) ensurePortal(ctx context.Context, key networkid.PortalKey, info *bridgev2.ChatInfo) error {
+ portal, err := d.connector.Bridge.GetPortalByKey(ctx, key)
+ if err != nil {
+ return fmt.Errorf("failed to get portal: %w", err)
+ }
+
+ if info == nil {
+ info, err = d.GetChatInfo(ctx, portal)
+ if err != nil {
+ return fmt.Errorf("failed to get chat info: %w", err)
+ }
+ }
+
+ if portal.MXID == "" {
+ // CreateMatrixRoom will indirectly lead to UpdateInfo being called.
+ if err := portal.CreateMatrixRoom(ctx, d.UserLogin, info); err != nil {
+ return fmt.Errorf("failed to create matrix room: %w", err)
+ }
+ } else {
+ portal.UpdateInfo(ctx, info, d.UserLogin, nil, time.Time{})
+ }
+
+ return nil
+}
+
+// queueGuildDeletion should be called to evict a guild from the bridge, e.g.
+// whenever it is determined that the user has left a Discord guild that is
+// currently bridged.
+//
+// The following occurs:
+//
+// - An attempt is made to delete all of the roles associated with the given
+// guild. This will proceed even upon error.
+// - A Matrix event is queued to delete the guild space and all of its contained
+// rooms.
+func (d *DiscordClient) queueGuildDeletion(
+ ctx context.Context,
+ guildID string,
+) {
+ log := zerolog.Ctx(ctx).With().
+ Str("guild_id", guildID).
+ Str("action", "queue guild deletion").
+ Logger()
+
+ // TODO: This is deleting roles globally. Other logins might still be in
+ // the guild.
+ if err := d.connector.DB.Role.DeleteByGuildID(ctx, guildID); err != nil {
+ // Best effort.
+ log.Err(err).Msg("Failed to delete guild roles from database, proceeding to delete guild space anyways")
+ }
+
+ log.Info().Msg("Queueing event to recursively delete the guild space")
+ d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.ChatDelete{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatDelete,
+ PortalKey: d.guildPortalKey(guildID),
+ },
+ OnlyForMe: true,
+ Children: true,
+ })
+}
+
+// reconcileGuildSpaces examines all existing guild spaces and deletes those
+// that do not appear in the provided set of guild IDs.
+func (d *DiscordClient) reconcileGuildSpaces(
+ ctx context.Context,
+ guildIDs exmaps.Set[string],
+) {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "reconcile guilds").
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ for portal := range d.existingPortals(ctx) {
+ guildID := discordid.ParseGuildPortalID(portal.ID)
+ if guildID == "" {
+ // Portal isn't a guild space.
+ continue
+ }
+ if guildIDs.Has(guildID) {
+ // Still a member of the guild.
+ continue
+ }
+ log := log.With().
+ Str("guild_id", guildID).
+ Logger()
+ ctx := log.WithContext(ctx)
+
+ log.Info().Msg("Guild no longer appears in READY payload (user has left), queueing deletion")
+ d.queueGuildDeletion(ctx, guildID)
+ }
+}
+
+// shouldBridgeChannel reports whether a channel should be bridged. This
+// considers information such as the type of the channel, the user's effective
+// permissions within the guild, which guilds are bridged, etc.
+func (d *DiscordClient) shouldBridgeChannel(
+ ctx context.Context,
+ ch *discordgo.Channel,
+) bool {
+ if ch == nil {
+ return false
+ }
+
+ // TODO(skip): This method is relatively hot, consider maintaining the set
+ // of bridged guild IDs in memory?
+ bridgedGuildIDs := d.bridgedGuildIDs()
+ if ch.GuildID != "" {
+ if _, ok := bridgedGuildIDs[ch.GuildID]; !ok {
+ // Only bridge guild channels that are part of bridged guilds.
+ return false
+ }
+ }
+
+ // Only ever bridge guild text channels.
+ // TODO(skip): Consider bridging voice channels (make sure to check for the
+ // right permission bits)?
+ if ch.Type != discordgo.ChannelTypeGuildText && ch.Type != discordgo.ChannelTypeGuildNews {
+ return false
+ }
+
+ if !d.canSeeGuildChannel(ctx, ch) {
+ return false
+ }
+
+ return true
+}
+
+func (d *DiscordClient) syncGuild(ctx context.Context, guildID string) error {
+ log := zerolog.Ctx(ctx).With().
+ Str("guild_id", guildID).
+ Str("action", "bridge guild").
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ guild, err := d.Session.State.Guild(guildID)
+ if errors.Is(err, discordgo.ErrStateNotFound) || guild == nil {
+ // This isn't problematic per se, because we can get here if a guild is
+ // unavailable due to an outage; when that happens, the guild is
+ // removed from the state entirely (via GUILD_DELETE).
+ log.Warn().Err(err).
+ Msg("Cannot sync guild that is not present in state")
+ return errors.New("couldn't find guild in state")
+ }
+
+ if err = d.syncGuildRoles(ctx, guildID, guild.Roles); err != nil {
+ return fmt.Errorf("failed to sync guild roles during guild sync: %w", err)
+ }
+
+ // Synchronously guarantee the proper creation of the guild space portal so
+ // child rooms are born with the correct `m.bridge` state.
+ portalKey := d.guildPortalKey(guild.ID)
+ if err := d.ensurePortal(ctx, portalKey, nil); err != nil {
+ return fmt.Errorf("failed to ensure guild space portal: %w", err)
+ }
+
+ visibleCategoryIDs := make(exmaps.Set[string])
+ visibleChannels := make([]*discordgo.Channel, 0, len(guild.Channels))
+ for _, guildCh := range guild.Channels {
+ if !d.shouldBridgeChannel(ctx, guildCh) {
+ continue
+ }
+ visibleChannels = append(visibleChannels, guildCh)
+ if guildCh.ParentID != "" {
+ visibleCategoryIDs.Add(guildCh.ParentID)
+ }
+ }
+ // Synchronously guarantee the proper creation of category space portals
+ // for the same reason that we do so for guild space portals.
+ //
+ // Note that we only care about syncing categories that contain at least
+ // one channel we can actually see. This matches the behavior of Discord's
+ // first-party clients. The permission bits on the category channel
+ // _itself_ are irrelevant.
+ for categoryID := range visibleCategoryIDs.Iter() {
+ category := d.channelWithID(ctx, categoryID)
+ if category == nil {
+ log.Error().Str("channel_id", categoryID).Msg("Failed to find category channel somehow, proceeding")
+ continue
+ }
+
+ err := d.ensurePortal(ctx, d.portalKeyForChannel(category), nil)
+ if err != nil {
+ log.Err(err).Msg("Failed to ensure category space, proceeding")
+ // FIXME The children of this category channel will still be synced
+ // but with bogus `m.bridge` state.
+ }
+ }
+ // Now that all possible parent spaces exist, we can fan out the syncing of
+ // all guild channels we can see.
+ for _, visibleCh := range visibleChannels {
+ d.queueChannelResync(ctx, visibleCh)
+ }
+
+ for _, thread := range guild.Threads {
+ err = d.upsertThreadInfoFromChannel(ctx, thread)
+ if err != nil {
+ log.Err(err).Str("thread_id", thread.ID).Msg("Failed to cache thread info during guild sync")
+ }
+ }
+
+ d.subscribeGuild(ctx, guildID)
+
+ return nil
+}
+
+func (d *DiscordClient) subscribeGuild(ctx context.Context, guildID string) {
+ log := zerolog.Ctx(ctx)
+
+ log.Debug().Msg("Subscribing to guild")
+ err := d.Session.SubscribeGuild(discordgo.GuildSubscribeData{
+ GuildID: guildID,
+ Typing: true,
+ Activities: true,
+ Threads: true,
+ })
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to subscribe to guild, proceeding")
+ }
+}
+
+func httpGet(ctx context.Context, httpClient *http.Client, url, thing string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to prepare request: %w", err)
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to download %s: %w", thing, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode > 300 {
+ return nil, fmt.Errorf("failed to download %s: got HTTP %d", thing, resp.StatusCode)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read %s data: %w", thing, err)
+ }
+ return data, nil
+}
+
+func (d *DiscordClient) makeEventSenderWithID(userID string) bridgev2.EventSender {
+ return bridgev2.EventSender{
+ IsFromMe: userID == d.Session.State.User.ID,
+ SenderLogin: discordid.MakeUserLoginID(userID),
+ Sender: discordid.MakeUserID(userID),
+ }
+}
+
+func (d *DiscordClient) selfEventSender() bridgev2.EventSender {
+ return d.makeEventSenderWithID(d.Session.State.User.ID)
+}
+
+func (d *DiscordClient) makeEventSender(user *discordgo.User) bridgev2.EventSender {
+ if user == nil {
+ panic("DiscordClient makeEventSender was passed a nil user")
+ }
+
+ return d.makeEventSenderWithID(user.ID)
+}
+
+func (d *DiscordClient) queueChannelResync(_ context.Context, ch *discordgo.Channel) {
+ d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
+ Client: d,
+ channel: ch,
+ createPortal: true,
+ })
+}
+
+func (d *DiscordClient) queueExistingChannelResync(_ context.Context, ch *discordgo.Channel) {
+ d.connector.Bridge.QueueRemoteEvent(d.UserLogin, &DiscordChatResync{
+ Client: d,
+ channel: ch,
+ createPortal: false,
+ })
+}
+
+func (d *DiscordClient) readStateForID(resourceID string) *discordgo.ReadState {
+ d.readStatesLock.RLock()
+ defer d.readStatesLock.RUnlock()
+
+ return d.readStates[resourceID]
+}
+
+func (d *DiscordClient) computeMutedUntil(muted bool, cfg *discordgo.MuteConfig) time.Time {
+ if !muted {
+ return bridgev2.Unmuted
+ }
+
+ // If Muted is true but we don't have a MuteConfig, then the mute is
+ // indefinite.
+ if cfg == nil {
+ return event.MutedForever
+ }
+
+ // Check for the explicit "forever" time window.
+ if cfg.SelectedTimeWindow != nil && *cfg.SelectedTimeWindow == -1 {
+ return event.MutedForever
+ }
+
+ endTime := cfg.EndTime
+ if endTime == nil {
+ d.UserLogin.Log.Warn().
+ Bool("muted", muted).
+ Any("mute_config", cfg).
+ Msg("Encountered bogus mute state, falling back to indefinite mute")
+ return event.MutedForever
+ }
+ return *endTime
+}
+
+// channelMutedUntil computes an appropriate UserLocalPortalInfo.MutedUntil time
+// for a given channel.
+//
+// This method works with private channels if an empty string is passed as the
+// guild ID.
+func (d *DiscordClient) channelMutedUntil(guildID string, channelID string) time.Time {
+ settings := d.guildSettingsForGuildID(guildID)
+ if settings == nil {
+ return bridgev2.Unmuted
+ }
+
+ // TODO: Might be worth speeding this up via map.
+ for _, override := range settings.ChannelOverrides {
+ if override.ChannelID == channelID {
+ return d.computeMutedUntil(override.Muted, override.MuteConfig)
+ }
+ }
+
+ return d.computeMutedUntil(settings.Muted, settings.MuteConfig)
+}
+
+func (d *DiscordClient) guildSettingsForGuildID(guildID string) *discordgo.UserGuildSettings {
+ d.guildSettingsLock.RLock()
+ defer d.guildSettingsLock.RUnlock()
+
+ return d.guildSettings[guildID]
+}
+
+func (d *DiscordClient) channelWithID(ctx context.Context, channelID string) *discordgo.Channel {
+ if d.Session == nil {
+ return nil
+ }
+
+ ch, err := d.Session.State.Channel(channelID)
+ if err != nil {
+ if errors.Is(err, discordgo.ErrStateNotFound) {
+ return nil
+ }
+
+ // Some other weird error happened. This is currently impossible but it's
+ // best to not rely on implementation details.
+ zerolog.Ctx(ctx).Err(err).
+ Str("channel_id", channelID).
+ Msg("Failed to look up channel")
+ return nil
+ }
+
+ return ch
+}
+
+func (d *DiscordClient) syncRemoteProfile(ctx context.Context) bool {
+ if !d.IsLoggedIn() {
+ return false
+ }
+
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "sync remote discord profile").
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ me := d.Session.State.User
+ if me == nil {
+ return false
+ }
+
+ log.Debug().Msg("Updating remote profile if needed")
+ changed := false
+ remoteName := makeRemoteName(me)
+
+ // Try to update our own ghost, which should upload the avatar if
+ // everything goes well.
+ ghost, err := d.connector.Bridge.GetGhostByID(ctx, discordid.MakeUserID(me.ID))
+ if err != nil {
+ log.Err(err).Msg("Failed to get own ghost, remote profile will lack an avatar")
+ } else if info, err := d.GetUserInfo(ctx, ghost); err != nil {
+ // Shouldn't happen as the user cache shouldn't even reach out to the
+ // network; our own user should be there by now.
+ log.Err(err).Msg("Failed to get own user info")
+ } else {
+ log.Debug().Msg("Updating own ghost with user info")
+ ghost.UpdateInfo(ctx, info)
+ }
+
+ profile := makeRemoteProfile(me, ghost)
+ if d.UserLogin.RemoteName != remoteName {
+ d.UserLogin.RemoteName = remoteName
+ changed = true
+ }
+ if d.UserLogin.RemoteProfile != profile {
+ d.UserLogin.RemoteProfile = profile
+ changed = true
+ }
+
+ if changed {
+ if err := d.UserLogin.Save(ctx); err != nil {
+ log.Err(err).Msg("Failed to save UserLogin while updating remote profile")
+ }
+ }
+ return changed
+ // NOTE: For clients to immediately get the new remote profile, you need to
+ // send a bridge state.
+}
+
+func (d *DiscordClient) resyncGhostsFromReady(ctx context.Context, ready *discordgo.Ready) {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "resync ghosts from ready").
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ scanned := 0
+ resynced := 0
+ for _, user := range ready.Users {
+ if ctx.Err() != nil {
+ return
+ }
+ scanned++
+
+ // TODO: For now, do not actively materialize ghosts by calling e.g.
+ // GetGhostByID. Before we consider switching to that method, verify
+ // the breadth of the users returned in READY by inspecting a payload.
+ ghost, err := d.connector.Bridge.GetExistingGhostByID(ctx, discordid.MakeUserID(user.ID))
+ if err != nil {
+ log.Err(err).Str("user_id", user.ID).
+ Msg("Failed to look up existing ghost while resyncing from READY")
+ continue
+ }
+ if ghost == nil {
+ // We've never bridged this user, so don't materialize a ghost.
+ continue
+ }
+
+ ghost.UpdateInfo(ctx, d.getUserInfo(ctx, user))
+ resynced++
+ }
+
+ log.Debug().
+ Int("n_ghosts_scanned", scanned).
+ Int("n_ghosts_resynced", resynced).
+ Msg("Finished resyncing ghosts from READY")
+}
+
+func (d *DiscordClient) wrapReceived40002(ctx context.Context, err error) error {
+ log := zerolog.Ctx(ctx)
+ log.Err(err).Msg("Received 40002 from Discord")
+
+ props := d.baseAnalyticsProps(ctx)
+ props["errorMessage"] = err.Error()
+ d.UserLogin.TrackAnalytics("Discord account verification required", props)
+
+ // TODO: Make this bridge state actually sticky/latching. This needs to
+ // stop the backfill loops.
+ d.UserLogin.BridgeState.Send(status.BridgeState{
+ StateEvent: status.StateBadCredentials,
+ UserAction: status.UserActionOpenNative,
+ Error: DCHTTP40002,
+ })
+
+ return bridgev2.WrapErrorInStatus(err).
+ // Tell clients to not retry.
+ WithStatus(event.MessageStatusFail).
+ WithIsCertain(true).
+ WithMessage(accountVerificationRequiredMessage).
+ WithSendNotice(true)
+}
+
+func (d *DiscordClient) tryWrappingError(ctx context.Context, err error) error {
+ if err == nil {
+ return nil
+ }
+
+ var restErr *discordgo.RESTError
+
+ if errors.As(err, &restErr) && restErr.Message != nil {
+ if restErr.Message.Code == discordgo.ErrCodeActionRequiredVerifiedAccount {
+ return d.wrapReceived40002(ctx, err)
+ }
+ }
+
+ return err
+}
+
+var snowflakeish = regexp.MustCompile(`\d{17,}`)
+
+func redactDiscordRESTPath(path string) string {
+ return snowflakeish.ReplaceAllLiteralString(path, "...")
+}
+
+func dmChannelRecipientID(ch *discordgo.Channel) *string {
+ if ch == nil {
+ return nil
+ }
+ if ch.Type != discordgo.ChannelTypeDM {
+ return nil
+ }
+ if len(ch.Recipients) != 1 {
+ return nil
+ }
+
+ return &ch.Recipients[0].ID
+}
+
+func (d *DiscordClient) baseAnalyticsProps(ctx context.Context) map[string]any {
+ props := make(map[string]any)
+ if ctx == nil {
+ return props
+ }
+
+ ch, ok := ctx.Value(contextKeyChannel).(*discordgo.Channel)
+ if ok && ch != nil {
+ risky := false
+ props["channelType"] = readableChannelType(ch.Type)
+
+ if recipientID := dmChannelRecipientID(ch); recipientID != nil {
+ relationshipDesc := "none"
+ if rel := d.relationshipWithUserID(*recipientID); rel != nil {
+ relationshipDesc = readableRelationshipType(rel.Type)
+ } else if ch.Type == discordgo.ChannelTypeDM {
+ // No relationship with the recipient and it's a 1:1 DM.
+ risky = true
+ }
+
+ props["relationshipWithRecipient"] = relationshipDesc
+ props["risky"] = risky
+ }
+ }
+
+ d.lastSendAttemptMutex.Lock()
+ if attempt := d.lastSendAttempt; attempt != nil {
+ props["lastInMemorySendAttemptAgeMs"] = time.Since(attempt.At).Milliseconds()
+ props["lastInMemorySendAttemptChannelType"] = readableChannelType(attempt.ChannelType)
+ if relType := attempt.RecipientRelationshipType; relType != nil {
+ props["lastInMemorySendAttemptRecipientRelationshipType"] = readableRelationshipType(*relType)
+ }
+ }
+ d.lastSendAttemptMutex.Unlock()
+
+ return props
+}
+
+func (d *DiscordClient) tapDiscordRESTResponse(req *http.Request, resp *http.Response, body []byte) {
+ // NOTE: discordgo calls this in a blocking fashion after reading the HTTP
+ // response from Discord, so don't block here.
+ ctx := context.Background()
+
+ if d.Session != nil && !d.Session.IsUser {
+ return
+ }
+
+ captcha := discordauth.CheckCaptcha(ctx, resp, body)
+ if captcha == nil {
+ return
+ }
+
+ redactedEndpoint := redactDiscordRESTPath(req.URL.Path)
+ props := d.baseAnalyticsProps(req.Context())
+ maps.Copy(props, map[string]any{
+ "apiEndpoint": redactedEndpoint,
+ "httpMethod": req.Method,
+ "captchaService": string(captcha.Service),
+ "captchaInvisible": captcha.Invisible,
+ "captchaUserFlow": captcha.UserFlow,
+ })
+
+ // (This fires a goroutine under the hood so it's alright to call this from
+ // here.)
+ d.UserLogin.TrackAnalytics("Discord CAPTCHA challenge", props)
+}
+
+func (d *DiscordClient) relationshipWithUserID(userID string) *discordgo.Relationship {
+ if d.Session == nil || d.Session.State == nil {
+ return nil
+ }
+
+ d.relationshipLock.RLock()
+ defer d.relationshipLock.RUnlock()
+
+ return d.relationships[userID]
+}
+
+func (d *DiscordClient) relationshipWithDMRecipient(ch *discordgo.Channel) *discordgo.Relationship {
+ if ch == nil {
+ return nil
+ }
+
+ recip := dmChannelRecipientID(ch)
+ if recip == nil {
+ return nil
+ }
+
+ rel := d.relationshipWithUserID(*recip)
+ return rel
+}
+
+// dmChannelForUserID finds the DM channel with the given user, if any.
+func (d *DiscordClient) dmChannelForUserID(userID string) *discordgo.Channel {
+ if d.Session == nil || d.Session.State == nil {
+ return nil
+ }
+
+ d.Session.State.RLock()
+ defer d.Session.State.RUnlock()
+
+ for _, ch := range d.Session.State.PrivateChannels {
+ if len(ch.Recipients) == 1 && ch.Recipients[0].ID == userID {
+ return ch
+ }
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) rebuildRelationships() {
+ if d.Session == nil || d.Session.State == nil {
+ return
+ }
+
+ d.relationshipLock.Lock()
+ defer d.relationshipLock.Unlock()
+
+ clear(d.relationships)
+
+ for _, rel := range d.Session.State.Relationships {
+ if rel == nil {
+ continue
+ }
+ d.relationships[rel.ID] = rel
+ }
+}
+
+func (d *DiscordClient) upsertRelationship(rel *discordgo.Relationship) {
+ if rel == nil {
+ return
+ }
+
+ d.relationshipLock.Lock()
+ defer d.relationshipLock.Unlock()
+
+ d.relationships[rel.ID] = rel
+}
+
+func (d *DiscordClient) removeRelationship(userID string) {
+ d.relationshipLock.Lock()
+ defer d.relationshipLock.Unlock()
+
+ delete(d.relationships, userID)
+}
diff --git a/pkg/connector/client_vitals.go b/pkg/connector/client_vitals.go
new file mode 100644
index 0000000..5150786
--- /dev/null
+++ b/pkg/connector/client_vitals.go
@@ -0,0 +1,65 @@
+package connector
+
+import (
+ "context"
+
+ "github.com/rs/zerolog"
+)
+
+func (d *DiscordClient) refreshSafetyHub(ctx context.Context) {
+ if !d.connector.Config.ReportScrubbedAccountStanding {
+ return
+ }
+
+ log := zerolog.Ctx(ctx).With().Str("action", "refresh safety hub").Logger()
+
+ log.Debug().Msg("Fetching safety hub data")
+ hub, err := d.Session.SafetyHub()
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to fetch safety hub information, not updating")
+ return
+ }
+
+ log.Info().
+ Int("account_standing", int(hub.AccountStanding.State)).
+ Msg("Fetched safety hub data")
+
+ d.vitalsMu.Lock()
+ d.safetyHub = hub
+ d.vitalsMu.Unlock()
+}
+
+// pokeVitals reevaluates the client's [vitals] according to current state and
+// latest fetched safety hub information.
+//
+// - Safety hub information is not fetched by this method.
+// - This method may end up kicking off a full sync in the background if the
+// bridge started off with bad vitals or there is one pending.
+func (d *DiscordClient) pokeVitals(ctx context.Context) {
+ log := zerolog.Ctx(ctx)
+
+ d.vitalsMu.Lock()
+ {
+ v := newVitals(d.Session, d.safetyHub)
+
+ log := v.logContext(log.With()).Logger()
+ log.Info().Msg("Reevaluated vitals")
+
+ d.vitals = &v
+ }
+ d.vitalsMu.Unlock()
+
+ // Emit unconditionally, somewhat relying on mautrix's deduping behavior to
+ // avoid excessive bridge state sends; avoid replicating "do we need user
+ // intervention?" logic here.
+ d.sendCurrentState(ctx)
+ // Kick off any pending full sync.
+ d.beginFullSync(ctx)
+}
+
+func (d *DiscordClient) peekVitals() (v *vitals) {
+ d.vitalsMu.Lock()
+ defer d.vitalsMu.Unlock()
+ v = d.vitals
+ return
+}
diff --git a/pkg/connector/config.go b/pkg/connector/config.go
new file mode 100644
index 0000000..e9e8ba7
--- /dev/null
+++ b/pkg/connector/config.go
@@ -0,0 +1,155 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ _ "embed"
+ "strings"
+ "text/template"
+
+ "github.com/bwmarrin/discordgo"
+ up "go.mau.fi/util/configupgrade"
+ "gopkg.in/yaml.v3"
+)
+
+//go:embed example-config.yaml
+var ExampleConfig string
+
+const defaultChannelNameTemplate = `{{if and .IsGuildChannel (not .IsCategory)}}#{{end}}{{.Name}}`
+
+type Config struct {
+ Guilds struct {
+ BridgingGuildIDs []string `yaml:"bridging_guild_ids"`
+ } `yaml:"guilds"`
+
+ // ChannelNameTemplate formats Matrix room names for Discord channels other
+ // than 1:1 DMs, which intentionally use bridgev2's ghost-derived default.
+ ChannelNameTemplate string `yaml:"channel_name_template"`
+ CustomEmojiReactions *bool `yaml:"custom_emoji_reactions"`
+ GuildAvatarsInRooms *bool `yaml:"guild_avatars_in_rooms"`
+
+ PerMessageProfiles *bool `yaml:"per_message_profiles_on_every_message_hack"`
+
+ ForbidDMingStrangers *bool `yaml:"forbid_dming_strangers"`
+
+ LogWhenDroppingMessages bool `yaml:"log_when_dropping_messages"`
+
+ // Proxy is a static proxy address (HTTP or SOCKS5) for connecting to
+ // Discord. Ignored when GetProxyFrom is set.
+ Proxy string `yaml:"proxy"`
+
+ // ProxyLoginMachine and ProxyLoginRemoteAuth control whether the
+ // respective login flows route through the configured proxy. See
+ // example-config.yaml for the tradeoffs. Both default to true.
+ ProxyLoginMachine bool `yaml:"proxy_login_machine"`
+ ProxyLoginRemoteAuth bool `yaml:"proxy_login_remoteauth"`
+
+ // GetProxyFrom is an HTTP endpoint that returns a JSON body with a string
+ // field called proxy_url, used to dynamically assign a proxy. It is
+ // re-fetched on every (re)connect so each session egresses from one IP.
+ GetProxyFrom string `yaml:"get_proxy_from"`
+
+ // ProxyMedia controls whether avatar, icon, and attachment downloads also
+ // go through the proxy. The gateway websocket and REST API always use it.
+ ProxyMedia bool `yaml:"proxy_media"`
+
+ ReportScrubbedAccountStanding bool `yaml:"report_scrubbed_account_standing"`
+
+ channelNameTemplate *template.Template `yaml:"-"`
+}
+
+type umConfig Config
+
+func (c *Config) UnmarshalYAML(node *yaml.Node) error {
+ err := node.Decode((*umConfig)(c))
+ if err != nil {
+ return err
+ }
+
+ if c.ChannelNameTemplate == "" {
+ c.ChannelNameTemplate = defaultChannelNameTemplate
+ }
+
+ c.channelNameTemplate, err = template.New("channel_name").Parse(c.ChannelNameTemplate)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// ChannelNameParams describes the values available to [Config.FormatChannelName].
+//
+// It intentionally includes both the raw Discord channel type and convenience
+// booleans so templates can express v1-style naming rules without relying on
+// numeric channel type constants.
+type ChannelNameParams struct {
+ Name string
+ ParentName string
+ GuildName string
+ Type discordgo.ChannelType
+ NSFW bool
+ IsDM bool
+ IsGroupDM bool
+ IsCategory bool
+ IsGuildChannel bool
+}
+
+// FormatChannelName renders [Config.ChannelNameTemplate] for non-guild-space
+// channel portals. One-to-one DMs intentionally bypass this helper so bridgev2
+// can derive the room name from the other user's ghost.
+func (c *Config) FormatChannelName(params *ChannelNameParams) string {
+ var buffer strings.Builder
+ _ = c.channelNameTemplate.Execute(&buffer, params)
+ return buffer.String()
+}
+
+func (c Config) ForbidDMingStrangersEnabled() bool {
+ return c.ForbidDMingStrangers == nil || *c.ForbidDMingStrangers
+}
+
+func (c Config) CustomEmojiReactionsEnabled() bool {
+ return c.CustomEmojiReactions == nil || *c.CustomEmojiReactions
+}
+
+func (c Config) GuildAvatarsInRoomsEnabled() bool {
+ return c.GuildAvatarsInRooms != nil && *c.GuildAvatarsInRooms
+}
+
+func (c Config) PerMessageProfilesEnabled() bool {
+ return c.PerMessageProfiles != nil && *c.PerMessageProfiles
+}
+
+func upgradeConfig(helper up.Helper) {
+ helper.Copy(up.List, "guilds", "bridging_guild_ids")
+ helper.Copy(up.Bool, "guilds", "guild_avatars_in_rooms")
+ helper.Copy(up.Bool, "forbid_dming_strangers")
+ helper.Copy(up.Str, "channel_name_template")
+ helper.Copy(up.Bool, "custom_emoji_reactions")
+ helper.Copy(up.Bool, "per_message_profiles_on_every_message_hack")
+ helper.Copy(up.Bool, "log_when_dropping_messages")
+ helper.Copy(up.Str, "proxy")
+ helper.Copy(up.Str, "get_proxy_from")
+ helper.Copy(up.Bool, "proxy_media")
+ helper.Copy(up.Bool, "proxy_login_machine")
+ helper.Copy(up.Bool, "proxy_login_remoteauth")
+ helper.Copy(up.Bool, "report_scrubbed_account_standing")
+}
+
+func (d *DiscordConnector) GetConfig() (example string, data any, upgrader up.Upgrader) {
+ return ExampleConfig, &d.Config, up.SimpleUpgrader(upgradeConfig)
+}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
new file mode 100644
index 0000000..3be3ec2
--- /dev/null
+++ b/pkg/connector/connector.go
@@ -0,0 +1,96 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/mautrix-discord/pkg/connector/discorddb"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+ "go.mau.fi/mautrix-discord/pkg/msgconv"
+)
+
+type DiscordConnector struct {
+ Bridge *bridgev2.Bridge
+ Config Config
+ DB *discorddb.DiscordDB
+ MsgConv *msgconv.MessageConverter
+ attachmentCache *attachmentCache
+ httpClient *http.Client
+}
+
+var (
+ _ bridgev2.NetworkConnector = (*DiscordConnector)(nil)
+ _ bridgev2.MaxFileSizeingNetwork = (*DiscordConnector)(nil)
+ _ bridgev2.TransactionIDGeneratingNetwork = (*DiscordConnector)(nil)
+)
+
+func (d *DiscordConnector) Init(bridge *bridgev2.Bridge) {
+ d.Bridge = bridge
+ d.DB = discorddb.New(bridge.DB.Database, bridge.Log.With().Str("db_section", "discord").Logger())
+ d.MsgConv = msgconv.NewMessageConverter(bridge)
+ d.MsgConv.PerMessageProfiles = d.Config.PerMessageProfilesEnabled()
+ d.attachmentCache = NewAttachmentCache()
+ d.MsgConv.CacheDirectMediaAttachment = d.attachmentCache.Insert
+ d.httpClient = d.Bridge.GetHTTPClientSettings().Compile()
+}
+
+func (d *DiscordConnector) SetMaxFileSize(maxSize int64) {
+ d.MsgConv.MaxFileSize = maxSize
+}
+
+func (d *DiscordConnector) Start(ctx context.Context) error {
+ log := zerolog.Ctx(ctx)
+
+ err := d.DB.Upgrade(ctx)
+ if err != nil {
+ log.Err(err).Msg("Failed to upgrade Discord database")
+ return err
+ }
+
+ log.Debug().Msg("Setting up provisioning API")
+
+ err = d.setUpProvisioningAPIs()
+ if err != nil {
+ log.Err(err).Msg("Failed to set up provisioning API, proceeding")
+ // Don't treat this error as fatal.
+ }
+
+ return nil
+}
+
+func (d *DiscordConnector) GetName() bridgev2.BridgeName {
+ return bridgev2.BridgeName{
+ DisplayName: "Discord",
+ NetworkURL: "https://discord.com",
+ NetworkIcon: "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC",
+ NetworkID: "discord",
+ BeeperBridgeType: "discordgo",
+ DefaultPort: 29334,
+ }
+}
+
+func (d *DiscordConnector) GenerateTransactionID(_ id.UserID, _ id.RoomID, _ event.Type) networkid.RawTransactionID {
+ return networkid.RawTransactionID(discordid.GenerateNonce())
+}
diff --git a/database/upgrades/upgrades.go b/pkg/connector/dbmeta.go
similarity index 66%
rename from database/upgrades/upgrades.go
rename to pkg/connector/dbmeta.go
index d6954d5..6d8fbd5 100644
--- a/database/upgrades/upgrades.go
+++ b/pkg/connector/dbmeta.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
+// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
@@ -14,19 +14,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package upgrades
+package connector
import (
- "embed"
+ "maunium.net/go/mautrix/bridgev2/database"
- "go.mau.fi/util/dbutil"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
)
-var Table dbutil.UpgradeTable
-
-//go:embed *.sql
-var rawUpgrades embed.FS
-
-func init() {
- Table.RegisterFS(rawUpgrades)
+func (d *DiscordConnector) GetDBMetaTypes() database.MetaTypes {
+ return database.MetaTypes{
+ Portal: func() any {
+ return &discordid.PortalMetadata{}
+ },
+ UserLogin: func() any {
+ return &discordid.UserLoginMetadata{}
+ },
+ }
}
diff --git a/pkg/connector/directmedia.go b/pkg/connector/directmedia.go
new file mode 100644
index 0000000..f7c68bb
--- /dev/null
+++ b/pkg/connector/directmedia.go
@@ -0,0 +1,205 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/mediaproxy"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+var (
+ _ bridgev2.DirectMediableNetwork = (*DiscordConnector)(nil)
+)
+
+func (d *DiscordConnector) Download(
+ ctx context.Context,
+ mediaID networkid.MediaID,
+ params map[string]string,
+) (mediaproxy.GetMediaResponse, error) {
+ info, err := discordid.ParseMediaID(mediaID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse media id for download: %w", err)
+ }
+
+ return d.downloadAttachment(ctx, info)
+}
+
+func (d *DiscordConnector) SetUseDirectMedia() {
+ d.MsgConv.DirectMedia = true
+}
+
+func (d *DiscordConnector) downloadAttachment(
+ ctx context.Context,
+ info *discordid.MediaInfo,
+) (*mediaproxy.GetMediaResponseURL, error) {
+ url, expiresAt, err := d.resolveAttachmentURL(ctx, info)
+ if err != nil {
+ return nil, fmt.Errorf("failed to refresh attachment url for download: %w", err)
+ }
+ if expiresAt.IsZero() {
+ // A zero expiry becomes effectively immutable caching in mediaproxy.
+ // Unknown expiry is safer as no-store for now.
+ expiresAt = time.Now()
+ }
+ return &mediaproxy.GetMediaResponseURL{
+ URL: url,
+ ExpiresAt: expiresAt,
+ }, nil
+}
+
+func (d *DiscordConnector) resolveAttachmentURL(ctx context.Context, info *discordid.MediaInfo) (url string, expires time.Time, err error) {
+ if entry, ok := d.attachmentCache.Get(info.MediaInfoV1); ok {
+ return entry.URL, entry.Expiry, nil
+ }
+
+ url, expiresAt, err := d.refreshAttachmentURL(ctx, info)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+
+ d.attachmentCache.Insert(info, url)
+ return url, expiresAt, nil
+}
+
+func (d *DiscordConnector) refreshAttachmentURL(
+ ctx context.Context,
+ info *discordid.MediaInfo,
+) (url string, expires time.Time, err error) {
+ log := zerolog.Ctx(ctx).With().Str("action", "refresh attachment url").Logger()
+ ctx = log.WithContext(ctx)
+
+ login, err := d.Bridge.GetExistingUserLoginByID(ctx, info.UserLoginID)
+ if err != nil {
+ return "", time.Time{}, err
+ } else if login == nil {
+ return "", time.Time{}, mautrix.MNotFound.WithMessage("Direct media login not found")
+ }
+
+ client, ok := login.Client.(*DiscordClient)
+ if !ok || client == nil || !client.IsLoggedIn() {
+ return "", time.Time{}, mautrix.MNotFound.WithMessage("Direct media login is not connected")
+ }
+
+ channelID := info.ChannelID
+ messageID := info.MessageID
+ attachmentID := info.AttachmentID
+
+ parentChannelID := channelID
+ threadChannelID := ""
+ threadInfo, err := d.DB.Thread.GetByThreadChannelID(ctx, string(info.UserLoginID), channelID)
+ if err != nil {
+ return "", time.Time{}, fmt.Errorf("failed to query thread info: %w", err)
+ } else if threadInfo != nil {
+ parentChannelID = threadInfo.ParentChannelID
+ threadChannelID = threadInfo.ThreadChannelID
+ }
+
+ var requestOptions []discordgo.RequestOption
+ portalKey := discordid.MakeChannelPortalKey(parentChannelID, info.UserLoginID, d.Bridge.Config.SplitPortals)
+ portal, err := d.Bridge.GetExistingPortalByKey(ctx, portalKey)
+ if err != nil {
+ return "", time.Time{}, fmt.Errorf("failed to query portal for direct media: %w", err)
+ } else if portal != nil {
+ if meta, ok := portal.Metadata.(*discordid.PortalMetadata); ok {
+ requestOptions = append(requestOptions, makeDiscordReferer(meta.GuildID, parentChannelID, threadChannelID))
+ }
+ } else if threadChannelID == "" {
+ // DMs still benefit from @me referers.
+ requestOptions = append(requestOptions, makeDiscordReferer("", parentChannelID, ""))
+ }
+
+ var messages []*discordgo.Message
+ if client.Session.IsUser {
+ messages, err = client.Session.ChannelMessages(channelID, 5, "", "", messageID, requestOptions...)
+ } else {
+ var msg *discordgo.Message
+ msg, err = client.Session.ChannelMessage(channelID, messageID, requestOptions...)
+ if err == nil && msg != nil {
+ messages = []*discordgo.Message{msg}
+ }
+ }
+ if err != nil {
+ return "", time.Time{}, fmt.Errorf("failed to fetch direct media message: %w", err)
+ }
+
+ for _, msg := range messages {
+ for _, att := range msg.Attachments {
+ if att.ID == attachmentID {
+ expiresAt := normalizeAttachmentExpiry(parseAttachmentExpiryFromURL(att.URL))
+ // (Trace is not the default log level, so this is only visible
+ // in development scenarios.)
+ log.Trace().
+ Str("channel_id", channelID).
+ Str("message_id", messageID).
+ Str("attachment_id", attachmentID).
+ Time("expires_at", expiresAt).
+ Msg("Resolved direct media attachment URL")
+ // TODO(skip): This is ignoring the rest of the attachments.
+ return att.URL, expiresAt, nil
+ }
+ }
+ }
+
+ return "", time.Time{}, mautrix.MNotFound.WithMessage("Attachment not found in message")
+}
+
+func parseAttachmentExpiryParam(ex string) time.Time {
+ tsBytes, err := hex.DecodeString(ex)
+ if err != nil || len(tsBytes) != 4 {
+ return time.Time{}
+ }
+
+ parsedTS := int64(binary.BigEndian.Uint32(tsBytes))
+ now := time.Now()
+ expiry := time.Unix(parsedTS, 0)
+ if expiry.Before(now) || expiry.After(now.Add(365*24*time.Hour)) {
+ // Looks to be invalid.
+ return time.Time{}
+ }
+ return expiry
+}
+
+func parseAttachmentExpiryFromURL(rawURL string) time.Time {
+ parsedURL, err := url.Parse(rawURL)
+ if err != nil {
+ return time.Time{}
+ }
+
+ return parseAttachmentExpiryParam(parsedURL.Query().Get("ex"))
+}
+
+func normalizeAttachmentExpiry(expiry time.Time) time.Time {
+ // Default to a validity period of 24 hours.
+ if expiry.IsZero() {
+ return time.Now().Add(24 * time.Hour)
+ }
+
+ return expiry
+}
diff --git a/pkg/connector/directmedia_cache.go b/pkg/connector/directmedia_cache.go
new file mode 100644
index 0000000..b4a588b
--- /dev/null
+++ b/pkg/connector/directmedia_cache.go
@@ -0,0 +1,91 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "sync"
+ "time"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+const attachmentCacheLife = 5 * time.Minute
+
+type attachmentCacheEntry struct {
+ Expiry time.Time
+ URL string
+}
+
+func (ce *attachmentCacheEntry) IsExpired() bool {
+ return time.Until(ce.Expiry) <= attachmentCacheLife
+}
+
+// attachmentCache tracks expiring attachment URLs from Discord. An
+// attachmentCache is safe for concurrent use by multiple goroutines.
+type attachmentCache struct {
+ sync.RWMutex
+ cache map[discordid.MediaInfoV1]attachmentCacheEntry
+}
+
+// TODO(skip): The cache grows in an unbounded fashion.
+
+func NewAttachmentCache() *attachmentCache {
+ return &attachmentCache{
+ cache: make(map[discordid.MediaInfoV1]attachmentCacheEntry),
+ }
+}
+
+func (ac *attachmentCache) Get(key discordid.MediaInfoV1) (*attachmentCacheEntry, bool) {
+ ac.Lock()
+ defer ac.Unlock()
+
+ cached, ok := ac.cache[key]
+ if !ok {
+ return nil, false
+ }
+
+ if cached.IsExpired() {
+ delete(ac.cache, key)
+ return nil, false
+ }
+
+ return &cached, true
+}
+
+func (ac *attachmentCache) Insert(info *discordid.MediaInfo, url string) {
+ if url == "" {
+ return
+ }
+
+ expiry := normalizeAttachmentExpiry(parseAttachmentExpiryFromURL(url))
+
+ ac.Lock()
+ defer ac.Unlock()
+
+ key := info.MediaInfoV1
+ entry := attachmentCacheEntry{
+ URL: url,
+ Expiry: expiry,
+ }
+
+ if expiry.IsZero() || entry.IsExpired() {
+ delete(ac.cache, key)
+ return
+ }
+
+ ac.cache[key] = entry
+}
diff --git a/pkg/connector/directmedia_test.go b/pkg/connector/directmedia_test.go
new file mode 100644
index 0000000..e4d09b8
--- /dev/null
+++ b/pkg/connector/directmedia_test.go
@@ -0,0 +1,21 @@
+package connector
+
+import (
+ "testing"
+ "time"
+)
+
+func TestParseAttachmentExpiryParam(t *testing.T) {
+ losAngeles, err := time.LoadLocation("America/Los_Angeles")
+ if err != nil {
+ t.Fatalf("failed to load test timezone: %v", err)
+ }
+
+ expiry := parseAttachmentExpiryParam("69be6214").In(losAngeles)
+ got := expiry.String()
+ want := "2026-03-21 02:17:08 -0700 PDT"
+
+ if got != want {
+ t.Fatalf("unexpected parsed expiry: got %q, want %q", got, want)
+ }
+}
diff --git a/pkg/connector/discorddb/00-latest-schema.sql b/pkg/connector/discorddb/00-latest-schema.sql
new file mode 100644
index 0000000..9ced24b
--- /dev/null
+++ b/pkg/connector/discorddb/00-latest-schema.sql
@@ -0,0 +1,52 @@
+-- v0 -> v3 (compatible with v1+): latest schema
+
+-- https://docs.discord.com/developers/resources/emoji#emoji-object
+CREATE TABLE custom_emoji (
+ discord_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ animated BOOLEAN NOT NULL,
+
+ mxc TEXT,
+
+ PRIMARY KEY (discord_id)
+);
+CREATE INDEX custom_emoji_mxc_idx ON custom_emoji (mxc);
+
+CREATE TABLE role (
+ discord_guild_id TEXT NOT NULL,
+ discord_id TEXT NOT NULL,
+
+ name TEXT NOT NULL,
+ icon TEXT,
+
+ mentionable BOOLEAN NOT NULL,
+ managed BOOLEAN NOT NULL,
+ hoist BOOLEAN NOT NULL,
+
+ color INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ permissions BIGINT NOT NULL,
+
+ PRIMARY KEY (discord_guild_id, discord_id)
+);
+
+CREATE TABLE discord_thread (
+ -- The ID of the UserLogin that witnessed the thread.
+ user_login_id TEXT NOT NULL,
+
+ -- The ID of the thread itself. For public threads, this exactly matches the
+ -- ID of the message that the thread originates from.
+ thread_channel_id TEXT NOT NULL,
+
+ -- The ID of the thread's "root" message. For public threads, this will
+ -- match `id` and therefore the message that the thread originates from.
+ -- For private threads, this will be NULL.
+ root_message_id TEXT,
+
+ -- The Discord channel ID that the thread belongs to.
+ parent_channel_id TEXT NOT NULL,
+
+ PRIMARY KEY (user_login_id, thread_channel_id)
+);
+CREATE UNIQUE INDEX discord_thread_user_login_root_msg_uidx
+ON discord_thread (user_login_id, root_message_id);
diff --git a/pkg/connector/discorddb/02-roles.sql b/pkg/connector/discorddb/02-roles.sql
new file mode 100644
index 0000000..c845ff5
--- /dev/null
+++ b/pkg/connector/discorddb/02-roles.sql
@@ -0,0 +1,19 @@
+-- v1 -> v2 (compatible with v1+): roles
+
+CREATE TABLE role (
+ discord_guild_id TEXT NOT NULL,
+ discord_id TEXT NOT NULL,
+
+ name TEXT NOT NULL,
+ icon TEXT,
+
+ mentionable BOOLEAN NOT NULL,
+ managed BOOLEAN NOT NULL,
+ hoist BOOLEAN NOT NULL,
+
+ color INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ permissions BIGINT NOT NULL,
+
+ PRIMARY KEY (discord_guild_id, discord_id)
+);
diff --git a/pkg/connector/discorddb/03-threads.sql b/pkg/connector/discorddb/03-threads.sql
new file mode 100644
index 0000000..2a5fe67
--- /dev/null
+++ b/pkg/connector/discorddb/03-threads.sql
@@ -0,0 +1,22 @@
+-- v2 -> v3 (compatible with v1+): threads
+
+CREATE TABLE discord_thread (
+ -- The ID of the UserLogin that witnessed the thread.
+ user_login_id TEXT NOT NULL,
+
+ -- The ID of the thread itself. For public threads, this exactly matches the
+ -- ID of the message that the thread originates from.
+ thread_channel_id TEXT NOT NULL,
+
+ -- The ID of the thread's "root" message. For public threads, this will
+ -- match `id` and therefore the message that the thread originates from.
+ -- For private threads, this will be NULL.
+ root_message_id TEXT,
+
+ -- The Discord channel ID that the thread belongs to.
+ parent_channel_id TEXT NOT NULL,
+
+ PRIMARY KEY (user_login_id, thread_channel_id)
+);
+CREATE UNIQUE INDEX discord_thread_user_login_root_msg_uidx
+ON discord_thread (user_login_id, root_message_id);
diff --git a/pkg/connector/discorddb/database.go b/pkg/connector/discorddb/database.go
new file mode 100644
index 0000000..e82cad1
--- /dev/null
+++ b/pkg/connector/discorddb/database.go
@@ -0,0 +1,56 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discorddb
+
+import (
+ "embed"
+
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/dbutil"
+)
+
+type DiscordDB struct {
+ *dbutil.Database
+ CustomEmoji *CustomEmojiQuery
+ Role *RoleQuery
+ Thread *ThreadQuery
+}
+
+var table = dbutil.BuildUpgradeTable().WithFS(upgrades).Finish()
+
+//go:embed *.sql
+var upgrades embed.FS
+
+func UpgradeTable() dbutil.UpgradeTable {
+ return table
+}
+
+func New(db *dbutil.Database, log zerolog.Logger) *DiscordDB {
+ db = db.Child("discord_version", table, dbutil.ZeroLogger(log))
+ return &DiscordDB{
+ Database: db,
+ CustomEmoji: &CustomEmojiQuery{
+ QueryHelper: dbutil.MakeQueryHelper(db, newCustomEmoji),
+ },
+ Role: &RoleQuery{
+ QueryHelper: dbutil.MakeQueryHelper(db, newRole),
+ },
+ Thread: &ThreadQuery{
+ QueryHelper: dbutil.MakeQueryHelper(db, newThread),
+ },
+ }
+}
diff --git a/pkg/connector/discorddb/emoji.go b/pkg/connector/discorddb/emoji.go
new file mode 100644
index 0000000..21f7280
--- /dev/null
+++ b/pkg/connector/discorddb/emoji.go
@@ -0,0 +1,81 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discorddb
+
+import (
+ "context"
+ "database/sql"
+
+ "go.mau.fi/util/dbutil"
+ "maunium.net/go/mautrix/id"
+)
+
+type CustomEmojiQuery struct {
+ *dbutil.QueryHelper[*CustomEmoji]
+}
+
+type CustomEmoji struct {
+ ID string
+ Name string
+ Animated bool
+ ImageMXC id.ContentURIString
+}
+
+func (ce *CustomEmoji) sqlVariables() []any {
+ return []any{ce.ID, ce.Name, ce.Animated, dbutil.StrPtr(ce.ImageMXC)}
+}
+
+func newCustomEmoji(_ *dbutil.QueryHelper[*CustomEmoji]) *CustomEmoji {
+ return &CustomEmoji{}
+}
+
+const (
+ getCustomEmojiByMXCQuery = `
+ SELECT discord_id, name, animated, mxc FROM custom_emoji WHERE mxc=$1 ORDER BY name
+ `
+ getCustomEmojiByDiscordIDQuery = `
+ SELECT discord_id, name, animated, mxc FROM custom_emoji WHERE discord_id=$1 ORDER BY name
+ `
+ upsertCustomEmojiQuery = `
+ INSERT INTO custom_emoji (discord_id, name, animated, mxc)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (discord_id) DO UPDATE
+ SET name = excluded.name, animated = excluded.animated, mxc = excluded.mxc
+ `
+)
+
+func (ceq *CustomEmojiQuery) GetByDiscordID(ctx context.Context, discordID string) (*CustomEmoji, error) {
+ return ceq.QueryOne(ctx, getCustomEmojiByDiscordIDQuery, &discordID)
+}
+
+func (ceq *CustomEmojiQuery) GetByMXC(ctx context.Context, mxc string) (*CustomEmoji, error) {
+ return ceq.QueryOne(ctx, getCustomEmojiByMXCQuery, &mxc)
+}
+
+func (ceq *CustomEmojiQuery) Put(ctx context.Context, emoji *CustomEmoji) error {
+ return ceq.Exec(ctx, upsertCustomEmojiQuery, emoji.sqlVariables()...)
+}
+
+func (ce *CustomEmoji) Scan(row dbutil.Scannable) (*CustomEmoji, error) {
+ var imageURL sql.NullString
+ err := row.Scan(&ce.ID, &ce.Name, &ce.Animated, &imageURL)
+ if err != nil {
+ return nil, err
+ }
+ ce.ImageMXC = id.ContentURIString(imageURL.String)
+ return ce, nil
+}
diff --git a/pkg/connector/discorddb/role.go b/pkg/connector/discorddb/role.go
new file mode 100644
index 0000000..1f9f7f7
--- /dev/null
+++ b/pkg/connector/discorddb/role.go
@@ -0,0 +1,151 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discorddb
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/bwmarrin/discordgo"
+ "go.mau.fi/util/dbutil"
+)
+
+type RoleQuery struct {
+ *dbutil.QueryHelper[*Role]
+}
+
+type Role struct {
+ GuildID string
+ discordgo.Role
+}
+
+func (r *Role) sqlVariables() []any {
+ return []any{
+ r.GuildID,
+ r.ID,
+ r.Name,
+ dbutil.StrPtr(r.Icon),
+ r.Mentionable,
+ r.Managed,
+ r.Hoist,
+ r.Color,
+ r.Position,
+ r.Permissions,
+ }
+}
+
+func newRole(_ *dbutil.QueryHelper[*Role]) *Role {
+ return &Role{}
+}
+
+const (
+ getRoleByIDQuery = `
+ SELECT discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions
+ FROM role
+ WHERE discord_guild_id=$1 AND discord_id=$2
+ `
+ getRolesByGuildIDQuery = `
+ SELECT discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions
+ FROM role
+ WHERE discord_guild_id=$1
+ ORDER BY position DESC, discord_id
+ `
+ upsertRoleQuery = `
+ INSERT INTO role (discord_guild_id, discord_id, name, icon, mentionable, managed, hoist, color, position, permissions)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+ ON CONFLICT (discord_guild_id, discord_id) DO UPDATE
+ SET name = excluded.name,
+ icon = excluded.icon,
+ mentionable = excluded.mentionable,
+ managed = excluded.managed,
+ hoist = excluded.hoist,
+ color = excluded.color,
+ position = excluded.position,
+ permissions = excluded.permissions
+ `
+ deleteRolesByGuildIDQuery = `
+ DELETE FROM role WHERE discord_guild_id=$1
+ `
+ deleteRoleByIDQuery = `
+ DELETE FROM role WHERE discord_guild_id=$1 AND discord_id=$2
+ `
+)
+
+func (rq *RoleQuery) GetByID(ctx context.Context, guildID, roleID string) (*Role, error) {
+ return rq.QueryOne(ctx, getRoleByIDQuery, &guildID, &roleID)
+}
+
+func (rq *RoleQuery) GetByGuildID(ctx context.Context, guildID string) ([]*Role, error) {
+ return rq.QueryMany(ctx, getRolesByGuildIDQuery, &guildID)
+}
+
+func (rq *RoleQuery) Put(ctx context.Context, role *Role) error {
+ return rq.Exec(ctx, upsertRoleQuery, role.sqlVariables()...)
+}
+
+func (rq *RoleQuery) PutMany(ctx context.Context, roles []*Role) error {
+ for _, role := range roles {
+ if err := rq.Put(ctx, role); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (rq *RoleQuery) DeleteByGuildID(ctx context.Context, guildID string) error {
+ return rq.Exec(ctx, deleteRolesByGuildIDQuery, &guildID)
+}
+
+func (rq *RoleQuery) DeleteByID(ctx context.Context, guildID, roleID string) error {
+ return rq.Exec(ctx, deleteRoleByIDQuery, &guildID, &roleID)
+}
+
+func (rq *RoleQuery) ReplaceGuildRoles(ctx context.Context, guildID string, roles []*Role) error {
+ return rq.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
+ if err := rq.DeleteByGuildID(ctx, guildID); err != nil {
+ return err
+ }
+ for _, role := range roles {
+ role.GuildID = guildID
+ if err := rq.Put(ctx, role); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+func (r *Role) Scan(row dbutil.Scannable) (*Role, error) {
+ var icon sql.NullString
+ err := row.Scan(
+ &r.GuildID,
+ &r.ID,
+ &r.Name,
+ &icon,
+ &r.Mentionable,
+ &r.Managed,
+ &r.Hoist,
+ &r.Color,
+ &r.Position,
+ &r.Permissions,
+ )
+ if err != nil {
+ return nil, err
+ }
+ r.Icon = icon.String
+ return r, nil
+}
diff --git a/pkg/connector/discorddb/thread.go b/pkg/connector/discorddb/thread.go
new file mode 100644
index 0000000..6a6a065
--- /dev/null
+++ b/pkg/connector/discorddb/thread.go
@@ -0,0 +1,106 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discorddb
+
+import (
+ "context"
+ "database/sql"
+
+ "go.mau.fi/util/dbutil"
+)
+
+type ThreadQuery struct {
+ *dbutil.QueryHelper[*Thread]
+}
+
+type Thread struct {
+ UserLoginID string
+ ThreadChannelID string
+ RootMessageID string
+ ParentChannelID string
+}
+
+func (t *Thread) sqlVariables() []any {
+ var rootMsgID *string
+ if t.RootMessageID != "" {
+ rootMsgID = &t.RootMessageID
+ }
+ return []any{
+ t.UserLoginID,
+ t.ThreadChannelID,
+ rootMsgID,
+ t.ParentChannelID,
+ }
+}
+
+func newThread(_ *dbutil.QueryHelper[*Thread]) *Thread {
+ return &Thread{}
+}
+
+const (
+ getThreadByChannelIDQuery = `
+ SELECT user_login_id, thread_channel_id, root_message_id, parent_channel_id
+ FROM discord_thread
+ WHERE user_login_id=$1 AND thread_channel_id=$2
+ `
+ getThreadByRootMessageIDQuery = `
+ SELECT user_login_id, thread_channel_id, root_message_id, parent_channel_id
+ FROM discord_thread
+ WHERE user_login_id=$1 AND root_message_id=$2
+ `
+ upsertThreadQuery = `
+ INSERT INTO discord_thread (user_login_id, thread_channel_id, root_message_id, parent_channel_id)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (user_login_id, thread_channel_id) DO UPDATE
+ SET root_message_id = excluded.root_message_id,
+ parent_channel_id = excluded.parent_channel_id
+ `
+ deleteThreadByChannelIDQuery = `
+ DELETE FROM discord_thread WHERE user_login_id=$1 AND thread_channel_id=$2
+ `
+)
+
+func (tq *ThreadQuery) GetByThreadChannelID(ctx context.Context, userLoginID, threadChannelID string) (*Thread, error) {
+ return tq.QueryOne(ctx, getThreadByChannelIDQuery, &userLoginID, &threadChannelID)
+}
+
+func (tq *ThreadQuery) GetByRootMessageID(ctx context.Context, userLoginID, rootMessageID string) (*Thread, error) {
+ return tq.QueryOne(ctx, getThreadByRootMessageIDQuery, &userLoginID, &rootMessageID)
+}
+
+func (tq *ThreadQuery) Put(ctx context.Context, thread *Thread) error {
+ return tq.Exec(ctx, upsertThreadQuery, thread.sqlVariables()...)
+}
+
+func (tq *ThreadQuery) DeleteByThreadChannelID(ctx context.Context, userLoginID, threadChannelID string) error {
+ return tq.Exec(ctx, deleteThreadByChannelIDQuery, &userLoginID, &threadChannelID)
+}
+
+func (t *Thread) Scan(row dbutil.Scannable) (*Thread, error) {
+ var rootMsgID sql.NullString
+ err := row.Scan(
+ &t.UserLoginID,
+ &t.ThreadChannelID,
+ &rootMsgID,
+ &t.ParentChannelID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ t.RootMessageID = rootMsgID.String
+ return t, nil
+}
diff --git a/pkg/connector/emoji.go b/pkg/connector/emoji.go
new file mode 100644
index 0000000..9e9388a
--- /dev/null
+++ b/pkg/connector/emoji.go
@@ -0,0 +1,106 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/id"
+
+ "go.mau.fi/mautrix-discord/pkg/connector/discorddb"
+)
+
+func (d *DiscordConnector) getCustomEmojiDownloadURL(emojiID string, animated bool) (string, string) {
+ // TODO probably best to leverage http.DetectContentType instead of
+ // assuming the media type
+ if animated {
+ return discordgo.EndpointEmojiAnimated(emojiID), "image/webp"
+ }
+ // TODO think about using webp for size savings
+ return discordgo.EndpointEmoji(emojiID), "image/png"
+}
+
+func (d *DiscordConnector) GetCustomEmojiByMXC(ctx context.Context, mxc string) (*discorddb.CustomEmoji, error) {
+ return d.DB.CustomEmoji.GetByMXC(ctx, mxc)
+}
+
+func (d *DiscordConnector) GetCustomEmojiMXC(ctx context.Context, emojiID, name string, animated bool) (id.ContentURIString, error) {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "get discord custom emoji").
+ Str("emoji_id", emojiID).
+ Str("emoji_name", name).
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ dbEmoji, err := d.DB.CustomEmoji.GetByDiscordID(ctx, emojiID)
+ if err != nil {
+ return "", fmt.Errorf("failed to get custom emoji from database: %w", err)
+ }
+
+ if dbEmoji != nil && dbEmoji.ImageMXC != "" {
+ if dbEmoji.Name != name || dbEmoji.Animated != animated {
+ // Make sure to save changed information.
+ dbEmoji.Name = name
+ dbEmoji.Animated = animated
+
+ err = d.DB.CustomEmoji.Put(ctx, dbEmoji)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to update custom emoji metadata in database")
+ }
+ }
+
+ return dbEmoji.ImageMXC, nil
+ }
+
+ // Custom emoji wasn't in the database or it lacked an MXC, so we have to
+ // download it.
+
+ emojiURL, mimeType := d.getCustomEmojiDownloadURL(emojiID, animated)
+ data, err := httpGet(ctx, d.httpClient, emojiURL, "emoji")
+ if err != nil {
+ return "", err
+ }
+
+ mxc, _, err := d.Bridge.Bot.UploadMedia(ctx, "", data, "", mimeType)
+
+ log = log.With().Str("image_mxc", string(mxc)).Logger()
+ ctx = log.WithContext(ctx)
+
+ if err != nil {
+ return "", fmt.Errorf("failed to upload emoji to Matrix: %w", err)
+ }
+
+ if dbEmoji == nil {
+ dbEmoji = &discorddb.CustomEmoji{
+ ID: emojiID,
+ }
+ }
+
+ dbEmoji.Name = name
+ dbEmoji.Animated = animated
+ dbEmoji.ImageMXC = mxc
+
+ err = d.DB.CustomEmoji.Put(ctx, dbEmoji)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to save custom emoji")
+ }
+
+ return mxc, nil
+}
diff --git a/pkg/connector/errorcodes.go b/pkg/connector/errorcodes.go
new file mode 100644
index 0000000..2148926
--- /dev/null
+++ b/pkg/connector/errorcodes.go
@@ -0,0 +1,75 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import "maunium.net/go/mautrix/bridgev2/status"
+
+const (
+ DCNotLoggedIn status.BridgeStateErrorCode = "dc-not-logged-in"
+ DCWebsocketDisconnect4004 status.BridgeStateErrorCode = "dc-websocket-disconnect-4004"
+ DCUnknownWebsocketError status.BridgeStateErrorCode = "dc-unknown-websocket-error"
+ DCHTTP40002 status.BridgeStateErrorCode = "dc-http-40002"
+ DCProxyResolveFail status.BridgeStateErrorCode = "dc-proxy-resolve-fail"
+)
+
+// [status.BridgeStateErrorCode]s for each required action. DCRequireCaptcha is
+// excluded as that RequiredAction is legacy.
+const (
+ DCRequireAgreements status.BridgeStateErrorCode = "dc-require-agreements" // "Terms of Service and Policy Updates"
+ DCRequireVerifiedEmail status.BridgeStateErrorCode = "dc-require-verified-email" // add a verified email
+ DCRequireVerifiedPhone status.BridgeStateErrorCode = "dc-require-verified-phone" // add a verified phone number
+ DCRequireReverifiedEmail status.BridgeStateErrorCode = "dc-require-reverified-email" // reaffirm ownership of existing email
+ DCRequireReverifiedPhone status.BridgeStateErrorCode = "dc-require-reverified-phone" // reaffirm ownership of existing phone number
+ DCRequireVerifiedEmailOrVerifiedPhone status.BridgeStateErrorCode = "dc-require-verified-email-or-verified-phone" // add a verified phone number or email
+ DCRequireReverifiedEmailOrVerifiedPhone status.BridgeStateErrorCode = "dc-require-reverified-email-or-verified-phone" // reaffirm ownership of existing email, or add a verified phone number
+ DCRequireVerifiedEmailOrReverifiedPhone status.BridgeStateErrorCode = "dc-require-verified-email-or-reverified-phone" // add a verified email, or reaffirm ownership of existing phone number
+ DCRequireReverifiedEmailOrReverifiedPhone status.BridgeStateErrorCode = "dc-require-reverified-email-or-reverified-phone" // reaffirm ownership of existing email or phone number
+ DCRequireSafetyFlows status.BridgeStateErrorCode = "dc-require-safety-flows" // server-driven safety flow UI
+
+ // NOTE: We expect the user to use a first-party client to read their
+ // system messages. Pressing the CTA button on the modal triggers a PATCH
+ // /users/@me with {flags:0}, clearing the HAS_UNREAD_URGENT_MESSAGES flag
+ // and dispatching a USER_UPDATE on the gateway (that we may observe).
+ //
+ // Critically, this HTTP request itself is likely to prompt an in-app
+ // CAPTCHA challenge.
+ DCUnreadSystemMessages status.BridgeStateErrorCode = "dc-unread-system-messages"
+)
+const accountVerificationRequiredMessage = "You need to verify your account in the Discord app."
+
+func init() {
+ status.BridgeStateHumanErrors.Update(status.BridgeStateErrorMap{
+ DCWebsocketDisconnect4004: "Please log in to your Discord account again.",
+ DCNotLoggedIn: "Please log in to your Discord account.",
+ DCProxyResolveFail: "Failed to update proxy",
+ DCHTTP40002: accountVerificationRequiredMessage,
+ // (For DCUnknownWebsocketError, provide a specific error message when
+ // sending state. If there were a generic message here, it would
+ // overwrite that.)
+ DCRequireAgreements: "Discord updated their terms and policies. Please open the Discord app to review them.",
+ DCRequireVerifiedEmail: "Please use the Discord app to add a verified email address to your account.",
+ DCRequireVerifiedPhone: "Please use the Discord app to add a verified phone number to your account.",
+ DCRequireReverifiedEmail: "Please use the Discord app to verify your email address.",
+ DCRequireReverifiedPhone: "Please use the Discord app to verify your phone number.",
+ DCRequireVerifiedEmailOrVerifiedPhone: "Please use the Discord app to add a verified email address or phone number to your account.",
+ DCRequireReverifiedEmailOrVerifiedPhone: "Please use the Discord app to verify your email address, or add a verified phone number to your account.",
+ DCRequireVerifiedEmailOrReverifiedPhone: "Please use the Discord app to verify your phone number, or add a verified email address to your account.",
+ DCRequireReverifiedEmailOrReverifiedPhone: "Please use the Discord app to verify your phone number or email address.",
+ DCRequireSafetyFlows: "Please use the Discord app to complete a safety check.",
+ DCUnreadSystemMessages: "Discord has an important message for you. Please open the Discord app to read it.",
+ })
+}
diff --git a/pkg/connector/events_chat_resync.go b/pkg/connector/events_chat_resync.go
new file mode 100644
index 0000000..9d2a5e5
--- /dev/null
+++ b/pkg/connector/events_chat_resync.go
@@ -0,0 +1,117 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+type DiscordChatResync struct {
+ Client *DiscordClient
+ channel *discordgo.Channel
+ createPortal bool
+}
+
+var (
+ _ bridgev2.RemoteChatResyncWithInfo = (*DiscordChatResync)(nil)
+ _ bridgev2.RemoteChatResyncBackfill = (*DiscordChatResync)(nil)
+ _ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordChatResync)(nil)
+)
+
+func (d *DiscordChatResync) AddLogContext(c zerolog.Context) zerolog.Context {
+ c = c.
+ Str("resyncing_channel_id", d.channel.ID).
+ Int("resyncing_channel_type", int(d.channel.Type)).
+ Bool("resyncing_can_create_portal", d.createPortal)
+ return c
+}
+
+func (d *DiscordChatResync) GetPortalKey() networkid.PortalKey {
+ ch := d.channel
+ return d.Client.portalKeyForChannel(ch)
+}
+
+func (d *DiscordChatResync) GetSender() bridgev2.EventSender {
+ return bridgev2.EventSender{}
+}
+
+func (d *DiscordChatResync) GetType() bridgev2.RemoteEventType {
+ return bridgev2.RemoteEventChatResync
+}
+
+func (d *DiscordChatResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
+ return d.Client.GetChatInfo(ctx, portal)
+
+}
+
+func (d *DiscordChatResync) ShouldCreatePortal() bool {
+ return d.createPortal
+}
+
+// compareMessageIDs compares two Discord message IDs.
+//
+// If the first ID is lower, -1 is returned.
+// If the second ID is lower, 1 is returned.
+// If the IDs are equal, 0 is returned.
+func compareMessageIDs(id1, id2 string) int {
+ if id1 == id2 {
+ return 0
+ }
+ if len(id1) < len(id2) {
+ return -1
+ } else if len(id2) < len(id1) {
+ return 1
+ }
+ if id1 < id2 {
+ return -1
+ }
+ return 1
+}
+
+func shouldBackfill(latestBridgedIDStr, latestIDFromServerStr string) bool {
+ return compareMessageIDs(latestBridgedIDStr, latestIDFromServerStr) == -1
+}
+
+func (d *DiscordChatResync) CheckNeedsBackfill(ctx context.Context, latestBridged *database.Message) (bool, error) {
+ log := zerolog.Ctx(ctx).With().
+ Str("resyncing_channel_id", d.channel.ID).
+ Str("resyncing_channel_last_message_id", d.channel.LastMessageID).
+ Str("resyncing_guild_id", d.channel.GuildID).
+ Bool("has_latest_bridged", latestBridged != nil).
+ Logger()
+
+ if latestBridged == nil {
+ needsBackfill := d.channel.LastMessageID != ""
+ log.Debug().Bool("needs_backfill", needsBackfill).Msg("Computed needs backfill")
+ return needsBackfill, nil
+ }
+
+ needsBackfill := shouldBackfill(
+ discordid.ParseMessageID(latestBridged.ID),
+ d.channel.LastMessageID,
+ )
+ log.Debug().Bool("needs_backfill", needsBackfill).Msg("Computed needs backfill")
+ return needsBackfill, nil
+}
diff --git a/pkg/connector/events_guild_resync.go b/pkg/connector/events_guild_resync.go
new file mode 100644
index 0000000..cd8ccc9
--- /dev/null
+++ b/pkg/connector/events_guild_resync.go
@@ -0,0 +1,61 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+)
+
+type DiscordGuildResync struct {
+ Client *DiscordClient
+ guild *discordgo.Guild
+ portalKey networkid.PortalKey
+}
+
+var (
+ _ bridgev2.RemoteChatResyncWithInfo = (*DiscordGuildResync)(nil)
+ _ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordGuildResync)(nil)
+)
+
+func (d *DiscordGuildResync) AddLogContext(c zerolog.Context) zerolog.Context {
+ return c.Str("guild_id", d.guild.ID).Str("guild_name", d.guild.Name)
+}
+
+func (d *DiscordGuildResync) GetPortalKey() networkid.PortalKey {
+ return d.portalKey
+}
+
+func (d *DiscordGuildResync) GetSender() bridgev2.EventSender {
+ return bridgev2.EventSender{}
+}
+
+func (d *DiscordGuildResync) GetType() bridgev2.RemoteEventType {
+ return bridgev2.RemoteEventChatResync
+}
+
+func (d *DiscordGuildResync) ShouldCreatePortal() bool {
+ return true
+}
+
+func (d *DiscordGuildResync) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
+ return d.Client.GetChatInfo(ctx, portal)
+}
diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml
new file mode 100644
index 0000000..ffe0f00
--- /dev/null
+++ b/pkg/connector/example-config.yaml
@@ -0,0 +1,80 @@
+# Configuration options related to Discord guilds (also known as "servers").
+guilds:
+ # UNSTABLE: The IDs of the guilds to bridge. This is a stopgap measure
+ # during bridge development. If no guild IDs are specified, then no guilds
+ # are bridged at all.
+ bridging_guild_ids: []
+
+ # Should guild channel portals take on the guild icon as their avatars?
+ guild_avatars_in_rooms: false
+
+# Should the bridge refuse to send direct messages to recipients the user isn't
+# friends with on Discord? Discord generally considers this to be a "risky"
+# action.
+forbid_dming_strangers: true
+
+# Template for Matrix room names created for Discord channels, except for 1:1
+# DMs. 1:1 DMs intentionally do not use this template as their room metadata is
+# derived from the other user's ghost (when private_chat_portal_meta is enabled).
+#
+# Available variables:
+# .Name - The Discord channel name.
+# .ParentName - The parent channel/category name, if any.
+# .GuildName - The guild name for guild channels.
+# .Type - The raw Discord channel type.
+# .NSFW - Whether the channel is marked NSFW.
+# .IsDM - Whether the channel is a 1:1 DM.
+# .IsGroupDM - Whether the channel is a group DM.
+# .IsCategory - Whether the channel is a guild category.
+# .IsGuildChannel - Whether the channel belongs to a guild.
+channel_name_template: "{{if and .IsGuildChannel (not .IsCategory)}}#{{end}}{{.Name}}"
+
+# Should incoming custom emoji reactions be bridged as mxc:// URIs?
+# If false, they are bridged as :shortcode: instead.
+custom_emoji_reactions: true
+
+# Should a per-message profile (sender display name and avatar) be
+# _unconditionally_ attached to every bridged Discord message part? This can
+# help some clients display message authorship properly (specifically, during
+# user-triggered backfill that involves yet-to-be-seen Discord users).
+per_message_profiles_on_every_message_hack: false
+
+# Should we log when messages from unbridged guild channels are dropped? This
+# only includes metadata such as channel and message ID.
+log_when_dropping_messages: true
+
+# Static proxy address (HTTP or SOCKS5) for connecting to Discord.
+proxy:
+# HTTP endpoint to request a new proxy address from, for dynamically assigned
+# proxies. The endpoint must return a JSON body with a string field called
+# proxy_url. It is re-fetched on every (re)connect so each session egresses from
+# one consistent IP.
+get_proxy_from:
+# Should avatar, icon, and attachment downloads also go through the proxy? The
+# gateway websocket and REST API always use it (when proxying).
+proxy_media: false
+# When a proxy is configured, should the "machine" bridgev2 login flow
+# configure its HTTP client with the proxy?
+#
+# (The "machine" login flow is the reverse engineered native login flow, which
+# employs a state machine - hence the name. It does not use web views except
+# for CAPTCHA handling, which occurs dynamically and only when needed.)
+#
+# Disabling this setting alone will make the bridge connect to Discord directly
+# during login, despite the resulting session using the proxy. Leaving this
+# enabled is safest.
+proxy_login_machine: true
+# When a proxy is configured, should the "remoteauth" bridgev2 login flow
+# configure its HTTP client with the proxy?
+#
+# (The "remoteauth" login flow is the QR-code-based login process that connects
+# to a WebSocket gateway entirely dedicated to this login process.)
+proxy_login_remoteauth: true
+
+# Should "Account Standing" information (viewable under the Account tab on
+# Discord) be fetched and incorporated into the "info" field of bridge states?
+#
+# Personally identifiable information such as message or channel identifiers,
+# message content, or attachment data are never incorporated into the reported
+# information.
+report_scrubbed_account_standing: false
diff --git a/pkg/connector/handlediscord.go b/pkg/connector/handlediscord.go
new file mode 100644
index 0000000..ef446fe
--- /dev/null
+++ b/pkg/connector/handlediscord.go
@@ -0,0 +1,1158 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+ "runtime/debug"
+ "slices"
+ "strconv"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exmaps"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/bridgev2/simplevent"
+ "maunium.net/go/mautrix/event"
+
+ "go.mau.fi/util/variationselector"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+ "go.mau.fi/mautrix-discord/pkg/router"
+)
+
+type DiscordEventMeta struct {
+ Type bridgev2.RemoteEventType
+ LogContext func(c zerolog.Context) zerolog.Context
+ route router.Route
+}
+
+func (em *DiscordEventMeta) AddLogContext(c zerolog.Context) zerolog.Context {
+ if em.LogContext == nil {
+ return c
+ }
+ c = em.LogContext(c)
+ return c
+}
+
+func (em *DiscordEventMeta) GetType() bridgev2.RemoteEventType {
+ return em.Type
+}
+
+func (em *DiscordEventMeta) GetPortalKey() networkid.PortalKey {
+ return em.route.PortalKey
+}
+
+func (em *DiscordEventMeta) PortalReceiverIsUncertain() bool {
+ return em.route.Uncertain
+}
+
+type DiscordMessage struct {
+ *DiscordEventMeta
+ Data *discordgo.Message
+ Client *DiscordClient
+ ThreadRootID *networkid.MessageID
+}
+
+func (m *DiscordMessage) ShouldCreatePortal() bool {
+ // Do not create a portal merely to bridge a message deletion or edit.
+ return m.Type == bridgev2.RemoteEventMessage
+}
+
+func (m *DiscordMessage) ConvertEdit(
+ ctx context.Context,
+ portal *bridgev2.Portal,
+ intent bridgev2.MatrixAPI,
+ existingParts []*database.Message,
+) (*bridgev2.ConvertedEdit, error) {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "convert discord edit").Logger()
+ ctx = log.WithContext(ctx)
+
+ // FIXME(skip): This will always reupload attachments, super wasteful.
+ newlyConverted := m.Client.connector.MsgConv.ToMatrix(
+ ctx,
+ portal,
+ intent,
+ m.Client.UserLogin,
+ m.Client.Session,
+ m.Data,
+ m.ThreadRootID,
+ )
+
+ // Detect the legacy scheme of naively assigning incrementing part IDs
+ // without using stable identifiers, so we don't cause churn on previously
+ // bridged messages that are edited.
+ if isLegacyNumericParts(existingParts) {
+ return legacyConvertEdit(ctx, newlyConverted, existingParts)
+ }
+
+ beforePartsByID := make(map[networkid.PartID]*database.Message, len(existingParts))
+ for _, part := range existingParts {
+ beforePartsByID[part.PartID] = part
+ }
+ afterPartIDs := make(exmaps.Set[networkid.PartID], len(newlyConverted.Parts))
+ for _, part := range newlyConverted.Parts {
+ afterPartIDs.Add(part.ID)
+ }
+
+ edit := &bridgev2.ConvertedEdit{}
+
+ // If a part ID is no longer present after converting the edited version,
+ // then it was deleted.
+ for _, part := range existingParts {
+ if !afterPartIDs.Has(part.PartID) {
+ edit.DeletedParts = append(edit.DeletedParts, part)
+ }
+ }
+
+ var addedParts []*bridgev2.ConvertedMessagePart
+ for _, part := range newlyConverted.Parts {
+ dbPart, ok := beforePartsByID[part.ID]
+ if !ok {
+ // Part ID is new, so it's being added.
+ addedParts = append(addedParts, part)
+ continue
+ }
+
+ // TODO(skip): Stash the edited timestamp of messages so we can
+ // actually discern between link previews/embeds and the message text
+ // actually being edited. As is, this will always replace the message
+ // body.
+ if part.ID == "" {
+ edit.ModifiedParts = append(edit.ModifiedParts, part.ToEditPart(dbPart))
+ }
+ }
+ if len(addedParts) > 0 {
+ edit.AddedParts = &bridgev2.ConvertedMessage{
+ Parts: addedParts,
+ ThreadRoot: newlyConverted.ThreadRoot,
+ }
+ }
+
+ return edit, nil
+}
+
+// isLegacyNumericParts reports whether the existing parts on a message were
+// assigned the old incrementing numeric part IDs.
+func isLegacyNumericParts(existingParts []*database.Message) bool {
+ if len(existingParts) == 0 {
+ return false
+ }
+
+ for _, part := range existingParts {
+ partIDString := string(part.PartID)
+ partID, err := strconv.Atoi(partIDString)
+ if err != nil {
+ return false
+ }
+ if partID < 0 || partID >= len(existingParts) {
+ // outside of range
+ return false
+ }
+ if strconv.Itoa(partID) != partIDString {
+ // round-trip
+ return false
+ }
+ }
+
+ return true
+}
+
+// legacyConvertEdit performs legacy message part edit handling, appropriate
+// for messages that were bridged before stable part IDs were assigned.
+func legacyConvertEdit(ctx context.Context, converted *bridgev2.ConvertedMessage, existing []*database.Message) (*bridgev2.ConvertedEdit, error) {
+ log := zerolog.Ctx(ctx)
+ slices.SortStableFunc(existing, func(a *database.Message, b *database.Message) int {
+ ai, _ := strconv.Atoi(string(a.PartID))
+ bi, _ := strconv.Atoi(string(b.PartID))
+ return ai - bi
+ })
+
+ if len(converted.Parts) != len(existing) {
+ log.Warn().
+ Int("n_parts_existing", len(existing)).
+ Int("n_parts_after_edit", len(converted.Parts)).
+ Msg("Ignoring legacy message edit that changed number of parts")
+ return nil, bridgev2.ErrIgnoringRemoteEvent
+ }
+
+ parts := make([]*bridgev2.ConvertedEditPart, 0, len(existing))
+ for pi, part := range converted.Parts {
+ parts = append(parts, part.ToEditPart(existing[pi]))
+ }
+
+ return &bridgev2.ConvertedEdit{
+ ModifiedParts: parts,
+ }, nil
+}
+
+var (
+ _ bridgev2.RemoteMessage = (*DiscordMessage)(nil)
+ _ bridgev2.RemoteMessageWithTransactionID = (*DiscordMessage)(nil)
+ _ bridgev2.RemoteMessageRemove = (*DiscordMessage)(nil)
+ _ bridgev2.RemoteEventThatMayCreatePortal = (*DiscordMessage)(nil)
+ _ bridgev2.RemoteEventWithUncertainPortalReceiver = (*DiscordMessage)(nil)
+ _ bridgev2.RemoteEdit = (*DiscordMessage)(nil)
+)
+
+func (m *DiscordMessage) GetTargetMessage() networkid.MessageID {
+ return discordid.MakeMessageID(m.Data.ID)
+}
+
+func (m *DiscordMessage) GetTransactionID() networkid.TransactionID {
+ if m.Data.Nonce == "" {
+ return ""
+ }
+ return networkid.TransactionID(m.Data.Nonce)
+}
+
+func (m *DiscordMessage) ConvertMessage(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI) (*bridgev2.ConvertedMessage, error) {
+ return m.Client.connector.MsgConv.ToMatrix(ctx, portal, intent, m.Client.UserLogin, m.Client.Session, m.Data, m.ThreadRootID), nil
+}
+
+func (m *DiscordMessage) GetID() networkid.MessageID {
+ return discordid.MakeMessageID(m.Data.ID)
+}
+
+func (m *DiscordMessage) GetSender() bridgev2.EventSender {
+ if m.Data.Author == nil {
+ // Message deletions don't have a sender associated with them.
+ return bridgev2.EventSender{}
+ }
+
+ return m.Client.makeEventSender(m.Data.Author)
+}
+
+func (d *DiscordClient) wrapDiscordMessage(ctx context.Context, msg *discordgo.Message, route *router.Route, typ bridgev2.RemoteEventType) DiscordMessage {
+ if msg == nil {
+ msg = &discordgo.Message{}
+ }
+
+ return DiscordMessage{
+ DiscordEventMeta: &DiscordEventMeta{
+ Type: typ,
+ route: *route,
+ },
+ Data: msg,
+ Client: d,
+ ThreadRootID: route.FromThreadRootMessageID(),
+ }
+}
+
+type DiscordReaction struct {
+ *DiscordEventMeta
+ Reaction *discordgo.MessageReaction
+ Client *DiscordClient
+
+ Emoji string
+ EmojiID networkid.EmojiID
+ Extra map[string]any
+}
+
+func (r *DiscordReaction) GetSender() bridgev2.EventSender {
+ return r.Client.makeEventSenderWithID(r.Reaction.UserID)
+}
+
+func (r *DiscordReaction) GetTargetMessage() networkid.MessageID {
+ return discordid.MakeMessageID(r.Reaction.MessageID)
+}
+
+func (r *DiscordReaction) GetRemovedEmojiID() networkid.EmojiID {
+ return r.EmojiID
+}
+
+var (
+ _ bridgev2.RemoteReaction = (*DiscordReaction)(nil)
+ _ bridgev2.RemoteEventWithUncertainPortalReceiver = (*DiscordReaction)(nil)
+ _ bridgev2.RemoteReactionRemove = (*DiscordReaction)(nil)
+ _ bridgev2.RemoteReactionWithExtraContent = (*DiscordReaction)(nil)
+)
+
+func (r *DiscordReaction) GetReactionEmoji() (string, networkid.EmojiID) {
+ return r.Emoji, r.EmojiID
+}
+
+func (r *DiscordReaction) GetReactionExtraContent() map[string]any {
+ return r.Extra
+}
+
+func (d *DiscordClient) wrapDiscordReaction(ctx context.Context, reaction *discordgo.MessageReaction, route *router.Route, beingAdded bool) (*DiscordReaction, error) {
+ if reaction == nil {
+ return nil, nil
+ }
+ evtType := bridgev2.RemoteEventReaction
+ if !beingAdded {
+ evtType = bridgev2.RemoteEventReactionRemove
+ }
+
+ var matrixEmoji string
+ var emojiID string
+ var extra map[string]any
+
+ if reaction.Emoji.ID != "" {
+ // A custom emoji.
+ emojiID = fmt.Sprintf("%s:%s", reaction.Emoji.Name, reaction.Emoji.ID)
+ shortcode := fmt.Sprintf(":%s:", reaction.Emoji.Name)
+
+ extra = map[string]any{
+ "fi.mau.discord.reaction": map[string]any{
+ "id": reaction.Emoji.ID,
+ "name": reaction.Emoji.Name,
+ // "mxc" is added later if it's `beingAdded`.
+ },
+ "com.beeper.reaction.shortcode": shortcode,
+ }
+
+ if beingAdded {
+ reactionMXC, err := d.connector.GetCustomEmojiMXC(
+ ctx,
+ reaction.Emoji.ID,
+ reaction.Emoji.Name,
+ reaction.Emoji.Animated,
+ )
+
+ if err != nil || reactionMXC == "" {
+ zerolog.Ctx(ctx).Err(err).
+ Str("emoji_id", reaction.Emoji.ID).
+ Str("emoji_name", reaction.Emoji.Name).
+ Msg("Failed to get Matrix MXC for custom emoji reaction being added")
+ return nil, err
+ }
+
+ extra["fi.mau.discord.reaction"].(map[string]any)["mxc"] = reactionMXC
+
+ if d.connector.Config.CustomEmojiReactionsEnabled() {
+ matrixEmoji = string(reactionMXC)
+ } else {
+ matrixEmoji = shortcode
+ }
+ }
+ } else {
+ // A Unicode emoji.
+ emojiID = reaction.Emoji.Name
+ matrixEmoji = variationselector.Add(reaction.Emoji.Name)
+ }
+
+ return &DiscordReaction{
+ DiscordEventMeta: &DiscordEventMeta{
+ Type: evtType,
+ route: *route,
+ },
+ Reaction: reaction,
+ Client: d,
+ Emoji: matrixEmoji,
+ EmojiID: discordid.MakeEmojiID(emojiID),
+ Extra: extra,
+ }, nil
+}
+
+func (d *DiscordClient) handleDiscordTyping(ctx context.Context, typing *discordgo.TypingStart, route *router.Route) {
+ if typing.UserID == d.Session.State.User.ID {
+ return
+ }
+
+ log := zerolog.Ctx(ctx).With().
+ Str("typing_channel_id", typing.ChannelID).
+ Str("typing_user_id", typing.UserID).
+ Str("typing_guild_id", typing.GuildID).
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ // Make sure we have this user's info in case we haven't seen them at all yet.
+ _ = d.userCache.Resolve(ctx, typing.UserID)
+
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Typing{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventTyping,
+ PortalKey: route.PortalKey,
+ Sender: d.makeEventSenderWithID(typing.UserID),
+ UncertainReceiver: route.Uncertain,
+ },
+ Timeout: 12 * time.Second,
+ Type: bridgev2.TypingTypeText,
+ })
+}
+
+func (d *DiscordClient) handleChannelCreate(ctx context.Context, ch *discordgo.ChannelCreate) error {
+ log := zerolog.Ctx(ctx).With().
+ Str("guild_id", ch.GuildID).
+ Str("channel_id", ch.ID).
+ Str("channel_parent_id", ch.ParentID).
+ Str("channel_type", readableChannelType(ch.Type)).
+ Str("action", "handle channel create").Logger()
+ ctx = log.WithContext(ctx)
+
+ if ch.GuildID == "" {
+ log.Debug().Msg("Private channel was created, creating portal")
+ } else {
+ if !d.shouldBridgeChannel(ctx, ch.Channel) {
+ log.Debug().Msg("Ignoring creation of guild channel that should not be bridged")
+ return nil
+ }
+
+ log.Debug().Msg("Guild channel was created")
+
+ // If the newly created channel is under a category, ensure that the
+ // corresponding parent space exists first, so m.bridge is correct.
+ if ch.ParentID != "" {
+ parentCh := d.channelWithID(ctx, ch.ParentID)
+ if parentCh == nil {
+ log.Error().Msg("Newly created guild channel has a parent channel, but it's not present in cache; dropping!")
+ return nil
+ }
+ log.Debug().Msg("Ensuring parent space for the newly created channel")
+ err := d.ensurePortal(ctx, d.portalKeyForChannel(parentCh), nil)
+ if err != nil {
+ log.Err(err).Msg("Failed to ensure category space, dropping!")
+ return nil
+ }
+ }
+ }
+
+ // This creates the portal.
+ d.queueChannelResync(ctx, ch.Channel)
+
+ return nil
+}
+
+func (d *DiscordClient) handleChannelUpdate(ctx context.Context, upd *discordgo.ChannelUpdate) error {
+ if upd.BeforeUpdate == nil {
+ // Channel doesn't exist in the discordgo's state; don't bother bridging.
+ return nil
+ }
+
+ log := zerolog.Ctx(ctx).With().Str("action", "handle channel update").Logger()
+ ctx = log.WithContext(ctx)
+
+ portalKey := d.portalKeyForChannel(upd.Channel)
+ portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
+ if err != nil {
+ return fmt.Errorf("failed to look up existing channel: %w", err)
+ }
+ if portal == nil {
+ // Don't bridge updates for channels we haven't actually bridged.
+ return nil
+ }
+
+ ts := time.Now()
+ // Re-use main GetChatInfo logic to avoid drift. The rest of this function
+ // is mostly removing what didn't change.
+ patch, err := d.GetChatInfo(ctx, portal)
+ if err != nil {
+ return fmt.Errorf("failed to recompute chat info: %w", err)
+ }
+
+ patch.Type = nil
+ patch.CanBackfill = false
+
+ old := upd.BeforeUpdate
+ // People leaving or joining a group DM isn't expressed via CHANNEL_UPDATE.
+ patch.Members = nil
+ if upd.Name == old.Name {
+ patch.Name = nil
+ }
+ if upd.Topic == old.Topic {
+ patch.Topic = nil
+ }
+ if upd.Icon == old.Icon {
+ patch.Avatar = nil
+ }
+ if upd.ParentID == old.ParentID {
+ patch.ParentID = nil
+ }
+
+ d.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatInfoChange,
+ PortalKey: portalKey,
+ Timestamp: ts,
+ },
+ ChatInfoChange: &bridgev2.ChatInfoChange{
+ ChatInfo: patch,
+ },
+ })
+
+ return nil
+}
+
+// handleChannelDelete handles a channel being deleted. This can be a guild
+// channel getting "actually" deleted or a private channel getting "closed".
+func (d *DiscordClient) handleChannelDelete(ctx context.Context, evt *discordgo.ChannelDelete) error {
+ portalKey := d.portalKeyForChannel(evt.Channel)
+ log := zerolog.Ctx(ctx).With().
+ Str("channel_id", evt.ID).
+ Str("guild_id", evt.GuildID).
+ Stringer("deleted_channel_portal_key", portalKey).Logger()
+
+ log.Debug().Msg("Handling channel deletion")
+ d.queueChatDelete(portalKey, evt.Channel.GuildID)
+
+ return nil
+}
+
+func (d *DiscordClient) queueChatDelete(portalKey networkid.PortalKey, deletedChannelGuildID string) {
+ ts := time.Now()
+
+ onlyForMe := true
+ if !d.connector.Bridge.Config.SplitPortals && deletedChannelGuildID != "" {
+ // When split portals are disabled and a guild channel was deleted,
+ // then it should be deleted for everyone.
+ onlyForMe = false
+ }
+
+ d.UserLogin.QueueRemoteEvent(&simplevent.ChatDelete{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatDelete,
+ PortalKey: portalKey,
+ Timestamp: ts,
+ },
+ OnlyForMe: onlyForMe,
+ // Do not pass Children: true as deleting a guild channel category
+ // merely detaches the parent_id from all child channels.
+ // CHANNEL_UPDATE events will be dispatched for all child channels,
+ // which should reparent them.
+ })
+}
+
+func (d *DiscordClient) handleThreadUpdate(ctx context.Context, thread *discordgo.Channel) error {
+ if thread == nil || !isThread(thread) {
+ return nil
+ }
+ return d.upsertThreadInfoFromChannel(ctx, thread)
+}
+
+func (d *DiscordClient) handleThreadDelete(ctx context.Context, thread *discordgo.Channel) error {
+ if thread == nil || thread.ID == "" {
+ return nil
+ }
+ return d.connector.DB.Thread.DeleteByThreadChannelID(ctx, string(d.UserLogin.ID), thread.ID)
+}
+
+func (d *DiscordClient) queueIndividualMembershipChange(
+ ctx context.Context,
+ portalKey networkid.PortalKey,
+ user *discordgo.User,
+ membership event.Membership,
+ ts time.Time,
+) {
+ log := zerolog.Ctx(ctx)
+
+ userID := discordid.MakeUserID(user.ID)
+ info := d.getUserInfo(ctx, user)
+
+ log.Debug().
+ Stringer("portal_key", portalKey).
+ Str("moving_user_id", user.ID).
+ Str("membership", string(membership)).
+ Msg("Queueing chat info change in response to membership change")
+
+ d.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatInfoChange,
+ PortalKey: portalKey,
+ Timestamp: ts,
+ },
+ ChatInfoChange: &bridgev2.ChatInfoChange{
+ MemberChanges: &bridgev2.ChatMemberList{
+ MemberMap: bridgev2.ChatMemberMap{
+ userID: bridgev2.ChatMember{
+ // TODO: Can't effectively send MemberSender here to
+ // attribute e.g. someone getting kicked from a group
+ // DM because that information isn't in the gateway
+ // payload. Might need to wait for the corresponding
+ // system message.
+ EventSender: d.makeEventSender(user),
+ Membership: membership,
+ UserInfo: info,
+ },
+ },
+ },
+ },
+ })
+}
+
+func (d *DiscordClient) handleRecipientAdd(ctx context.Context, evt *discordgo.ChannelRecipientAdd, route *router.Route) error {
+ d.queueIndividualMembershipChange(ctx, route.PortalKey, evt.User, event.MembershipJoin, time.Now())
+ return nil
+}
+
+func (d *DiscordClient) handleRecipientRemove(ctx context.Context, evt *discordgo.ChannelRecipientRemove, route *router.Route) error {
+ d.queueIndividualMembershipChange(ctx, route.PortalKey, evt.User, event.MembershipLeave, time.Now())
+ return nil
+}
+
+func (d *DiscordClient) handleGuildMemberJoinMessage(ctx context.Context, msg *discordgo.Message, route *router.Route) {
+ ts := msg.Timestamp
+ if ts.IsZero() {
+ ts = time.Now()
+ }
+ d.queueIndividualMembershipChange(ctx, route.PortalKey, msg.Author, event.MembershipJoin, ts)
+}
+
+func (d *DiscordClient) handlePresenceUpdate(ctx context.Context, evt *discordgo.PresenceUpdate) {
+ // NOTE: This can potentially be a _very_ hot code path for users who can
+ // see a lot of other users (e.g. member of a large guild, member of many
+ // guilds).
+
+ user := evt.User
+ if user == nil || user.ID == "" {
+ return
+ }
+
+ // We only care about profile updates, so bail if it's just the
+ // status/activity that changed.
+ if user.Username == "" && user.GlobalName == "" && user.Discriminator == "" && user.Avatar == "" {
+ return
+ }
+
+ log := zerolog.Ctx(ctx).With().
+ Str("presence_update_guild_id", evt.GuildID).
+ Str("presence_update_user_id", user.ID).
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ // Incorporate the user delta into the cache.
+ merged := d.userCache.MergePartialUser(user)
+ if merged == nil {
+ // The user wasn't cached in the first place, so this presence update
+ // is likely irrelevant to us.
+ log.Trace().Msg("Ignoring presence update for uncached user")
+ return
+ }
+
+ // Check if a ghost actually exists for this user; don't eagerly
+ // materialize ghosts just because we happen to be subscribed to their
+ // presence.
+ ghost, err := d.connector.Bridge.GetExistingGhostByID(ctx, discordid.MakeUserID(user.ID))
+ if err != nil {
+ log.Err(err).Msg("Failed to look up existing ghost for presence update")
+ return
+ }
+ if ghost == nil {
+ return
+ }
+
+ log.Debug().Msg("Dispatching ghost info update after profile change")
+ ghost.UpdateInfo(ctx, d.getUserInfo(ctx, merged))
+}
+
+func (d *DiscordClient) handleMessageAck(ctx context.Context, ack *discordgo.MessageAck, bridged bool, route *router.Route) {
+ d.readStatesLock.Lock()
+ zerolog.Ctx(ctx).Trace().
+ Str("channel_id", ack.ChannelID).
+ Str("message_id", ack.MessageID).
+ Msg("Updating state with MESSAGE_ACK")
+
+ // TODO: mention_count can appear in MESSAGE_ACK payloads. Update it if it's
+ // present and not `null`. This needs discordgo changes. (There's even more
+ // missing fields than this.)
+ d.readStates[ack.ChannelID] = &discordgo.ReadState{
+ ID: ack.ChannelID,
+ LastMessageID: discordgo.StringOrInt(ack.MessageID),
+ }
+ d.readStatesLock.Unlock()
+
+ if bridged {
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &simplevent.Receipt{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventReadReceipt,
+ PortalKey: route.PortalKey,
+ Sender: d.selfEventSender(),
+ UncertainReceiver: route.Uncertain,
+ },
+ LastTarget: discordid.MakeMessageID(ack.MessageID),
+ })
+ }
+}
+
+// channelIsBridged uses routing logic to check whether a portal (with an
+// existing room) exists for a given Discord channel ID.
+func (d *DiscordClient) channelIsBridged(ctx context.Context, channelID string) (bool, *router.Route) {
+ log := zerolog.Ctx(ctx)
+
+ route, err := d.Route(ctx, channelID)
+ if err != nil {
+ log.Err(err).Msg("Failed to route channel when determining channel bridgedness")
+ return false, nil
+ }
+ existingPortal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, route.PortalKey)
+ if err != nil {
+ log.Err(err).Msg("Failed to look up existing portal when determining channel bridgedness")
+ return false, route
+ }
+ return existingPortal != nil && existingPortal.MXID != "", route
+}
+
+func (d *DiscordClient) handleUserGuildSettingsUpdate(ctx context.Context, evt *discordgo.UserGuildSettingsUpdate) {
+ log := zerolog.Ctx(ctx)
+ log.Debug().Msg("Handling user guild settings update")
+ d.applySingleGuildSettings(evt.UserGuildSettings)
+}
+
+func messageCtx(ctx context.Context, msg *discordgo.Message) (context.Context, *zerolog.Logger) {
+ if msg == nil {
+ return ctx, zerolog.Ctx(ctx)
+ }
+
+ wipLog := zerolog.Ctx(ctx).With().
+ Str("guild_id", msg.GuildID).
+ Str("channel_id", msg.ChannelID).
+ Str("message_id", msg.ID)
+ if msg.Author != nil {
+ wipLog = wipLog.Str("author_id", msg.Author.ID).
+ Bool("author_bot", msg.Author.Bot)
+ }
+ if msg.WebhookID != "" {
+ wipLog = wipLog.Str("webhook_id", msg.WebhookID)
+ }
+ log := wipLog.Logger()
+
+ return log.WithContext(ctx), &log
+}
+
+func (d *DiscordClient) handleDiscordStateEvent(rawEvt any) {
+ ctx := d.UserLogin.Bridge.BackgroundCtx
+ log := zerolog.Ctx(ctx)
+
+ switch evt := rawEvt.(type) {
+ case *discordgo.ReadySupplemental:
+ log.Info().
+ Int("n_lazy_private_channels", len(evt.LazyPrivateChannels)).
+ Msg("Received supplemental READY")
+ case *discordgo.Ready:
+ readiedBefore := d.seenReady.Swap(true)
+
+ d.applyReadyPayload(ctx, evt)
+ // A READY after the first one means the gateway handed us a fresh
+ // session instead of resuming (our resume was refused or the session
+ // was invalidated), so Discord didn't replay the events we missed
+ // while offline.
+ if readiedBefore {
+ log.Info().Msg("Reconnected without resuming, init sync is needed")
+ d.fullSyncDone.Store(false)
+ }
+ // Block everyone and make sure our vitals are up-to-date so the bridge
+ // can immediately send out a bridge state that reflects it.
+ //
+ // This will also kick off a full sync (if needed). Notably, without
+ // this line, fresh bridges would never perform an initial full sync.
+ d.pokeVitals(ctx)
+ case *discordgo.MessageCreate:
+ if evt.Author == nil {
+ return
+ }
+
+ urgent := evt.Flags&discordgo.MessageFlagsUrgent != 0
+ system := evt.Author.System
+ if !urgent && !system {
+ return
+ }
+
+ var le *zerolog.Event
+ if urgent {
+ le = log.Warn()
+ } else {
+ le = log.Info()
+ }
+ le.Bool("message_urgent", urgent).
+ Bool("message_system", system).
+ Msg("Received system message")
+
+ if urgent {
+ // Discord's first-party client does this too.
+ //
+ // When it comes to this flag bit in particular (perhaps all
+ // "editable" user flags?), the Gateway doesn't send a USER_UPDATE
+ // event to sessions when it becomes set. However, when another
+ // client does PATCH /users/@me with {flags:…}, that _does_ result
+ // in a Gateway event.
+ d.Session.State.Lock()
+ d.Session.State.User.Flags |= discordgo.UserFlagHasUnreadUrgentMessages
+ d.Session.State.Unlock()
+ // Transition into BAD_CREDENTIALS ASAP.
+ d.pokeVitals(ctx)
+
+ // The account's safety standing might've changed. (There isn't a
+ // handy Gateway event for this.) Do this in a goroutine to avoid
+ // blocking.
+ go func() {
+ d.refreshSafetyHub(ctx)
+ d.pokeVitals(ctx)
+ }()
+ }
+ case *discordgo.UserRequiredActionUpdate:
+ if evt.RequiredAction == "" {
+ log.Info().Msg("Required action was performed")
+ } else {
+ log.Error().Str("required_action", string(evt.RequiredAction)).
+ Msg("Required action was updated")
+ }
+ d.pokeVitals(ctx)
+ case *discordgo.RelationshipAdd:
+ d.upsertRelationship(evt.Relationship)
+ case *discordgo.RelationshipUpdate:
+ d.upsertRelationship(evt.Relationship)
+ case *discordgo.RelationshipRemove:
+ d.removeRelationship(evt.ID)
+ }
+}
+
+func (d *DiscordClient) handleRelationshipNickChange(ctx context.Context, userID, nickname string) {
+ ch := d.dmChannelForUserID(userID)
+ if ch == nil {
+ return
+ }
+
+ portalKey := d.portalKeyForChannel(ch)
+ portal, err := d.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
+ if err != nil {
+ zerolog.Ctx(ctx).Err(err).Msg("Failed to look up DM portal for relationship nick change")
+ return
+ }
+ if portal == nil || portal.MXID == "" {
+ return
+ }
+
+ var name *string
+ if nickname != "" {
+ name = &nickname
+ } else {
+ name = bridgev2.DefaultChatName
+ }
+
+ d.UserLogin.QueueRemoteEvent(&simplevent.ChatInfoChange{
+ EventMeta: simplevent.EventMeta{
+ Type: bridgev2.RemoteEventChatInfoChange,
+ PortalKey: portalKey,
+ Timestamp: time.Now(),
+ },
+ ChatInfoChange: &bridgev2.ChatInfoChange{
+ ChatInfo: &bridgev2.ChatInfo{
+ Name: name,
+ },
+ },
+ })
+}
+
+func (d *DiscordClient) handleDiscordEvent(rawEvt any) {
+ defer func() {
+ err := recover()
+ if err == nil {
+ return
+ }
+
+ d.UserLogin.Log.Error().
+ Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
+ Any(zerolog.ErrorFieldName, err).
+ Msg("Panic in Discord event handler")
+
+ props := d.baseAnalyticsProps(d.UserLogin.Bridge.BackgroundCtx)
+ props["eventType"] = fmt.Sprintf("%T", rawEvt)
+ props["error"] = fmt.Sprint(err)
+
+ d.UserLogin.TrackAnalytics("Discord event handler panic", props)
+ }()
+
+ log := d.UserLogin.Log.With().Str("action", "handle discord event").
+ Type("event_type", rawEvt).
+ Logger()
+ ctx := log.WithContext(d.UserLogin.Bridge.BackgroundCtx)
+
+ // NOTE: discordgo seemingly dispatches both the proper unmarshalled type
+ // (e.g. `*discordgo.TypingStart`) _as well as_ a "raw" *discordgo.Event
+ // (e.g. `*discordgo.Event` with `Type` of `TYPING_START`) for every gateway
+ // event.
+
+ // NOTE: We explicitly return early from paths where we would otherwise
+ // QueueRemoteEvent for a portal that hasn't been bridged by the user yet.
+ // (Specifically, we check for an extant portal with an associated room.)
+ // This avoids the eager creation of stub portals that have bogus metadata
+ // (e.g. GuildID == "" despite being a guild channel). This is because you
+ // can't specify metadata upfront when a portal is implicitly created. We
+ // might want to rely on our metadata always being "correct" in the future.
+ //
+ // This also helps avoid excessive "Dropping event as portal doesn't exist"
+ // logs from Mautrix. You receive events for every guild you're in, so this
+ // can become noisy fast.
+
+ switch evt := rawEvt.(type) {
+ case *discordgo.Ready:
+ log.Info().
+ Int("n_dms", len(evt.PrivateChannels)).
+ Int("n_guilds", len(evt.Guilds)).
+ Int("n_merged_members", len(evt.MergedMembers)).
+ Int("n_relationships", len(evt.Relationships)).
+ Int("n_users", len(evt.Users)).
+ Msg("Received READY dispatch from discordgo")
+
+ // Catch up on profile changes that might've occurred while we were
+ // offline.
+ d.syncRemoteProfile(ctx)
+ go d.resyncGhostsFromReady(ctx, evt)
+ d.refreshSafetyHub(ctx)
+ d.pokeVitals(ctx)
+ d.sendCurrentState(ctx) // (pokeVitals already enqueued a new bridge state but let's be explicit about it here.)
+ case *discordgo.Resumed:
+ // (All missed gateway events have been replayed, and all subsequent
+ // events will be new.)
+ log.Info().Msg("Received RESUMED dispatch from discordgo")
+ d.refreshSafetyHub(ctx)
+ d.sendCurrentState(ctx)
+ case *discordgo.InvalidAuth:
+ log.Warn().Msg("Got logged out of Discord due to invalid token")
+ d.tokenInvalidated(ctx, "while connected")
+ case *discordgo.TypingStart:
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+ d.handleDiscordTyping(ctx, evt, route)
+ case *discordgo.GuildCreate:
+ if evt.Unavailable {
+ break
+ }
+ if err := d.syncGuildRoles(ctx, evt.ID, evt.Roles); err != nil {
+ log.Err(err).Str("guild_id", evt.ID).Msg("Failed to sync guild roles from guild create event")
+ }
+ case *discordgo.GuildUpdate:
+ if err := d.syncGuildRoles(ctx, evt.ID, evt.Roles); err != nil {
+ log.Err(err).Str("guild_id", evt.ID).Msg("Failed to sync guild roles from guild update event")
+ }
+ case *discordgo.GuildRoleCreate:
+ roleID := ""
+ if evt.Role != nil {
+ roleID = evt.Role.ID
+ }
+ if err := d.upsertGuildRole(ctx, evt.GuildID, evt.Role); err != nil {
+ log.Err(err).Str("guild_id", evt.GuildID).Str("role_id", roleID).Msg("Failed to store role create event")
+ }
+ case *discordgo.GuildRoleUpdate:
+ roleID := ""
+ if evt.Role != nil {
+ roleID = evt.Role.ID
+ }
+ if err := d.upsertGuildRole(ctx, evt.GuildID, evt.Role); err != nil {
+ log.Err(err).Str("guild_id", evt.GuildID).Str("role_id", roleID).Msg("Failed to store role update event")
+ }
+ case *discordgo.GuildRoleDelete:
+ if err := d.connector.DB.Role.DeleteByID(ctx, evt.GuildID, evt.RoleID); err != nil {
+ log.Err(err).Str("guild_id", evt.GuildID).Str("role_id", evt.RoleID).Msg("Failed to delete role from database")
+ }
+ case *discordgo.ChannelCreate:
+ if err := d.handleChannelCreate(ctx, evt); err != nil {
+ log.Err(err).Msg("Failed to handle channel create")
+ }
+ case *discordgo.ChannelUpdate:
+ bridged, _ := d.channelIsBridged(ctx, evt.ID)
+ if !bridged {
+ return
+ }
+ err := d.handleChannelUpdate(ctx, evt)
+ if err != nil {
+ log.Err(err).Msg("Failed to handle channel update")
+ }
+ case *discordgo.ChannelDelete:
+ // The route computed by channelIsBridged will always be uncertain
+ // because the channel has already disappeared from discordgo's state.
+ bridged, _ := d.channelIsBridged(ctx, evt.ID)
+ if !bridged {
+ return
+ }
+ if err := d.handleChannelDelete(ctx, evt); err != nil {
+ log.Err(err).Msg("Failed to handle channel delete")
+ }
+ case *discordgo.ChannelRecipientAdd:
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+ if err := d.handleRecipientAdd(ctx, evt, route); err != nil {
+ log.Err(err).Msg("Failed to handle channel recipient add")
+ }
+ case *discordgo.ChannelRecipientRemove:
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+ if err := d.handleRecipientRemove(ctx, evt, route); err != nil {
+ log.Err(err).Msg("Failed to handle channel recipient remove")
+ }
+ case *discordgo.ThreadCreate:
+ err := d.handleThreadUpdate(ctx, evt.Channel)
+ if err != nil {
+ log.Err(err).Str("thread_id", evt.ID).Msg("Failed to handle thread create event")
+ }
+ case *discordgo.ThreadUpdate:
+ err := d.handleThreadUpdate(ctx, evt.Channel)
+ if err != nil {
+ log.Err(err).Str("thread_id", evt.ID).Msg("Failed to handle thread update event")
+ }
+ case *discordgo.ThreadDelete:
+ err := d.handleThreadDelete(ctx, evt.Channel)
+ if err != nil {
+ log.Err(err).Str("thread_id", evt.ID).Msg("Failed to handle thread delete event")
+ }
+ case *discordgo.ThreadListSync:
+ for _, thread := range evt.Threads {
+ err := d.handleThreadUpdate(ctx, thread)
+ if err != nil {
+ log.Err(err).Str("thread_id", thread.ID).Msg("Failed to handle thread in thread list sync event")
+ }
+ }
+ case *discordgo.MessageCreate:
+ if evt.Author == nil {
+ log.Trace().Int("message_type", int(evt.Message.Type)).
+ Str("guild_id", evt.GuildID).
+ Str("message_id", evt.ID).
+ Str("channel_id", evt.ChannelID).
+ Msg("Dropping message that lacks an author")
+ return
+ }
+ ctx, log := messageCtx(ctx, evt.Message)
+ inBridgedChannel, route := d.channelIsBridged(ctx, evt.ChannelID)
+ isDM := route != nil && route.FromChannel != nil && channelIsPrivate(route.FromChannel)
+ if !inBridgedChannel && !isDM {
+ if d.connector.Config.LogWhenDroppingMessages {
+ log.Debug().
+ Str("channel_id", evt.ChannelID).
+ Str("message_id", evt.ID).
+ Bool("route_uncertain", route != nil && route.Uncertain).
+ Bool("from_channel_known", route != nil && route.FromChannel != nil).
+ Bool("from_thread_known", route != nil && route.FromThread != nil).
+ Msg("Dropping message for non-bridged channel")
+ }
+ return
+ }
+
+ if evt.Message.Type == discordgo.MessageTypeGuildMemberJoin {
+ d.userCache.UpdateWithMessage(evt.Message)
+ d.handleGuildMemberJoinMessage(ctx, evt.Message, route)
+ return
+ }
+
+ if err := d.upsertThreadInfoFromMessage(ctx, evt.Message); err != nil {
+ log.Err(err).Msg("Failed to persist thread info from message create")
+ }
+ d.userCache.UpdateWithMessage(evt.Message)
+
+ wrappedEvt := d.wrapDiscordMessage(ctx, evt.Message, route, bridgev2.RemoteEventMessage)
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
+ case *discordgo.MessageUpdate:
+ ctx, log := messageCtx(ctx, evt.Message)
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+
+ if err := d.upsertThreadInfoFromMessage(ctx, evt.Message); err != nil {
+ log.Err(err).Str("message_id", evt.ID).Msg("Failed to persist thread info from message update")
+ }
+
+ wrappedEvt := d.wrapDiscordMessage(ctx, evt.Message, route, bridgev2.RemoteEventEdit)
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
+ case *discordgo.UserUpdate:
+ // The current user changed. (This is not sent out for anyone else.)
+ log.Info().Msg("Current user was updated")
+
+ // discordgo does not update State.User for us. This is probably a bug.
+ // Do it ourselves in the meantime.
+ var oldFlags discordgo.UserFlags
+ {
+ state := d.Session.State
+ state.Lock()
+ oldFlags = d.Session.State.User.Flags
+ *d.Session.State.User = *evt.User
+ state.Unlock()
+ }
+ d.userCache.UpdateWithUserUpdate(evt)
+ user := evt.User
+
+ if oldFlags != user.Flags {
+ log.Info().
+ Int("old_user_flags", int(oldFlags)).
+ Int("new_user_flags", int(user.Flags)).
+ Msg("User flags were updated")
+ }
+ d.pokeVitals(ctx)
+ d.syncRemoteProfile(ctx)
+ d.sendCurrentState(ctx)
+ case *discordgo.MessageDelete:
+ ctx, _ := messageCtx(ctx, evt.Message)
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+
+ wrappedEvt := d.wrapDiscordMessage(ctx, evt.Message, route, bridgev2.RemoteEventMessageRemove)
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, &wrappedEvt)
+ // TODO *discordgo.MessageDeleteBulk
+ case *discordgo.MessageReactionAdd:
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+ wrappedEvt, err := d.wrapDiscordReaction(ctx, evt.MessageReaction, route, true)
+ if err != nil {
+ log.Err(err).Msg("Dropping incoming reaction due to error")
+ } else {
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, wrappedEvt)
+ }
+ // TODO case *discordgo.MessageReactionRemoveAll:
+ // TODO case *discordgo.MessageReactionRemoveEmoji: (needs impl. in discordgo)
+ case *discordgo.MessageReactionRemove:
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ if !bridged {
+ return
+ }
+ wrappedEvt, err := d.wrapDiscordReaction(ctx, evt.MessageReaction, route, false)
+ if err != nil {
+ log.Err(err).Msg("Dropping incoming reaction removal due to error")
+ } else {
+ d.UserLogin.Bridge.QueueRemoteEvent(d.UserLogin, wrappedEvt)
+ }
+ // NOTE: Relationship updates are also handled in handleDiscordStateEvent,
+ // which is synchronously invoked before this one. This is to ensure
+ // coherence in the face of concurrency, because this method is always
+ // dispatched on a new goroutine.
+ case *discordgo.RelationshipAdd:
+ d.handleRelationshipNickChange(ctx, evt.ID, evt.Nickname)
+ case *discordgo.RelationshipUpdate:
+ d.handleRelationshipNickChange(ctx, evt.ID, evt.Nickname)
+ case *discordgo.RelationshipRemove:
+ d.handleRelationshipNickChange(ctx, evt.ID, "")
+ case *discordgo.PresenceUpdate:
+ d.handlePresenceUpdate(ctx, evt)
+ case *discordgo.MessageAck:
+ bridged, route := d.channelIsBridged(ctx, evt.ChannelID)
+ d.handleMessageAck(ctx, evt, bridged, route)
+ case *discordgo.UserGuildSettingsUpdate:
+ d.handleUserGuildSettingsUpdate(ctx, evt)
+ case *discordgo.GuildDelete:
+ if evt.Unavailable {
+ log.Warn().Str("guild_id", evt.ID).Msg("Guild became unavailable")
+ // Leave the portals alone if the guild only went away due to
+ // availability (a Discord outage).
+ return
+ }
+ d.queueGuildDeletion(ctx, evt.ID)
+ }
+}
diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go
new file mode 100644
index 0000000..5a75843
--- /dev/null
+++ b/pkg/connector/handlematrix.go
@@ -0,0 +1,614 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "maps"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/event"
+
+ "go.mau.fi/util/ptr"
+ "go.mau.fi/util/variationselector"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+var (
+ _ bridgev2.ReactionHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.RedactionHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.EditHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.ReadReceiptHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.TypingHandlingNetworkAPI = (*DiscordClient)(nil)
+ _ bridgev2.MuteHandlingNetworkAPI = (*DiscordClient)(nil)
+)
+
+type contextKey int
+
+const (
+ contextKeyChannel contextKey = iota
+)
+
+type SendAttempt struct {
+ At time.Time
+ ChannelType discordgo.ChannelType
+ RecipientRelationshipType *discordgo.RelationshipType
+}
+
+func (d *DiscordClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
+ if !d.IsLoggedIn() {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ log := zerolog.Ctx(ctx).With().Str("action", "matrix message send").Logger()
+ ctx = log.WithContext(ctx)
+
+ portal := msg.Portal
+ guildID := portal.Metadata.(*discordid.PortalMetadata).GuildID
+ parentChannelID := discordid.ParseChannelPortalID(portal.ID)
+ channelID := parentChannelID
+ threadChannelID := ""
+ threadRootRemoteID := getMatrixThreadRootRemoteMessageID(msg.ThreadRoot)
+
+ if threadRootRemoteID != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, threadRootRemoteID)
+ if err != nil {
+ return nil, err
+ }
+ if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ } else if guildID != "" {
+ var startErr error
+ threadChannelID, startErr = d.startThreadFromMatrix(ctx, guildID, parentChannelID, threadRootRemoteID, getThreadName(msg.Content))
+ if startErr != nil {
+ // If creating the thread failed, try resolving it once more in case it already exists.
+ thread, err = d.getThreadByRootMessageID(ctx, threadRootRemoteID)
+ if err != nil {
+ return nil, err
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ } else {
+ return nil, fmt.Errorf("failed to create Discord thread from Matrix message: %w", startErr)
+ }
+ }
+ }
+ }
+ if threadChannelID != "" {
+ channelID = threadChannelID
+ }
+ refererOpt := makeDiscordReferer(guildID, parentChannelID, threadChannelID)
+
+ ch := d.channelWithID(ctx, channelID)
+ ctx = context.WithValue(ctx, contextKeyChannel, ch)
+
+ // Perform any required screening before making any requests to Discord at
+ // all (message conversion does).
+ if err := d.screenOutgoingMessage(ctx, ch); err != nil {
+ return nil, err
+ }
+
+ sendReq, err := d.connector.MsgConv.ToDiscord(ctx, d.Session, msg, channelID, refererOpt)
+ if err != nil {
+ return nil, err
+ }
+
+ if sendReq.Reference != nil && sendReq.Reference.ChannelID == parentChannelID && threadChannelID != "" {
+ sendReq.Reference.ChannelID = threadChannelID
+ }
+
+ if ch != nil {
+ var relType *discordgo.RelationshipType
+ if rel := d.relationshipWithDMRecipient(ch); rel != nil {
+ relType = &rel.Type
+ }
+
+ if channelIsPrivate(ch) {
+ // NOTE: These analytics are so that we can get some data on what's
+ // causing Discord to disable/restrict/ban accounts. For message
+ // attempts, we only send these for DMs at the moment.
+ //
+ // (This fires a goroutine internally so it won't block.)
+ d.sendOutgoingMessageAttemptAnalytics(ctx, map[string]any{
+ "messageFlags": sendReq.Flags,
+ "messageType": sendReq.Type,
+ "hasAttachments": len(sendReq.Attachments) > 0,
+ "hasEmbeds": len(sendReq.Embeds) > 0,
+ "isReplying": sendReq.Reference != nil && sendReq.Reference.Type == discordgo.MessageReferenceTypeDefault,
+ })
+ }
+
+ d.lastSendAttemptMutex.Lock()
+ d.lastSendAttempt = &SendAttempt{
+ At: time.Now(),
+ ChannelType: ch.Type,
+ RecipientRelationshipType: relType,
+ }
+ d.lastSendAttemptMutex.Unlock()
+ }
+
+ sentMsg, err := d.Session.ChannelMessageSendComplex(channelID, sendReq, refererOpt, discordgo.WithContext(ctx))
+ if err != nil {
+ return nil, d.tryWrappingError(ctx, err)
+ }
+ sentMsgTimestamp, _ := discordgo.SnowflakeTimestamp(sentMsg.ID)
+ dbMessage := &database.Message{
+ ID: discordid.MakeMessageID(sentMsg.ID),
+ SenderID: discordid.MakeUserID(sentMsg.Author.ID),
+ Timestamp: sentMsgTimestamp,
+ }
+ if threadRootRemoteID != "" {
+ dbMessage.ThreadRoot = discordid.MakeMessageID(threadRootRemoteID)
+ }
+
+ return &bridgev2.MatrixMessageResponse{
+ DB: dbMessage,
+ }, nil
+}
+
+var errCannotDMStranger = errors.New("can't direct message a stranger")
+
+func (d *DiscordClient) screenOutgoingMessage(ctx context.Context, destCh *discordgo.Channel) error {
+ log := zerolog.Ctx(ctx)
+
+ if d.connector.Config.ForbidDMingStrangersEnabled() {
+ dmRecipID := dmChannelRecipientID(destCh)
+ if dmRecipID != nil {
+ rel := d.relationshipWithUserID(*dmRecipID)
+ friendsWithDMRecip := rel != nil && rel.Type == discordgo.RelationshipFriend
+
+ dmRecip := d.userCache.Resolve(ctx, *dmRecipID)
+
+ if dmRecip != nil && !dmRecip.Bot && !friendsWithDMRecip {
+ loggedRelType := "none"
+ if rel != nil {
+ loggedRelType = readableRelationshipType(rel.Type)
+ }
+ log.Info().
+ Str("relationship_type", loggedRelType).
+ Msg("Preventing direct message send to a stranger")
+
+ return bridgev2.WrapErrorInStatus(errCannotDMStranger).
+ WithStatus(event.MessageStatusFail).
+ WithIsCertain(true).
+ WithMessage("You can't message users who aren't on your friends list. To continue, use the Discord app to chat or add them as a friend.").
+ WithSendNotice(true)
+ }
+ }
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) sendOutgoingMessageAttemptAnalytics(ctx context.Context, extra map[string]any) {
+ props := d.baseAnalyticsProps(ctx)
+ maps.Copy(props, extra)
+
+ d.UserLogin.TrackAnalytics("Discord outgoing message attempt", props)
+}
+
+func (d *DiscordClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
+ if !d.IsLoggedIn() {
+ return bridgev2.ErrNotLoggedIn
+ }
+
+ log := zerolog.Ctx(ctx).With().Str("action", "matrix message edit").Logger()
+ ctx = log.WithContext(ctx)
+
+ content, _ := d.connector.MsgConv.ConvertMatrixMessageContent(
+ ctx,
+ msg.Portal,
+ msg.Content,
+ // Disregard link previews for now. Discord generally allows you to
+ // remove individual link previews from a message though.
+ []string{},
+ )
+
+ guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ parentChannelID := discordid.ParseChannelPortalID(msg.Portal.ID)
+ channelID := parentChannelID
+ threadChannelID := ""
+ if msg.EditTarget != nil && msg.EditTarget.ThreadRoot != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(msg.EditTarget.ThreadRoot))
+ if err != nil {
+ return fmt.Errorf("failed to resolve target thread for message edit: %w", err)
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ channelID = threadChannelID
+ }
+ }
+
+ _, err := d.Session.ChannelMessageEdit(
+ channelID,
+ discordid.ParseMessageID(msg.EditTarget.ID),
+ content,
+ makeDiscordReferer(guildID, parentChannelID, threadChannelID),
+ )
+ if err != nil {
+ return d.tryWrappingError(ctx, err)
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) PreHandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
+ if !d.IsLoggedIn() {
+ return bridgev2.MatrixReactionPreResponse{}, bridgev2.ErrNotLoggedIn
+ }
+
+ emojiID := reaction.Content.RelatesTo.Key
+
+ // Figure out if this is a custom emoji or not.
+ if strings.HasPrefix(emojiID, "mxc://") {
+ customEmoji, err := d.connector.GetCustomEmojiByMXC(ctx, emojiID)
+
+ if err != nil {
+ return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("failed to get custom emoji by mxc: %w", err)
+ } else if customEmoji == nil || customEmoji.ID == "" || customEmoji.Name == "" {
+ return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("unknown custom emoji mxc: %q", emojiID)
+ }
+
+ emojiID = fmt.Sprintf("%s:%s", customEmoji.Name, customEmoji.ID)
+ } else {
+ emojiID = variationselector.FullyQualify(emojiID)
+ }
+
+ return bridgev2.MatrixReactionPreResponse{
+ SenderID: discordid.UserLoginIDToUserID(d.UserLogin.ID),
+ EmojiID: discordid.MakeEmojiID(emojiID),
+ }, nil
+}
+
+func (d *DiscordClient) HandleMatrixReaction(ctx context.Context, reaction *bridgev2.MatrixReaction) (*database.Reaction, error) {
+ if !d.IsLoggedIn() {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ portal := reaction.Portal
+ meta := portal.Metadata.(*discordid.PortalMetadata)
+ parentChannelID := discordid.ParseChannelPortalID(portal.ID)
+ channelID := parentChannelID
+ threadChannelID := ""
+ if reaction.TargetMessage != nil && reaction.TargetMessage.ThreadRoot != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(reaction.TargetMessage.ThreadRoot))
+ if err != nil {
+ return nil, err
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ channelID = threadChannelID
+ }
+ }
+
+ return nil, d.tryWrappingError(ctx, d.Session.MessageReactionAddUser(
+ meta.GuildID,
+ channelID,
+ discordid.ParseMessageID(reaction.TargetMessage.ID),
+ discordid.ParseEmojiID(reaction.PreHandleResp.EmojiID),
+ makeDiscordReferer(meta.GuildID, parentChannelID, threadChannelID),
+ ))
+}
+
+func (d *DiscordClient) HandleMatrixReactionRemove(ctx context.Context, removal *bridgev2.MatrixReactionRemove) error {
+ if !d.IsLoggedIn() {
+ return bridgev2.ErrNotLoggedIn
+ }
+
+ removing := removal.TargetReaction
+ emojiID := removing.EmojiID
+ parentChannelID := discordid.ParseChannelPortalID(removal.Portal.ID)
+ channelID := parentChannelID
+ threadChannelID := ""
+ guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ targetMessage, err := d.UserLogin.Bridge.DB.Message.GetFirstPartByID(ctx, d.UserLogin.ID, removing.MessageID)
+ if err != nil {
+ return err
+ }
+ if targetMessage != nil && targetMessage.ThreadRoot != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(targetMessage.ThreadRoot))
+ if err != nil {
+ return err
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ channelID = threadChannelID
+ }
+ }
+
+ return d.tryWrappingError(ctx, d.Session.MessageReactionRemoveUser(
+ guildID,
+ channelID,
+ discordid.ParseMessageID(removing.MessageID),
+ discordid.ParseEmojiID(emojiID),
+ discordid.ParseUserLoginID(d.UserLogin.ID),
+ makeDiscordReferer(guildID, parentChannelID, threadChannelID),
+ ))
+}
+
+func (d *DiscordClient) HandleMatrixMessageRemove(ctx context.Context, removal *bridgev2.MatrixMessageRemove) error {
+ if !d.IsLoggedIn() {
+ return bridgev2.ErrNotLoggedIn
+ }
+
+ guildID := removal.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ parentChannelID := discordid.ParseChannelPortalID(removal.Portal.ID)
+ channelID := parentChannelID
+ threadChannelID := ""
+ if removal.TargetMessage != nil && removal.TargetMessage.ThreadRoot != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, discordid.ParseMessageID(removal.TargetMessage.ThreadRoot))
+ if err != nil {
+ return err
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ channelID = threadChannelID
+ }
+ }
+ messageID := discordid.ParseMessageID(removal.TargetMessage.ID)
+ return d.tryWrappingError(ctx, d.Session.ChannelMessageDelete(channelID, messageID, makeDiscordReferer(guildID, parentChannelID, threadChannelID)))
+}
+
+func (d *DiscordClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
+ if !d.IsLoggedIn() {
+ return bridgev2.ErrNotLoggedIn
+ }
+
+ log := msg.Portal.Log.With().
+ Str("event_id", string(msg.EventID)).
+ Str("action", "matrix read receipt").Logger()
+
+ guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ parentChannelID := discordid.ParseChannelPortalID(msg.Portal.ID)
+ threadChannelID := ""
+ threadRootRemoteID := ""
+ threadID := msg.Receipt.ThreadID
+ threadScoped := threadID != "" && threadID != event.ReadReceiptThreadMain
+
+ if threadScoped {
+ rootMsg, err := d.UserLogin.Bridge.DB.Message.GetPartByMXID(ctx, threadID)
+ if err != nil {
+ log.Err(err).Msg("Failed to resolve thread root event from receipt")
+ return err
+ } else if rootMsg != nil {
+ threadRootRemoteID = discordid.ParseMessageID(rootMsg.ID)
+ if rootMsg.ThreadRoot != "" {
+ threadRootRemoteID = discordid.ParseMessageID(rootMsg.ThreadRoot)
+ }
+ thread, err := d.getThreadByRootMessageID(ctx, threadRootRemoteID)
+ if err != nil {
+ log.Err(err).Msg("Failed to resolve thread channel from thread root")
+ return err
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ }
+ }
+ }
+ if threadScoped && threadRootRemoteID == "" {
+ log.Debug().Stringer("receipt_thread_id", threadID).Msg("Dropping thread-scoped read receipt: unknown thread root")
+ return nil
+ }
+
+ var targetMessage *database.Message
+ var targetMessageID string
+
+ // Figure out the ID of the Discord message that we'll mark as read. If the
+ // receipt didn't exactly correspond with a message, try finding one close
+ // by to use as the target.
+ if msg.ExactMessage != nil {
+ targetMessage = msg.ExactMessage
+ targetMessageID = discordid.ParseMessageID(targetMessage.ID)
+ log = log.With().
+ Str("message_id", targetMessageID).
+ Logger()
+ } else {
+ var err error
+ if threadScoped && threadRootRemoteID != "" {
+ targetMessage, err = d.UserLogin.Bridge.DB.Message.GetLastThreadMessage(ctx, msg.Portal.PortalKey, discordid.MakeMessageID(threadRootRemoteID))
+ if err != nil {
+ log.Err(err).Msg("Failed to find latest thread message")
+ return err
+ }
+ if targetMessage != nil && targetMessage.Timestamp.After(msg.ReadUpTo) {
+ targetMessage = nil
+ }
+ } else {
+ targetMessage, err = d.UserLogin.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, msg.ReadUpTo)
+ if err != nil {
+ log.Err(err).Msg("Failed to find closest message part")
+ return err
+ }
+ }
+
+ if targetMessage != nil {
+ // The read receipt didn't specify an exact message but we were able to
+ // find one close by.
+
+ targetMessageID = discordid.ParseMessageID(targetMessage.ID)
+ log = log.With().
+ Str("closest_message_id", targetMessageID).
+ Str("closest_event_id", targetMessage.MXID.String()).
+ Logger()
+ log.Debug().
+ Msg("Read receipt target event not found, using closest message")
+ } else {
+ log.Debug().Msg("Dropping read receipt: no messages found")
+ return nil
+ }
+ }
+
+ if threadScoped && targetMessage != nil {
+ targetMsgThreadRoot := discordid.ParseMessageID(targetMessage.ThreadRoot)
+ if targetMsgThreadRoot == "" {
+ targetMsgThreadRoot = discordid.ParseMessageID(targetMessage.ID)
+ }
+ if threadRootRemoteID != "" && targetMsgThreadRoot != threadRootRemoteID {
+ log.Debug().
+ Str("receipt_thread_root", threadRootRemoteID).
+ Str("target_thread_root", targetMsgThreadRoot).
+ Msg("Dropping read receipt due to thread mismatch")
+ return nil
+ }
+ if threadChannelID == "" && targetMsgThreadRoot != "" {
+ thread, err := d.getThreadByRootMessageID(ctx, targetMsgThreadRoot)
+ if err != nil {
+ return err
+ } else if thread != nil {
+ threadChannelID = thread.ThreadChannelID
+ }
+ }
+ }
+
+ channelID := parentChannelID
+ if threadChannelID != "" {
+ channelID = threadChannelID
+ }
+ resp, err := d.Session.ChannelMessageAckNoToken(
+ channelID,
+ targetMessageID,
+ makeDiscordReferer(guildID, parentChannelID, threadChannelID),
+ )
+ if err != nil {
+ log.Err(err).Msg("Failed to send read receipt to Discord")
+ return err
+ } else if resp.Token != nil {
+ log.Debug().
+ Str("unexpected_resp_token", *resp.Token).
+ Msg("Marked message as read on Discord (and got unexpected non-nil token)")
+ } else {
+ log.Debug().Msg("Marked message as read on Discord")
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) viewingChannel(ctx context.Context, portal *bridgev2.Portal) error {
+ if portal.Metadata.(*discordid.PortalMetadata).GuildID != "" {
+ // Only private channels need this logic.
+ return nil
+ }
+
+ d.markedOpenedLock.Lock()
+ defer d.markedOpenedLock.Unlock()
+
+ channelID := discordid.ParseChannelPortalID(portal.ID)
+ log := zerolog.Ctx(ctx).With().
+ Str("channel_id", channelID).Logger()
+
+ lastMarkedOpenedTs := d.markedOpened[channelID]
+ if lastMarkedOpenedTs.IsZero() {
+ d.markedOpened[channelID] = time.Now()
+
+ err := d.Session.MarkViewing(channelID)
+
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to mark user as viewing channel")
+ return err
+ }
+
+ log.Trace().Msg("Marked channel as being viewed")
+ } else {
+ log.Trace().Str("channel_id", channelID).
+ Msg("Already marked channel as viewed, not doing so")
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
+ if !d.IsLoggedIn() {
+ return bridgev2.ErrNotLoggedIn
+ }
+
+ log := zerolog.Ctx(ctx)
+
+ // Don't mind if this fails.
+ _ = d.viewingChannel(ctx, msg.Portal)
+
+ guildID := msg.Portal.Metadata.(*discordid.PortalMetadata).GuildID
+ channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
+ err := d.Session.ChannelTyping(channelID, makeDiscordReferer(guildID, channelID, ""))
+
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to mark user as typing")
+ return err
+ }
+
+ log.Debug().Msg("Marked user as typing")
+ return nil
+}
+
+func (d *DiscordClient) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) error {
+ if !d.IsLoggedIn() {
+ return bridgev2.ErrNotLoggedIn
+ }
+
+ channelID := discordid.ParseChannelPortalID(msg.Portal.ID)
+ log := zerolog.Ctx(ctx).With().
+ Str("muting_channel_id", channelID).
+ Int64("muting_until", msg.Content.MutedUntil).
+ Logger()
+ ctx = log.WithContext(ctx)
+ log.Debug().Msg("Handling Matrix mute")
+
+ ch := d.channelWithID(ctx, channelID)
+ if ch == nil {
+ log.Error().Msg("Failed to find channel to mute")
+ return fmt.Errorf("failed to mute non-existent channel %s", channelID)
+ }
+
+ mutedUntil := msg.Content.GetMutedUntilTime()
+ isMuting := mutedUntil.After(time.Now())
+ override := discordgo.UserGuildSettingsChannelOverrideEdit{
+ Muted: ptr.Ptr(isMuting),
+ }
+ if isMuting && mutedUntil != event.MutedForever {
+ // At the time of writing, arbitrary mute durations are supported by
+ // Discord; you aren't restricted to the official client's choices
+ // of 15 minutes, 1 hour, 3 hours, 8 hours, and 24 hours.
+ secs := int(math.Round(msg.Content.GetMuteDuration().Seconds()))
+ override.MuteConfig = &discordgo.MuteConfig{
+ EndTime: &mutedUntil,
+ SelectedTimeWindow: &secs,
+ }
+ }
+
+ overrides := make(map[string]*discordgo.UserGuildSettingsChannelOverrideEdit)
+ overrides[ch.ID] = &override
+
+ edit := discordgo.UserGuildSettingsEdit{
+ ChannelOverrides: overrides,
+ }
+
+ log.Debug().Interface("muting_override", override).Msg("Computed channel override for mute")
+
+ guildID := ch.GuildID
+ if guildID == "" {
+ // Target private channels properly.
+ guildID = "@me"
+ }
+ _, err := d.Session.UserGuildSettingsEdit(guildID, &edit)
+ if err != nil {
+ return fmt.Errorf("failed to edit guild settings in response to mute: %w", err)
+
+ }
+ return nil
+}
diff --git a/pkg/connector/id.go b/pkg/connector/id.go
new file mode 100644
index 0000000..1dd859f
--- /dev/null
+++ b/pkg/connector/id.go
@@ -0,0 +1,56 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "github.com/bwmarrin/discordgo"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+func (d *DiscordClient) portalKeyForChannel(ch *discordgo.Channel) networkid.PortalKey {
+ switch ch.Type {
+ case discordgo.ChannelTypeDM:
+ return d.dmChannelPortalKey(ch.ID)
+ case discordgo.ChannelTypeGroupDM:
+ return d.groupDMChannelPortalKey(ch.ID)
+ default:
+ return d.guildChannelPortalKey(ch.ID)
+ }
+}
+
+func (d *DiscordClient) guildChannelPortalKey(channelID string) networkid.PortalKey {
+ wantReceiver := d.connector.Bridge.Config.SplitPortals
+ return discordid.MakeChannelPortalKey(channelID, d.UserLogin.ID, wantReceiver)
+}
+
+func (d *DiscordClient) groupDMChannelPortalKey(channelID string) networkid.PortalKey {
+ // Same logic as guild channels (only specify a receiver when split portals
+ // are enabled).
+ return d.guildChannelPortalKey(channelID)
+}
+
+func (d *DiscordClient) dmChannelPortalKey(channelID string) networkid.PortalKey {
+ // 1:1 DMs should _always_ have a receiver.
+ return discordid.MakeChannelPortalKey(channelID, d.UserLogin.ID, true)
+}
+
+func (d *DiscordClient) guildPortalKey(guildID string) networkid.PortalKey {
+ wantReceiver := d.connector.Bridge.Config.SplitPortals
+ return discordid.MakeGuildPortalKey(guildID, d.UserLogin.ID, wantReceiver)
+}
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
new file mode 100644
index 0000000..677e262
--- /dev/null
+++ b/pkg/connector/login.go
@@ -0,0 +1,76 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+const LoginStepIDComplete = "fi.mau.discord.login.complete"
+
+func (d *DiscordConnector) GetLoginFlows() []bridgev2.LoginFlow {
+ return []bridgev2.LoginFlow{
+ {
+ ID: LoginFlowIDBrowser,
+ Name: "Browser",
+ Description: "Log in to your Discord account in a web browser.",
+ },
+ {
+ ID: LoginFlowIDRemoteAuth,
+ Name: "QR Code",
+ Description: "Scan a QR code with the Discord mobile app to log in.",
+ },
+ {
+ ID: LoginFlowIDToken,
+ Name: "Token",
+ Description: "Provide a Discord user token to connect with.",
+ },
+ {
+ ID: LoginFlowIDMachine,
+ Name: "Email/Phone & Password",
+ Description: "Log in with an email or phone number and a password. Supports multi-factor authentication (e.g. TOTP, SMS, etc.)",
+ },
+ }
+}
+
+func (d *DiscordConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
+ login := DiscordGenericLogin{
+ connector: d,
+ User: user,
+ }
+
+ switch flowID {
+ case LoginFlowIDToken:
+ return &DiscordTokenLogin{DiscordGenericLogin: &login}, nil
+ case LoginFlowIDRemoteAuth:
+ return &DiscordRemoteAuthLogin{DiscordGenericLogin: &login}, nil
+ case LoginFlowIDBrowser:
+ return &DiscordBrowserLogin{DiscordGenericLogin: &login}, nil
+ case LoginFlowIDMachine:
+ mach, err := NewDiscordMachineLogin(ctx, &login)
+ if err != nil {
+ return nil, fmt.Errorf("failed to set up discord login machine: %w", err)
+ }
+
+ return mach, nil
+ default:
+ return nil, bridgev2.ErrInvalidLoginFlowID
+ }
+}
diff --git a/pkg/connector/login_browser.go b/pkg/connector/login_browser.go
new file mode 100644
index 0000000..43f2a9e
--- /dev/null
+++ b/pkg/connector/login_browser.go
@@ -0,0 +1,97 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+const LoginFlowIDBrowser = "token"
+
+type DiscordBrowserLogin struct {
+ *DiscordGenericLogin
+}
+
+var _ bridgev2.LoginProcessCookies = (*DiscordBrowserLogin)(nil)
+
+const ExtractDiscordTokenJS = `
+new Promise((resolve) => {
+ let mautrixDiscordTokenCheckInterval
+
+ const iframe = document.createElement('iframe')
+ document.head.append(iframe)
+
+ mautrixDiscordTokenCheckInterval = setInterval(() => {
+ const token = iframe.contentWindow.localStorage.token
+ if (token) {
+ resolve({ token: token.slice(1, -1) })
+ clearInterval(mautrixDiscordTokenCheckInterval)
+ }
+ }, 200)
+})
+`
+
+func (dl *DiscordBrowserLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeCookies,
+ StepID: "fi.mau.discord.cookies",
+ Instructions: "Log in with Discord.",
+ CookiesParams: &bridgev2.LoginCookiesParams{
+ URL: "https://discord.com/login",
+ UserAgent: "",
+ Fields: []bridgev2.LoginCookieField{{
+ ID: "token",
+ Required: true,
+ Sources: []bridgev2.LoginCookieFieldSource{{
+ Type: bridgev2.LoginCookieTypeSpecial,
+ Name: "fi.mau.discord.token",
+ }},
+ }},
+ ExtractJS: ExtractDiscordTokenJS,
+ },
+ }, nil
+}
+
+func (dl *DiscordBrowserLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) {
+ log := zerolog.Ctx(ctx)
+
+ token := cookies["token"]
+ if token == "" {
+ log.Error().Msg("Received empty token")
+ return nil, fmt.Errorf("received empty token")
+ }
+ log.Debug().Msg("Logging in with submitted cookie")
+
+ ul, err := dl.FinalizeCreatingLogin(ctx, token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't log in via browser: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ Instructions: dl.CompleteInstructions(),
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
diff --git a/pkg/connector/login_generic.go b/pkg/connector/login_generic.go
new file mode 100644
index 0000000..7e3f513
--- /dev/null
+++ b/pkg/connector/login_generic.go
@@ -0,0 +1,111 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+// DiscordGenericLogin is embedded within each struct that implements
+// bridgev2.LoginProcess in order to encapsulate the common behavior that needs
+// to occur after procuring a valid user token. Namely, creating a gateway
+// connection to Discord and an associated UserLogin to wrap things up.
+//
+// It also implements a baseline Cancel method that closes the gateway
+// connection.
+type DiscordGenericLogin struct {
+ User *bridgev2.User
+ connector *DiscordConnector
+
+ Session *discordgo.Session
+
+ // The Discord user we've authenticated as. This is only non-nil if
+ // a call to FinalizeCreatingLogin has succeeded.
+ DiscordUser *discordgo.User
+}
+
+func (dl *DiscordGenericLogin) FinalizeCreatingLogin(ctx context.Context, token string) (*bridgev2.UserLogin, error) {
+ log := zerolog.Ctx(ctx).With().Str("action", "finalize login").Logger()
+
+ // TODO we don't need an entire discordgo session for this as we're just
+ // interested in /users/@me
+ log.Info().Msg("Creating initial session with provided token")
+ session, err := NewDiscordSession(ctx, dl.connector.Bridge.GetHTTPClientSettings(), token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create discord session: %w", err)
+ }
+ dl.Session = session
+
+ // Proxy the @me call so the IP presented to Discord is consistent.
+ if dl.connector.proxyConfigured() {
+ if err := dl.connector.applyProxyToSession(ctx, session, "login"); err != nil {
+ return nil, fmt.Errorf("couldn't resolve proxy for login: %w", err)
+ }
+ }
+
+ log.Info().Msg("Requesting @me with provided token")
+ self, err := session.User("@me")
+ if err != nil {
+ return nil, userVisibleLoginError(ctx, fmt.Errorf("couldn't request self user (bad credentials?): %w", err))
+ }
+ dl.DiscordUser = self
+
+ log.Info().Msg("Fetched @me")
+ ul, err := dl.User.NewLogin(ctx, &database.UserLogin{
+ ID: discordid.MakeUserLoginID(self.ID),
+ // (This will lack an avatar. Don't want to block login finalization on
+ // downloading it.)
+ RemoteProfile: makeRemoteProfile(self, nil),
+ RemoteName: makeRemoteName(self),
+ Metadata: &discordid.UserLoginMetadata{
+ Token: token,
+ HeartbeatSession: session.HeartbeatSession,
+ },
+ }, &bridgev2.NewLoginParams{
+ DeleteOnConflict: true,
+ })
+ if err != nil {
+ dl.Cancel()
+ return nil, fmt.Errorf("couldn't create login during finalization: %w", err)
+ }
+
+ (ul.Client.(*DiscordClient)).Connect(ctx)
+
+ return ul, nil
+}
+
+func (dl *DiscordGenericLogin) CompleteInstructions() string {
+ return fmt.Sprintf("Logged in as %s", dl.DiscordUser.Username)
+}
+
+func (dl *DiscordGenericLogin) Cancel() {
+ if dl.Session != nil {
+ dl.User.Log.Debug().Msg("Login cancelled, closing session")
+ err := dl.Session.Close()
+ if err != nil {
+ dl.User.Log.Err(err).Msg("Couldn't close Discord session in response to login cancellation")
+ }
+ }
+}
diff --git a/pkg/connector/login_machine.go b/pkg/connector/login_machine.go
new file mode 100644
index 0000000..95dcd20
--- /dev/null
+++ b/pkg/connector/login_machine.go
@@ -0,0 +1,727 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exhttp"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/discordauth"
+ "go.mau.fi/mautrix-discord/pkg/discordtransport"
+)
+
+var ErrMissingLoginInput = bridgev2.RespError{
+ ErrCode: "FI.MAU.DISCORD.MISSING_LOGIN_INPUT",
+ Err: "Missing required login input",
+ StatusCode: http.StatusBadRequest,
+}
+
+func userVisibleLoginError(ctx context.Context, err error) error {
+ var apiErr discordauth.APIError
+ if !errors.As(err, &apiErr) {
+ return userVisibleRESTError(ctx, err)
+ }
+
+ zerolog.Ctx(ctx).Err(apiErr).
+ Int("discord_error_code", int(apiErr.Code)).
+ Msg("Propagating Discord error to user")
+
+ if apiErr.AnyFieldHasError(discordauth.AccountCompromisedResetPassword) {
+ return bridgev2.RespError{
+ ErrCode: "FI.MAU.DISCORD.ACCOUNT_COMPROMISED",
+ Err: "Discord has flagged this account as compromised and requires a password reset. Reset your password on Discord, then try logging in again.",
+ StatusCode: http.StatusForbidden,
+ }
+ }
+
+ // Directly surface the root error message, falling back to the debug
+ // representation.
+ msg := apiErr.Message
+ if msg == "" {
+ msg = apiErr.Error()
+ }
+
+ return bridgev2.RespError{
+ ErrCode: fmt.Sprintf("FI.MAU.DISCORD.API_%d", apiErr.Code),
+ Err: msg,
+ StatusCode: http.StatusBadRequest,
+ }
+}
+
+// userVisibleRESTError handles plain discordgo REST failures, which is what the token,
+// browser and QR flows produce when Discord rejects a token outright.
+func userVisibleRESTError(ctx context.Context, err error) error {
+ var restErr *discordgo.RESTError
+ if !errors.As(err, &restErr) || restErr.Response == nil {
+ return err
+ }
+
+ logEvt := zerolog.Ctx(ctx).Err(err).Int("http_status", restErr.Response.StatusCode)
+ if restErr.Message != nil {
+ logEvt = logEvt.Int("discord_error_code", restErr.Message.Code)
+ }
+ logEvt.Msg("Propagating Discord REST error to user")
+
+ switch restErr.Response.StatusCode {
+ case http.StatusUnauthorized:
+ return bridgev2.RespError{
+ ErrCode: "FI.MAU.DISCORD.INVALID_TOKEN",
+ Err: "Discord rejected that token. Please log in again to get a fresh one.",
+ StatusCode: http.StatusUnauthorized,
+ }
+ case http.StatusForbidden:
+ return bridgev2.RespError{
+ ErrCode: "FI.MAU.DISCORD.FORBIDDEN",
+ Err: "Discord refused access to this account. Check the account on Discord, then try again.",
+ StatusCode: http.StatusForbidden,
+ }
+ case http.StatusTooManyRequests:
+ return bridgev2.RespError{
+ ErrCode: "FI.MAU.DISCORD.RATE_LIMITED",
+ Err: "Discord is rate limiting the sign-in. Please wait a few minutes and try again.",
+ StatusCode: http.StatusTooManyRequests,
+ }
+ }
+
+ // Discord's own message is written for end users, so prefer it when present.
+ if restErr.Message != nil && restErr.Message.Message != "" {
+ return bridgev2.RespError{
+ ErrCode: fmt.Sprintf("FI.MAU.DISCORD.API_%d", restErr.Message.Code),
+ Err: restErr.Message.Message,
+ StatusCode: http.StatusBadRequest,
+ }
+ }
+ return err
+}
+
+const LoginFlowIDMachine = "machine"
+const LoginStepIDMachineInitialCreds = "fi.mau.discord.creds"
+const LoginStepIDMachineCaptcha = "fi.mau.discord.captcha"
+const LoginStepIDMachineEmailVerification = "fi.mau.discord.email_verification"
+const LoginStepIDMachineSMSVerification = "fi.mau.discord.sms_verification"
+const LoginStepIDMachineMFAMethod = "fi.mau.discord.mfa.method"
+const LoginStepIDMachineMFATOTP = "fi.mau.discord.mfa.totp"
+const LoginStepIDMachineMFABackup = "fi.mau.discord.mfa.backup"
+const LoginStepIDMachineMFASMS = "fi.mau.discord.mfa.sms"
+const InputDataFieldIDUsernameOrPhone = "username_or_phone"
+const InputDataFieldIDPassword = "password"
+const InputDataFieldIDMFAMethod = "mfa_method"
+const InputDataFieldIDMFABackupCode = "mfa_backup_code"
+const InputDataFieldIDMFASMSCode = "mfa_sms_code"
+const InputDataFieldIDMFATOTPCode = "mfa_totp_code"
+const InputDataFieldIDEmailVerification = "email_verification"
+const InputDataFieldIDSMSCode = "sms_code" // IP verification via SMS code
+
+type mfaOption string
+
+const (
+ mfaSms mfaOption = "Text me a code"
+ mfaTotp mfaOption = "Use my authenticator app"
+ mfaBackup mfaOption = "Enter a backup code"
+)
+
+type DiscordMachineLogin struct {
+ *DiscordGenericLogin
+ Machine *discordauth.AuthMachine
+
+ mfaChallenge *discordauth.LoginMFARequired
+}
+
+var _ bridgev2.LoginProcessUserInput = (*DiscordMachineLogin)(nil)
+var _ bridgev2.LoginProcessCookies = (*DiscordMachineLogin)(nil)
+
+func NewDiscordMachineLogin(ctx context.Context, login *DiscordGenericLogin) (*DiscordMachineLogin, error) {
+ launchSig, err := discordgo.NewVanillaSignature()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate launch signature: %w", err)
+ }
+
+ personality := discordauth.Personality{
+ UserAgent: discordgo.DroidBrowserUserAgent,
+ Locale: "en-US",
+ TimeZone: "UTC",
+ DebugOptions: discordauth.DefaultDebugOptions,
+ // TODO dedupe with droid.go in discordgo
+ SuperProperties: discordauth.SuperProperties{
+ OS: "Windows",
+ Browser: "Chrome",
+ SystemLocale: "en-US",
+ HasClientMods: false,
+ BrowserUserAgent: discordgo.DroidBrowserUserAgent,
+ BrowserVersion: discordgo.DroidBrowserVersion,
+ OSVersion: "10",
+ ReleaseChannel: "stable",
+ ClientBuildNumber: 497254,
+ ClientLaunchID: uuid.NewString(),
+ LaunchSignature: launchSig,
+ ClientAppState: "focused",
+ },
+ // TODO(skip): These are different for different kinds of requests.
+ ExtraHeaders: map[string]string{
+ "sec-ch-ua": discordgo.DroidBaseHeaders["Sec-Ch-Ua"],
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": discordgo.DroidBaseHeaders["Sec-Ch-Ua-Platform"],
+ "sec-fetch-dest": "empty",
+ "sec-fetch-mode": "cors",
+ "sec-fetch-site": "same-origin",
+ },
+ }
+
+ // Resolve the HTTP client settings (proxy) for use during login.
+ // NOTE(skip): This is grossly tangled. Think of a way to restructure this.
+ var settings exhttp.ClientSettings
+ if login.connector.Config.ProxyLoginMachine {
+ settings, err = login.connector.resolveHTTPClientSettings(ctx, "login")
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve proxy: %w", err)
+ }
+ } else {
+ settings = login.connector.Bridge.GetHTTPClientSettings()
+ }
+
+ http, err := discordtransport.CompileTransport(settings, discordtransport.TransportOptions{
+ CookieJar: true,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create http client: %w", err)
+ }
+
+ ml := &DiscordMachineLogin{
+ DiscordGenericLogin: login,
+ }
+ ml.Machine = discordauth.NewAuthMachine(ctx, http, &personality)
+ return ml, nil
+}
+
+func (d *DiscordMachineLogin) Cancel() {
+ d.DiscordGenericLogin.Cancel()
+}
+
+// initialCredsStep returns the very first login step needed to kick off the
+// authentication flow (email or phone number and password).
+func initialCredsStep(instructions string) *bridgev2.LoginStep {
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: LoginStepIDMachineInitialCreds,
+ Instructions: instructions,
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldTypeUsername,
+ ID: InputDataFieldIDUsernameOrPhone,
+ Name: "Email or phone number",
+ },
+ {
+ Type: bridgev2.LoginInputFieldTypePassword,
+ ID: InputDataFieldIDPassword,
+ Name: "Password",
+ },
+ },
+ },
+ }
+}
+
+const newLocationInstructionPreamble = "Your login was correct, but Discord " +
+ "detected Beeper as a new login location."
+const textedCodeInstruction = "Enter the code Discord just texted you."
+
+func emailVerificationStep() *bridgev2.LoginStep {
+ // Forcing a dummy input like this isn't ideal by any means, but
+ // chat-command login and Beeper iOS cannot handle a user_input step with
+ // no inputs.
+ instructions := newLocationInstructionPreamble + " Check your email for a " +
+ "verification link, then choose the option below to continue."
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: LoginStepIDMachineEmailVerification,
+ Instructions: instructions,
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldTypeSelect,
+ ID: InputDataFieldIDEmailVerification,
+ Name: "Verification",
+ Options: []string{
+ "I’ve verified the login",
+ },
+ },
+ },
+ },
+ }
+}
+
+type smsCodeStepOptions struct {
+ loginStepID string
+ inputFieldID string
+ instructions string // optional, defaults to [textedCodeInstruction]
+}
+
+func smsCodeStep(opts smsCodeStepOptions) *bridgev2.LoginStep {
+ if opts.instructions == "" {
+ opts.instructions = textedCodeInstruction
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: opts.loginStepID,
+ Instructions: opts.instructions,
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Description: "The code might take a moment to arrive.",
+ ID: opts.inputFieldID,
+ Name: "Verification code",
+ // TODO enforce length
+ Pattern: `^(\d+)$`,
+ Type: bridgev2.LoginInputFieldType2FACode,
+ },
+ },
+ },
+ }
+}
+
+func (d *DiscordMachineLogin) mfaMethodStep(ctx context.Context, prompt *discordauth.MFAChallengePrompt) (*bridgev2.LoginStep, error) {
+ challenge := prompt.LoginMFARequired
+ if challenge == nil {
+ return nil, fmt.Errorf("auth machine returned an MFA prompt without a challenge")
+ }
+
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "discord machine continue mfa").
+ Str("login_instance_id", challenge.LoginInstanceID).
+ Bool("mfa_required", challenge.MFARequired).
+ Bool("mfa_sms_enabled", challenge.SMSEnabled).
+ Bool("mfa_totp_enabled", challenge.TOTPEnabled).
+ Bool("mfa_backup_codes_accepted", challenge.BackupCodesAccepted).
+ Logger()
+ log.Info().Msg("Entering MFA login flow")
+
+ mfaOptions := make([]string, 0, 3)
+ if challenge.SMSEnabled {
+ mfaOptions = append(mfaOptions, string(mfaSms))
+ }
+ if challenge.TOTPEnabled {
+ mfaOptions = append(mfaOptions, string(mfaTotp))
+ }
+ if challenge.BackupCodesAccepted {
+ mfaOptions = append(mfaOptions, string(mfaBackup))
+ }
+ if len(mfaOptions) == 0 {
+ return nil, fmt.Errorf("no supported MFA methods available (WebAuthn is unimplemented)")
+ }
+
+ instructions := "How do you want to verify it’s you?"
+ if prompt.Reason != "" {
+ instructions = "That code didn’t work. Choose how you’d like to verify and try again."
+ if challenge.BackupCodesAccepted {
+ instructions += " If your authenticator app isn’t working, you can use a backup code instead."
+ }
+ }
+
+ d.mfaChallenge = challenge
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: LoginStepIDMachineMFAMethod,
+ Instructions: instructions,
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldTypeSelect,
+ ID: InputDataFieldIDMFAMethod,
+ Name: "Verification Method",
+ Options: mfaOptions,
+ },
+ },
+ },
+ }, nil
+}
+
+func mfaCodeStep(authType discordauth.AuthenticatorType) (*bridgev2.LoginStep, error) {
+ switch authType {
+ case discordauth.AuthenticatorBackup:
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: LoginStepIDMachineMFABackup,
+ Instructions: "If your authenticator app is unavailable, you can sign in with a backup code. Backup codes are meant for emergencies only.",
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldTypePassword,
+ ID: InputDataFieldIDMFABackupCode,
+ Name: "Backup code",
+ Description: "You won’t be able to use this backup code again.",
+ },
+ },
+ },
+ }, nil
+ case discordauth.AuthenticatorTOTP:
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: LoginStepIDMachineMFATOTP,
+ Instructions: "Enter the code from your authenticator app.",
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldType2FACode,
+ ID: InputDataFieldIDMFATOTPCode,
+ Name: "Authentication code",
+ // TODO enforce length
+ Pattern: `^(\d+)$`,
+ },
+ },
+ },
+ }, nil
+ case discordauth.AuthenticatorSMS:
+ return smsCodeStep(smsCodeStepOptions{
+ loginStepID: LoginStepIDMachineMFASMS,
+ inputFieldID: InputDataFieldIDMFASMSCode,
+ }), nil
+ default:
+ return nil, fmt.Errorf("unknown mfa authenticator type %q", authType)
+ }
+}
+
+type ExtractionConfig struct {
+ SiteKey string `json:"siteKey"`
+ Invisible bool `json:"invisible"`
+ RqData string `json:"rqdata,omitempty"`
+}
+
+const CaptchaExtractionField = "captcha_token"
+
+// The CAPTCHA must be rendered on a discord.com origin for hCaptcha to accept
+// the sitekey. The exact Discord URL is mostly irrelevant, but it would be
+// nice to avoid loading the actual SPA.
+const captchaPageURL = "https://discord.com/company-information"
+
+const captchaExtractionJSTemplate = `new Promise((res0, rej0) => {
+ if (window.__meow_captchaPromise) {
+ window.__meow_captchaPromise.then(res0, rej0)
+ return
+ }
+
+ const CFG = %__CONFIG_REPLACEME__%
+ window.__meow_captchaPromise = new Promise((resolve, reject) => {
+ window.__meow_h = () => {
+ const c = document.createElement('div')
+ c.style.cssText = 'position:fixed;inset:0;z-index:2147483646;' +
+ 'background:#fff;display:flex;align-items:center;' +
+ 'justify-content:center;padding:2rem'
+ document.body.append(c)
+
+ const id = hcaptcha.render(c, {
+ sitekey: CFG.siteKey,
+ size: CFG.invisible ? 'invisible' : 'normal',
+ callback: (token) => resolve({ captcha_token: token }),
+ 'error-callback': (e) => reject(new Error('hcaptcha: ' + e)),
+ 'expired-callback': () => reject(new Error('hcaptcha token expired')),
+ 'chalexpired-callback': () => reject(new Error('hcaptcha challenge expired')),
+ })
+
+ if (CFG.rqdata) {
+ hcaptcha.setData(id, {rqdata: CFG.rqdata})
+ }
+ if (CFG.invisible) {
+ hcaptcha.execute(id)
+ }
+ }
+
+ const s = document.createElement('script')
+ s.src = 'https://js.hcaptcha.com/1/api.js?render=explicit&onload=__meow_h&recaptchacompat=off'
+ s.onerror = () => reject(new Error('failed to load hcaptcha'))
+ document.head.append(s)
+ })
+
+ window.__meow_captchaPromise.then(res0, rej0)
+})`
+
+func captchaExtractionJS(cap *discordauth.Captcha) (string, error) {
+ cfg := ExtractionConfig{
+ Invisible: cap.Invisible,
+ }
+ if cap.SiteKey != nil {
+ cfg.SiteKey = *cap.SiteKey
+ }
+ if cap.RqData != nil {
+ cfg.RqData = *cap.RqData
+ }
+
+ stateJSON, err := json.Marshal(cfg)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal extraction state: %w", err)
+ }
+
+ return strings.Replace(captchaExtractionJSTemplate, "%__CONFIG_REPLACEME__%", string(stateJSON), 1), nil
+}
+
+func (d *DiscordMachineLogin) captchaStep(ctx context.Context, cap *discordauth.Captcha) (*bridgev2.LoginStep, error) {
+ log := cap.LogContext(zerolog.Ctx(ctx).With()).Logger()
+
+ log.Info().Msg("Encountered CAPTCHA challenge")
+
+ if cap.Service != discordauth.CaptchaServiceHCaptcha {
+ return nil, fmt.Errorf("%s captchas are currently unsupported", cap.Service)
+ }
+
+ extractJS, err := captchaExtractionJS(cap)
+ if err != nil {
+ return nil, fmt.Errorf("failed to compute captcha extraction JS: %w", err)
+ }
+ log.Debug().Str("captcha_js", extractJS).Msg("Computed CAPTCHA solution extraction JS")
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeCookies,
+ StepID: LoginStepIDMachineCaptcha,
+ Instructions: "Discord is presenting a CAPTCHA challenge.",
+ CookiesParams: &bridgev2.LoginCookiesParams{
+ URL: captchaPageURL,
+ ExtractJS: extractJS,
+ Fields: []bridgev2.LoginCookieField{{
+ ID: CaptchaExtractionField,
+ Required: true,
+ Sources: []bridgev2.LoginCookieFieldSource{{
+ Type: bridgev2.LoginCookieTypeSpecial,
+ Name: CaptchaExtractionField,
+ }},
+ }},
+ },
+ }, nil
+}
+
+func (d *DiscordMachineLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ if err := d.Machine.Prepare(ctx); err != nil {
+ return nil, fmt.Errorf("failed to prepare login: %w", err)
+ }
+
+ prompt, done, err := d.Machine.Advance(ctx, nil)
+ if err != nil {
+ return nil, userVisibleLoginError(ctx, fmt.Errorf("failed to start login: %w", err))
+ }
+ if done != nil {
+ return d.finalize(ctx, done)
+ }
+ return d.stepForPrompt(ctx, prompt)
+}
+
+func (d *DiscordMachineLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) {
+ solutionToken := cookies[CaptchaExtractionField]
+ if solutionToken == "" {
+ return nil, ErrMissingLoginInput.AppendMessage(": captcha solution")
+ }
+
+ return d.answer(ctx, &discordauth.Answer{
+ Solution: &discordauth.CaptchaSolution{
+ Solution: solutionToken,
+ },
+ })
+}
+
+func (d *DiscordMachineLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
+ if _, ok := input[InputDataFieldIDUsernameOrPhone]; ok {
+ return d.submitCreds(ctx, input)
+ }
+ if _, ok := input[InputDataFieldIDPassword]; ok {
+ return d.submitCreds(ctx, input)
+ }
+
+ if code, ok := input[InputDataFieldIDSMSCode]; ok {
+ return d.answer(ctx, &discordauth.Answer{SMSCode: strings.TrimSpace(code)})
+ }
+ if _, ok := input[InputDataFieldIDEmailVerification]; ok {
+ return d.answer(ctx, &discordauth.Answer{})
+ }
+
+ if selected, ok := input[InputDataFieldIDMFAMethod]; ok {
+ authType, err := mfaOptionToAuthenticator(selected)
+ if err != nil {
+ return nil, err
+ }
+ return d.answer(ctx, &discordauth.Answer{
+ PickedMFAType: &authType,
+ })
+ }
+ if hasAnyInput(input, InputDataFieldIDMFABackupCode, InputDataFieldIDMFATOTPCode, InputDataFieldIDMFASMSCode) {
+ cont, err := d.mfaContinueFromInput(input)
+ if err != nil {
+ return nil, err
+ }
+ return d.answer(ctx, &discordauth.Answer{
+ MFAContinue: cont,
+ })
+ }
+
+ return nil, fmt.Errorf("unrecognized machine login input")
+}
+
+func (d *DiscordMachineLogin) submitCreds(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
+ username := strings.TrimSpace(input[InputDataFieldIDUsernameOrPhone])
+ password := discordauth.NewSensitive(input[InputDataFieldIDPassword])
+ if username == "" {
+ return nil, ErrMissingLoginInput.AppendMessage(": username")
+ }
+ if password.IsZero() {
+ return nil, ErrMissingLoginInput.AppendMessage(": password")
+ }
+
+ return d.answer(ctx, &discordauth.Answer{
+ Creds: &discordauth.Creds{
+ Login: username,
+ Password: password,
+ },
+ })
+}
+
+func (d *DiscordMachineLogin) answer(ctx context.Context, answer *discordauth.Answer) (*bridgev2.LoginStep, error) {
+ prompt, done, err := d.Machine.Advance(ctx, answer)
+ if err != nil {
+ return nil, userVisibleLoginError(ctx, err)
+ }
+ if done != nil {
+ zerolog.Ctx(ctx).Info().
+ Any("required_actions", done.RequiredActions).
+ Msg("Login finished")
+ return d.finalize(ctx, done)
+ }
+ return d.stepForPrompt(ctx, prompt)
+}
+
+func (d *DiscordMachineLogin) stepForPrompt(ctx context.Context, prompt *discordauth.Prompt) (*bridgev2.LoginStep, error) {
+ if prompt == nil {
+ return nil, fmt.Errorf("auth machine did not advance")
+ }
+ log := zerolog.Ctx(ctx)
+
+ switch {
+ case prompt.CredsPrompt != nil:
+ return initialCredsStep(prompt.CredsPrompt.Reason), nil
+ case prompt.EmailVerify:
+ log.Info().Msg("Prompting user to verify the IP address via email")
+ return emailVerificationStep(), nil
+ case prompt.PhoneVerifyPrompt != nil:
+ log.Info().Msg("Prompting user to verify the IP address via SMS")
+ var instructions string
+ if prompt.PhoneVerifyPrompt.Retrying {
+ instructions = "That code didn’t work. Check your information and try again."
+ } else {
+ instructions = fmt.Sprintf("%s %s", newLocationInstructionPreamble, textedCodeInstruction)
+ }
+ return smsCodeStep(smsCodeStepOptions{
+ loginStepID: LoginStepIDMachineSMSVerification,
+ inputFieldID: InputDataFieldIDSMSCode,
+ instructions: instructions,
+ }), nil
+ case prompt.Captcha != nil:
+ return d.captchaStep(ctx, prompt.Captcha)
+ case prompt.MFAChallengePrompt != nil:
+ return d.mfaMethodStep(ctx, prompt.MFAChallengePrompt)
+ case prompt.MFACodePrompt != nil:
+ return mfaCodeStep(prompt.MFACodePrompt.Type)
+ default:
+ return nil, fmt.Errorf("auth machine returned an empty prompt")
+ }
+}
+
+func hasAnyInput(input map[string]string, fields ...string) bool {
+ for _, field := range fields {
+ if _, ok := input[field]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func mfaOptionToAuthenticator(selected string) (discordauth.AuthenticatorType, error) {
+ switch mfaOption(strings.TrimSpace(selected)) {
+ case mfaBackup:
+ return discordauth.AuthenticatorBackup, nil
+ case mfaTotp:
+ return discordauth.AuthenticatorTOTP, nil
+ case mfaSms:
+ return discordauth.AuthenticatorSMS, nil
+ default:
+ return "", fmt.Errorf("unknown mfa method %q", selected)
+ }
+}
+
+func (d *DiscordMachineLogin) mfaContinueFromInput(input map[string]string) (*discordauth.MFAContinue, error) {
+ if d.mfaChallenge == nil {
+ return nil, fmt.Errorf("no MFA challenge is active")
+ }
+
+ authType, code, ok := mfaCodeFromInput(input)
+ if !ok {
+ return nil, ErrMissingLoginInput.AppendMessage(": two-factor code")
+ }
+ if authType == discordauth.AuthenticatorBackup {
+ // Discord presents MFA backup codes to the user with dashes, but the
+ // backend doesn't actually accept them. Follow in the footsteps of the
+ // first party clients and remove them from the user input.
+ code = strings.ReplaceAll(code, "-", "")
+ }
+ code = strings.TrimSpace(code)
+ if code == "" {
+ return nil, ErrMissingLoginInput.AppendMessage(": two-factor code")
+ }
+
+ return &discordauth.MFAContinue{
+ Type: authType,
+ MFAContinuation: discordauth.MFAContinuation{
+ MFAState: d.mfaChallenge.MFAState,
+ Code: code,
+ },
+ }, nil
+}
+
+func mfaCodeFromInput(input map[string]string) (discordauth.AuthenticatorType, string, bool) {
+ if code, ok := input[InputDataFieldIDMFABackupCode]; ok {
+ return discordauth.AuthenticatorBackup, code, true
+ }
+ if code, ok := input[InputDataFieldIDMFATOTPCode]; ok {
+ return discordauth.AuthenticatorTOTP, code, true
+ }
+ if code, ok := input[InputDataFieldIDMFASMSCode]; ok {
+ return discordauth.AuthenticatorSMS, code, true
+ }
+ return "", "", false
+}
+
+func (d *DiscordMachineLogin) finalize(ctx context.Context, done *discordauth.LoginCompleted) (*bridgev2.LoginStep, error) {
+ ul, err := d.FinalizeCreatingLogin(ctx, done.Token.UnwrapSensitive())
+ if err != nil {
+ return nil, fmt.Errorf("couldn't log in via machine: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
diff --git a/pkg/connector/login_remoteauth.go b/pkg/connector/login_remoteauth.go
new file mode 100644
index 0000000..5ceec3f
--- /dev/null
+++ b/pkg/connector/login_remoteauth.go
@@ -0,0 +1,145 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/remoteauth"
+)
+
+const LoginFlowIDRemoteAuth = "qr"
+
+type DiscordRemoteAuthLogin struct {
+ *DiscordGenericLogin
+
+ hasClosed bool
+ remoteAuthClient *remoteauth.Client
+ qrChan chan string
+ doneChan chan struct{}
+}
+
+var _ bridgev2.LoginProcessDisplayAndWait = (*DiscordRemoteAuthLogin)(nil)
+
+func (dl *DiscordRemoteAuthLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ log := zerolog.Ctx(ctx)
+
+ log.Debug().Msg("Creating new remoteauth client")
+ restClient, wsClient, err := dl.connector.resolveTransport(ctx, "login", dl.connector.Config.ProxyLoginRemoteAuth)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't resolve proxy for remoteauth: %w", err)
+ }
+ client, err := remoteauth.New(wsClient, restClient)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create Discord remoteauth client: %w", err)
+ }
+
+ dl.remoteAuthClient = client
+
+ dl.qrChan = make(chan string)
+ dl.doneChan = make(chan struct{})
+
+ log.Info().Msg("Starting the QR code login process")
+ err = client.Dial(ctx, dl.qrChan, dl.doneChan)
+ if err != nil {
+ log.Err(err).Msg("Couldn't connect to Discord remoteauth websocket")
+ close(dl.qrChan)
+ close(dl.doneChan)
+ return nil, fmt.Errorf("couldn't connect to Discord remoteauth websocket: %w", err)
+ }
+
+ log.Info().Msg("Waiting for QR code to be ready")
+
+ select {
+ case qrCode := <-dl.qrChan:
+ log.Info().Int("qr_code_data_len", len(qrCode)).Msg("Received QR code, creating login step")
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeDisplayAndWait,
+ StepID: "fi.mau.discord.qr",
+ Instructions: "On your phone, find “Scan QR Code” in Discord’s settings.",
+ DisplayAndWaitParams: &bridgev2.LoginDisplayAndWaitParams{
+ Type: bridgev2.LoginDisplayTypeQR,
+ Data: qrCode,
+ },
+ }, nil
+ case <-ctx.Done():
+ log.Debug().Msg("Cancelled while waiting for QR code")
+ return nil, ctx.Err()
+ }
+}
+
+// Wait implements bridgev2.LoginProcessDisplayAndWait.
+func (dl *DiscordRemoteAuthLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) {
+ if dl.doneChan == nil {
+ panic("can't wait for discord remoteauth without a doneChan")
+ }
+
+ log := zerolog.Ctx(ctx)
+
+ log.Debug().Msg("Waiting for remoteauth")
+ select {
+ case <-dl.doneChan:
+ user, err := dl.remoteAuthClient.Result()
+ if err != nil {
+ log.Err(err).Msg("Discord remoteauth failed")
+ return nil, userVisibleLoginError(ctx, fmt.Errorf("discord remoteauth failed: %w", err))
+ }
+ log.Debug().Msg("Discord remoteauth succeeded")
+
+ return dl.finalizeSuccessfulLogin(ctx, user)
+ case <-ctx.Done():
+ log.Debug().Msg("Cancelled while waiting for remoteauth to complete")
+ return nil, ctx.Err()
+ }
+}
+
+func (dl *DiscordRemoteAuthLogin) finalizeSuccessfulLogin(ctx context.Context, user remoteauth.User) (*bridgev2.LoginStep, error) {
+ ul, err := dl.FinalizeCreatingLogin(ctx, user.Token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't log in via remoteauth: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ Instructions: dl.CompleteInstructions(),
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
+
+func (dl *DiscordRemoteAuthLogin) Cancel() {
+ // Tolerate multiple attempts to cancel.
+ if dl.hasClosed {
+ return
+ }
+ dl.hasClosed = true
+
+ dl.User.Log.Debug().Msg("Discord remoteauth cancelled")
+ dl.DiscordGenericLogin.Cancel()
+
+ // remoteauth.Client doesn't seem to expose a cancellation method.
+ close(dl.doneChan)
+ close(dl.qrChan)
+}
diff --git a/pkg/connector/login_token.go b/pkg/connector/login_token.go
new file mode 100644
index 0000000..1d234ae
--- /dev/null
+++ b/pkg/connector/login_token.go
@@ -0,0 +1,72 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "maunium.net/go/mautrix/bridgev2"
+)
+
+const LoginFlowIDToken = "DEBUG_USERINPUT_token"
+
+type DiscordTokenLogin struct {
+ *DiscordGenericLogin
+}
+
+var _ bridgev2.LoginProcessUserInput = (*DiscordTokenLogin)(nil)
+
+func (dl *DiscordTokenLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeUserInput,
+ StepID: "fi.mau.discord.enter_token",
+ UserInputParams: &bridgev2.LoginUserInputParams{
+ Fields: []bridgev2.LoginInputDataField{
+ {
+ Type: bridgev2.LoginInputFieldTypePassword,
+ ID: "token",
+ Name: "Discord user account token",
+ // Cribbed from https://regex101.com/r/1GMR0y/1.
+ Pattern: `^(mfa\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,}\.[a-zA-Z0-9_-]{6,7}\.[a-zA-Z0-9_-]{27,})$`,
+ },
+ },
+ },
+ }, nil
+}
+
+func (dl *DiscordTokenLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
+ token := input["token"]
+ if token == "" {
+ return nil, fmt.Errorf("no token provided")
+ }
+
+ ul, err := dl.FinalizeCreatingLogin(ctx, token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't login from token: %w", err)
+ }
+
+ return &bridgev2.LoginStep{
+ Type: bridgev2.LoginStepTypeComplete,
+ StepID: LoginStepIDComplete,
+ Instructions: dl.CompleteInstructions(),
+ CompleteParams: &bridgev2.LoginCompleteParams{
+ UserLoginID: ul.ID,
+ UserLogin: ul,
+ },
+ }, nil
+}
diff --git a/pkg/connector/provisioning.go b/pkg/connector/provisioning.go
new file mode 100644
index 0000000..055ea57
--- /dev/null
+++ b/pkg/connector/provisioning.go
@@ -0,0 +1,473 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exhttp"
+ "maunium.net/go/mautrix"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+const (
+ ErrCodeNotConnected = "FI.MAU.DISCORD.NOT_CONNECTED"
+ ErrCodeAlreadyLoggedIn = "FI.MAU.DISCORD.ALREADY_LOGGED_IN"
+ ErrCodeAlreadyConnected = "FI.MAU.DISCORD.ALREADY_CONNECTED"
+ ErrCodeConnectFailed = "FI.MAU.DISCORD.CONNECT_FAILED"
+ ErrCodeDisconnectFailed = "FI.MAU.DISCORD.DISCONNECT_FAILED"
+ ErrCodeGuildBridgeFailed = "M_UNKNOWN"
+ ErrCodeGuildUnbridgeFailed = "M_UNKNOWN"
+ ErrCodeGuildNotBridged = "FI.MAU.DISCORD.GUILD_NOT_BRIDGED"
+ ErrCodeLoginPrepareFailed = "FI.MAU.DISCORD.LOGIN_PREPARE_FAILED"
+ ErrCodeLoginConnectionFailed = "FI.MAU.DISCORD.LOGIN_CONN_FAILED"
+ ErrCodeLoginFailed = "FI.MAU.DISCORD.LOGIN_FAILED"
+ ErrCodePostLoginConnFailed = "FI.MAU.DISCORD.POST_LOGIN_CONNECTION_FAILED"
+)
+
+type ProvisioningAPI struct {
+ log zerolog.Logger
+ connector *DiscordConnector
+ prov bridgev2.IProvisioningAPI
+}
+
+func (d *DiscordConnector) setUpProvisioningAPIs() error {
+ c, ok := d.Bridge.Matrix.(bridgev2.MatrixConnectorWithProvisioning)
+ if !ok {
+ return errors.New("matrix connector doesn't support provisioning; not setting up")
+ }
+
+ prov := c.GetProvisioning()
+ r := prov.GetRouter()
+ if r == nil {
+ return errors.New("matrix connector's provisioning api didn't return a router")
+ }
+
+ log := d.Bridge.Log.With().Str("component", "provisioning").Logger()
+ p := &ProvisioningAPI{
+ connector: d,
+ log: log,
+ prov: prov,
+ }
+
+ // NOTE: aim to provide backwards compatibility with v1 provisioning APIs
+ r.HandleFunc("POST /v1/login/token", p.legacyTokenLogin)
+ r.HandleFunc("GET /v1/ping", p.legacyPing)
+ r.HandleFunc("POST /v1/logout", p.legacyLogout)
+ r.HandleFunc("GET /v1/guilds", p.makeHandler(p.guildsList, true))
+ r.HandleFunc("POST /v1/guilds/{guildID}", p.makeHandler(p.bridgeGuild, true))
+ // Unbridging doesn't touch discordgo, so it's okay to do it even when
+ // logged out.
+ r.HandleFunc("DELETE /v1/guilds/{guildID}", p.makeHandler(p.unbridgeGuild, false))
+
+ return nil
+}
+
+type provHandler func(http.ResponseWriter, *http.Request, *bridgev2.UserLogin, *DiscordClient)
+
+func (p *ProvisioningAPI) makeHandler(handler provHandler, enforceLoggedIn bool) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ user := p.prov.GetUser(r)
+
+ logins := user.GetUserLogins()
+ if len(logins) < 1 {
+ mautrix.RespError{
+ ErrCode: ErrCodeNotConnected,
+ Err: "user has no logins",
+ }.Write(w)
+ return
+ }
+
+ login := logins[0]
+ client := login.Client.(*DiscordClient)
+
+ if !client.IsLoggedIn() && enforceLoggedIn {
+ mautrix.RespError{
+ ErrCode: ErrCodeNotConnected,
+ Err: "not logged in to discord",
+ }.Write(w)
+ return
+ }
+
+ handler(w, r, login, client)
+ }
+}
+
+type guildEntry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ // TODO v1 uses `id.ContentURI` whereas we stuff the discord cdn url here
+ AvatarURL string `json:"avatar_url"`
+
+ // new in v2:
+ Bridged bool `json:"bridged"`
+ Available bool `json:"available"`
+
+ // legacy fields from v1:
+ MXID string `json:"mxid"`
+ AutoBridge bool `json:"auto_bridge_channels"`
+ BridgingMode string `json:"bridging_mode"`
+}
+type respGuildsList struct {
+ Guilds []guildEntry `json:"guilds"`
+}
+
+func (p *ProvisioningAPI) guildsList(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
+ ctx := r.Context()
+ p.log.Info().Str("login_id", discordid.ParseUserLoginID(login.ID)).Msg("guilds list requested via provisioning api")
+
+ bridgedGuildIDs := client.bridgedGuildIDs()
+
+ var resp respGuildsList
+ resp.Guilds = []guildEntry{}
+
+ state := client.Session.State
+
+ state.RLock()
+ for _, guild := range state.Guilds {
+ portalKey := client.guildPortalKey(guild.ID)
+ portal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
+ if err != nil {
+ p.log.Err(err).
+ Str("guild_id", guild.ID).
+ Msg("Failed to get guild portal for provisioning list")
+ }
+
+ _, beingBridged := bridgedGuildIDs[guild.ID]
+ mxid := ""
+ if portal != nil && portal.MXID != "" {
+ mxid = portal.MXID.String()
+ } else if beingBridged {
+ // Beeper Desktop expects the space to exist by the time it receives
+ // our HTTP response. If it doesn't, then the space won't appear
+ // until the app is reloaded, and the toggle in the user interface
+ // won't respond to the user's click.
+ //
+ // Pre-bridgev2, we synchronously bridged guilds. However, this
+ // might take a while for guilds with many channels.
+ //
+ // To solve this, generate a deterministic room ID to use as the
+ // MXID so that it recognizes the guild as bridged, even if the
+ // portals haven't been created just yet. This lets us
+ // asynchronously bridge guilds while keeping the UI responsive.
+ mxid = p.connector.Bridge.Matrix.GenerateDeterministicRoomID(portalKey).String()
+ }
+
+ resp.Guilds = append(resp.Guilds, guildEntry{
+ // For now, have the ID exactly correspond to the portal ID. This
+ // practically means that the ID will begin with an asterisk (the
+ // guild portal ID sigil).
+ //
+ // Otherwise, Beeper Desktop will show a duplicate space for every
+ // guild, as it recognizes the guild returned from this HTTP
+ // endpoint and the actual space itself as separate "entities".
+ // (Despite this, they point to identical rooms.)
+ ID: string(discordid.MakeGuildPortalIDWithID(guild.ID)),
+ Name: guild.Name,
+ AvatarURL: discordgo.EndpointGuildIcon(guild.ID, guild.Icon),
+ Bridged: beingBridged,
+ Available: !guild.Unavailable,
+
+ // v1 (legacy) backwards compat:
+ MXID: mxid,
+ AutoBridge: beingBridged,
+ BridgingMode: "everything",
+ })
+ }
+ defer state.RUnlock()
+
+ exhttp.WriteJSONResponse(w, 200, resp)
+}
+
+// normalizeGuildID removes the guild portal sigil from a guild ID if it's
+// there.
+//
+// This helps facilitate code that would like to accept portal keys
+// corresponding to guilds as well as plain Discord guild IDs.
+func normalizeGuildID(guildID string) string {
+ return strings.TrimPrefix(guildID, discordid.GuildPortalKeySigil)
+}
+
+// collectAllGuildPortals fetches all portals associated with a guild. This
+// includes the guild space portal itself as well as child portals inside of
+// portals that represent guild category channels.
+//
+// The order of the returned slice is undefined.
+func (p *ProvisioningAPI) collectAllGuildPortals(ctx context.Context, guild *bridgev2.Portal) ([]*bridgev2.Portal, error) {
+ if guild == nil {
+ return nil, nil
+ }
+
+ // Fetch all top-level channels and category channels.
+ children, err := p.connector.Bridge.GetChildPortals(ctx, guild.PortalKey)
+ if err != nil {
+ return nil, err
+ }
+
+ portals := make([]*bridgev2.Portal, 0, 1+len(children))
+ portals = append(portals, guild)
+ portals = append(portals, children...)
+
+ // Fetch channels that are inside of categories.
+ for _, child := range children {
+ grandchildren, err := p.connector.Bridge.GetChildPortals(ctx, child.PortalKey)
+ if err != nil {
+ return nil, err
+ }
+ portals = append(portals, grandchildren...)
+ }
+
+ return portals, nil
+}
+
+func (p *ProvisioningAPI) bridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
+ guildID := normalizeGuildID(r.PathValue("guildID"))
+ if guildID == "" {
+ mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
+ return
+ }
+
+ p.log.Info().
+ Str("login_id", discordid.ParseUserLoginID(login.ID)).
+ Str("guild_id", guildID).
+ Msg("requested to bridge guild via provisioning api")
+
+ meta := login.Metadata.(*discordid.UserLoginMetadata)
+
+ if meta.BridgedGuildIDs == nil {
+ meta.BridgedGuildIDs = map[string]bool{}
+ }
+ _, alreadyBridged := meta.BridgedGuildIDs[guildID]
+ meta.BridgedGuildIDs[guildID] = true
+
+ if err := login.Save(r.Context()); err != nil {
+ p.log.Err(err).Msg("Failed to save login after guild bridge request")
+ mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
+ return
+ }
+
+ go func() {
+ if err := client.syncGuild(p.connector.Bridge.BackgroundCtx, guildID); err != nil {
+ p.log.Err(err).Msg("Failed to sync guild in response to manual guild provision")
+ }
+ }()
+
+ responseStatus := 201
+ if alreadyBridged {
+ responseStatus = 200
+ }
+ exhttp.WriteJSONResponse(w, responseStatus, nil)
+}
+
+// Legacy v1 provisioning endpoints for backwards compatibility with clients
+// that haven't migrated to the bridgev2 provisioning API yet.
+
+func (p *ProvisioningAPI) legacyTokenLogin(w http.ResponseWriter, r *http.Request) {
+ user := p.prov.GetUser(r)
+
+ if logins := user.GetUserLogins(); len(logins) > 0 {
+ for _, login := range logins {
+ client := login.Client.(*DiscordClient)
+ if client.HasToken() {
+ exhttp.WriteJSONResponse(w, http.StatusConflict, mautrix.RespError{
+ ErrCode: ErrCodeAlreadyLoggedIn,
+ Err: "already logged in to Discord",
+ })
+ return
+ }
+ }
+ }
+
+ var body struct {
+ Token string `json:"token"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ exhttp.WriteJSONResponse(w, http.StatusBadRequest, mautrix.RespError{
+ ErrCode: mautrix.MBadJSON.ErrCode,
+ Err: "failed to parse request body",
+ })
+ return
+ }
+ if body.Token == "" {
+ exhttp.WriteJSONResponse(w, http.StatusBadRequest, mautrix.RespError{
+ ErrCode: mautrix.MBadJSON.ErrCode,
+ Err: "missing token",
+ })
+ return
+ }
+
+ login, err := p.connector.CreateLogin(r.Context(), user, LoginFlowIDToken)
+ if err != nil {
+ p.log.Err(err).Msg("Failed to create login process")
+ exhttp.WriteJSONResponse(w, http.StatusInternalServerError, mautrix.RespError{
+ ErrCode: ErrCodeLoginPrepareFailed,
+ Err: "failed to prepare login",
+ })
+ return
+ }
+ _, err = login.Start(r.Context())
+ if err != nil {
+ p.log.Err(err).Msg("Failed to start login process")
+ exhttp.WriteJSONResponse(w, http.StatusInternalServerError, mautrix.RespError{
+ ErrCode: ErrCodeLoginPrepareFailed,
+ Err: "failed to start login",
+ })
+ return
+ }
+ _, err = login.(bridgev2.LoginProcessUserInput).SubmitUserInput(r.Context(), map[string]string{
+ "token": body.Token,
+ })
+ if err != nil {
+ p.log.Err(err).Msg("Failed to submit token")
+ exhttp.WriteJSONResponse(w, http.StatusUnauthorized, mautrix.RespError{
+ ErrCode: ErrCodePostLoginConnFailed,
+ Err: "failed to connect to Discord",
+ })
+ return
+ }
+
+ discordUser := login.(*DiscordTokenLogin).DiscordUser
+ exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{
+ "success": true,
+ "id": discordUser.ID,
+ "username": discordUser.Username,
+ "discriminator": discordUser.Discriminator,
+ })
+}
+
+func (p *ProvisioningAPI) legacyPing(w http.ResponseWriter, r *http.Request) {
+ user := p.prov.GetUser(r)
+
+ resp := map[string]any{
+ "mxid": user.MXID,
+ "management_room": user.ManagementRoom,
+ }
+
+ discord := map[string]any{
+ "logged_in": false,
+ "connected": false,
+ }
+
+ if logins := user.GetUserLogins(); len(logins) > 0 {
+ login := logins[0]
+ client := login.Client.(*DiscordClient)
+ discord["id"] = discordid.ParseUserLoginID(login.ID)
+ discord["logged_in"] = client.HasToken()
+ discord["connected"] = client.IsLoggedIn()
+ if client.Session != nil {
+ discord["conn"] = map[string]any{
+ "last_heartbeat_ack": client.Session.LastHeartbeatAck.UnixMilli(),
+ "last_heartbeat_sent": client.Session.LastHeartbeatSent.UnixMilli(),
+ }
+ }
+ }
+
+ resp["Discord"] = discord
+ exhttp.WriteJSONResponse(w, http.StatusOK, resp)
+}
+
+func (p *ProvisioningAPI) legacyLogout(w http.ResponseWriter, r *http.Request) {
+ user := p.prov.GetUser(r)
+ logins := user.GetUserLogins()
+ if len(logins) == 0 {
+ exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{
+ "success": true,
+ "status": "not logged in",
+ })
+ return
+ }
+ logins[0].Logout(r.Context())
+ exhttp.WriteJSONResponse(w, http.StatusOK, map[string]any{
+ "success": true,
+ "status": "logged out successfully",
+ })
+}
+
+func (p *ProvisioningAPI) unbridgeGuild(w http.ResponseWriter, r *http.Request, login *bridgev2.UserLogin, client *DiscordClient) {
+ guildID := normalizeGuildID(r.PathValue("guildID"))
+ if guildID == "" {
+ mautrix.MInvalidParam.WithMessage("no guild id").Write(w)
+ return
+ }
+
+ log := p.log.With().
+ Str("login_id", discordid.ParseUserLoginID(login.ID)).
+ Str("guild_id", guildID).
+ Str("action", "unbridge guild").
+ Logger()
+ ctx := log.WithContext(r.Context())
+
+ log.Info().Msg("Unbridging guild via provisioning API")
+
+ // Immediately record user intent by committing the change to UserLogin
+ // metadata, even if the portal deletion we're about to attempt fails.
+ meta := login.Metadata.(*discordid.UserLoginMetadata)
+ if meta.BridgedGuildIDs != nil {
+ delete(meta.BridgedGuildIDs, guildID)
+ }
+ if err := login.Save(ctx); err != nil {
+ log.Err(err).Msg("Failed to save login after guild unbridge request")
+ mautrix.MUnknown.WithMessage("failed to save login: %v", err).Write(w)
+ return
+ }
+
+ portalKey := client.guildPortalKey(guildID)
+ guildPortal, err := p.connector.Bridge.GetExistingPortalByKey(ctx, portalKey)
+ if err != nil {
+ log.Err(err).Msg("Failed to get guild portal")
+ mautrix.MUnknown.WithMessage("failed to get portal: %v", err).Write(w)
+ return
+ }
+ if guildPortal == nil || guildPortal.MXID == "" {
+ mautrix.RespError{
+ ErrCode: ErrCodeGuildNotBridged,
+ Err: "guild is not bridged",
+ }.Write(w)
+ return
+ }
+
+ deletingPortals, err := p.collectAllGuildPortals(ctx, guildPortal)
+ if err != nil {
+ log.Err(err).Msg("Failed to collect portal subtree for deletion")
+ mautrix.MUnknown.WithMessage("failed to collect portal subtree for deletion: %v", err).Write(w)
+ return
+ }
+ // DeleteManyPortals will sort by depth for us so children get deleted
+ // before their parents.
+ bridgev2.DeleteManyPortals(ctx, deletingPortals, func(portal *bridgev2.Portal, del bool, err error) {
+ log.Err(err).
+ Stringer("portal_mxid", portal.MXID).
+ Bool("delete_room", del).
+ Msg("Failed during portal cleanup")
+ })
+
+ log.Info().
+ Int("deleted_portals", len(deletingPortals)).
+ Msg("Finished unbridging")
+ exhttp.WriteJSONResponse(w, 200, map[string]any{
+ "success": true,
+ "deleted_portals": len(deletingPortals),
+ })
+}
diff --git a/pkg/connector/proxy.go b/pkg/connector/proxy.go
new file mode 100644
index 0000000..cefbfb5
--- /dev/null
+++ b/pkg/connector/proxy.go
@@ -0,0 +1,207 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exhttp"
+ "maunium.net/go/mautrix"
+
+ "go.mau.fi/mautrix-discord/pkg/discordtransport"
+)
+
+const proxyResolveTimeout = 30 * time.Second
+
+type respGetProxy struct {
+ ProxyURL string `json:"proxy_url"`
+}
+
+// proxyConfigured reports whether any proxy (static or dynamic) is configured.
+func (d *DiscordConnector) proxyConfigured() bool {
+ return d.Config.GetProxyFrom != "" || d.Config.Proxy != ""
+}
+
+// getProxy returns the effective proxy URL to use for Discord traffic
+// according to the config.
+func (d *DiscordConnector) getProxy(ctx context.Context, reason string) (string, error) {
+ if d.Config.GetProxyFrom == "" {
+ // Use the static proxy, if any.
+ return d.Config.Proxy, nil
+ }
+
+ parsed, err := url.Parse(d.Config.GetProxyFrom)
+ if err != nil {
+ return "", fmt.Errorf("failed to parse dynamic proxy endpoint address: %w", err)
+ }
+
+ q := parsed.Query()
+ q.Set("reason", reason)
+ parsed.RawQuery = q.Encode()
+
+ ctx, cancel := context.WithTimeout(ctx, proxyResolveTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to prepare dynamic proxy request: %w", err)
+ }
+ req.Header.Set("User-Agent", mautrix.DefaultUserAgent)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to send dynamic proxy request: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 300 || resp.StatusCode < 200 {
+ return "", fmt.Errorf("unexpected status code %d", resp.StatusCode)
+ }
+
+ var respData respGetProxy
+ err = json.NewDecoder(resp.Body).Decode(&respData)
+ if err != nil {
+ return "", fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ // Treat an empty proxy_url as a resolution failure.
+ if respData.ProxyURL == "" {
+ return "", fmt.Errorf("dynamic proxy endpoint returned an empty proxy_url")
+ }
+
+ return respData.ProxyURL, nil
+}
+
+// resolveHTTPClientSettings returns the HTTP client settings that are currently
+// at play. This includes any configured proxy (fetching a dynamic proxy if
+// configured to do so).
+//
+// It may perform a blocking HTTP request to the dynamic proxy endpoint, so it
+// should only be called at connection and login boundaries. Code that wants an
+// HTTP client per request should use the already-resolved session.Client or
+// DiscordClient.httpClient.
+func (d *DiscordConnector) resolveHTTPClientSettings(
+ ctx context.Context,
+ reason string,
+) (exhttp.ClientSettings, error) {
+ proxyURL, err := d.getProxy(ctx, reason)
+ if err != nil {
+ return exhttp.ClientSettings{}, fmt.Errorf("failed to get proxy: %w", err)
+ }
+
+ settings, err := d.Bridge.GetHTTPClientSettings().WithProxy(proxyURL)
+ if err != nil {
+ return exhttp.ClientSettings{}, fmt.Errorf("failed to apply proxy %q: %w", proxyURL, err)
+ }
+
+ return settings, nil
+}
+
+// resolveTransport returns the REST HTTP client and the gateway (WebSocket
+// upgrade) HTTP client. When proxy is true, both are configured to use any
+// configured proxy. The gateway client is pinned to HTTP/1.1; see
+// [discordtransport.CompileGatewayClient].
+func (d *DiscordConnector) resolveTransport(
+ ctx context.Context,
+ reason string,
+ proxy bool,
+) (restClient, wsClient *http.Client, err error) {
+ // NOTE(skip): This is grossly tangled. Think of a way to restructure this.
+ var settings exhttp.ClientSettings
+ if proxy {
+ settings, err = d.resolveHTTPClientSettings(ctx, reason)
+ if err != nil {
+ return nil, nil, err
+ }
+ } else {
+ settings = d.Bridge.GetHTTPClientSettings()
+ }
+ restClient, err = discordtransport.CompileTransport(settings, discordtransport.TransportOptions{CookieJar: true})
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to compile REST transport: %w", err)
+ }
+ wsClient, err = discordtransport.CompileGatewayClient(settings, discordtransport.TransportOptions{CookieJar: true})
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to compile gateway transport: %w", err)
+ }
+ return restClient, wsClient, nil
+}
+
+// applyProxyToSession resolves the proxy once and points the session's REST
+// client and gateway dialer at it. Used to proxy a session that isn't yet owned
+// by a DiscordClient (e.g. the @me validation during login), so the caller can
+// fail the operation if the proxy can't be resolved.
+func (d *DiscordConnector) applyProxyToSession(
+ ctx context.Context,
+ session *discordgo.Session,
+ reason string,
+) error {
+ settings, err := d.resolveHTTPClientSettings(ctx, reason)
+ if err != nil {
+ return err
+ }
+ return discordtransport.ApplyToSession(session, settings)
+}
+
+// updateProxy re-resolves the proxy once for the given reason and applies it to
+// the session's REST client, gateway dialer, and (when proxy_media is enabled)
+// the media HTTP client.
+//
+// It returns whether the proxy was successfully updated. On failure, the
+// previously applied settings are kept.
+func (d *DiscordClient) updateProxy(ctx context.Context, reason string) bool {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "update proxy").
+ Str("reason", reason).Logger()
+
+ settings, err := d.connector.resolveHTTPClientSettings(ctx, reason)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to update proxy, keeping previous settings")
+ return false
+ }
+
+ if d.Session != nil {
+ if err := discordtransport.ApplyToSession(d.Session, settings); err != nil {
+ log.Warn().Err(err).Msg("Failed to apply settings to session, keeping previous settings")
+ return false
+ }
+ }
+
+ if d.connector.Config.ProxyMedia {
+ d.httpClient = settings.Compile()
+ }
+
+ log.Debug().
+ Str("proxy_host", proxyHostFromSettings(settings)).
+ Bool("proxying_media", d.connector.Config.ProxyMedia).
+ Msg("Updated proxy")
+ return true
+}
+
+func proxyHostFromSettings(settings exhttp.ClientSettings) string {
+ if settings.ProxyAddress == "" {
+ return "(none)"
+ }
+ if u, err := url.Parse(settings.ProxyAddress); err == nil {
+ return u.Host
+ }
+ return "(unparsable)"
+}
diff --git a/pkg/connector/role.go b/pkg/connector/role.go
new file mode 100644
index 0000000..6538041
--- /dev/null
+++ b/pkg/connector/role.go
@@ -0,0 +1,106 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+
+ "go.mau.fi/mautrix-discord/pkg/connector/discorddb"
+)
+
+// (Used by formatter_tag.go via an interface.)
+func (d *DiscordConnector) GetRoleByID(ctx context.Context, guildID, roleID string) (*discorddb.Role, error) {
+ return d.DB.Role.GetByID(ctx, guildID, roleID)
+}
+
+func guildRoleChanged(oldRole *discorddb.Role, newRole *discordgo.Role) bool {
+ return oldRole.Name != newRole.Name ||
+ oldRole.Icon != newRole.Icon ||
+ oldRole.Mentionable != newRole.Mentionable ||
+ oldRole.Managed != newRole.Managed ||
+ oldRole.Hoist != newRole.Hoist ||
+ oldRole.Color != newRole.Color ||
+ oldRole.Position != newRole.Position ||
+ oldRole.Permissions != newRole.Permissions
+}
+
+func (d *DiscordClient) syncGuildRoles(ctx context.Context, guildID string, roles []*discordgo.Role) error {
+ if len(roles) == 0 {
+ return nil
+ }
+
+ existingRoles, err := d.connector.DB.Role.GetByGuildID(ctx, guildID)
+ if err != nil {
+ return fmt.Errorf("failed to get existing guild roles: %w", err)
+ }
+
+ existingRoleMap := make(map[string]*discorddb.Role, len(existingRoles))
+ for _, role := range existingRoles {
+ existingRoleMap[role.ID] = role
+ }
+
+ err = d.connector.DB.Role.GetDB().DoTxn(ctx, nil, func(ctx context.Context) error {
+ for _, role := range roles {
+ if role == nil {
+ continue
+ }
+
+ existingRole := existingRoleMap[role.ID]
+ if existingRole == nil || guildRoleChanged(existingRole, role) {
+ if err := d.connector.DB.Role.Put(ctx, &discorddb.Role{
+ GuildID: guildID,
+ Role: *role,
+ }); err != nil {
+ return fmt.Errorf("failed to upsert guild role: %w", err)
+ }
+ }
+
+ delete(existingRoleMap, role.ID)
+ }
+
+ for _, removedRole := range existingRoleMap {
+ if err := d.connector.DB.Role.DeleteByID(ctx, guildID, removedRole.ID); err != nil {
+ return fmt.Errorf("failed to delete removed guild role: %w", err)
+ }
+ }
+
+ return nil
+ })
+ if err != nil {
+ return fmt.Errorf("failed to sync guild roles: %w", err)
+ }
+
+ return nil
+}
+
+func (d *DiscordClient) upsertGuildRole(ctx context.Context, guildID string, role *discordgo.Role) error {
+ if role == nil {
+ return nil
+ }
+
+ if err := d.connector.DB.Role.Put(ctx, &discorddb.Role{
+ GuildID: guildID,
+ Role: *role,
+ }); err != nil {
+ return fmt.Errorf("failed to upsert guild role: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/connector/router.go b/pkg/connector/router.go
new file mode 100644
index 0000000..a2d25dd
--- /dev/null
+++ b/pkg/connector/router.go
@@ -0,0 +1,127 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+
+ "github.com/rs/zerolog"
+
+ "go.mau.fi/mautrix-discord/pkg/connector/discorddb"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+ "go.mau.fi/mautrix-discord/pkg/router"
+)
+
+var _ router.Router = (*DiscordClient)(nil)
+
+func (d *DiscordClient) uncertainRoute(ctx context.Context, channelID string) *router.Route {
+ log := zerolog.Ctx(ctx)
+ log.Warn().Str("channel_id", channelID).Msg("Creating an uncertain route")
+
+ return &router.Route{
+ // It's generally bad to call into discordid for PortalKey
+ // construction since the helpers on DiscordClient ensure receiver
+ // correctness, but this is a bit of a special case as we're
+ // uncertain who the receiver should even be.
+ PortalKey: discordid.MakeChannelPortalKey(channelID, d.UserLogin.ID, true),
+ PortalChannelID: channelID,
+ Uncertain: true,
+ }
+}
+
+// FIXME(skip): This method is infallible now, remove the error from the
+// signature in the interface and refactor.
+func (d *DiscordClient) Route(ctx context.Context, channelID string) (*router.Route, error) {
+ ch := d.channelWithID(ctx, channelID)
+ dbThread, err := d.connector.DB.Thread.GetByThreadChannelID(
+ ctx,
+ discordid.ParseUserLoginID(d.UserLogin.ID),
+ channelID,
+ )
+ if err != nil {
+ // Even if we can't touch the database right now, we can try examining
+ // the channel from State to make a routing decision.
+ zerolog.Ctx(ctx).Warn().
+ Err(err).
+ Str("channel_id", channelID).
+ Msg("Failed to look up potential thread channel ID, proceeding with route")
+ dbThread = nil
+ }
+
+ // Most routes will just go to the channel the event originated from. (Not
+ // true for threads right now.)
+ r := router.Route{
+ PortalChannelID: channelID,
+ FromChannel: ch,
+ FromThread: dbThread,
+ }
+
+ if dbThread != nil {
+ // If the channel exists in the database as a thread, we immediately
+ // know how to be receiver-correct (i.e. we can set a correct PortalKey),
+ // even if the channel doesn't exist in State.
+
+ // Threaded Discord messages need to be bridged to the Matrix room
+ // that portals to the _parent_ Discord channel, since we always bridge
+ // threads via m.thread right now.
+ r.PortalChannelID = dbThread.ParentChannelID
+ r.PortalKey = d.guildChannelPortalKey(dbThread.ParentChannelID)
+
+ if ch == nil {
+ return &r, nil
+ }
+ }
+
+ if ch == nil {
+ // We can't know the proper PortalKey for this channel. Return an
+ // uncertain route instead.
+ //
+ // TODO: Maybe we can just ask the REST API for the channel?
+ return d.uncertainRoute(ctx, channelID), nil
+ }
+
+ if isThread(ch) {
+ if dbThread == nil {
+ // This is a thread we haven't seen before, so insert it into the database.
+ rootMsgID := defaultThreadRootMessageID(ch)
+ if upsertErr := d.upsertThreadInfo(ctx, channelID, rootMsgID, ch.ParentID); upsertErr != nil {
+ // Even if we can't save the thread to the database, we can still
+ // use the routing decision.
+ zerolog.Ctx(ctx).Warn().
+ Err(upsertErr).
+ Str("thread_channel_id", channelID).
+ Str("parent_channel_id", ch.ParentID).
+ Msg("Failed to upsert newly discovered thread, proceeding with route")
+ }
+ thread := discorddb.Thread{
+ UserLoginID: discordid.ParseUserLoginID(d.UserLogin.ID),
+ ThreadChannelID: channelID,
+ RootMessageID: rootMsgID,
+ ParentChannelID: ch.ParentID,
+ }
+
+ // Duplicated from above.
+ r.PortalChannelID = thread.ParentChannelID
+ r.PortalKey = d.guildChannelPortalKey(thread.ParentChannelID)
+ r.FromThread = &thread
+ }
+ } else {
+ r.PortalKey = d.portalKeyForChannel(ch)
+ }
+
+ return &r, nil
+}
diff --git a/pkg/connector/session.go b/pkg/connector/session.go
new file mode 100644
index 0000000..efcf7eb
--- /dev/null
+++ b/pkg/connector/session.go
@@ -0,0 +1,57 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exhttp"
+
+ "go.mau.fi/mautrix-discord/pkg/discordtransport"
+)
+
+func NewDiscordSession(ctx context.Context, settings exhttp.ClientSettings, token string) (*discordgo.Session, error) {
+ log := zerolog.Ctx(ctx)
+
+ session, err := discordgo.New(token)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create discord session: %w", err)
+ }
+
+ // Cloak sessions regardless of proxy.
+ if err := discordtransport.ApplyToSession(session, settings); err != nil {
+ return nil, fmt.Errorf("couldn't apply TLS cloak to Discord session: %w", err)
+ }
+
+ // Don't bother tracking things we don't care/support right now. Presences
+ // are especially expensive to track as they occur extremely frequently.
+ session.State.TrackPresences = false
+ session.State.TrackVoice = false
+
+ // Set up logging.
+ session.LogLevel = discordgo.LogInformational
+ session.Logger = func(msgL, caller int, format string, a ...any) {
+ // FIXME(skip): Hook up zerolog properly.
+ log.Debug().Str("component", "discordgo").Msgf(strings.TrimSpace(format), a...) // zerolog-allow-msgf
+ }
+
+ return session, nil
+}
diff --git a/pkg/connector/thread.go b/pkg/connector/thread.go
new file mode 100644
index 0000000..091a0fb
--- /dev/null
+++ b/pkg/connector/thread.go
@@ -0,0 +1,189 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/event"
+
+ "go.mau.fi/mautrix-discord/pkg/connector/discorddb"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+func isThread(ch *discordgo.Channel) bool {
+ return ch.Type == discordgo.ChannelTypeGuildPublicThread ||
+ ch.Type == discordgo.ChannelTypeGuildPrivateThread ||
+ ch.Type == discordgo.ChannelTypeGuildNewsThread
+}
+
+func defaultThreadRootMessageID(ch *discordgo.Channel) string {
+ if ch == nil || !isThread(ch) {
+ return ""
+ }
+ if ch.Type == discordgo.ChannelTypeGuildPrivateThread {
+ return ""
+ }
+ return ch.ID
+}
+
+func (d *DiscordClient) upsertThreadInfo(ctx context.Context, threadChannelID, rootMessageID, parentChannelID string) error {
+ if threadChannelID == "" || parentChannelID == "" {
+ return nil
+ }
+ return d.connector.DB.Thread.Put(ctx, &discorddb.Thread{
+ UserLoginID: string(d.UserLogin.ID),
+ ThreadChannelID: threadChannelID,
+ RootMessageID: rootMessageID,
+ ParentChannelID: parentChannelID,
+ })
+}
+
+func (d *DiscordClient) upsertThreadInfoFromChannel(ctx context.Context, ch *discordgo.Channel) error {
+ if ch == nil || !isThread(ch) {
+ return nil
+ }
+ return d.upsertThreadInfo(ctx, ch.ID, defaultThreadRootMessageID(ch), ch.ParentID)
+}
+
+func (d *DiscordClient) upsertThreadInfoFromMessage(ctx context.Context, msg *discordgo.Message) error {
+ if msg == nil || msg.Flags&discordgo.MessageFlagsHasThread == 0 || msg.Thread == nil {
+ return nil
+ }
+ threadChannelID := msg.Thread.ID
+ if threadChannelID == "" {
+ threadChannelID = msg.ID
+ }
+ parentChannelID := msg.Thread.ParentID
+ if parentChannelID == "" {
+ parentChannelID = msg.ChannelID
+ }
+ return d.upsertThreadInfo(ctx, threadChannelID, msg.ID, parentChannelID)
+}
+
+func (d *DiscordClient) getThreadByRootMessageID(ctx context.Context, rootMessageID string) (*discorddb.Thread, error) {
+ if rootMessageID == "" {
+ return nil, nil
+ }
+ thread, err := d.connector.DB.Thread.GetByRootMessageID(ctx, string(d.UserLogin.ID), rootMessageID)
+ if err != nil || thread != nil {
+ return thread, err
+ }
+
+ ch, err := d.Session.State.Channel(rootMessageID)
+ if err == nil && ch != nil && isThread(ch) && defaultThreadRootMessageID(ch) == rootMessageID {
+ if upsertErr := d.upsertThreadInfo(ctx, ch.ID, rootMessageID, ch.ParentID); upsertErr != nil {
+ return nil, upsertErr
+ }
+ return &discorddb.Thread{
+ UserLoginID: string(d.UserLogin.ID),
+ ThreadChannelID: ch.ID,
+ RootMessageID: rootMessageID,
+ ParentChannelID: ch.ParentID,
+ }, nil
+ }
+
+ return nil, nil
+}
+
+func getMatrixThreadRootRemoteMessageID(threadRoot *database.Message) string {
+ if threadRoot == nil {
+ return ""
+ }
+ remoteID := discordid.ParseMessageID(threadRoot.ID)
+ if threadRoot.ThreadRoot != "" {
+ remoteID = discordid.ParseMessageID(threadRoot.ThreadRoot)
+ }
+ return remoteID
+}
+
+func makeDiscordReferer(guildID, parentChannelID, threadChannelID string) discordgo.RequestOption {
+ if threadChannelID != "" && threadChannelID != parentChannelID {
+ return discordgo.WithThreadReferer(guildID, parentChannelID, threadChannelID)
+ }
+ return discordgo.WithChannelReferer(guildID, parentChannelID)
+}
+
+func getThreadName(content *event.MessageEventContent) string {
+ body := ""
+ if content != nil {
+ body = content.Body
+ }
+ if len(body) == 0 {
+ return "thread"
+ }
+
+ fields := strings.Fields(body)
+ var title string
+ for _, field := range fields {
+ if len(title)+len(field) < 40 {
+ title += field + " "
+ } else if len(title) == 0 {
+ title = field[:40]
+ break
+ } else {
+ break
+ }
+ }
+ title = strings.TrimSpace(title)
+ if title == "" {
+ return "thread"
+ }
+ return title
+}
+
+func (d *DiscordClient) startThreadFromMatrix(
+ ctx context.Context,
+ guildID string,
+ parentChannelID string,
+ rootMessageID string,
+ threadName string,
+) (string, error) {
+ if !d.IsLoggedIn() {
+ return "", fmt.Errorf("can't create thread without being logged into Discord")
+ }
+
+ threadType := discordgo.ChannelTypeGuildPublicThread
+ parentCh, err := d.Session.State.Channel(parentChannelID)
+ if err == nil && parentCh != nil && parentCh.Type == discordgo.ChannelTypeGuildNews {
+ threadType = discordgo.ChannelTypeGuildNewsThread
+ }
+
+ ch, err := d.Session.MessageThreadStartComplex(
+ parentChannelID,
+ rootMessageID,
+ &discordgo.ThreadStart{
+ Name: threadName,
+ AutoArchiveDuration: 24 * 60,
+ Type: threadType,
+ Location: "Message",
+ },
+ makeDiscordReferer(guildID, parentChannelID, ""),
+ )
+ if err != nil {
+ return "", d.tryWrappingError(ctx, err)
+ }
+
+ if upsertErr := d.upsertThreadInfo(ctx, ch.ID, rootMessageID, parentChannelID); upsertErr != nil {
+ return "", upsertErr
+ }
+ return ch.ID, nil
+}
diff --git a/pkg/connector/usercache.go b/pkg/connector/usercache.go
new file mode 100644
index 0000000..046f42f
--- /dev/null
+++ b/pkg/connector/usercache.go
@@ -0,0 +1,214 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "errors"
+ "maps"
+ "net/http"
+ "slices"
+ "sync"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+// NOTE: Not simply using `exsync.Map` because we want the lock to be held
+// during HTTP requests.
+
+type UserCache struct {
+ session *discordgo.Session
+ cache map[string]*discordgo.User
+ lock sync.Mutex
+}
+
+func NewUserCache(session *discordgo.Session) *UserCache {
+ return &UserCache{
+ session: session,
+ cache: make(map[string]*discordgo.User),
+ }
+}
+
+func (uc *UserCache) UpdateWithReady(ready *discordgo.Ready) {
+ if ready == nil {
+ return
+ }
+
+ uc.lock.Lock()
+ defer uc.lock.Unlock()
+
+ self := ready.User
+ uc.cache[self.ID] = self
+
+ for _, user := range ready.Users {
+ uc.cache[user.ID] = user
+ }
+}
+
+// UpdateWithMessage updates the user cache with the users involved in a single
+// message (author, mentioned, mentioned author, etc.)
+//
+// The updated user IDs are returned.
+func (uc *UserCache) UpdateWithMessage(msg *discordgo.Message) []string {
+ if msg == nil {
+ return []string{}
+ }
+
+ // For now just forward to HandleMessages until a need for a specialized
+ // path makes itself known.
+ return uc.UpdateWithMessages([]*discordgo.Message{msg})
+}
+
+// UpdateWithMessages updates the user cache with the total set of users involved
+// with multiple messages (authors, mentioned users, mentioned authors, etc.)
+//
+// The updated user IDs are returned.
+func (uc *UserCache) UpdateWithMessages(msgs []*discordgo.Message) []string {
+ if len(msgs) == 0 {
+ return []string{}
+ }
+
+ collectedUsers := map[string]*discordgo.User{}
+ for _, msg := range msgs {
+ collectedUsers[msg.Author.ID] = msg.Author
+
+ referenced := msg.ReferencedMessage
+ if referenced != nil && referenced.Author != nil {
+ collectedUsers[referenced.Author.ID] = referenced.Author
+ }
+
+ for _, mentioned := range msg.Mentions {
+ collectedUsers[mentioned.ID] = mentioned
+ }
+
+ // Message snapshots lack `author` entirely and seemingly have an empty
+ // `mentions` array, even when the original message actually mentions
+ // someone.
+ }
+
+ uc.lock.Lock()
+ defer uc.lock.Unlock()
+
+ for _, user := range collectedUsers {
+ uc.cache[user.ID] = user
+ }
+
+ return slices.Collect(maps.Keys(collectedUsers))
+}
+
+func (uc *UserCache) UpdateWithUserUpdate(update *discordgo.UserUpdate) {
+ if update == nil || update.User == nil {
+ return
+ }
+
+ uc.lock.Lock()
+ defer uc.lock.Unlock()
+
+ uc.cache[update.ID] = update.User
+}
+
+// MergePartialUser merges the populated fields of a partial user (such as the
+// one embedded in a PRESENCE_UPDATE) into the cached full user, returning the
+// merged result. This is important to keep the cache coherent in the face of
+// partial updates.
+//
+// If there is no cached user to merge into, nil is returned.
+func (uc *UserCache) MergePartialUser(partial *discordgo.User) *discordgo.User {
+ if partial == nil || partial.ID == "" {
+ return nil
+ }
+
+ uc.lock.Lock()
+ defer uc.lock.Unlock()
+
+ existing := uc.cache[partial.ID]
+ if existing == nil {
+ return nil
+ }
+
+ // Copy before we mutate, as the pointer is shared.
+ // TODO: This doesn't distinguish between an empty field and an absent one.
+ merged := *existing
+ if partial.Username != "" {
+ merged.Username = partial.Username
+ }
+ if partial.GlobalName != "" {
+ merged.GlobalName = partial.GlobalName
+ }
+ if partial.Discriminator != "" {
+ merged.Discriminator = partial.Discriminator
+ }
+ if partial.Avatar != "" {
+ merged.Avatar = partial.Avatar
+ }
+
+ uc.cache[partial.ID] = &merged
+ return &merged
+}
+
+// Resolve looks up a user in the cache, requesting the user from the Discord
+// HTTP API if not present.
+//
+// If the user cannot be found, then its nonexistence is cached. This is to
+// avoid excessive requests when e.g. backfilling messages from a user that has
+// since been deleted since connecting. If some other error occurs, the cache
+// isn't touched and nil is returned.
+//
+// Otherwise, the cache is updated as you'd expect.
+func (uc *UserCache) Resolve(ctx context.Context, userID string) *discordgo.User {
+ if userID == discordid.DeletedGuildUserID {
+ return &discordid.DeletedGuildUser
+ }
+
+ // Hopefully this isn't too contentious?
+ uc.lock.Lock()
+ defer uc.lock.Unlock()
+
+ cachedUser, present := uc.cache[userID]
+ if cachedUser != nil {
+ return cachedUser
+ } else if present {
+ // If a `nil` is present in the map, then we already know that the user
+ // doesn't exist.
+ return nil
+ }
+
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "resolve user").
+ Str("user_id", userID).Logger()
+
+ log.Trace().Msg("Fetching user")
+ user, err := uc.session.User(userID)
+
+ var restError *discordgo.RESTError
+ if errors.As(err, &restError) && restError.Response.StatusCode == http.StatusNotFound {
+ log.Info().Msg("Tried to resolve a user that doesn't exist, caching nonexistence")
+ uc.cache[userID] = nil
+
+ return nil
+ } else if err != nil {
+ log.Err(err).Msg("Failed to resolve user")
+ return nil
+ }
+
+ uc.cache[userID] = user
+
+ return user
+}
diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go
new file mode 100644
index 0000000..a5096ad
--- /dev/null
+++ b/pkg/connector/userinfo.go
@@ -0,0 +1,118 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/ptr"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/bridgev2/status"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+)
+
+func readableRelationshipType(rel discordgo.RelationshipType) (desc string) {
+ desc = "unknown"
+
+ switch rel {
+ case discordgo.RelationshipBlocked:
+ desc = "blocked"
+ case discordgo.RelationshipFriend:
+ desc = "friend"
+ case discordgo.RelationshipIncomingFriendRequest:
+ desc = "recipient wants to be friends"
+ case discordgo.RelationshipOutgoingFriendRequest:
+ desc = "sender wants to be friends"
+ }
+
+ return
+}
+
+// makeRemoteName computes an appropriate value for the RemoteName field on
+// [bridgev2.UserLogin].
+func makeRemoteName(u *discordgo.User) string {
+ return u.String()
+}
+
+// makeRemoteProfile creates a [status.makeRemoteProfile] from a
+// [discordgo.User]. A [bridgev2.Ghost] may optionally be passed to provide an
+// avatar.
+func makeRemoteProfile(u *discordgo.User, ghost *bridgev2.Ghost) (p status.RemoteProfile) {
+ p.Phone = u.Phone
+ p.Email = u.Email
+ p.Username = u.String()
+ p.Name = u.GlobalName
+ if ghost != nil {
+ p.Avatar = ghost.AvatarMXC
+ }
+ return
+}
+
+func (d *DiscordClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
+ // We define `UserID`s and `UserLoginID`s to be interchangeable, i.e. they map
+ // directly to Discord user IDs ("snowflakes"), so we can perform a direct comparison.
+ return userID == discordid.UserLoginIDToUserID(d.UserLogin.ID)
+}
+
+func (d *DiscordClient) makeUserAvatar(u *discordgo.User) *bridgev2.Avatar {
+ url := u.AvatarURL("256")
+
+ return &bridgev2.Avatar{
+ ID: discordid.MakeAvatarID(url),
+ Get: func(ctx context.Context) ([]byte, error) {
+ return httpGet(ctx, d.httpClient, url, "user avatar")
+ },
+ }
+}
+
+func (d *DiscordClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
+ if d.Session == nil {
+ return nil, bridgev2.ErrNotLoggedIn
+ }
+
+ log := zerolog.Ctx(ctx)
+
+ if ghost.ID == "" {
+ log.Warn().Msg("Tried to get user info for ghost with no ID")
+ return nil, nil
+ }
+
+ discordUserID := discordid.ParseUserID(ghost.ID)
+ discordUser := d.userCache.Resolve(ctx, discordUserID)
+ if discordUser == nil {
+ log.Error().Str("discord_user_id", discordUserID).
+ Msg("Failed to resolve user")
+ return nil, nil
+ }
+
+ return d.getUserInfo(ctx, discordUser), nil
+}
+
+func (d *DiscordClient) getUserInfo(ctx context.Context, user *discordgo.User) *bridgev2.UserInfo {
+ return &bridgev2.UserInfo{
+ // FIXME clear this for webhooks (stash in ghost metadata)
+ Identifiers: []string{fmt.Sprintf("discord:%s", user.String())},
+ Name: ptr.Ptr(user.DisplayName()),
+ Avatar: d.makeUserAvatar(user),
+ IsBot: &user.Bot,
+ }
+}
diff --git a/pkg/connector/vitals.go b/pkg/connector/vitals.go
new file mode 100644
index 0000000..394828b
--- /dev/null
+++ b/pkg/connector/vitals.go
@@ -0,0 +1,211 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package connector
+
+import (
+ "encoding/json"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+)
+
+// vitals summarizes account-level health and safety signals for the logged-in
+// Discord user, as observed at the time it was built.
+type vitals struct {
+ // Quarantined reports whether the user's account has been placed under
+ // "Limited Access". Accounts in this state cannot send friend requests,
+ // join new guilds, nor initiate new direct messages.
+ //
+ // For more information, see Discord's support article on the topic:
+ // https://support.discord.com/hc/en-us/articles/6461420677527-Limited-Access-FAQ
+ Quarantined bool `json:"quarantined"`
+ // A RequiredAction is some interactive user flow that must be completed
+ // before the user can continue using their Discord account.
+ RequiredAction discordgo.RequiredAction `json:"required_action,omitempty"`
+ // HasUnreadSystemMessages reports whether the user has unread messages from
+ // Discord's official system account.
+ //
+ // First-party clients clear this flag once the user opens that system DM.
+ HasUnreadSystemMessages bool `json:"has_unread_system_messages"`
+
+ // FlaggedAsSpammer reports whether Discord has flagged the account as a
+ // spammer.
+ //
+ // Other users see a flagged account's messages as collapsed by default.
+ // However, other flagged spammers see them normally.
+ FlaggedAsSpammer bool `json:"flagged_as_spammer"`
+
+ // VerifiedEmail reports whether the user has verified their account's
+ // email.
+ //
+ // If this is false, [discordgo.ErrCodeActionRequiredVerifiedAccount]
+ // errors are likely.
+ VerifiedEmail bool `json:"verified_email"`
+ // HasPhone reports whether a verified phone number is associated with the
+ // user account.
+ HasPhone bool `json:"has_phone"`
+
+ Safety *vitalsSafety `json:"safety,omitempty"`
+}
+
+type vitalsSafety struct {
+ // Standing quantifies a user account's standing with Discord.
+ //
+ // A value of 100 is "all good".
+ Standing discordgo.AccountStanding `json:"standing"`
+ // AffectedBySpamClassification reports whether an active spam
+ // classification currently applies to the account.
+ //
+ // This does not include classifications that were caused by guild
+ // membership nor ownership.
+ AffectedBySpamClassification bool `json:"affected_by_spam_classification"`
+}
+
+// newVitals returns the vitals derived from the session s and information
+// fetched from the Discord safety hub.
+//
+// If hub is nil, [vitals.Safety] will also be nil.
+func newVitals(
+ s *discordgo.Session,
+ hub *discordgo.SafetyHub,
+) (v vitals) {
+ if s == nil || s.State.User == nil {
+ return
+ }
+ s.State.RLock()
+ defer s.State.RUnlock()
+ user := s.State.User
+ flags := user.Flags
+
+ v.Quarantined = flags&discordgo.UserFlagQuarantined != 0
+ // This can change on the fly via USER_REQUIRED_ACTION_UPDATE from the
+ // gateway.
+ v.RequiredAction = s.State.RequiredAction
+ v.HasUnreadSystemMessages = flags&discordgo.UserFlagHasUnreadUrgentMessages != 0
+ v.FlaggedAsSpammer = flags&discordgo.UserFlagSpammer != 0
+ v.VerifiedEmail = user.Verified
+ v.HasPhone = user.Phone != ""
+
+ if hub != nil {
+ v.Safety = &vitalsSafety{}
+ v.Safety.Standing = hub.AccountStanding.State
+ v.Safety.AffectedBySpamClassification = affectedBySpamClassification(hub)
+ }
+
+ return
+}
+
+// RequiresUserIntervention reports whether the account is currently in a state
+// that the bridge cannot resolve on its own, and which blocks normal operation
+// until the user personally resolves the condition.
+//
+// Persistent flags and states that don't _necessarily_ hamper bridge operation
+// are not considered by this function.
+func (v *vitals) RequiresUserIntervention() bool {
+ if v == nil {
+ return false
+ }
+
+ if v.RequiredAction != "" {
+ return true
+ }
+
+ if v.HasUnreadSystemMessages {
+ // We can technically resolve this on behalf of the user via PATCH
+ // /users/@me (and this is what first-party clients ultimately do), but
+ // it's probably best to have them use the official app for now.
+ return true
+ }
+
+ return false
+}
+
+// Unimpeded reports whether user intervention is not currently required _and_
+// Discord has not applied any long-standing flags or states to the account
+// that could potentially hamper bridge operation and account reputation.
+func (v *vitals) Unimpeded() bool {
+ if v.RequiresUserIntervention() {
+ return false
+ }
+ if v.FlaggedAsSpammer {
+ return false
+ }
+ if v.Safety != nil {
+ if v.Safety.Standing != discordgo.StandingAllGood || v.Safety.AffectedBySpamClassification {
+ return false
+ }
+ }
+ return true
+}
+
+func (v *vitals) infoMap() (map[string]any, error) {
+ // This is a bit gross but guarantees that the in-memory map representation
+ // remain consistent with the JSON one.
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+
+ var m map[string]any
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func (v *vitals) logContext(c zerolog.Context) zerolog.Context {
+ c = c.Bool("vitals_quarantined", v.Quarantined).
+ Bool("vitals_has_unread_system_messages", v.HasUnreadSystemMessages).
+ Bool("vitals_flagged_as_spammer", v.FlaggedAsSpammer).
+ Bool("vitals_verified_email", v.VerifiedEmail).
+ Bool("vitals_has_phone", v.HasPhone)
+ if v.RequiredAction != "" {
+ c = c.Str("vitals_required_action", string(v.RequiredAction))
+ }
+ if v.Safety != nil {
+ c = c.Int("vitals_safety_standing", int(v.Safety.Standing)).
+ Bool("vitals_safety_affected_by_spam_classification", v.Safety.AffectedBySpamClassification)
+ }
+ c = c.Bool("vitals_unimpeded", v.Unimpeded()).
+ Bool("vitals_requires_user_intervention", v.RequiresUserIntervention())
+ return c
+}
+
+func affectedBySpamClassification(hub *discordgo.SafetyHub) bool {
+ now := time.Now()
+ cs := slices.Concat(hub.Classifications, hub.GuildClassifications)
+ for _, c := range cs {
+ if c.MaxExpirationTime != nil && now.After(*c.MaxExpirationTime) {
+ // Classification has expired.
+ continue
+ }
+ if c.GuildMetadata != nil {
+ // Disregard classifications caused by guild membership or
+ // ownership.
+ continue
+ }
+ if !(c.IsSpam || strings.ToLower(strings.TrimSpace(c.Description)) == "spam") {
+ // Classification is not due to spam.
+ continue
+ }
+ return true
+ }
+ return false
+}
diff --git a/pkg/connector/vitals.md b/pkg/connector/vitals.md
new file mode 100644
index 0000000..c15bfe8
--- /dev/null
+++ b/pkg/connector/vitals.md
@@ -0,0 +1,332 @@
+# Vitals
+
+Each user login on the bridge (i.e. each `DiscordClient`) collates a number of
+account-level health and safety signals into an (effectively) immutable
+structure named `vitals`.
+
+The main goals of this abstraction are to (eventually):
+
+- Aid in tracing the cause of rare failures; vitals are logged every time they
+ are evaluated.
+- Have an easily serializable bag of data that can be used to provide a rough
+ measure of how "at risk" the user account is.
+- Automatically inform bridge operation by helping determine which API/Gateway
+ actions are likely to fail. (This is not yet implemented.)
+- Define simple logic around whether "user intervention" is required (which
+ prompts the bridge to halt all outgoing activity and enter `BAD_CREDENTIALS`
+ in order to protect the user's account and be a good API citizen).
+
+## Signals
+
+The following is an exhaustive enumeration of the fields that are present on the
+`vitals` struct, which is reported under the `info.vitals` field of the mautrix
+bridge state. For example:
+
+```json
+"info": {
+ // (... other, arbitrary data ...)
+ "vitals": {
+ "flagged_as_spammer": false,
+ "has_phone": false,
+ "has_unread_system_messages": false,
+ "quarantined": false,
+ "required_action": "AGREEMENTS",
+ "safety": {
+ "affected_by_spam_classification": false,
+ "standing": 100
+ },
+ "verified_email": true
+ }
+}
+```
+
+(Because `required_action` is a "hard" signal, as described below, this vitals
+state implies that the bridge is in `BAD_CREDENTIALS`.)
+
+A `?` character following the type of a field indicates that the field may be
+entirely absent (i.e. not merely `null`) under the described circumstances.
+
+### "Inert" Signals
+
+Inert signals are passive attributes that may explain certain failures.
+
+#### `verified_email` boolean
+
+- Presence/nullability: Always present
+- Origin: user object's `verified` field
+- Live updating: Unknown (however, correctly incorporated if it were to be sent
+ via `USER_UPDATE`)
+- Effect on bridge: Likely to cause backfill failures (TODO: should eventually
+ pause backfill queues when this happens)
+
+User accounts that lack a verified email are very likely to receive API code
+error 40002 ("You need to verify your account in order to perform this action").
+
+Note that first-party clients strongly encourage you to verify your email in
+many UI surfaces immediately after registration.
+
+#### `has_phone` boolean
+
+- Presence/nullability: Always present
+- Origin: presence of non-empty string on the user object's `phone` field
+- Live updating: Unknown (however, correctly incorporated if it were to be sent
+ via `USER_UPDATE`)
+- Effect on bridge: None
+
+Denotes whether the user has a phone number associated with their account that
+has been verified (via e.g. SMS).
+
+### "Soft" Signals
+
+Signals explicitly applied by Discord that may or may not impede bridge
+operation. These are reported because they would be significant in the event of
+bridge malfunction, and useful for reconstructing the cause of a failure.
+
+#### `quarantined` boolean
+
+- Presence/nullability: Always present
+- Origin: bit `1 << 44` (`QUARANTINED`) on the user object's `flags` bit field
+- Live updating: Unknown (however, correctly incorporated if it were to be sent
+ via `USER_UPDATE`)
+- Effect on bridge: None (TODO: should eventually affect bridge operation)
+
+Discord's automated heuristics may decide to place arbitrary restrictions on
+users' accounts, "quarantining" them. This places limitations on the following
+operations (this list is likely incomplete):
+
+- Sending outgoing friend requests
+- Sending direct messages to friends added _while_ quarantined
+- Joining new guilds ("servers")
+- Creating private channels (starting new direct messages)
+
+Direct messages may be sent as usual to friends who were added before the
+`QUARANTINED` flag was applied.
+
+For more information, see:
+
+- ["Limited Access FAQ" - Discord Support](https://support.discord.com/hc/en-us/articles/6461420677527-Limited-Access-FAQ)
+
+#### `flagged_as_spammer` boolean
+
+- Presence/nullability: Always present
+- Origin: bit `1 << 20` (`SPAMMER`) on the user object's `flags` bit field
+- Live updating: Unknown (however, correctly incorporated if it were to be sent
+ via `USER_UPDATE`)
+- Effect on bridge: None
+
+Other users see messages from a `SPAMMER`-flagged account as collapsed by
+default. However, other `SPAMMER`-flagged accounts see them normally.
+
+This flag can be applied automatically and heuristically, even to user accounts
+with verified phone numbers (where `has_phone` is `true`).
+
+#### `safety` object?
+
+This is a sub-object directly under `vitals`.
+
+It is completely absent when the bridge configuration's
+`report_scrubbed_account_standing` field is `false` (which is the default
+value). Otherwise, the object itself is present once a Safety Hub fetch has
+succeeded.
+
+Note that "scrubbed" merely refers to the omission of any personally
+identifiable information in the following fields; Safety Hub classification data
+inherently encapsulates sensitive data as it replicates any offending content
+for the user to see (in first-party clients; the bridge's data modeling does not
+make an effort to deserialize the sensitive fields).
+
+##### `safety.standing` integer
+
+- Presence/nullability: Always present with parent
+- Origin: Safety Hub (GET `/api/v…/safety-hub/@me`)
+- Live updating: Yes (based on best-effort, unverified heuristics)
+- Effect on bridge: None (TODO: should eventually affect bridge operation)
+
+A number that quantifies the user account's standing with Discord as visible in
+the "Safety Hub". The currently known values are:
+
+| Value | User facing description in first-party clients | Note |
+| ----- | ---------------------------------------------- | ------------- |
+| 100 | "Your account is all good" | Natural state |
+| 200 | "Your account is limited" |
+| 300 | "Your account is very limited" |
+| 400 | "Your account is at risk" |
+| 500 | "Your account is suspended" |
+
+##### `safety.affected_by_spam_classification` boolean
+
+- Presence/nullability: Always present with parent
+- Origin: Safety Hub (GET `/api/v…/safety-hub/@me`)
+- Live updating: Yes (based on best-effort, unverified heuristics)
+- Effect on bridge: None
+
+Denotes whether an active spam classification currently applies to the user
+account. This does not include classifications that were caused by guild
+membership or ownership.
+
+> TODO: The bridge needs to poke its vitals once a classification's expiration
+> time is reached.
+
+### "Hard" Signals
+
+All "hard" signals _immediately_ prevent further usage of the bridge. When a
+"hard" signal is detected, the bridge is immediately put into `BAD_CREDENTIALS`
+and most outgoing requests to Discord fail with
+[`ErrNotLoggedIn`][not-logged-in] (see [Bridge State](#bridge-state)). This
+condition is also internally referred to as "requiring user intervention"
+(`RequiresUserIntervention`).
+
+[not-logged-in]:
+ https://github.com/mautrix/go/blob/f6531777f56c4a8276b65c1439e991b860c1ecb9/bridgev2/errors.go#L35
+
+The bridge tries its best to continue bridging _incoming_ events, especially
+since it is important that the urgent Discord system message be made visible to
+the user as soon as possible. However, all outgoing operations such as message
+sending, editing, deletion, reactions, etc. will fail.
+
+Whether user intervention is currently required is not directly reported in the
+bridge state's `info`, but is logged at runtime. A `BAD_CREDENTIALS` state can
+be used to infer if intervention is needed.
+
+#### `required_action` string?
+
+- Presence/nullability: Entirely absent when not applicable
+- Origin: `required_action` field on the `READY` payload received from the
+ Gateway
+- Live updating: Yes (via `USER_REQUIRED_ACTION_UPDATE`)
+- Effect on bridge: **Requires user intervention**
+
+A required action is applied to a user account when an interactive security or
+safety flow must be completed before the account may be further used.
+
+The currently known values are:
+
+| `required_action` | Bridge state error code | Resolution process in a first-party client |
+| ---------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------- |
+| `AGREEMENTS` | `dc-require-agreements` | Read and review potential terms of service and/or policy updates |
+| `REQUIRE_CAPTCHA` (legacy; not expected in practice) | (unmapped) | N/A |
+| `REQUIRE_VERIFIED_EMAIL` | `dc-require-verified-email` | Add a verified email |
+| `REQUIRE_VERIFIED_PHONE` | `dc-require-verified-phone` | Add a verified phone number |
+| `REQUIRE_REVERIFIED_EMAIL` | `dc-require-reverified-email` | Reaffirm ownership of existing email |
+| `REQUIRE_REVERIFIED_PHONE` | `dc-require-reverified-phone` | Reaffirm ownership of existing phone number |
+| `REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE` | `dc-require-verified-email-or-verified-phone` | Add a verified phone number or email |
+| `REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE` | `dc-require-reverified-email-or-verified-phone` | Reaffirm ownership of existing email, or add a verified phone number |
+| `REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE` | `dc-require-verified-email-or-reverified-phone` | Add a verified email, or reaffirm ownership of existing phone number |
+| `REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE` | `dc-require-reverified-email-or-reverified-phone` | Reaffirm ownership of existing email or phone number |
+| `REQUIRE_SAFETY_FLOWS` | `dc-require-safety-flows` | Proceed through server-driven safety flow UI (age verification, etc.) |
+
+(Note that the `AGREEMENTS` value lacks `REQUIRE_` despite containing it in the
+error code.)
+
+#### `has_unread_system_messages` boolean
+
+- Presence/nullability: Always present
+- Origin: bit `1 << 13` (`HAS_UNREAD_URGENT_MESSAGES`) on the user object's
+ `flags` bit field
+- Live updating: Yes (via `MESSAGE_CREATE`)
+- Effect on bridge: **Requires user intervention**
+
+Denotes that a user has unread "urgent" messages from Discord's official system
+account.
+
+These are currently known to be sent when the account standing has changed.
+
+##### Implementation
+
+As of 2026-07-16 it has been determined that the first-party client
+synchronously mutates the in-memory (Flux) user object to incorporate this flag
+when an "urgent" message is received:
+
+```js
+function Q(e) {
+ let {message: t} = e;
+ if ((L(t, !0), null != t.flags && r.Lt(t.flags, A.pr7.URGENT))) {
+ let e = T[m.default.getId()];
+ return (
+ null != e &&
+ ((T[m.default.getId()] = e.set(
+ "flags",
+ r.lA(e.flags, A.nhx.HAS_UNREAD_URGENT_MESSAGES, !0),
+ )),
+ !0)
+ );
+ }
+ return !1;
+}
+```
+
+This behavior is replicated in our synchronous state handling path. We wait for
+the user to use a first-party client to read their system messages, which leads
+to this flag being removed and a resulting `USER_UPDATE` event on the Gateway.
+
+The bridge could theoretically remove the flag itself, but we have opted not to
+do this at this time as it is likely to lead to an in-app CAPTCHA challenge that
+is most easily resolved in Discord's client.
+
+## Bridge State
+
+On the usual bridge state update path (i.e. the one that is responsible for
+reporting `CONNECTED`), vitals are always consulted. If user intervention is
+required, the bridge enters the `BAD_CREDENTIALS` state and most (if not all)
+outgoing operations that make requests to Discord will fail with
+[`ErrNotLoggedIn`][not-logged-in]. The reported `UserAction` is always
+[`UserActionOpenNative`][open-native].
+
+[open-native]:
+ https://github.com/mautrix/go/blob/f6531777f56c4a8276b65c1439e991b860c1ecb9/bridgev2/status/bridgestate.go#L80
+
+Barring any bugs, the bridge should automatically respond to any resolution
+performed by the user in the first-party client, according to the "live
+updating" field specified on each signal.
+
+### Error Codes
+
+A bridge state may only report a single error at a time, so unread system
+messages currently take priority over any required action. Whether this
+corresponds to the actual behavior in first-party clients is currently
+unverified.
+
+The error code for `has_unread_system_messages` being `true` is
+`dc-unread-system-messages`. Each required action has a corresponding error code
+that is described in the [`required_action`](#required_action-string) of this
+document.
+
+## Lifecycle
+
+Vitals are "poked" (reevaluated) on the following events:
+
+- Gateway `READY` (successfully connected with a fresh state snapshot).
+- When the user's object is updated via Gateway `USER_UPDATE`, such as the
+ `flags` bit field.
+ - Critically, this handles the `HAS_UNREAD_URGENT_MESSAGES` flag being removed
+ from another client.
+- When an urgent system message from Discord is received.
+- When the user's required action changes (`USER_REQUIRED_ACTION_UPDATE`).
+
+As part of the reevaluation, the last fetched Safety Hub information (if
+present) is incorporated into the vitals and the heuristics that follow.
+
+After reevaluation:
+
+- A bridge state is unconditionally
+ [sent](https://github.com/mautrix/go/blob/f6531777f56c4a8276b65c1439e991b860c1ecb9/bridgev2/bridgestate.go#L293)
+ to mautrix.
+ - Instead of trying to be clever about when a new bridge state is truly
+ needed, we implicitly rely on mautrix's deduplication logic to avoid
+ excessive updates.
+- If needed, a "full sync" is kicked off in the background (say, if the bridge
+ started off with bad vitals and never had a chance to perform a full sync).
+
+### Safety Hub
+
+Safety Hub information is fetched on the following events when the bridge
+configuration allows it, before vitals are poked:
+
+- Asynchronously when an urgent system message is received.
+ - When your account standing changes for whatever reason, Discord sends a
+ system message that is flagged as urgent.
+- Gateway `READY`.
+- Gateway `RESUMED`.
+
+The Gateway seemingly does not dedicate an event to Safety Hub information
+changing, so the bridge must fetch it opportunistically.
diff --git a/pkg/discordauth/NOTES.md b/pkg/discordauth/NOTES.md
new file mode 100644
index 0000000..f23c8e3
--- /dev/null
+++ b/pkg/discordauth/NOTES.md
@@ -0,0 +1,51 @@
+## `POST /api/v9/auth/login`
+
+### request
+
+...
+
+### response
+
+#### new login location
+
+HTTP 400
+
+```json
+{
+ "message": "Invalid Form Body",
+ "code": 50035,
+ "errors": {
+ "login": {
+ "_errors": [
+ {
+ "code": "ACCOUNT_LOGIN_VERIFICATION_EMAIL",
+ "message": "New login location detected, please check your e-mail."
+ }
+ ]
+ }
+ }
+}
+```
+
+## `POST /api/v9/auth/authorize-ip`
+
+### request
+
+```json
+{
+ "token": "..."
+}
+```
+
+### response
+
+#### when link has expired
+
+```json
+{
+ "message": "Invalid authentication token",
+ "code": 50014
+}
+```
+
+- UI prompts the user to log in again to get another link
diff --git a/pkg/discordauth/SEQUENCE_DIAGRAM.md b/pkg/discordauth/SEQUENCE_DIAGRAM.md
new file mode 100644
index 0000000..ecab025
--- /dev/null
+++ b/pkg/discordauth/SEQUENCE_DIAGRAM.md
@@ -0,0 +1,81 @@
+```mermaid
+sequenceDiagram
+ actor User
+ participant Bridge
+ participant Discord
+
+ note over User: Login preemption flows:
+
+ rect rgb(254 246 181 / 50%)
+ note over User,Discord: This flow may occur spontaneously, as a response to ANY request, even those containing a CAPTCHA solution, as well as OUTSIDE OF LOGIN FLOWS. As Discord can reply to a CAPTCHA solution with another CAPTCHA challenge, an implementation will likely require a loop.
In other words: ANY HTTP arrow going from Discord to Bridge may suddenly enter this flow without prior warning.
+ Discord->>Bridge: HTTP 400, CAPTCHA challenge (regardless of the would-be outcome)
+ alt Challenge is invisible
+ Bridge->>Bridge: ???
+ note right of Bridge: How this is handled is currently unknown.
+ else Challenge isn't invisible (majority of cases)
+ Bridge->>User: Modally present CAPTCHA challenge
+ end
+ User->>Bridge: CAPTCHA solution
+ Bridge->>Discord: Retry request with the same body, incorporating CAPTCHA solution in headers
+ end
+
+ rect rgb(181 244 254 / 50%)
+ note over User,Discord: When attempting to log in from a "new location" (IP address unfamiliar to Discord), the following occurs for a login that would otherwise complete successfully (returning a user token and ID, among other data):
+ Discord->>Bridge: HTTP 400, error code 50035, "Invalid Form Body"
+ note right of Bridge: The form error code sent by Discord is "ACCOUNT_LOGIN_VERIFICATION_EMAIL". The message is "New login location detected, please check your e-mail."
+ Bridge->>User: Fail the entire log in flow. The user must authorize the IP address first, then attempt the log in again. As with ordinary login attempts, MFA and or CAPTCHAs may be involved.
+ User->>Discord: Visits the email-provided log in link. After a redirect, the page performs POST /auth/authorize-ip with an opaque token.
+ Discord->>Discord: The IP address is now allowed to log in to the user's account.
+ end
+
+ rect rgb(200 210 255 / 50%)
+ note over User,Discord: If the user's Discord account is suspended, a would-be successful login attempt instead yields a "suspended user token."
+ Discord->>Bridge: HTTP 403, user ID and "suspended user token"
+ end
+
+ note over User: Login flows:
+
+ alt
+ note over User,Discord: Log in with email or phone number, and password (Creds)
+ User->>Bridge: Specifies an email or phone number as well as a password
+ Bridge->>Discord: POST /auth/login
+
+ alt User does not have MFA set up (LoginCompleted)
+ note over Bridge: ("New location" preemption flow is possible. When skipped, the following occurs:)
+ Discord->>Bridge: User token, ID, locale, and theme settings
+ Bridge->>Bridge: Save token and log in
+ else User has MFA set up and it is required for log in
+ Discord->>Bridge: HTTP 200, which MFA methods the user has set up, and an opaque "ticket" (LoginMFARequired)
+ Bridge->>+User: Modally ask the user which MFA method to use
+ activate User
+ activate User
+
+ alt Chosen MFA method: SMS
+ User->>-Bridge: I would like to proceed with SMS-based MFA
+ Bridge->>Discord: POST /auth/mfa/sms/send with the "ticket" from earlier (SMSSendRequest)
+ Discord->>User: Sends a short numeric code to the user via SMS
+ note over User: "Your Discord verification code is: 123456"
+ User->>Bridge: Provides the received code
+ Bridge->>Discord: POST /auth/mfa/sms with the code and the "ticket" (MFAContinuation)
+ else Chosen MFA method: TOTP
+ User->>-Bridge: I would like to proceed with TOTP-based MFA, providing the TOTP code
+ Bridge->>Discord: POST /auth/mfa/totp with the code and the "ticket" (MFAContinuation)
+ else Chosen MFA method: TOTP backup code
+ User->>-Bridge: I would like to proceed with a TOTP backup code, providing it
+ Bridge->>Discord: PSOT /auth/mfa/backup with the code and the "ticket" (MFAContinuation)
+ end
+ end
+ note over Bridge: After making a successful request to /auth/mfa/… with any MFA type, the login either succeeds or is preempted due to login location (IP address) or user suspension.
+ else
+ note over User,Discord: Log in by scanning QR code with Discord mobile app
+ User->>Bridge: I want to log in with a QR code
+ Bridge->>Discord: Connect to "remoteauth" gateway (WebSocket)
+ Discord->>Bridge: …
+ Bridge->>User: Present QR code, wait for scan
+ note right of User: The remainder of this flow is omitted for now.
+ else
+ note over User,Discord: Log in via WebAuthn (passkey, security key)
+
+ note right of User: This flow is omitted for now.
+ end
+```
diff --git a/pkg/discordauth/captcha.go b/pkg/discordauth/captcha.go
new file mode 100644
index 0000000..2842770
--- /dev/null
+++ b/pkg/discordauth/captcha.go
@@ -0,0 +1,115 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+
+ "github.com/rs/zerolog"
+)
+
+const HeaderCaptchaKey = "x-captcha-key"
+const HeaderCaptchaSessionID = "x-captcha-session-id"
+const HeaderCaptchaRqToken = "x-captcha-rqtoken"
+
+type CaptchaService string
+
+const (
+ CaptchaServiceHCaptcha CaptchaService = "hcaptcha"
+ CaptchaServiceReCaptcha CaptchaService = "recaptcha"
+ CaptchaServiceReCaptchaEnterprise CaptchaService = "recaptcha_enterprise"
+)
+
+// HCaptcha holds the information specific to hCaptcha within a [Captcha]
+// challenge. This is only separated for organizational purposes.
+type HCaptcha struct {
+ SiteKey *string `json:"captcha_sitekey"`
+ SessionID *string `json:"captcha_session_id"` // re-sent in `x-captcha-session-id`
+ RqData *string `json:"captcha_rqdata"`
+ RqToken *string `json:"captcha_rqtoken"` // re-sent in `x-captcha-rqtoken`
+}
+
+func (hc *HCaptcha) SpotCheck() bool {
+ return hc.SiteKey != nil && *hc.SiteKey != "" &&
+ hc.SessionID != nil && *hc.SessionID != "" &&
+ hc.RqData != nil && *hc.RqData != "" &&
+ hc.RqToken != nil && *hc.RqToken != ""
+}
+
+func (hc *HCaptcha) UpdateHeaders(header *http.Header) {
+ header.Del(HeaderCaptchaSessionID)
+ header.Del(HeaderCaptchaRqToken)
+
+ if hc.SessionID != nil {
+ header.Set(HeaderCaptchaSessionID, *hc.SessionID)
+ }
+ if hc.RqToken != nil {
+ header.Set(HeaderCaptchaRqToken, *hc.RqToken)
+ }
+}
+
+// A CAPTCHA challenge from Discord.
+//
+// This may be returned from any endpoint at any time. To test for the presence
+// of a captcha challenge, test the following criteria:
+//
+// 1. The HTTP status of the response is 400.
+//
+// 2. The captcha_key field is present on the root object of the response body
+// when parsed as JSON.
+type Captcha struct {
+ HCaptcha
+ Key []string `json:"captcha_key"`
+ Service CaptchaService `json:"captcha_service"`
+ Invisible bool `json:"should_serve_invisible"`
+ UserFlow *string `json:"user_flow"` // Unknown.
+}
+
+func (c *Captcha) LogContext(ctx zerolog.Context) zerolog.Context {
+ return ctx.
+ Str("captcha_service", string(c.Service)).
+ Strs("captcha_key", c.Key).
+ Bool("captcha_invisible", c.Invisible)
+}
+
+// CheckCaptcha tries to detect a CAPTCHA challenge in an HTTP response from
+// Discord, returning a [Captcha] when one is found.
+func CheckCaptcha(ctx context.Context, resp *http.Response, body []byte) *Captcha {
+ if resp.StatusCode != 400 {
+ return nil
+ }
+
+ log := zerolog.Ctx(ctx)
+
+ var challenge Captcha
+
+ err := json.Unmarshal(body, &challenge)
+ if err != nil {
+ // We should only hit this if the JSON is malformed or something, which
+ // is probably worth knowing about.
+ log.Warn().Err(err).Msg("Failed to unmarshal potential captcha challenge")
+ return nil
+ }
+
+ if len(challenge.Key) > 0 {
+ return &challenge
+ }
+
+ return nil
+}
diff --git a/pkg/discordauth/context.go b/pkg/discordauth/context.go
new file mode 100644
index 0000000..116240b
--- /dev/null
+++ b/pkg/discordauth/context.go
@@ -0,0 +1,58 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+)
+
+// EncodeBasicContextProperties creates a value for [HeaderContextProperties]
+// when only the "location" key is needed. This is unlikely to suffice for most
+// location types.
+func EncodeBasicContextProperties(location ContextLocation) (string, error) {
+ encoded, err := json.Marshal(map[string]string{"location": string(location)})
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal basic context properties: %w", err)
+ }
+
+ return base64.StdEncoding.EncodeToString(encoded), nil
+}
+
+type ContextLocation string
+
+// This is not a comprehensive listing.
+const (
+ ContextLocationLogin ContextLocation = "Login"
+ ContextLocationRegister ContextLocation = "Register"
+ ContextLocationInvite ContextLocation = "Accept Invite Page"
+ ContextLocationVerify ContextLocation = "Verify Email"
+ ContextLocationDisableEmailNotifications ContextLocation = "Disable Email Notifications"
+ ContextLocationDisableServerHighlightNotifications ContextLocation = "Disable Server Highlight Notifications"
+ ContextLocationAuthorizeIp ContextLocation = "Authorize Ip"
+ ContextLocationRejectIp ContextLocation = "Reject Ip"
+ ContextLocationRejectMfa ContextLocation = "Reject MFA"
+ ContextLocationReport ContextLocation = "Report Illegal Content"
+ ContextLocationReportSecondLook ContextLocation = "Report Second Look"
+ ContextLocationAuthorizePayment ContextLocation = "Authorize Payment"
+ ContextLocationReset ContextLocation = "Reset"
+ ContextLocationAccountRevert ContextLocation = "Account Revert"
+ ContextLocationHandoff ContextLocation = "Handoff"
+ ContextLocationUnknown ContextLocation = "Unknown"
+ ContextLocationLanding ContextLocation = "Landing"
+)
diff --git a/pkg/discordauth/error.go b/pkg/discordauth/error.go
new file mode 100644
index 0000000..d8a2f27
--- /dev/null
+++ b/pkg/discordauth/error.go
@@ -0,0 +1,269 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// TODO(skip): Some overlap with this and discordgo. Sort that out.
+
+type APIError struct {
+ Message string `json:"message"`
+ Code ErrCode `json:"code"`
+
+ // Detailed errors. Returned for e.g. [InvalidFormBody].
+ //
+ // The keys in this map correspond to the top-level keys that were sent in
+ // your request body. The value reconstructs the shape of the data that was
+ // sent, with arbitrary depth.
+ //
+ // For example, a request body such as
+ //
+ // { "friends": [ { "enjoys_pineapple_on_pizza": false } ] }
+ //
+ // might result in this erroneous reply:
+ //
+ // {
+ // ...,
+ // "errors": {
+ // "friends": {
+ // "0": {
+ // "enjoys_pineapple_on_pizza": {
+ // "_errors": [
+ // {
+ // "code": "CHECK_YOUR_OPINION",
+ // "message": "Everybody likes pineapple on pizza. Try again."
+ // }
+ // ]
+ // }
+ // }
+ // }
+ // }
+ // }
+ //
+ // Notice how:
+ //
+ // - The intermediate values are always objects. Array indices are
+ // represented with strings.
+ //
+ // - The erroneous request value terminates in an object containing an
+ // array keyed under _errors.
+ //
+ // The _errors array further contains objects of shape { code, message }.
+ Errors map[string]json.RawMessage `json:"errors"`
+
+ // The raw HTTP response body.
+ ResponseBody []byte `json:"-"`
+}
+
+// A FormError communicates detailed error information for certain JSON field.
+type FormError struct {
+ Code FormErrorCode `json:"code"`
+ Message string `json:"message"`
+}
+
+type FormErrorCode string
+
+const (
+ // AccountLoginVerificationEmail is raised when the user is logging in from
+ // a new IP address and must check their email for a verification link.
+ AccountLoginVerificationEmail FormErrorCode = "ACCOUNT_LOGIN_VERIFICATION_EMAIL"
+
+ // AccountCompromisedResetPassword is raised when Discord is forcing the
+ // user to reset the password to their account.
+ AccountCompromisedResetPassword FormErrorCode = "ACCOUNT_COMPROMISED_RESET_PASSWORD"
+
+ // InvalidLogin is raised when the username/phone or password was
+ // incorrect.
+ InvalidLogin FormErrorCode = "INVALID_LOGIN"
+)
+
+// FormFieldErrors returns the [FormError] values associated with the given key
+// that had been sent in the request. If the key isn't present or the errors
+// array is empty for whatever reason, nil is returned.
+//
+// NOTE/TODO: This function does not currently support accessing fields beyond
+// the first level.
+func (err *APIError) FormFieldErrors(key string) ([]FormError, error) {
+ leafMsg, ok := err.Errors[key]
+ if !ok {
+ return nil, nil
+ }
+
+ type ErrorsLeaf struct {
+ Errors []FormError `json:"_errors"`
+ }
+
+ var leaf ErrorsLeaf
+ if err := json.Unmarshal(leafMsg, &leaf); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal errors leaf: %w", err)
+ }
+
+ return leaf.Errors, nil
+}
+
+// AnyFieldHasError reports whether any field has a given [FormErrorCode].
+// false is returned on error conditions.
+func (err *APIError) AnyFieldHasError(code FormErrorCode) bool {
+ for key := range err.Errors {
+ if err.FieldHasError(key, code) {
+ return true
+ }
+ }
+ return false
+}
+
+// FieldHasError reports whether a certain field has a given [FormErrorCode].
+// false is returned on error conditions.
+func (err *APIError) FieldHasError(key string, code FormErrorCode) bool {
+ errs, inspectionErr := err.FormFieldErrors(key)
+ if inspectionErr != nil {
+ return false
+ }
+ for _, e := range errs {
+ if e.Code == code {
+ return true
+ }
+ }
+ return false
+}
+
+var _ error = (*APIError)(nil)
+
+// IsUserInputError reports whether the error was ultimately due to user input
+// error and can be resolved by providing correct values.
+//
+// When this is true, it is appropriate to prompt the user for the same
+// value(s) again.
+func (err APIError) IsUserInputError() bool {
+ actuallyInvalidForm := err.Code == InvalidFormBody &&
+ // these errors use InvalidFormBody, but aren't actually user input
+ // errors
+ !err.RequiresEmailVerification() &&
+ !err.IsAccountCompromised()
+
+ return actuallyInvalidForm ||
+ // error codes that aren't InvalidFormBody but are user input errors
+ err.Code == MFAInvalidCode ||
+ err.Code == InvalidVerificationCode
+}
+
+// IsAccountCompromised reports whether the error was due to Discord
+// determining that the user's account has been compromised. This prevents
+// login; the user must reset their password with Discord.
+func (err APIError) IsAccountCompromised() bool {
+ // We have observed this field error code on the "login" field
+ // specifically, but check all fields just to be safe.
+ return err.Code == InvalidFormBody && err.AnyFieldHasError(AccountCompromisedResetPassword)
+}
+
+// RequiresEmailVerification reports whether the error is due to a correct
+// login from an unfamiliar IP address.
+//
+// An email is automatically sent to the user containing a link to verify the
+// IP. After this is done, the login can be retried.
+func (err APIError) RequiresEmailVerification() bool {
+ return err.Code == InvalidFormBody &&
+ err.FieldHasError("login", AccountLoginVerificationEmail)
+}
+
+// RequiresPhoneVerification reports whether the error is due to Discord
+// requiring phone number verification.
+func (err APIError) RequiresPhoneVerification() bool {
+ return err.Code == SMSAuthVerificationNeeded
+}
+
+// FieldErrorString returns a nicely formatted string that presents all of the
+// contained field errors in the following format:
+//
+// login: "New login location detected, please check your e-mail." (ACCOUNT_LOGIN_VERIFICATION_EMAIL)
+//
+// Multiple fields are separated with semicolons. Note that this does not
+// include the root error message nor code.
+func (err APIError) FieldErrorString() string {
+ fieldErrs := make([]string, 0)
+
+ for key := range err.Errors {
+ errs, inspectionErr := err.FormFieldErrors(key)
+ if inspectionErr != nil {
+ continue
+ }
+
+ summaries := make([]string, 0)
+ for _, err := range errs {
+ summaries = append(summaries, fmt.Sprintf("\"%s\" (%s)", err.Message, err.Code))
+ }
+
+ fieldErrs = append(fieldErrs, fmt.Sprintf("%s: %s", key, strings.Join(summaries, ", ")))
+ }
+
+ return strings.Join(fieldErrs, "; ")
+}
+
+func (err APIError) Error() string {
+ msg := fmt.Sprintf("Discord API error %d: \"%s\"", err.Code, err.Message)
+
+ if err.Code == InvalidFormBody && err.Errors != nil {
+ return msg + ": " + err.FieldErrorString()
+ }
+
+ return msg
+}
+
+type ErrCode int
+
+const (
+ RateLimited ErrCode = 31001
+ RateLimitedResource ErrCode = 31002
+
+ AccountScheduledForDeletion ErrCode = 20011
+ AccountDisabled ErrCode = 20013
+
+ Unauthorized ErrCode = 40001
+ AccountVerificationNeeded ErrCode = 40002
+ CloudflareBlocked ErrCode = 40333
+
+ InvalidAuthenticationToken ErrCode = 50014
+ InvalidFormBody ErrCode = 50035
+ InvalidVerificationCode ErrCode = 50037
+
+ MFAAlreadyEnrolled ErrCode = 60001
+ MFANotEnrolled ErrCode = 60002
+ MFARequired ErrCode = 60003
+ MustBeVerified ErrCode = 60004
+ MFAInvalidSecret ErrCode = 60005
+ MFAInvalidAuthTicket ErrCode = 60006
+ MFAInvalidCode ErrCode = 60008
+ MFAInvalidSession ErrCode = 60009
+ SMSAuthNotEnrolled ErrCode = 60010
+ InvalidKey ErrCode = 60011
+ SMSAuthCannotBeEnabled ErrCode = 60012
+ MFARequiredForShopListings ErrCode = 60015
+ MFAEmailIneligible ErrCode = 60019
+ CredentialUndiscoverableOrInvalid ErrCode = 60021
+
+ SMSAuthUnableToSendMessage ErrCode = 70003
+ SMSAuthPhoneNumberRecentlyUsedElsewhere ErrCode = 70004
+ SMSAuthPhoneNumberIsVoIPOrLandline ErrCode = 70005
+ SMSAuthVerificationNeeded ErrCode = 70007
+ SMSAuthPhoneNumberAlreadyUsedElsewhere ErrCode = 70008
+ PasswordResetLinkSentToEmail ErrCode = 70009
+ SMSAuthPhoneNumberCannotBeAssociated ErrCode = 70011
+)
diff --git a/pkg/discordauth/error_test.go b/pkg/discordauth/error_test.go
new file mode 100644
index 0000000..3ea2d36
--- /dev/null
+++ b/pkg/discordauth/error_test.go
@@ -0,0 +1,43 @@
+package discordauth
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestFormFieldErrors_AccountLoginVerificationEmail(t *testing.T) {
+ body := []byte(`{
+ "message": "Invalid Form Body",
+ "code": 50035,
+ "errors": {
+ "login": {
+ "_errors": [
+ {
+ "code": "ACCOUNT_LOGIN_VERIFICATION_EMAIL",
+ "message": "New login location detected, please check your e-mail."
+ }
+ ]
+ }
+ }
+ }`)
+
+ var apiErr APIError
+ if err := json.Unmarshal(body, &apiErr); err != nil {
+ t.Fatalf("failed to unmarshal: %v", err)
+ }
+
+ if apiErr.Code != InvalidFormBody {
+ t.Fatalf("expected code %d, got %d", InvalidFormBody, apiErr.Code)
+ }
+
+ errs, err := apiErr.FormFieldErrors("login")
+ if err != nil {
+ t.Fatalf("FormFieldErrors returned error: %v", err)
+ }
+ if len(errs) != 1 {
+ t.Fatalf("expected 1 error, got %d", len(errs))
+ }
+ if FormErrorCode(errs[0].Code) != AccountLoginVerificationEmail {
+ t.Fatalf("expected code %s, got %s", AccountLoginVerificationEmail, errs[0].Code)
+ }
+}
diff --git a/config/config.go b/pkg/discordauth/experiments.go
similarity index 62%
rename from config/config.go
rename to pkg/discordauth/experiments.go
index d704651..b42680e 100644
--- a/config/config.go
+++ b/pkg/discordauth/experiments.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
+// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
@@ -14,22 +14,24 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package config
+package discordauth
-import (
- "maunium.net/go/mautrix/bridge/bridgeconfig"
- "maunium.net/go/mautrix/id"
-)
+type Fingerprint string
-type Config struct {
- *bridgeconfig.BaseConfig `yaml:",inline"`
-
- Bridge BridgeConfig `yaml:"bridge"`
+func (f Fingerprint) HeaderValue() string {
+ return string(f)
}
-func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool {
- _, homeserver, _ := userID.Parse()
- _, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver]
-
- return hasSecret
+func (f Fingerprint) IsZero() bool {
+ return f == ""
+}
+
+type ExperimentsApex struct {
+ InstallationID string `json:"installation"`
+}
+
+type ExperimentsLegacy struct {
+ Fingerprint Fingerprint `json:"fingerprint"`
+ // `json:"assignments"`
+ // `json:"guild_experiments"`
}
diff --git a/pkg/discordauth/http.go b/pkg/discordauth/http.go
new file mode 100644
index 0000000..748fb90
--- /dev/null
+++ b/pkg/discordauth/http.go
@@ -0,0 +1,47 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "fmt"
+ "net/http"
+)
+
+type HTTP interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+func respIsOk(resp *http.Response) bool {
+ if resp == nil {
+ return false
+ }
+
+ return resp.StatusCode >= 200 && resp.StatusCode < 300
+}
+
+type HTTPError struct {
+ body []byte
+ resp *http.Response
+}
+
+func (err HTTPError) Error() string {
+ if err.body != nil && len(err.body) < 1_024*16 { // arbitrarily cap at 16 KiB
+ return fmt.Sprintf("Discord replied with HTTP %d: %s", err.resp.StatusCode, string(err.body))
+ }
+
+ return fmt.Sprintf("Discord replied with HTTP %d", err.resp.StatusCode)
+}
diff --git a/pkg/discordauth/login.go b/pkg/discordauth/login.go
new file mode 100644
index 0000000..7e60171
--- /dev/null
+++ b/pkg/discordauth/login.go
@@ -0,0 +1,55 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+// Creds are some credentials that you use to initiate a login to Discord.
+//
+// This isn't all that is needed to log in successfully; you may have to solve
+// a CAPTCHA, verify your login location, participate in MFA, etc.
+type Creds struct {
+ GiftCodeSKUID *string `json:"gift_code_sku_id"`
+ Login string `json:"login"`
+ LoginSource *string `json:"login_source"`
+ Password Sensitive[string] `json:"password"`
+ Undelete bool `json:"undelete"`
+}
+
+func NewCreds(emailOrPhone string, password string) *Creds {
+ return &Creds{
+ Login: emailOrPhone,
+ Password: NewSensitive(password),
+ Undelete: false,
+ }
+}
+
+// A LoginCompleted is returned from Discord when a log in flow terminates in
+// success. This includes the presence of any involved MFA flows.
+type LoginCompleted struct {
+ Token Sensitive[string] `json:"token"`
+ UserID string `json:"user_id"`
+ UserSettings UserSettings `json:"user_settings"`
+ RequiredActions []string `json:"required_actions"`
+}
+
+func (lc *LoginCompleted) HasToken() bool {
+ return !lc.Token.IsZero()
+}
+
+type UserSettings struct {
+ Locale string `json:"locale"`
+ Theme string `json:"theme"`
+}
diff --git a/pkg/discordauth/machine.go b/pkg/discordauth/machine.go
new file mode 100644
index 0000000..51787e8
--- /dev/null
+++ b/pkg/discordauth/machine.go
@@ -0,0 +1,408 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ "github.com/rs/zerolog"
+)
+
+// An AuthMachine is a resumable state machine that embodies the core logic to
+// authenticate a user account with Discord. It is concerned with:
+//
+// - Detecting CAPTCHA challenges, reporting them, and attaching the subsequent
+// solution to the retried request.
+// - Recognizing conditions such as IP verification and other required
+// actions.
+// - Accruing the state and fingerprints necessary to send the correct set of
+// headers with each HTTP request.
+//
+// The machine converses purely in terms of [Prompt] (what we need from the
+// user) and [Answer] (what the user provided); it does not presuppose how this
+// information is presented to the user.
+//
+// Construct one with [NewAuthMachine], call [AuthMachine.Prepare] once, then
+// call [AuthMachine.Advance] repeatedly until a [LoginCompleted] is yielded,
+// which signals completion.
+//
+// An AuthMachine is single-use and not safe for use by multiple goroutines.
+type AuthMachine struct {
+ log *zerolog.Logger
+ http HTTP
+ APIBase string
+
+ Fingerprint Fingerprint
+ InstallationID string
+ Personality *Personality
+
+ login string // phone number/email
+ pending *pendingRequest // primary operation; logically, the "position in the login flow"
+ interrupt *pendingRequest // op needed to resolve an interruption (phone verification)
+ lastPrompt *Prompt // last prompt that was shown to the user
+ mfa *LoginMFARequired // set upon entering MFA flow
+ finished bool
+}
+
+func (am *AuthMachine) currentOp() *pendingRequest {
+ if am.interrupt != nil {
+ return am.interrupt
+ }
+ return am.pending
+}
+
+func NewAuthMachine(
+ ctx context.Context,
+ http HTTP,
+ personality *Personality,
+) *AuthMachine {
+ log := zerolog.Ctx(ctx).With().
+ Str("component", "discord auth").
+ Logger()
+
+ return &AuthMachine{
+ log: &log,
+ http: http,
+ APIBase: "https://discord.com/api/v9",
+
+ Personality: personality,
+ }
+}
+
+type CredsPrompt struct {
+ Reason string
+}
+type MFACodePrompt struct {
+ Type AuthenticatorType
+}
+type MFAChallengePrompt struct {
+ *LoginMFARequired
+ Reason string
+}
+type PhoneVerifyPrompt struct {
+ Phone string // the phone number that the code was sent to
+ Retrying bool // whether the previous attempt failed and we're asking again
+}
+
+// A Prompt is a request for user input or interaction; the [AuthMachine] is
+// suspended until the user's response is fed back to [AuthMachine.Advance] as
+// an [Answer]. Exactly one field is set (non-nil or non-zero).
+//
+// Most prompts are ordinary steps of the login flow, but Captcha,
+// PhoneVerifyPrompt, and EmailVerify are interruptions: they preempt whatever
+// operation was in flight, and answering them ends up retrying it instead of
+// starting a new one.
+type Prompt struct {
+ // CredsPrompt is non-nil when we need to prompt for the user's email/phone
+ // number.
+ CredsPrompt *CredsPrompt
+ // Captcha is non-nil when a CAPTCHA challenge must be solved before
+ // proceeding.
+ Captcha *Captcha
+ // MFAChallengePrompt is non-nil when the initial credentials were
+ // accepted, but MFA is required to log in.
+ //
+ // Use the contained information to have the user select an authenticator
+ // type to log in with (TOTP, backup code, or SMS).
+ MFAChallengePrompt *MFAChallengePrompt
+ // MFACodePrompt is non-nil when the selected authenticator type has been
+ // noted and the state machine is ready to accept the MFA code (TOTP,
+ // backup code, or SMS code).
+ MFACodePrompt *MFACodePrompt
+ // PhoneVerifyPrompt is non-nil when a code was sent to the phone number
+ // associated with the Discord account via SMS.
+ //
+ // The code is needed to verify our current IP address.
+ PhoneVerifyPrompt *PhoneVerifyPrompt
+ // EmailVerify is true if an email requesting IP verification has been sent
+ // to the email associated with the Discord account.
+ EmailVerify bool
+}
+
+// A CaptchaSolution contains a solution to a [Captcha].
+type CaptchaSolution struct {
+ Solution string
+}
+
+// An Answer responds to the last [Prompt] returned from
+// [AuthMachine.Advance]. Populate only the field corresponding to the field
+// that was set on the prompt:
+//
+// - [Prompt.CredsPrompt]: [Answer.Creds]
+// - [Prompt.MFAChallengePrompt]: [Answer.PickedMFAType]
+// - [Prompt.MFACodePrompt]: [Answer.MFAContinue]
+// - [Prompt.EmailVerify]: none (an empty Answer resumes the flow)
+// - [Prompt.PhoneVerifyPrompt]: [Answer.SMSCode]
+// - [Prompt.Captcha]: [Answer.Solution]
+type Answer struct {
+ Creds *Creds
+ PickedMFAType *AuthenticatorType
+ MFAContinue *MFAContinue
+ SMSCode string
+ Solution *CaptchaSolution
+}
+
+// Advance consumes an [Answer] to the previously returned [Prompt] (nil on
+// the first call) and drives authentication forward, performing whatever
+// Discord API requests are needed.
+//
+// When the error is nil, exactly one of the other return values is non-nil:
+// the next [Prompt] to present, or a [LoginCompleted]. Once a [LoginCompleted]
+// has been returned, the state machine has completed and further calls to
+// Advance will error.
+//
+// An error does not advance the machine; it remains positioned at the last
+// prompt.
+func (am *AuthMachine) Advance(ctx context.Context, answer *Answer) (*Prompt, *LoginCompleted, error) {
+ log := zerolog.Ctx(ctx).With().
+ Str("action", "advance discord auth state machine").
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ if am.finished {
+ return nil, nil, fmt.Errorf("cannot advance finished auth machine")
+ }
+
+ prompt, completed, err := am.advance(ctx, answer)
+ if prompt != nil {
+ // Remembering which prompt was presented last lets us know what to
+ // expect out of the next [Answer] (see [AuthMachine.advance]).
+ am.lastPrompt = prompt
+ }
+ if completed != nil {
+ am.finished = true
+ }
+ return prompt, completed, err
+}
+
+func (am *AuthMachine) advance(ctx context.Context, answer *Answer) (*Prompt, *LoginCompleted, error) {
+ log := zerolog.Ctx(ctx)
+
+ lastPrompt := am.lastPrompt
+ if lastPrompt == nil {
+ // Initial state; prompt for email/phone number and password.
+ return &Prompt{CredsPrompt: &CredsPrompt{}}, nil, nil
+ }
+
+ // The cases in the following switch explicitly check for API contract
+ // violations (specifically, nilness of the corresponding answer field) to
+ // help clients catch errors, especially since race conditions are likely
+ // possible.
+ var captchaSolution *CaptchaSolution
+ switch {
+ case lastPrompt.CredsPrompt != nil:
+ // The user submitted their email/phone number and password.
+ if answer.Creds == nil {
+ return nil, nil, fmt.Errorf("expected credentials in answer")
+ }
+ am.login = answer.Creds.Login
+ am.pending = loginOp(answer.Creds)
+ case lastPrompt.MFAChallengePrompt != nil:
+ // The user picked the MFA method they would like to proceed with.
+ if answer.PickedMFAType == nil {
+ return nil, nil, fmt.Errorf("expected picked mfa type in answer")
+ }
+ picked := *answer.PickedMFAType
+ log.Info().Str("mfa_type", string(picked)).Msg("Continuing with MFA flow")
+
+ if picked == AuthenticatorSMS {
+ am.pending = sendMFASMSOp(&am.mfa.MFAState)
+ // Fall into the pump to perform the request.
+ break
+ }
+ return &Prompt{MFACodePrompt: &MFACodePrompt{
+ Type: picked,
+ }}, nil, nil
+ case lastPrompt.MFACodePrompt != nil:
+ // After having picked the authenticator type, the user inputted their
+ // MFA code (the TOTP, backup code, or SMS code).
+ if answer.MFAContinue == nil {
+ return nil, nil, fmt.Errorf("expected mfa continue in answer")
+ }
+ am.pending = continueMFAOp(answer.MFAContinue, am.mfa)
+ case lastPrompt.EmailVerify:
+ // The user has authorized our IP address. Retry the last request.
+ case lastPrompt.PhoneVerifyPrompt != nil:
+ // Our IP address needs to be verified via phone number. The user has
+ // inputted the received SMS code.
+ if answer.SMSCode == "" {
+ return nil, nil, fmt.Errorf("expected sms code in answer")
+ }
+ am.interrupt = verifyPhoneNumberOp(VerifyPhoneNumberRequest{
+ Phone: lastPrompt.PhoneVerifyPrompt.Phone,
+ Code: answer.SMSCode,
+ })
+ case lastPrompt.Captcha != nil:
+ // The user solved the CAPTCHA challenge. Retry the last request.
+ if answer.Solution == nil {
+ return nil, nil, fmt.Errorf("expected captcha solution in answer")
+ }
+ captchaSolution = answer.Solution
+ default:
+ return nil, nil, fmt.Errorf("cannot advance from unhandled prompt")
+ }
+
+ return am.pump(ctx, captchaSolution)
+}
+
+// pump drives the machine's operations forward until one of them yields a
+// [Prompt] for the user, the login is completed, or an error occurs.
+func (am *AuthMachine) pump(
+ ctx context.Context,
+ solution *CaptchaSolution,
+) (*Prompt, *LoginCompleted, error) {
+ needSolution := am.lastPrompt != nil && am.lastPrompt.Captcha != nil
+ if needSolution && solution == nil {
+ return nil, nil, fmt.Errorf("cannot proceed without a captcha solution")
+ }
+
+ for {
+ op := am.currentOp()
+
+ log := zerolog.Ctx(ctx).With().
+ Str("machine_op_name", op.name).
+ Logger()
+ opCtx := log.WithContext(ctx)
+
+ // Build the request data.
+ req, err := op.request(opCtx, am)
+ if err != nil {
+ return nil, nil, fmt.Errorf("constructing %s request: %w", op.name, err)
+ }
+ if needSolution {
+ // Apply the CAPTCHA solution to the request.
+ req.Header.Set(HeaderCaptchaKey, solution.Solution)
+ am.lastPrompt.Captcha.UpdateHeaders(&req.Header)
+ // Only apply the solution to the first request in this pump of the
+ // state machine; don't smear it across all requests. (Another
+ // CAPTCHA will terminate the current pump.)
+ needSolution = false
+ }
+
+ // Send the request to Discord.
+ body, err := am.exchange(opCtx, req)
+ var capErr *CaptchaError
+ var apiErr APIError
+ // Here is where we perform uniform error handling for all
+ // [pendingRequest]s. We presume that a CAPTCHA challenge may be presented
+ // in lieu of a response to _any_ request.
+ switch {
+ case errors.As(err, &capErr):
+ return &Prompt{Captcha: capErr.Captcha}, nil, nil
+ case errors.As(err, &apiErr):
+ if apiErr.RequiresEmailVerification() {
+ return &Prompt{EmailVerify: true}, nil, nil
+ }
+ if apiErr.RequiresPhoneVerification() {
+ // This presumes that the "code sent to phone via SMS, then IP
+ // authorization" flow can only be triggered by logging in with
+ // a phone number instead of an email.
+ return &Prompt{PhoneVerifyPrompt: &PhoneVerifyPrompt{
+ Phone: am.login,
+ }}, nil, nil
+ }
+
+ // Give the current [pendingRequest] a chance to handle the error.
+ if op.fail != nil {
+ prompt, err := op.fail(opCtx, am, apiErr)
+ if prompt != nil {
+ zerolog.Ctx(opCtx).Info().
+ Int("discord_error_code", int(apiErr.Code)).
+ Str("discord_error_message", apiErr.Message).
+ Str("discord_field_errors", apiErr.FieldErrorString()).
+ Msg("Discord API error was converted into a prompt")
+ }
+ return prompt, nil, err
+ }
+ return nil, nil, apiErr
+ case err != nil:
+ return nil, nil, fmt.Errorf("making %s request: %w", op.name, err)
+ }
+
+ prompt, done, err := op.succeed(opCtx, am, body)
+ if err != nil || prompt != nil || done != nil {
+ return prompt, done, err
+ }
+ // If we're here, then the op we just finished wants to run more ops.
+ // Verify that something changed pending or interrupt.
+ nextOp := am.currentOp()
+ if nextOp == op {
+ // Don't infinitely loop on the same op.
+ return nil, nil, fmt.Errorf("%s operation did not change the op", op.name)
+ }
+ }
+}
+
+// handleAuthResponse tries to consume a response body from /auth/login or
+// /auth/mfa/ as a successful login, handling any encountered MFA
+// challenge.
+func (am *AuthMachine) handleAuthResponse(
+ ctx context.Context,
+ body []byte,
+) (*Prompt, *LoginCompleted, error) {
+ log := zerolog.Ctx(ctx)
+
+ var completed LoginCompleted
+ err := json.Unmarshal(body, &completed)
+ if err != nil {
+ err = fmt.Errorf("unmarshaling login response: %w", err)
+ return nil, nil, err
+ }
+
+ if !completed.HasToken() {
+ log.Debug().Msg("Response lacked a token, attempting to handle as MFA")
+
+ am.mfa = &LoginMFARequired{}
+ err = json.Unmarshal(body, &am.mfa)
+ if err != nil {
+ err = fmt.Errorf("unmarshaling mfa required response: %w", err)
+ return nil, nil, err
+ }
+ if !am.mfa.MFARequired {
+ // Discord responded with something else that we don't know
+ // about yet.
+ err = fmt.Errorf("unknown login response")
+ return nil, nil, err
+ }
+
+ log := log.With().
+ Str("mfa_login_instance_id", am.mfa.LoginInstanceID).
+ Bool("mfa_accepting_backup_codes", am.mfa.BackupCodesAccepted).
+ Bool("mfa_sms_enabled", am.mfa.SMSEnabled).
+ Bool("mfa_totp_enabled", am.mfa.TOTPEnabled).
+ Bool("mfa_has_webauthn_credential", am.mfa.WebAuthnCredential != nil).
+ Logger()
+
+ log.Info().Msg("Need to log in with MFA")
+
+ return &Prompt{MFAChallengePrompt: &MFAChallengePrompt{
+ LoginMFARequired: am.mfa,
+ }}, nil, nil
+ }
+
+ if am.mfa != nil && completed.UserID == "" {
+ log.Debug().Msg("Fixing up login completion with the user ID from the MFA challenge")
+ completed.UserID = am.mfa.UserID
+ }
+ log.Info().
+ Str("user_locale", completed.UserSettings.Locale).
+ Msg("Logged in successfully")
+ return nil, &completed, nil
+}
diff --git a/pkg/discordauth/machine_experiments.go b/pkg/discordauth/machine_experiments.go
new file mode 100644
index 0000000..8784e3a
--- /dev/null
+++ b/pkg/discordauth/machine_experiments.go
@@ -0,0 +1,129 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+func basicFetch[T any](
+ ctx context.Context,
+ am *AuthMachine,
+ route string,
+ what string,
+ model *T,
+ augmentReq func(*http.Request) error,
+) error {
+ url := am.APIBase + route
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return fmt.Errorf("constructing %s request: %w", what, err)
+ }
+
+ if augmentReq != nil {
+ if err := augmentReq(req); err != nil {
+ return fmt.Errorf("augmenting basic %s request: %w", what, err)
+ }
+ }
+
+ // TODO: Since this augments the request, verify the exact headers sent in
+ // experiment fetch requests to ensure they match.
+ body, err := am.exchange(ctx, req)
+ if err != nil {
+ return fmt.Errorf("fetching %s: %w", what, err)
+ }
+
+ if model != nil {
+ err = json.Unmarshal(body, model)
+ if err != nil {
+ return fmt.Errorf("unmarshaling %s: %w", what, err)
+ }
+ }
+ return nil
+}
+
+func (am *AuthMachine) legacyExperiments(ctx context.Context) (*ExperimentsLegacy, error) {
+ exps := ExperimentsLegacy{}
+ err := basicFetch(ctx, am,
+ "/experiments?with_guild_experiments=true",
+ "legacy experiments",
+ &exps,
+ func(req *http.Request) error {
+ // Set X-Context-Properties. This is only relevant for this endpoint.
+ contextProps, err := EncodeBasicContextProperties(ContextLocationLogin)
+ if err != nil {
+ return fmt.Errorf("encoding login context properties: %w", err)
+ }
+ req.Header.Set(HeaderContextProperties, contextProps)
+ return nil
+ },
+ )
+ if err != nil {
+ return nil, err
+ }
+ return &exps, nil
+}
+
+func (am *AuthMachine) apexExperiments(ctx context.Context) (*ExperimentsApex, error) {
+ exps := ExperimentsApex{}
+ if err := basicFetch(ctx, am,
+ "/apex/experiments?surface=2",
+ "apex experiments",
+ &exps,
+ nil,
+ ); err != nil {
+ return nil, err
+ }
+ return &exps, nil
+}
+
+// Prepare loads the login page and situates the AuthMachine with an
+// experiments-related [Fingerprint]. It is important for Prepare to be called
+// before the machine consumes credentials.
+func (am *AuthMachine) Prepare(ctx context.Context) error {
+ log := am.log.With().Str("action", "prepare discord auth machine").Logger()
+ ctx = log.WithContext(ctx)
+
+ if !am.Fingerprint.IsZero() {
+ log.Debug().Msg("Already prepared")
+ return nil
+ }
+
+ log.Info().Msg("Preparing Discord auth")
+
+ legacy, err := am.legacyExperiments(ctx)
+ if err != nil {
+ return fmt.Errorf("fetching legacy experiments: %w", err)
+ }
+
+ apex, err := am.apexExperiments(ctx)
+ if err != nil {
+ return fmt.Errorf("fetching apex experiments: %w", err)
+ }
+
+ am.InstallationID = apex.InstallationID
+ // (Apex experiments aren't fetched with the fingerprint, so only set it
+ // now.)
+ if !legacy.Fingerprint.IsZero() {
+ am.Fingerprint = legacy.Fingerprint
+ }
+
+ return nil
+}
diff --git a/pkg/discordauth/machine_http.go b/pkg/discordauth/machine_http.go
new file mode 100644
index 0000000..d5de525
--- /dev/null
+++ b/pkg/discordauth/machine_http.go
@@ -0,0 +1,150 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "maps"
+ "net/http"
+
+ "github.com/rs/zerolog"
+)
+
+// decorateReq modifies the given [http.Request] according to the current
+// [Personality] and accrued fingerprints and state.
+func (am *AuthMachine) decorateReq(req *http.Request) error {
+ // Add all personality headers to the request.
+ personalityHeaders, err := am.Personality.Headers()
+ if err != nil {
+ return fmt.Errorf("getting personality headers: %w", err)
+ }
+ maps.Copy(req.Header, personalityHeaders)
+
+ debugOptions := am.Personality.DebugOptions
+ if debugOptions != "" {
+ req.Header.Set(HeaderDebugOptions, debugOptions)
+ }
+ if am.InstallationID != "" {
+ req.Header.Set(HeaderInstallationID, am.InstallationID)
+ }
+ if !am.Fingerprint.IsZero() {
+ req.Header.Set(HeaderFingerprint, am.Fingerprint.HeaderValue())
+ }
+ return nil
+}
+
+// do performs an HTTP request, mutating it to contain headers from the
+// [Personality] and all other relevant state.
+func (am *AuthMachine) do(
+ ctx context.Context,
+ req *http.Request,
+) (*http.Response, error) {
+ if err := am.decorateReq(req); err != nil {
+ return nil, fmt.Errorf("decorating request: %w", err)
+ }
+
+ resp, err := am.http.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("making http request: %w", err)
+ }
+
+ return resp, err
+}
+
+// A CaptchaError reports that Discord preempted a request with a CAPTCHA
+// challenge. Callers that can converse with the user may recover the challenge
+// and retry the request with a solution.
+type CaptchaError struct {
+ Captcha *Captcha
+}
+
+func (e *CaptchaError) Error() string {
+ return "discord presented a captcha challenge"
+}
+
+// exchange performs a single HTTP request against Discord that is mutated to
+// contain headers from the [Personality] and all other relevant state accrued
+// so far.
+//
+// The response body is consumed in its entirety.
+//
+// CAPTCHA challenges are recognized and returned as [CaptchaError]s. Other
+// Discord API errors are returned as [APIError]s, or [HTTPError]s when
+// unrecognized.
+func (am *AuthMachine) exchange(ctx context.Context, req *http.Request) ([]byte, error) {
+ // TODO: Retry on transient network failures?
+ resp, err := am.do(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading response body: %w", err)
+ }
+ if err := resp.Body.Close(); err != nil {
+ log := zerolog.Ctx(ctx)
+ log.Warn().Err(err).Msg("Failed to close response body, proceeding")
+ }
+
+ if cap := CheckCaptcha(ctx, resp, body); cap != nil {
+ return body, &CaptchaError{Captcha: cap}
+ }
+ if !respIsOk(resp) {
+ var apiErr APIError
+ if err := json.Unmarshal(body, &apiErr); err != nil || apiErr.Code == 0 {
+ // We got an error but couldn't unmarshal it into an APIError;
+ // perhaps some Cloudflare/load balancer thing. Return a
+ // generic error.
+ return body, HTTPError{body: body, resp: resp}
+ }
+ apiErr.ResponseBody = body
+ return body, apiErr
+ }
+ return body, nil
+}
+
+// post constructs an [http.Request] that POSTs a JSON-marshaled body.
+func (am *AuthMachine) post(
+ ctx context.Context,
+ endpoint string,
+ jsonBody any,
+) (*http.Request, error) {
+ jsonBytes, err := json.Marshal(jsonBody)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling post body: %w", err)
+ }
+
+ url := am.APIBase + endpoint
+
+ log := zerolog.Ctx(ctx).With().
+ Str("http_url", url).
+ Logger()
+ ctx = log.WithContext(ctx)
+
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes))
+ if err != nil {
+ return nil, fmt.Errorf("constructing post request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ return req, nil
+}
diff --git a/pkg/discordauth/machine_op.go b/pkg/discordauth/machine_op.go
new file mode 100644
index 0000000..da40ae5
--- /dev/null
+++ b/pkg/discordauth/machine_op.go
@@ -0,0 +1,214 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/rs/zerolog"
+)
+
+// pendingRequest is an intent to perform some discrete, effectful HTTP
+// operation that the [AuthMachine] can attempt, derived directly from some
+// user input.
+//
+// Fundamentally, a pendingRequest holds enough information to construct an
+// HTTP request, interpret a successful response, and potentially translate API
+// errors into the next [Prompt].
+//
+// pendingRequests are essential to letting [AuthMachine] properly suspend
+// around challenges and preemptions such as CAPTCHA and IP verification flows,
+// where the same request needs to be retried verbatim at some indeterminate
+// point in the future.
+type pendingRequest struct {
+ name string
+
+ // request builds a fresh HTTP request for this operation.
+ //
+ // [AuthMachine.pump] may call request more than once for the same
+ // pendingRequest, such as after CAPTCHA or email verification. It must not
+ // return a request whose body has already been consumed.
+ request func(context.Context, *AuthMachine) (*http.Request, error)
+
+ // succeed interprets a successful HTTP response body and advances the
+ // authentication flow.
+ //
+ // The flow may be advanced by doing one of:
+ //
+ // - Returning exactly one of a non-nil [Prompt], a [LoginCompleted], or
+ // an error; the other two results are nil.
+ // - Mutating [AuthMachine.interrupt] and returning nil for all results.
+ // This is done when more HTTP requests need to be made.
+ succeed func(context.Context, *AuthMachine, []byte) (*Prompt, *LoginCompleted, error)
+
+ // fail, when non-nil, defines bespoke Discord API error handling for this
+ // operation.
+ //
+ // Generic API errors, CAPTCHA challenges, email verification, and other
+ // higher-level concerns are already handled by [AuthMachine.pump]. fail is
+ // for cases where this specific pendingRequest knows that an API error
+ // should become another prompt, e.g. "invalid credentials, try again".
+ //
+ // By returning a non-nil [Prompt], the user can be sent back to
+ // a previous step.
+ //
+ // Any error returned from this function is propagated out of the state
+ // machine.
+ fail func(context.Context, *AuthMachine, APIError) (*Prompt, error)
+}
+
+func loginOp(creds *Creds) *pendingRequest {
+ return &pendingRequest{
+ name: "login",
+ request: func(ctx context.Context, am *AuthMachine) (*http.Request, error) {
+ // Prepare was not called?
+ if am.Fingerprint.IsZero() {
+ return nil, fmt.Errorf("cannot consume credentials without a fingerprint")
+ }
+ return am.post(ctx, "/auth/login", creds)
+ },
+ succeed: func(ctx context.Context, am *AuthMachine, body []byte) (*Prompt, *LoginCompleted, error) {
+ return am.handleAuthResponse(ctx, body)
+ },
+ fail: func(ctx context.Context, am *AuthMachine, err APIError) (*Prompt, error) {
+ if err.IsUserInputError() {
+ return &Prompt{CredsPrompt: &CredsPrompt{Reason: "Invalid email/phone number or password."}}, nil
+ }
+ return nil, err
+ },
+ }
+}
+
+// authorizeIPAddressOp is used to authorize our IP address for login using a
+// token that was sent to the user's email or phone number (via SMS).
+func authorizeIPAddressOp(token string) *pendingRequest {
+ return &pendingRequest{
+ name: "authorize_ip_address",
+ request: func(ctx context.Context, am *AuthMachine) (*http.Request, error) {
+ return am.post(ctx, "/auth/authorize-ip", struct {
+ Token string `json:"token"`
+ }{
+ Token: token,
+ })
+ },
+ succeed: func(ctx context.Context, am *AuthMachine, body []byte) (*Prompt, *LoginCompleted, error) {
+ // Now that we've verified our IP address, we can replay the login
+ // request.
+ am.interrupt = nil
+ return nil, nil, nil
+ },
+ fail: func(ctx context.Context, am *AuthMachine, err APIError) (*Prompt, error) {
+ // This is currently rare/impossible in practice because we
+ // immediately consume the IP authorization token after receiving
+ // it, but it could be useful in the future.
+ if err.Code == InvalidAuthenticationToken {
+ zerolog.Ctx(ctx).Warn().Msg("IP authorization token was invalid")
+ // Have the user login from scratch so we can receive a fresh
+ // authorization token.
+ am.interrupt = nil
+ return &Prompt{CredsPrompt: &CredsPrompt{}}, nil
+ }
+ return nil, err
+ },
+ }
+}
+
+type VerifyPhoneNumberRequest struct {
+ Phone string `json:"phone"` // the phone number that received the code
+ Code string `json:"code"` // the code that was received
+}
+type VerifyPhoneNumberResponse struct {
+ Token string `json:"token"` // token that can be used to verify IP
+}
+
+func verifyPhoneNumberOp(req VerifyPhoneNumberRequest) *pendingRequest {
+ return &pendingRequest{
+ name: "verify_phone_number",
+ request: func(ctx context.Context, am *AuthMachine) (*http.Request, error) {
+ return am.post(ctx, "/phone-verifications/verify", req)
+ },
+ succeed: func(ctx context.Context, am *AuthMachine, body []byte) (*Prompt, *LoginCompleted, error) {
+ var r VerifyPhoneNumberResponse
+ if err := json.Unmarshal(body, &r); err != nil {
+ return nil, nil, fmt.Errorf("unmarshaling verify phone response: %w", err)
+ }
+ if r.Token == "" {
+ // TODO: find a safe, privacy-preserving way to log the
+ // response body in case the schema changes?
+ return nil, nil, fmt.Errorf("no token received after verifying phone")
+ }
+ // Verify our IP address with the code we just received.
+ am.interrupt = authorizeIPAddressOp(r.Token)
+ return nil, nil, nil
+ },
+ fail: func(ctx context.Context, am *AuthMachine, err APIError) (*Prompt, error) {
+ if err.IsUserInputError() {
+ return &Prompt{PhoneVerifyPrompt: &PhoneVerifyPrompt{
+ Phone: am.login,
+ Retrying: true,
+ }}, nil
+ }
+ return nil, err
+ },
+ }
+}
+
+func sendMFASMSOp(mfaState *MFAState) *pendingRequest {
+ return &pendingRequest{
+ name: "send_sms",
+ request: func(ctx context.Context, am *AuthMachine) (*http.Request, error) {
+ return am.post(ctx, "/auth/mfa/sms/send", SMSSendRequest{
+ Ticket: mfaState.Ticket,
+ })
+ },
+ succeed: func(ctx context.Context, am *AuthMachine, body []byte) (*Prompt, *LoginCompleted, error) {
+ log := zerolog.Ctx(ctx)
+ log.Info().Msg("Sent MFA code to SMS")
+ return &Prompt{MFACodePrompt: &MFACodePrompt{Type: AuthenticatorSMS}}, nil, nil
+ },
+ }
+}
+
+func continueMFAOp(cont *MFAContinue, challenge *LoginMFARequired) *pendingRequest {
+ return &pendingRequest{
+ name: "continue_mfa",
+ request: func(ctx context.Context, am *AuthMachine) (*http.Request, error) {
+ return am.post(ctx, fmt.Sprintf("/auth/mfa/%s", cont.Type), cont.MFAContinuation)
+ },
+ succeed: func(ctx context.Context, am *AuthMachine, body []byte) (*Prompt, *LoginCompleted, error) {
+ // /auth/mfa/, upon success, responds with a body similar to
+ // that returned by /auth/login.
+ prompt, completed, err := am.handleAuthResponse(ctx, body)
+ if err != nil {
+ return nil, nil, err
+ }
+ return prompt, completed, nil
+ },
+ fail: func(ctx context.Context, am *AuthMachine, err APIError) (*Prompt, error) {
+ if err.IsUserInputError() {
+ return &Prompt{MFAChallengePrompt: &MFAChallengePrompt{
+ LoginMFARequired: challenge,
+ Reason: err.Error(),
+ }}, nil
+ }
+ return nil, err
+ },
+ }
+}
diff --git a/pkg/discordauth/machine_test.go b/pkg/discordauth/machine_test.go
new file mode 100644
index 0000000..1eb49a3
--- /dev/null
+++ b/pkg/discordauth/machine_test.go
@@ -0,0 +1,402 @@
+package discordauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+type testHTTPClient func(req *http.Request) (*http.Response, error)
+
+func (thc testHTTPClient) Do(req *http.Request) (*http.Response, error) {
+ return thc(req)
+}
+
+func newTestPersonality() *Personality {
+ return &Personality{
+ UserAgent: "test-agent",
+ Locale: "en-US",
+ TimeZone: "UTC",
+ DebugOptions: DefaultDebugOptions,
+ SuperProperties: SuperProperties{
+ OS: "Windows",
+ Browser: "Chrome",
+ BrowserUserAgent: "test-agent",
+ BrowserVersion: "1.0.0.0",
+ OSVersion: "10",
+ ReleaseChannel: "stable",
+ ClientBuildNumber: 1,
+ ClientLaunchID: "launch-id",
+ ClientAppState: "focused",
+ },
+ }
+}
+
+func newResponse(status int, body string) *http.Response {
+ return &http.Response{
+ StatusCode: status,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+}
+
+func TestDoAddsDebugOptionsHeader(t *testing.T) {
+ var gotHeader http.Header
+ client := testHTTPClient(func(req *http.Request) (*http.Response, error) {
+ gotHeader = req.Header.Clone()
+ return newResponse(http.StatusOK, `{"ok":true}`), nil
+ })
+
+ am := NewAuthMachine(context.Background(), client, newTestPersonality())
+ req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com/test", nil)
+ if err != nil {
+ t.Fatalf("failed to create request: %v", err)
+ }
+
+ resp, err := am.do(context.Background(), req)
+ if err != nil {
+ t.Fatalf("do returned error: %v", err)
+ }
+ if err := resp.Body.Close(); err != nil {
+ t.Fatalf("failed to close response body: %v", err)
+ }
+ if gotHeader.Get(HeaderDebugOptions) != "bugReporterEnabled" {
+ t.Fatalf("expected %s header to be set, got %q", HeaderDebugOptions, gotHeader.Get(HeaderDebugOptions))
+ }
+}
+
+const testCaptchaBody = `{` +
+ `"captcha_key":["captcha-required"],` +
+ `"captcha_service":"hcaptcha",` +
+ `"captcha_sitekey":"sk",` +
+ `"captcha_session_id":"sess",` +
+ `"captcha_rqdata":"rqd",` +
+ `"captcha_rqtoken":"rqt"` +
+ `}`
+
+const (
+ testLoginSuccessBody = `{"token":"test-token","user_id":"1234","user_settings":{"locale":"en-US"}}`
+ testPhoneVerificationNeededBody = `{"code":70007,"message":""}`
+ testInvalidFormBody = `{"code":50035,"message":""}`
+
+ testIPVerificationToken = "foobar123"
+ testVerifiedPhone = `{"token":"` + testIPVerificationToken + `"}`
+)
+
+// Verbatim response body observed in production (PLAT-37962).
+const testAccountCompromisedBody = `{` +
+ `"code":50035,` +
+ `"errors":{"login":{"_errors":[{` +
+ `"code":"ACCOUNT_COMPROMISED_RESET_PASSWORD",` +
+ `"message":"Please reset your password to log in."}]}},` +
+ `"message":"Invalid Form Body"` +
+ `}`
+
+// advanceToCaptchaPrompt drives the machine from its initial state through
+// credential submission, at which point the (canned) HTTP client is expected to
+// answer with a CAPTCHA challenge.
+func advanceToCaptchaPrompt(t *testing.T, am *AuthMachine) *Prompt {
+ t.Helper()
+ ctx := context.Background()
+
+ prompt, done, err := am.Advance(ctx, nil)
+ if err != nil {
+ t.Fatalf("initial advance errored: %v", err)
+ }
+ if done != nil {
+ t.Fatalf("unexpected completion on initial advance")
+ }
+ if prompt == nil || prompt.CredsPrompt == nil {
+ t.Fatalf("expected a credentials prompt, got %+v", prompt)
+ }
+
+ prompt, done, err = am.Advance(ctx, &Answer{Creds: NewCreds("user@example.com", "hunter2")})
+ if err != nil {
+ t.Fatalf("advance with credentials errored: %v", err)
+ }
+ if done != nil {
+ t.Fatalf("unexpected completion when a CAPTCHA was expected")
+ }
+ if prompt == nil || prompt.Captcha == nil {
+ t.Fatalf("expected a CAPTCHA prompt, got %+v", prompt)
+ }
+ return prompt
+}
+
+// TestAdvanceCaptchaChallengeYieldsPrompt ensures that a CAPTCHA challenge
+// returned mid-request surfaces as a Prompt rather than terminating the login
+// with an error.
+func TestAdvanceCaptchaChallengeYieldsPrompt(t *testing.T) {
+ client := testHTTPClient(func(req *http.Request) (*http.Response, error) {
+ return newResponse(http.StatusBadRequest, testCaptchaBody), nil
+ })
+
+ am := NewAuthMachine(context.Background(), client, newTestPersonality())
+ am.Fingerprint = "test-fingerprint"
+
+ advanceToCaptchaPrompt(t, am)
+}
+
+// TestAdvanceCaptchaSolutionRetriesWithHeader ensures that answering a CAPTCHA
+// prompt retries the interrupted request with the solution and challenge state
+// threaded into the request headers.
+func TestAdvanceCaptchaSolutionRetriesWithHeader(t *testing.T) {
+ const solution = "solved-captcha-token"
+
+ var calls int
+ var retryHeader http.Header
+ client := testHTTPClient(func(req *http.Request) (*http.Response, error) {
+ calls++
+ switch calls {
+ case 1:
+ return newResponse(http.StatusBadRequest, testCaptchaBody), nil
+ case 2:
+ retryHeader = req.Header.Clone()
+ return newResponse(http.StatusOK, testLoginSuccessBody), nil
+ default:
+ t.Fatalf("unexpected HTTP call #%d", calls)
+ return nil, nil
+ }
+ })
+
+ am := NewAuthMachine(context.Background(), client, newTestPersonality())
+ am.Fingerprint = "test-fingerprint"
+
+ advanceToCaptchaPrompt(t, am)
+
+ prompt, done, err := am.Advance(context.Background(), &Answer{
+ Solution: &CaptchaSolution{Solution: solution},
+ })
+ if err != nil {
+ t.Fatalf("advance with CAPTCHA solution errored: %v", err)
+ }
+ if prompt != nil {
+ t.Fatalf("expected login to complete, got prompt %+v", prompt)
+ }
+ if done == nil {
+ t.Fatalf("expected a completed login")
+ }
+ if got := done.Token.UnwrapSensitive(); got != "test-token" {
+ t.Fatalf("unexpected token %q", got)
+ }
+ if calls != 2 {
+ t.Fatalf("expected exactly 2 HTTP calls, got %d", calls)
+ }
+ if got := retryHeader.Get(HeaderCaptchaKey); got != solution {
+ t.Fatalf("expected %s header %q on retry, got %q", HeaderCaptchaKey, solution, got)
+ }
+ if got := retryHeader.Get(HeaderCaptchaSessionID); got != "sess" {
+ t.Fatalf("expected %s header %q on retry, got %q", HeaderCaptchaSessionID, "sess", got)
+ }
+}
+
+func TestAdvanceAccountCompromisedPropagates(t *testing.T) {
+ client := testHTTPClient(func(req *http.Request) (*http.Response, error) {
+ expectPostRequest(t, req, "/api/v9/auth/login")
+ return newResponse(http.StatusBadRequest, testAccountCompromisedBody), nil
+ })
+
+ ctx := context.Background()
+
+ am := NewAuthMachine(ctx, client, newTestPersonality())
+ am.Fingerprint = "test-fingerprint"
+
+ prompt, done := mustAdvance(t, ctx, am, nil)
+ if done != nil || prompt == nil || prompt.CredsPrompt == nil {
+ t.Fatalf("expected credentials prompt, got prompt=%+v done=%+v", prompt, done)
+ }
+
+ prompt, done, err := am.Advance(ctx, &Answer{
+ Creds: NewCreds("user@example.com", "hunter2"),
+ })
+ if prompt != nil || done != nil {
+ t.Fatalf("expected only an error, got prompt=%+v done=%+v", prompt, done)
+ }
+
+ var apiErr APIError
+ if !errors.As(err, &apiErr) {
+ t.Fatalf("expected an APIError, got: %v", err)
+ }
+ if !apiErr.IsAccountCompromised() {
+ t.Fatalf("expected error to be recognized as account compromised: %v", err)
+ }
+ if apiErr.IsUserInputError() {
+ t.Fatal("account compromised error must not count as a user input error")
+ }
+}
+
+func TestIPVerificationViaSMS(t *testing.T) {
+ const (
+ loginPhone = "+15555550123"
+ password = "hunter2"
+ wrongCode = "111111"
+ rightCode = "222222"
+ )
+
+ var calls int
+ client := testHTTPClient(func(req *http.Request) (*http.Response, error) {
+ calls++
+ switch calls {
+ case 1:
+ expectPostRequest(t, req, "/api/v9/auth/login")
+ expectLoginRequestBody(t, req, loginPhone, password)
+ return newResponse(http.StatusBadRequest, testPhoneVerificationNeededBody), nil
+ case 2:
+ expectPostRequest(t, req, "/api/v9/phone-verifications/verify")
+ expectPhoneVerificationRequestBody(t, req, loginPhone, wrongCode)
+ return newResponse(http.StatusBadRequest, testInvalidFormBody), nil
+ case 3:
+ expectPostRequest(t, req, "/api/v9/phone-verifications/verify")
+ expectPhoneVerificationRequestBody(t, req, loginPhone, rightCode)
+ return newResponse(http.StatusOK, testVerifiedPhone), nil
+ case 4:
+ expectPostRequest(t, req, "/api/v9/auth/authorize-ip")
+ expectAuthorizeIPRequestBody(t, req, testIPVerificationToken)
+ return newResponse(http.StatusNoContent, ""), nil
+ case 5:
+ expectPostRequest(t, req, "/api/v9/auth/login")
+ expectLoginRequestBody(t, req, loginPhone, password)
+ return newResponse(http.StatusOK, testLoginSuccessBody), nil
+ default:
+ t.Fatalf("unexpected HTTP call #%d", calls)
+ return nil, nil
+ }
+ })
+
+ ctx := context.Background()
+
+ am := NewAuthMachine(ctx, client, newTestPersonality())
+ am.Fingerprint = "test-fingerprint"
+
+ prompt, done := mustAdvance(t, ctx, am, nil)
+ if done != nil || prompt == nil || prompt.CredsPrompt == nil {
+ t.Fatalf("expected credentials prompt, got prompt=%+v done=%+v", prompt, done)
+ }
+
+ prompt, done = mustAdvance(t, ctx, am, &Answer{
+ Creds: NewCreds(loginPhone, password),
+ })
+ phonePrompt := expectPhoneVerifyPrompt(t, prompt, done)
+ if phonePrompt.Phone != loginPhone {
+ t.Fatalf("expected phone verify prompt for %q, got %q", loginPhone, phonePrompt.Phone)
+ }
+ if phonePrompt.Retrying {
+ t.Fatal("initial phone verify prompt should not be retrying")
+ }
+
+ prompt, done = mustAdvance(t, ctx, am, &Answer{
+ SMSCode: wrongCode,
+ })
+ phonePrompt = expectPhoneVerifyPrompt(t, prompt, done)
+ if !phonePrompt.Retrying {
+ t.Fatal("phone verify prompt should be retrying")
+ }
+
+ prompt, completed := mustAdvance(t, ctx, am, &Answer{
+ SMSCode: rightCode,
+ })
+ if prompt != nil {
+ t.Fatalf("unexpected prompt: %+v", prompt)
+ }
+ if completed == nil {
+ t.Fatal("expected login to complete")
+ }
+ if got := completed.Token.UnwrapSensitive(); got != "test-token" {
+ t.Fatalf("unexpected token %q", got)
+ }
+ if calls != 5 {
+ t.Fatalf("expected exactly 5 HTTP calls, got %d", calls)
+ }
+}
+
+func mustAdvance(
+ t *testing.T,
+ ctx context.Context,
+ am *AuthMachine,
+ answer *Answer,
+) (*Prompt, *LoginCompleted) {
+ t.Helper()
+
+ prompt, done, err := am.Advance(ctx, answer)
+ if err != nil {
+ t.Fatalf("advance returned error: %v", err)
+ }
+ return prompt, done
+}
+
+func expectPhoneVerifyPrompt(t *testing.T, prompt *Prompt, done *LoginCompleted) *PhoneVerifyPrompt {
+ t.Helper()
+
+ if done != nil || prompt == nil || prompt.PhoneVerifyPrompt == nil {
+ t.Fatalf("expected phone verify prompt, got prompt=%+v done=%+v", prompt, done)
+ }
+ return prompt.PhoneVerifyPrompt
+}
+
+func expectPostRequest(t *testing.T, req *http.Request, path string) {
+ t.Helper()
+
+ if req.Method != http.MethodPost {
+ t.Fatalf("expected POST request, got %s", req.Method)
+ }
+ if req.URL.Path != path {
+ t.Fatalf("expected request path %q, got %q", path, req.URL.Path)
+ }
+}
+
+func expectLoginRequestBody(t *testing.T, req *http.Request, login string, password string) {
+ t.Helper()
+
+ var got struct {
+ Login string `json:"login"`
+ Password string `json:"password"`
+ }
+ mustDecodeRequestJSON(t, req, &got)
+
+ if got.Login != login {
+ t.Fatalf("expected login %q, got %q", login, got.Login)
+ }
+ if got.Password != password {
+ t.Fatalf("expected password %q, got %q", password, got.Password)
+ }
+}
+
+func expectPhoneVerificationRequestBody(t *testing.T, req *http.Request, phone string, code string) {
+ t.Helper()
+
+ var got VerifyPhoneNumberRequest
+ mustDecodeRequestJSON(t, req, &got)
+
+ if got.Phone != phone {
+ t.Fatalf("expected phone %q, got %q", phone, got.Phone)
+ }
+ if got.Code != code {
+ t.Fatalf("expected code %q, got %q", code, got.Code)
+ }
+}
+
+func expectAuthorizeIPRequestBody(t *testing.T, req *http.Request, token string) {
+ t.Helper()
+
+ var got struct {
+ Token string `json:"token"`
+ }
+ mustDecodeRequestJSON(t, req, &got)
+
+ if got.Token != token {
+ t.Fatalf("expected IP verification token %q, got %q", token, got.Token)
+ }
+}
+
+func mustDecodeRequestJSON(t *testing.T, req *http.Request, v any) {
+ t.Helper()
+
+ defer req.Body.Close()
+ if err := json.NewDecoder(req.Body).Decode(v); err != nil {
+ t.Fatalf("decoding request body: %v", err)
+ }
+}
diff --git a/pkg/discordauth/mfa.go b/pkg/discordauth/mfa.go
new file mode 100644
index 0000000..842ab97
--- /dev/null
+++ b/pkg/discordauth/mfa.go
@@ -0,0 +1,93 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+// An AuthenticatorType is what you append to "/auth/mfa/" to respond to an MFA
+// challenge with that MFA method.
+//
+// For example, to use a TOTP code, you'd POST to "/auth/mfa/totp".
+// [AuthenticatorTOTP] is "totp".
+type AuthenticatorType string
+
+const (
+ AuthenticatorTOTP AuthenticatorType = "totp"
+ AuthenticatorSMS AuthenticatorType = "sms"
+ AuthenticatorBackup AuthenticatorType = "backup"
+ AuthenticatorWebAuthn AuthenticatorType = "webauthn"
+ AuthenticatorPassword AuthenticatorType = "password"
+)
+
+// An [MFAContinue] combines an [AuthenticatorType] with an [MFAContinuation],
+// which lets the [AuthMachine] know how to make the HTTP request to Discord.
+//
+// This is sent back in an [Answer] when the client is ready to let the library
+// know how to proceed with the MFA log in flow.
+type MFAContinue struct {
+ Type AuthenticatorType
+ MFAContinuation
+}
+
+// An MFAState encapsulates the essential, opaque data that is received from
+// Discord when MFA is required to proceed with a log in. This data must be
+// sent back as part of your MFA response ([MFAContinuation]).
+//
+// This struct exists solely for organizational purposes.
+type MFAState struct {
+ Ticket Sensitive[string] `json:"ticket"`
+ LoginInstanceID string `json:"login_instance_id"`
+}
+
+// A LoginMFARequired is returned from Discord's login endpoint when the
+// password is accepted, but another authentication factor is required.
+type LoginMFARequired struct {
+ MFAState
+
+ UserID string `json:"user_id"`
+ MFARequired bool `json:"mfa"` // multi-factor authentication is required to log in
+ SMSEnabled bool `json:"sms"` // whether SMS-based MFA is enabled
+ BackupCodesAccepted bool `json:"backup"` // whether backup codes can be used in the response
+ TOTPEnabled bool `json:"totp"`
+ WebAuthnCredential *string `json:"webauthn"` // JSON string of {"publicKey": {"challenge": ...}}
+}
+
+// POST an MFAContinuation to Discord upon receiving a [LoginMFARequired] and
+// you have the necessary code (TOTP, SMS, backup, WebAuthn, etc.) to continue.
+type MFAContinuation struct {
+ MFAState
+
+ // The TOTP, SMS code, backup code, or Webauthn credential used to complete
+ // the MFA flow.
+ //
+ // Backup codes are displayed hyphenated in Discord's UI, which visually
+ // splits them in half. Discord's API will not accept backup codes with the
+ // hyphens intact, so they must be stripped before submission.
+ Code string `json:"code"`
+
+ GiftCodeSKUID *string `json:"gift_code_sku_id"`
+ LoginSource *string `json:"login_source"`
+}
+
+// POST an SMSSendRequest to Discord upon receiving a [LoginMFARequired] if SMS
+// is a permitted MFA path and you'd like to send an SMS code to the user.
+type SMSSendRequest struct {
+ Ticket Sensitive[string] `json:"ticket"`
+}
+
+// SMSSendResponse is what Discord returns from /auth/mfa/sms/send.
+type SMSSendResponse struct {
+ Phone string `json:"phone"` // partially redacted phone number
+}
diff --git a/pkg/discordauth/personality.go b/pkg/discordauth/personality.go
new file mode 100644
index 0000000..4af4102
--- /dev/null
+++ b/pkg/discordauth/personality.go
@@ -0,0 +1,111 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "encoding"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+const HeaderInstallationID = "x-installation-id"
+const HeaderDiscordLocale = "x-discord-locale"
+const HeaderDiscordTimezone = "x-discord-timezone"
+const HeaderSuperProperties = "x-super-properties"
+const HeaderContextProperties = "x-context-properties"
+const HeaderFingerprint = "x-fingerprint"
+const HeaderDebugOptions = "x-debug-options"
+
+const DefaultDebugOptions = "bugReporterEnabled"
+
+// Personality encapsulates some settings that clients are likely to want to
+// customize. These values are sent in nearly every HTTP request to Discord.
+type Personality struct {
+ UserAgent string
+ Locale string // `x-discord-locale`
+ TimeZone string // `x-discord-timezone`
+ DebugOptions string // `x-debug-options`
+ SuperProperties SuperProperties // `x-super-properties` (base64)
+
+ ExtraHeaders map[string]string
+}
+
+func (p *Personality) Headers() (http.Header, error) {
+ superProps, err := p.SuperProperties.MarshalText()
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal super properties: %w", err)
+ }
+
+ header := make(http.Header)
+ header.Set("User-Agent", p.UserAgent)
+ header.Set(HeaderDiscordLocale, p.Locale)
+ header.Set(HeaderDiscordTimezone, p.TimeZone)
+ header.Set(HeaderSuperProperties, string(superProps))
+
+ for k, v := range p.ExtraHeaders {
+ header.Set(k, v)
+ }
+
+ return header, nil
+}
+
+// FIXME(skip): This is missing client_heartbeat_session_id... that's only
+// relevant when you have a gateway connection, though (?)
+
+type SuperProperties struct {
+ OS string `json:"os"`
+ Browser string `json:"browser"`
+ Device string `json:"device"`
+ SystemLocale string `json:"system_locale"`
+ HasClientMods bool `json:"has_client_mods"`
+ BrowserUserAgent string `json:"browser_user_agent"`
+ BrowserVersion string `json:"browser_version"`
+ OSVersion string `json:"os_version"`
+ Referrer string `json:"referrer"`
+ ReferringDomain string `json:"referring_domain"`
+ ReferrerCurrent string `json:"referrer_current"`
+ ReferringDomainCurrent string `json:"referring_domain_current"`
+ ReleaseChannel string `json:"release_channel"`
+ ClientBuildNumber int `json:"client_build_number"`
+ ClientEventSource *string `json:"client_event_source"`
+ ClientLaunchID string `json:"client_launch_id"`
+ LaunchSignature discordgo.LaunchSignature `json:"launch_signature"`
+ ClientAppState string `json:"client_app_state"`
+}
+
+var _ encoding.TextMarshaler = (*SuperProperties)(nil)
+
+func (sp *SuperProperties) MarshalText() ([]byte, error) {
+ // TODO(skip): Little bit of weird looking indirection here so we don't
+ // recurse infinitely. Should probably just remove this, then.
+ type superProperties SuperProperties
+ spJson, err := json.Marshal((*superProperties)(sp))
+ if err != nil {
+ return nil, err
+ }
+
+ // Avoid the string() call that EncodeToString incurs.
+ encoding := base64.StdEncoding
+ buf := make([]byte, encoding.EncodedLen(len(spJson)))
+ encoding.Encode(buf, spJson)
+
+ return buf, nil
+}
diff --git a/pkg/discordauth/personality_test.go b/pkg/discordauth/personality_test.go
new file mode 100644
index 0000000..dc12ff6
--- /dev/null
+++ b/pkg/discordauth/personality_test.go
@@ -0,0 +1,34 @@
+package discordauth
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "testing"
+)
+
+func TestPersonalityHeadersEncodesSuperProperties(t *testing.T) {
+ personality := newTestPersonality()
+
+ headers, err := personality.Headers()
+ if err != nil {
+ t.Fatalf("Headers returned error: %v", err)
+ }
+
+ encoded := headers.Get(HeaderSuperProperties)
+ if encoded == "" {
+ t.Fatal("expected super properties header to be set")
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ t.Fatalf("failed to decode super properties header: %v", err)
+ }
+
+ var parsed map[string]any
+ if err = json.Unmarshal(decoded, &parsed); err != nil {
+ t.Fatalf("failed to unmarshal super properties JSON: %v", err)
+ }
+ if parsed["client_build_number"] != float64(1) {
+ t.Fatalf("expected client_build_number to equal 1, got %#v", parsed["client_build_number"])
+ }
+}
diff --git a/pkg/discordauth/sensitive.go b/pkg/discordauth/sensitive.go
new file mode 100644
index 0000000..f35b269
--- /dev/null
+++ b/pkg/discordauth/sensitive.go
@@ -0,0 +1,67 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordauth
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "reflect"
+)
+
+// Sensitive is a trivial mitigation that guards against accidental leakage of
+// important values such as passwords. It is not a security boundary and may be
+// trivially unwrapped via [Sensitive.UnwrapSensitive], reflection, etc.
+type Sensitive[T any] struct {
+ inner T
+}
+
+var _ json.Marshaler = (*Sensitive[any])(nil)
+var _ json.Unmarshaler = (*Sensitive[any])(nil)
+
+func NewSensitive[T any](inner T) Sensitive[T] {
+ return Sensitive[T]{inner}
+}
+
+func (s Sensitive[T]) IsZero() bool {
+ return reflect.ValueOf(s.inner).IsZero()
+}
+
+// UnwrapSensitive returns the sensitive data inside.
+func (s Sensitive[T]) UnwrapSensitive() T {
+ return s.inner
+}
+
+func (Sensitive[T]) Format(f fmt.State, verb rune) {
+ _, _ = io.WriteString(f, "")
+}
+
+func (Sensitive[T]) String() string {
+ return ""
+}
+
+func (Sensitive[T]) GoString() string {
+ return ""
+}
+
+func (s Sensitive[T]) MarshalJSON() ([]byte, error) {
+ return json.Marshal(s.inner)
+}
+
+func (s *Sensitive[T]) UnmarshalJSON(data []byte) error {
+ return json.Unmarshal(data, &s.inner)
+}
diff --git a/pkg/discordid/dbmeta.go b/pkg/discordid/dbmeta.go
new file mode 100644
index 0000000..d8b6ba3
--- /dev/null
+++ b/pkg/discordid/dbmeta.go
@@ -0,0 +1,58 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordid
+
+import (
+ "github.com/bwmarrin/discordgo"
+ "maunium.net/go/mautrix/bridgev2/database"
+)
+
+type PortalMetadata struct {
+ // The ID of the Discord guild that the channel corresponding to this portal
+ // belongs to.
+ //
+ // For private channels (DMs and group DMs), this will be the zero value
+ // (an empty string).
+ GuildID string `json:"guild_id"`
+
+ // The type of Discord channel this portal corresponds to.
+ //
+ // This is omitted for guild space portals.
+ ChannelType *discordgo.ChannelType `json:"channel_type,omitempty"`
+}
+
+type UserLoginMetadata struct {
+ Token string `json:"token"`
+ HeartbeatSession discordgo.HeartbeatSession `json:"heartbeat_session"`
+ BridgedGuildIDs map[string]bool `json:"bridged_guild_ids,omitempty"`
+}
+
+var _ database.MetaMerger = (*UserLoginMetadata)(nil)
+
+func (ulm *UserLoginMetadata) CopyFrom(incoming any) {
+ incomingMeta, ok := incoming.(*UserLoginMetadata)
+ if !ok || incomingMeta == nil {
+ return
+ }
+
+ if incomingMeta.Token != "" {
+ ulm.Token = incomingMeta.Token
+ }
+ ulm.HeartbeatSession = discordgo.NewHeartbeatSession()
+
+ // Retain the BridgedGuildIDs from the existing login.
+}
diff --git a/pkg/discordid/id.go b/pkg/discordid/id.go
new file mode 100644
index 0000000..686b4b1
--- /dev/null
+++ b/pkg/discordid/id.go
@@ -0,0 +1,176 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordid
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+)
+
+// DeletedGuildUserID is a magic user ID that is used in place of an actual user
+// ID once they have deleted their account. This only applies in non-private
+// (i.e. guild) contexts, such as guild channel message authors and mentions.
+//
+// Note that this user ID can also appear in message content as part of user
+// mention markup ("<@456226577798135808>").
+const DeletedGuildUserID = "456226577798135808"
+
+// DeletedGuildUser is the user returned from the Discord API as a stand-in for
+// users who have since deleted their account. As the name suggests, this only
+// applies to fetched entities within guilds.
+var DeletedGuildUser = discordgo.User{
+ ID: DeletedGuildUserID,
+ Username: "Deleted User",
+ Discriminator: "0000",
+}
+
+const DiscordEpochMillis = 1420070400000
+
+// GenerateNonce creates a Discord-style snowflake nonce for message idempotency.
+func GenerateNonce() string {
+ snowflake := (time.Now().UnixMilli() - DiscordEpochMillis) << 22
+ return strconv.FormatInt(snowflake, 10)
+}
+
+func MakeUserID(userID string) networkid.UserID {
+ return networkid.UserID(userID)
+}
+
+func ParseUserID(userID networkid.UserID) string {
+ return string(userID)
+}
+
+func MakeUserLoginID(userID string) networkid.UserLoginID {
+ return networkid.UserLoginID(userID)
+}
+
+func ParseUserLoginID(id networkid.UserLoginID) string {
+ return string(id)
+}
+
+// UserLoginIDToUserID converts a UserLoginID to a UserID. In Discord, both
+// are the same underlying snowflake.
+func UserLoginIDToUserID(id networkid.UserLoginID) networkid.UserID {
+ return networkid.UserID(id)
+}
+
+// MakeChannelPortalKey creates a PortalKey from a Discord channel ID and the
+// user login it was received from.
+//
+// If you can reach a DiscordClient, prefer calling the helper methods defined
+// on it instead, as split portal configuration will be respected for you.
+func MakeChannelPortalKey(channelID string, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
+ key.ID = MakeChannelPortalIDWithID(channelID)
+ if wantReceiver {
+ key.Receiver = userLoginID
+ }
+ return
+}
+
+func MakeChannelPortalKeyWithID(channelID string) (key networkid.PortalKey) {
+ key.ID = MakeChannelPortalIDWithID(channelID)
+ return
+}
+
+func MakeChannelPortalIDWithID(channelID string) networkid.PortalID {
+ return networkid.PortalID(channelID)
+}
+
+func ParseChannelPortalID(portalID networkid.PortalID) string {
+ return string(portalID)
+}
+
+func MakeMessageID(messageID string) networkid.MessageID {
+ return networkid.MessageID(messageID)
+}
+
+func ParseMessageID(messageID networkid.MessageID) string {
+ return string(messageID)
+}
+
+func MakeEmojiID(emojiName string) networkid.EmojiID {
+ return networkid.EmojiID(emojiName)
+}
+
+func ParseEmojiID(emojiID networkid.EmojiID) string {
+ return string(emojiID)
+}
+
+func MakeAvatarID(avatar string) networkid.AvatarID {
+ return networkid.AvatarID(avatar)
+}
+
+func MakePartID(attachmentID string) networkid.PartID {
+ return networkid.PartID(attachmentID)
+}
+
+func ParsePartID(attachmentID string) string {
+ return string(attachmentID)
+}
+
+// The string prepended to [networkid.PortalKey]s identifying spaces that
+// bridge Discord guilds.
+//
+// Every Discord guild created before August 2017 contained a channel
+// having _the same ID as the guild itself_. This channel also functioned as
+// the "default channel" in that incoming members would view this channel by
+// default. It was also impossible to delete.
+//
+// After this date, these "default channels" became deletable, and fresh guilds
+// were no longer created with a channel that exactly corresponded to the guild
+// ID.
+//
+// To accommodate Discord guilds created before this API change that have also
+// never deleted the default channel, we need a way to distinguish between the
+// guild and the default channel. Otherwise, we wouldn't be able to bridge both
+// the channel portal as well as the guild space; their keys would conflict.
+//
+// "*" was chosen as the asterisk character is used to filter by guilds in
+// the quick switcher (in Discord's first-party clients).
+//
+// For more information, see: https://discord.com/developers/docs/change-log#breaking-change-default-channels:~:text=New%20guilds%20will%20no%20longer.
+const GuildPortalKeySigil = "*"
+
+func MakeGuildPortalIDWithID(guildID string) networkid.PortalID {
+ return networkid.PortalID(GuildPortalKeySigil + guildID)
+}
+
+func MakeGuildPortalKey(guildID string, userLoginID networkid.UserLoginID, wantReceiver bool) (key networkid.PortalKey) {
+ key.ID = MakeGuildPortalIDWithID(guildID)
+ if wantReceiver {
+ key.Receiver = userLoginID
+ }
+ return
+}
+
+// ParseGuildPortalID converts a [network.PortalID] pointing to a guild space
+// back into the guild's ID on Discord.
+//
+// If the portal ID does not point to a guild, then an empty string is returned.
+func ParseGuildPortalID(portalID networkid.PortalID) string {
+ opaque := string(portalID)
+ if strings.HasPrefix(opaque, GuildPortalKeySigil) {
+ guildID := opaque[1:]
+ return guildID
+ }
+
+ return ""
+}
diff --git a/pkg/discordid/mediaid.go b/pkg/discordid/mediaid.go
new file mode 100644
index 0000000..bcfa789
--- /dev/null
+++ b/pkg/discordid/mediaid.go
@@ -0,0 +1,166 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordid
+
+import (
+ "encoding/binary"
+ "fmt"
+ "strconv"
+
+ "maunium.net/go/mautrix/bridgev2/networkid"
+)
+
+type DirectMediaType byte
+
+const (
+ DirectMediaTypeV1 DirectMediaType = 1
+
+ encodedSnowflakeSize = 8
+ encodedMediaIDV1Size = 1 + 4*encodedSnowflakeSize
+)
+
+func (dmt DirectMediaType) isSupported() bool {
+ switch dmt {
+ case DirectMediaTypeV1:
+ return true
+ }
+ return false
+}
+
+type MediaInfoV1 struct {
+ UserLoginID networkid.UserLoginID
+ ChannelID string
+ MessageID string
+ AttachmentID string
+}
+
+type MediaInfo struct {
+ Type DirectMediaType
+ MediaInfoV1
+}
+
+func NewMediaInfoV1(userLoginID networkid.UserLoginID, channelID, messageID, attachmentID string) MediaInfo {
+ return MediaInfo{
+ Type: DirectMediaTypeV1,
+ MediaInfoV1: MediaInfoV1{
+ UserLoginID: userLoginID,
+ ChannelID: channelID,
+ MessageID: messageID,
+ AttachmentID: attachmentID,
+ },
+ }
+}
+
+func (mi *MediaInfo) Encode() ([]byte, error) {
+ buf := make([]byte, 1, encodedMediaIDV1Size)
+ buf[0] = byte(mi.Type)
+
+ appendSnowflake := func(what, snowflakeStr string) error {
+ snowflake, err := strconv.ParseUint(snowflakeStr, 10, 64)
+ if err != nil {
+ return fmt.Errorf("invalid %s: %w", what, err)
+ }
+
+ buf = binary.BigEndian.AppendUint64(buf, snowflake)
+ return nil
+ }
+
+ if err := appendSnowflake("user login id", ParseUserLoginID(mi.UserLoginID)); err != nil {
+ return nil, err
+ }
+ if err := appendSnowflake("channel id", mi.ChannelID); err != nil {
+ return nil, err
+ }
+ if err := appendSnowflake("message id", mi.MessageID); err != nil {
+ return nil, err
+ }
+ if err := appendSnowflake("attachment id", mi.AttachmentID); err != nil {
+ return nil, err
+ }
+
+ return buf, nil
+}
+
+func ParseMediaID(mediaID networkid.MediaID) (*MediaInfo, error) {
+ var info MediaInfo
+
+ ptr := 0
+ read := func(size int, what string) ([]byte, error) {
+ if len(mediaID) < ptr+size {
+ return nil, fmt.Errorf("media ID too short (%d bytes) to read %d byte %s starting at byte %d", len(mediaID), size, what, ptr)
+ }
+ b := mediaID[ptr : ptr+size]
+ ptr += size
+ return b, nil
+ }
+ readOne := func(what string) (byte, error) {
+ b, err := read(1, what)
+ if err != nil {
+ return 0, err
+ }
+ return b[0], nil
+ }
+ readSnowflake := func(what string) (string, error) {
+ snowflakeBytes, err := read(encodedSnowflakeSize, what)
+ if err != nil {
+ return "", err
+ }
+
+ snowflake := binary.BigEndian.Uint64(snowflakeBytes)
+ return strconv.FormatUint(snowflake, 10), nil
+ }
+
+ mediaType, err := readOne("media type")
+ if err != nil {
+ return nil, err
+ }
+ info.Type = DirectMediaType(mediaType)
+
+ if !info.Type.isSupported() {
+ return nil, fmt.Errorf("unrecognized media type %d", info.Type)
+ }
+
+ userLoginID, err := readSnowflake("user login id")
+ info.UserLoginID = networkid.UserLoginID(userLoginID)
+ if err != nil {
+ return nil, err
+ }
+
+ channelID, err := readSnowflake("channel id")
+ info.ChannelID = channelID
+ if err != nil {
+ return nil, err
+ }
+
+ messageID, err := readSnowflake("message id")
+ info.MessageID = messageID
+ if err != nil {
+ return nil, err
+ }
+
+ attachmentID, err := readSnowflake("attachment id")
+ info.AttachmentID = attachmentID
+ if err != nil {
+ return nil, err
+ }
+
+ if ptr != len(mediaID) {
+ return nil, fmt.Errorf("media ID has %d trailing bytes", len(mediaID)-ptr)
+ }
+
+ return &info, nil
+}
diff --git a/pkg/discordid/mediaid_test.go b/pkg/discordid/mediaid_test.go
new file mode 100644
index 0000000..4fbe14a
--- /dev/null
+++ b/pkg/discordid/mediaid_test.go
@@ -0,0 +1,104 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordid
+
+import "testing"
+
+func TestMediaIDRoundTrip(t *testing.T) {
+ testCases := []struct {
+ name string
+ userLoginID string
+ channelID string
+ messageID string
+ attachmentID string
+ }{
+ {
+ name: "single digit",
+ userLoginID: "1",
+ channelID: "2",
+ messageID: "3",
+ attachmentID: "4",
+ },
+ {
+ name: "mixed short lengths",
+ userLoginID: "12",
+ channelID: "345",
+ messageID: "6789",
+ attachmentID: "12345",
+ },
+ {
+ name: "discord sized",
+ userLoginID: "12345678901234567",
+ channelID: "234567890123456789",
+ messageID: "345678901234567890",
+ attachmentID: "456789012345678901",
+ },
+ {
+ name: "nineteen digits",
+ userLoginID: "1000000000000000000",
+ channelID: "1000000000000000001",
+ messageID: "1000000000000000002",
+ attachmentID: "1000000000000000003",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ want := NewMediaInfoV1(
+ MakeUserLoginID(tc.userLoginID),
+ tc.channelID,
+ tc.messageID,
+ tc.attachmentID,
+ )
+
+ encoded, err := want.Encode()
+ if err != nil {
+ t.Fatalf("Encode() failed: %v", err)
+ }
+ if len(encoded) != encodedMediaIDV1Size {
+ t.Fatalf("Encode() returned %d bytes, want %d", len(encoded), encodedMediaIDV1Size)
+ }
+
+ got, err := ParseMediaID(encoded)
+ if err != nil {
+ t.Fatalf("ParseMediaID() failed: %v", err)
+ }
+ if *got != want {
+ t.Fatalf("roundtrip mismatch:\n got: %#v\n want: %#v", *got, want)
+ }
+ })
+ }
+}
+
+func TestParseMediaIDRejectsTruncatedData(t *testing.T) {
+ info := NewMediaInfoV1(
+ MakeUserLoginID("123456789012345678"),
+ "223456789012345678",
+ "323456789012345678",
+ "423456789012345678",
+ )
+
+ encoded, err := info.Encode()
+ if err != nil {
+ t.Fatalf("Encode() returned error: %v", err)
+ }
+
+ _, err = ParseMediaID(encoded[:len(encoded)-1])
+ if err == nil {
+ t.Fatal("ParseMediaID() unexpectedly succeeded for truncated data")
+ }
+}
diff --git a/pkg/discordtransport/http.go b/pkg/discordtransport/http.go
new file mode 100644
index 0000000..e18565c
--- /dev/null
+++ b/pkg/discordtransport/http.go
@@ -0,0 +1,189 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package discordtransport
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "net/http"
+ "net/http/cookiejar"
+ "strings"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/imroc/req/v3"
+ utls "github.com/refraction-networking/utls"
+ "go.mau.fi/util/exhttp"
+ "golang.org/x/net/publicsuffix"
+)
+
+type TransportOptions struct {
+ // CookieJar controls whether to create a cookie jar and attach it to the
+ // returned [http.Client].
+ CookieJar bool
+}
+
+// compileChromeClient builds an [http.Client] whose transport impersonates
+// Chrome's TLS fingerprint (via req's uTLS-backed transport) and applies any
+// given exhttp settings (proxy, timeouts, HTTP/1.1 forcing, etc.).
+func compileChromeClient(
+ settings exhttp.ClientSettings,
+ opts TransportOptions,
+ onlyAdvertiseHTTP1InALPN bool,
+) (*http.Client, error) {
+ reqClient := req.C().ImpersonateChrome()
+
+ // By default, req infers the proxy from the environment. If a proxy is not
+ // specified via exhttp, we don't want one at all, so remove the
+ // req-specified proxy before applying exhttp settings.
+ // MakeTransportOverride does not remove the proxy on its own.
+ reqClient.SetProxy(nil)
+
+ if onlyAdvertiseHTTP1InALPN {
+ forceHTTP1ChromeFingerprint(reqClient)
+ }
+
+ // Apply exhttp ClientSettings to the req Client. Note that we reuse req's
+ // transport layer (which implements Chrome impersonation) but don't use
+ // its request pipeline, so we don't get automatic retry, etc.
+ http := req.WithTransportOverride(settings, reqClient).Compile()
+
+ if opts.CookieJar {
+ // ClientSettings.Compile() does not instantiate a cookie jar, so do it
+ // ourselves.
+ jar, err := cookiejar.New(&cookiejar.Options{
+ PublicSuffixList: publicsuffix.List,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create cookie jar: %w", err)
+ }
+ http.Jar = jar
+ }
+
+ return http, nil
+}
+
+// CompileTransport builds the REST HTTP client: Chrome-impersonating and, like
+// real Chrome's API traffic, free to negotiate HTTP/2 over ALPN.
+func CompileTransport(
+ settings exhttp.ClientSettings,
+ opts TransportOptions,
+) (*http.Client, error) {
+ return compileChromeClient(settings, opts, false)
+}
+
+// CompileGatewayClient builds the HTTP client used to perform the Discord
+// Gateway WebSocket handshake. Implementation-wise, it is identical to
+// [CompileTransport] except that it pins the connection to HTTP/1.1.
+//
+// This is for two reasons:
+// - coder/websocket doesn't implement WebSockets over HTTP/2 (RFC 8441).
+// - Modern Chrome doesn't actually seem to use HTTP/2 for WebSockets.
+func CompileGatewayClient(
+ settings exhttp.ClientSettings,
+ opts TransportOptions,
+) (*http.Client, error) {
+ // This doesn't actually do much (it eventually tells req to not bother
+ // setting up an HTTP/2 transport), since it doesn't actually affect the
+ // ClientHello.
+ settings.DisableHTTP2 = true
+ return compileChromeClient(settings, opts, true)
+}
+
+// forceHTTP1ChromeFingerprint overrides the req client's TLS handshake so the
+// uTLS ClientHello keeps Chrome's full fingerprint but advertises _only_
+// http/1.1 in ALPN.
+func forceHTTP1ChromeFingerprint(c *req.Client) {
+ // (This is adapted from uTLS's SetTLSFingerprint.)
+ c.SetTLSHandshake(func(ctx context.Context, addr string, plainConn net.Conn) (net.Conn, *tls.ConnectionState, error) {
+ hostname := addr
+ if i := strings.LastIndex(addr, ":"); i != -1 {
+ hostname = addr[:i]
+ }
+
+ // NOTE: The ClientHelloID here _must_ match what req's
+ // ImpersonateChrome uses.
+ spec, err := utls.UTLSIdToSpec(utls.HelloChrome_120)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to build Chrome uTLS spec: %w", err)
+ }
+
+ // The actual changes we're making here:
+ exts := spec.Extensions[:0]
+ for _, ext := range spec.Extensions {
+ switch e := ext.(type) {
+ // Drop the ALPS (application_settings) extension. Modern Chrome
+ // will stop offering h2 there when ALPN omits it. Match that
+ // behavior.
+ case *utls.ApplicationSettingsExtension:
+ continue
+
+ // Patch the ALPN extension to exclusively offer http/1.1.
+ case *utls.ALPNExtension:
+ e.AlpnProtocols = []string{"http/1.1"}
+ }
+ exts = append(exts, ext)
+ }
+ spec.Extensions = exts
+
+ tlsConfig := c.GetTLSClientConfig()
+ uconn := utls.UClient(plainConn, &utls.Config{
+ ServerName: hostname,
+ NextProtos: []string{"http/1.1"},
+ RootCAs: tlsConfig.RootCAs,
+ InsecureSkipVerify: tlsConfig.InsecureSkipVerify,
+ KeyLogWriter: tlsConfig.KeyLogWriter,
+ }, utls.HelloCustom)
+ if err := uconn.ApplyPreset(&spec); err != nil {
+ return nil, nil, fmt.Errorf("failed to apply Chrome uTLS spec: %w", err)
+ }
+ if err := uconn.HandshakeContext(ctx); err != nil {
+ return nil, nil, err
+ }
+
+ cs := uconn.ConnectionState()
+ return uconn, &tls.ConnectionState{
+ Version: cs.Version,
+ HandshakeComplete: cs.HandshakeComplete,
+ DidResume: cs.DidResume,
+ CipherSuite: cs.CipherSuite,
+ NegotiatedProtocol: cs.NegotiatedProtocol,
+ ServerName: cs.ServerName,
+ PeerCertificates: cs.PeerCertificates,
+ VerifiedChains: cs.VerifiedChains,
+ }, nil
+ })
+}
+
+// ApplyToSession points a discordgo session's REST client and gateway HTTP
+// client at the given settings. Both impersonate Chrome's TLS fingerprint; the
+// REST client may use HTTP/2 ([CompileTransport]) while the gateway client is
+// pinned to HTTP/1.1 for the WebSocket upgrade ([CompileGatewayClient]).
+func ApplyToSession(session *discordgo.Session, settings exhttp.ClientSettings) error {
+ restClient, err := CompileTransport(settings, TransportOptions{CookieJar: true})
+ if err != nil {
+ return err
+ }
+ gatewayClient, err := CompileGatewayClient(settings, TransportOptions{CookieJar: true})
+ if err != nil {
+ return err
+ }
+ session.Client = restClient
+ session.GatewayHTTPClient = gatewayClient
+ return nil
+}
diff --git a/pkg/meowcord/README.md b/pkg/meowcord/README.md
new file mode 100644
index 0000000..e7b721a
--- /dev/null
+++ b/pkg/meowcord/README.md
@@ -0,0 +1,88 @@
+# meowcord
+
+meowcord is a Go library that comprehensively implements [Discord's REST
+API][discord-rest] and [Gateway][discord-gateway].
+
+meowcord is a hard fork of [bwmarrin/discordgo][bwmarrin-discordgo] (via
+[Beeper's fork][beeper-discordgo]), originally BSD-3-Clause.
+
+[discord-rest]: https://docs.discord.com/developers/reference
+[discord-gateway]: https://docs.discord.com/developers/events/gateway
+[beeper-discordgo]: https://github.com/beeper/discordgo
+[bwmarrin-discordgo]: https://github.com/bwmarrin/discordgo
+
+
+> [!IMPORTANT]
+> **meowcord does not make any stability or compatibility promises at this time
+> and is not designed for external use.** meowcord is mostly intended to be used
+> solely by [mautrix-discord], and therefore places emphasis on support and
+> functionality for user accounts (i.e. not bot accounts).
+
+[mautrix-discord]: https://github.com/mautrix/discord
+
+Notable deviations and enhancements from discordgo include (but are not limited
+to):
+
+- Requires Go 1.25 (August 2025) or newer.
+- Comprehensive user account support.
+ - Many user-specific REST endpoints and gateway event types (OP 13, OP 14,
+ etc.) have been added.
+ - `X-Super-Properties`, launch signatures, heartbeat sessions, etc. are
+ supported.
+ - Many types have been extended with undocumented fields.
+- Uses [coder/websocket][coder-ws] to communicate with the Discord Gateway
+ instead of [gorilla/websocket][gorilla-ws].
+- Support for [zlib transport compression
+ (`compress=zlib-stream`)][zlib-stream].
+- Enhanced state handling.
+
+[zlib-stream]: https://docs.discord.com/developers/events/gateway#zlib-stream
+[coder-ws]: https://github.com/coder/websocket
+[gorilla-ws]: https://github.com/gorilla/websocket
+
+## Fork Provenance & Licensing
+
+meowcord incorporates the following commits and all of their ancestors:
+
+- [beeper/discordgo][beeper-discordgo]:
+ [`8051e14a447170269a615268c4d83b55e6fcf2fe`](https://github.com/beeper/discordgo/commit/8051e14a447170269a615268c4d83b55e6fcf2fe)
+ (authored 2026-08-08)
+- [bwmarrin/discordgo][bwmarrin-discordgo]:
+ [`f43dd94faaacd5b163e9e783f14b5bd8be639fc9`](https://github.com/bwmarrin/discordgo/commit/f43dd94faaacd5b163e9e783f14b5bd8be639fc9)
+ (authored 2026-02-14)
+
+Those forks are BSD-3-Clause. This package is **now distributed under AGPL-3.0**
+to match mautrix-discord.
+
+discordgo's original license is preserved below for attribution only - it is
+**not** the current license of this code:
+
+```
+Copyright (c) 2015, Bruce Marriner
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of discordgo nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
diff --git a/pkg/meowcord/components.go b/pkg/meowcord/components.go
new file mode 100644
index 0000000..7973f46
--- /dev/null
+++ b/pkg/meowcord/components.go
@@ -0,0 +1,794 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "encoding/json"
+ "fmt"
+)
+
+// ComponentType is type of component.
+type ComponentType uint
+
+// MessageComponent types.
+const (
+ ActionsRowComponent ComponentType = 1
+ ButtonComponent ComponentType = 2
+ SelectMenuComponent ComponentType = 3
+ TextInputComponent ComponentType = 4
+ UserSelectMenuComponent ComponentType = 5
+ RoleSelectMenuComponent ComponentType = 6
+ MentionableSelectMenuComponent ComponentType = 7
+ ChannelSelectMenuComponent ComponentType = 8
+ SectionComponent ComponentType = 9
+ TextDisplayComponent ComponentType = 10
+ ThumbnailComponent ComponentType = 11
+ MediaGalleryComponent ComponentType = 12
+ FileComponentType ComponentType = 13
+ SeparatorComponent ComponentType = 14
+ ContentInventoryEntryComponent ComponentType = 16
+ ContainerComponent ComponentType = 17
+ LabelComponent ComponentType = 18
+ FileUploadComponent ComponentType = 19
+ CheckpointCardComponent ComponentType = 20
+ RadioGroupComponent ComponentType = 21
+ CheckboxGroupComponent ComponentType = 22
+ CheckboxComponent ComponentType = 23
+)
+
+// MessageComponent is a base interface for all message components.
+type MessageComponent interface {
+ json.Marshaler
+ Type() ComponentType
+}
+
+type unmarshalableMessageComponent struct {
+ MessageComponent
+}
+
+// UnmarshalJSON is a helper function to unmarshal MessageComponent object.
+func (umc *unmarshalableMessageComponent) UnmarshalJSON(src []byte) error {
+ var v struct {
+ Type ComponentType `json:"type"`
+ }
+ err := json.Unmarshal(src, &v)
+ if err != nil {
+ return err
+ }
+
+ switch v.Type {
+ case ActionsRowComponent:
+ umc.MessageComponent = &ActionsRow{}
+ case ButtonComponent:
+ umc.MessageComponent = &Button{}
+ case SelectMenuComponent, ChannelSelectMenuComponent, UserSelectMenuComponent,
+ RoleSelectMenuComponent, MentionableSelectMenuComponent:
+ umc.MessageComponent = &SelectMenu{}
+ case TextInputComponent:
+ umc.MessageComponent = &TextInput{}
+ case SectionComponent:
+ umc.MessageComponent = &Section{}
+ case TextDisplayComponent:
+ umc.MessageComponent = &TextDisplay{}
+ case ThumbnailComponent:
+ umc.MessageComponent = &Thumbnail{}
+ case MediaGalleryComponent:
+ umc.MessageComponent = &MediaGallery{}
+ case FileComponentType:
+ umc.MessageComponent = &FileComponent{}
+ case SeparatorComponent:
+ umc.MessageComponent = &Separator{}
+ case ContentInventoryEntryComponent:
+ umc.MessageComponent = &ContentInventoryEntry{}
+ case ContainerComponent:
+ umc.MessageComponent = &Container{}
+ case LabelComponent:
+ umc.MessageComponent = &Label{}
+ case FileUploadComponent:
+ umc.MessageComponent = &FileUpload{}
+ case CheckpointCardComponent:
+ umc.MessageComponent = &CheckpointCard{}
+ default:
+ umc.MessageComponent = &UnknownComponent{}
+ }
+ return json.Unmarshal(src, umc.MessageComponent)
+}
+
+// MessageComponentFromJSON is a helper function for unmarshaling message components
+func MessageComponentFromJSON(b []byte) (MessageComponent, error) {
+ var u unmarshalableMessageComponent
+ err := u.UnmarshalJSON(b)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal into MessageComponent: %w", err)
+ }
+ return u.MessageComponent, nil
+}
+
+// ActionsRow is a top-level container component for displaying a row of interactive components.
+type ActionsRow struct {
+ // Can contain Button, SelectMenu and TextInput.
+ // NOTE: maximum of 5.
+ Components []MessageComponent `json:"components"`
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+}
+
+// MarshalJSON is a method for marshaling ActionsRow to a JSON object.
+func (r ActionsRow) MarshalJSON() ([]byte, error) {
+ type actionsRow ActionsRow
+
+ return Marshal(struct {
+ actionsRow
+ Type ComponentType `json:"type"`
+ }{
+ actionsRow: actionsRow(r),
+ Type: r.Type(),
+ })
+}
+
+// UnmarshalJSON is a helper function to unmarshal Actions Row.
+func (r *ActionsRow) UnmarshalJSON(data []byte) error {
+ type actionsRow ActionsRow
+ var v struct {
+ actionsRow
+ RawComponents []unmarshalableMessageComponent `json:"components"`
+ }
+ err := json.Unmarshal(data, &v)
+ if err != nil {
+ return err
+ }
+ *r = ActionsRow(v.actionsRow)
+
+ r.Components = make([]MessageComponent, len(v.RawComponents))
+ for i, v := range v.RawComponents {
+ r.Components[i] = v.MessageComponent
+ }
+
+ return err
+}
+
+// Type is a method to get the type of a component.
+func (r ActionsRow) Type() ComponentType {
+ return ActionsRowComponent
+}
+
+// ButtonStyle is style of button.
+type ButtonStyle uint
+
+// Button styles.
+const (
+ // PrimaryButton is a button with blurple color.
+ PrimaryButton ButtonStyle = 1
+ // SecondaryButton is a button with grey color.
+ SecondaryButton ButtonStyle = 2
+ // SuccessButton is a button with green color.
+ SuccessButton ButtonStyle = 3
+ // DangerButton is a button with red color.
+ DangerButton ButtonStyle = 4
+ // LinkButton is a special type of button which navigates to a URL. Has grey color.
+ LinkButton ButtonStyle = 5
+ // PremiumButton is a special type of button with a blurple color that links to a SKU.
+ PremiumButton ButtonStyle = 6
+)
+
+// ComponentEmoji represents button emoji, if it does have one.
+type ComponentEmoji struct {
+ Name string `json:"name,omitempty"`
+ ID string `json:"id,omitempty"`
+ Animated bool `json:"animated,omitempty"`
+}
+
+// Button represents button component.
+type Button struct {
+ Label string `json:"label"`
+ Style ButtonStyle `json:"style"`
+ Disabled bool `json:"disabled"`
+ Emoji *ComponentEmoji `json:"emoji,omitempty"`
+
+ // NOTE: Only button with LinkButton style can have link. Also, URL is mutually exclusive with CustomID.
+ URL string `json:"url,omitempty"`
+ CustomID string `json:"custom_id,omitempty"`
+ // Identifier for a purchasable SKU. Only available when using premium-style buttons.
+ SKUID string `json:"sku_id,omitempty"`
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+}
+
+// MarshalJSON is a method for marshaling Button to a JSON object.
+func (b Button) MarshalJSON() ([]byte, error) {
+ type button Button
+
+ if b.Style == 0 {
+ b.Style = PrimaryButton
+ }
+
+ return Marshal(struct {
+ button
+ Type ComponentType `json:"type"`
+ }{
+ button: button(b),
+ Type: b.Type(),
+ })
+}
+
+// Type is a method to get the type of a component.
+func (Button) Type() ComponentType {
+ return ButtonComponent
+}
+
+// SelectMenuOption represents an option for a select menu.
+type SelectMenuOption struct {
+ Label string `json:"label,omitempty"`
+ Value string `json:"value"`
+ Description string `json:"description"`
+ Emoji *ComponentEmoji `json:"emoji,omitempty"`
+ // Determines whenever option is selected by default or not.
+ Default bool `json:"default"`
+}
+
+// SelectMenuDefaultValueType represents the type of an entity selected by default in auto-populated select menus.
+type SelectMenuDefaultValueType string
+
+// SelectMenuDefaultValue types.
+const (
+ SelectMenuDefaultValueUser SelectMenuDefaultValueType = "user"
+ SelectMenuDefaultValueRole SelectMenuDefaultValueType = "role"
+ SelectMenuDefaultValueChannel SelectMenuDefaultValueType = "channel"
+)
+
+// SelectMenuDefaultValue represents an entity selected by default in auto-populated select menus.
+type SelectMenuDefaultValue struct {
+ // ID of the entity.
+ ID string `json:"id"`
+ // Type of the entity.
+ Type SelectMenuDefaultValueType `json:"type"`
+}
+
+// SelectMenuType represents select menu type.
+type SelectMenuType ComponentType
+
+// SelectMenu types.
+const (
+ StringSelectMenu = SelectMenuType(SelectMenuComponent)
+ UserSelectMenu = SelectMenuType(UserSelectMenuComponent)
+ RoleSelectMenu = SelectMenuType(RoleSelectMenuComponent)
+ MentionableSelectMenu = SelectMenuType(MentionableSelectMenuComponent)
+ ChannelSelectMenu = SelectMenuType(ChannelSelectMenuComponent)
+)
+
+// SelectMenu represents select menu component.
+type SelectMenu struct {
+ // Type of the select menu.
+ MenuType SelectMenuType `json:"type,omitempty"`
+ // CustomID is a developer-defined identifier for the select menu.
+ CustomID string `json:"custom_id,omitempty"`
+ // The text which will be shown in the menu if there's no default options or all options was deselected and component was closed.
+ Placeholder string `json:"placeholder"`
+ // This value determines the minimal amount of selected items in the menu.
+ MinValues *int `json:"min_values,omitempty"`
+ // This value determines the maximal amount of selected items in the menu.
+ // If MaxValues or MinValues are greater than one then the user can select multiple items in the component.
+ MaxValues int `json:"max_values,omitempty"`
+ // List of default values for auto-populated select menus.
+ // NOTE: Number of entries should be in the range defined by MinValues and MaxValues.
+ DefaultValues []SelectMenuDefaultValue `json:"default_values,omitempty"`
+
+ Options []SelectMenuOption `json:"options,omitempty"`
+ Disabled bool `json:"disabled"`
+ Required *bool `json:"required,omitempty"`
+
+ // NOTE: Can only be used in SelectMenu with Channel menu type.
+ ChannelTypes []ChannelType `json:"channel_types,omitempty"`
+
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+
+ // List of values that is only populated when receiving an interaction response; do not fill this manually.
+ Values []string `json:"values,omitempty"`
+}
+
+// Type is a method to get the type of a component.
+func (s SelectMenu) Type() ComponentType {
+ if s.MenuType != 0 {
+ return ComponentType(s.MenuType)
+ }
+ return SelectMenuComponent
+}
+
+// MarshalJSON is a method for marshaling SelectMenu to a JSON object.
+func (s SelectMenu) MarshalJSON() ([]byte, error) {
+ type selectMenu SelectMenu
+
+ return Marshal(struct {
+ selectMenu
+ Type ComponentType `json:"type"`
+ }{
+ selectMenu: selectMenu(s),
+ Type: s.Type(),
+ })
+}
+
+// TextInput represents text input component.
+type TextInput struct {
+ CustomID string `json:"custom_id"`
+ Label string `json:"label,omitempty"`
+ Style TextInputStyle `json:"style"`
+ Placeholder string `json:"placeholder,omitempty"`
+ Value string `json:"value,omitempty"`
+ Required *bool `json:"required,omitempty"`
+ MinLength int `json:"min_length,omitempty"`
+ MaxLength int `json:"max_length,omitempty"`
+
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+}
+
+// Type is a method to get the type of a component.
+func (TextInput) Type() ComponentType {
+ return TextInputComponent
+}
+
+// MarshalJSON is a method for marshaling TextInput to a JSON object.
+func (m TextInput) MarshalJSON() ([]byte, error) {
+ type inputText TextInput
+
+ return Marshal(struct {
+ inputText
+ Type ComponentType `json:"type"`
+ }{
+ inputText: inputText(m),
+ Type: m.Type(),
+ })
+}
+
+// TextInputStyle is style of text in TextInput component.
+type TextInputStyle uint
+
+// Text styles
+const (
+ TextInputShort TextInputStyle = 1
+ TextInputParagraph TextInputStyle = 2
+)
+
+// Section is a top-level layout component that allows you to join text contextually with an accessory.
+type Section struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ // Array of text display components; max of 3.
+ Components []MessageComponent `json:"components"`
+ // Can be Button or Thumbnail
+ Accessory MessageComponent `json:"accessory"`
+}
+
+// UnmarshalJSON is a method for unmarshaling Section from JSON
+func (s *Section) UnmarshalJSON(data []byte) error {
+ type section Section
+
+ var v struct {
+ section
+ RawComponents []unmarshalableMessageComponent `json:"components"`
+ RawAccessory unmarshalableMessageComponent `json:"accessory"`
+ }
+
+ err := json.Unmarshal(data, &v)
+ if err != nil {
+ return err
+ }
+
+ *s = Section(v.section)
+ s.Accessory = v.RawAccessory.MessageComponent
+ s.Components = make([]MessageComponent, len(v.RawComponents))
+ for i, v := range v.RawComponents {
+ s.Components[i] = v.MessageComponent
+ }
+
+ return nil
+}
+
+// Type is a method to get the type of a component.
+func (Section) Type() ComponentType {
+ return SectionComponent
+}
+
+// MarshalJSON is a method for marshaling Section to a JSON object.
+func (s Section) MarshalJSON() ([]byte, error) {
+ type section Section
+
+ return Marshal(struct {
+ section
+ Type ComponentType `json:"type"`
+ }{
+ section: section(s),
+ Type: s.Type(),
+ })
+}
+
+// TextDisplay is a top-level component that allows you to add markdown-formatted text to the message.
+type TextDisplay struct {
+ Content string `json:"content"`
+}
+
+// Type is a method to get the type of a component.
+func (TextDisplay) Type() ComponentType {
+ return TextDisplayComponent
+}
+
+// MarshalJSON is a method for marshaling TextDisplay to a JSON object.
+func (t TextDisplay) MarshalJSON() ([]byte, error) {
+ type textDisplay TextDisplay
+
+ return Marshal(struct {
+ textDisplay
+ Type ComponentType `json:"type"`
+ }{
+ textDisplay: textDisplay(t),
+ Type: t.Type(),
+ })
+}
+
+// Thumbnail component can be used as an accessory for a section component.
+type Thumbnail struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ Media UnfurledMediaItem `json:"media"`
+ Description *string `json:"description,omitempty"`
+ Spoiler bool `json:"spoiler,omitempty"`
+}
+
+// Type is a method to get the type of a component.
+func (Thumbnail) Type() ComponentType {
+ return ThumbnailComponent
+}
+
+// MarshalJSON is a method for marshaling Thumbnail to a JSON object.
+func (t Thumbnail) MarshalJSON() ([]byte, error) {
+ type thumbnail Thumbnail
+
+ return Marshal(struct {
+ thumbnail
+ Type ComponentType `json:"type"`
+ }{
+ thumbnail: thumbnail(t),
+ Type: t.Type(),
+ })
+}
+
+// MediaGallery is a top-level component allows you to group images, videos or gifs into a gallery grid.
+type MediaGallery struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ // Array of media gallery items; max of 10.
+ Items []MediaGalleryItem `json:"items"`
+}
+
+// Type is a method to get the type of a component.
+func (MediaGallery) Type() ComponentType {
+ return MediaGalleryComponent
+}
+
+// MarshalJSON is a method for marshaling MediaGallery to a JSON object.
+func (m MediaGallery) MarshalJSON() ([]byte, error) {
+ type mediaGallery MediaGallery
+
+ return Marshal(struct {
+ mediaGallery
+ Type ComponentType `json:"type"`
+ }{
+ mediaGallery: mediaGallery(m),
+ Type: m.Type(),
+ })
+}
+
+// MediaGalleryItem represents an item used in MediaGallery.
+type MediaGalleryItem struct {
+ Media UnfurledMediaItem `json:"media"`
+ Description *string `json:"description,omitempty"`
+ Spoiler bool `json:"spoiler"`
+}
+
+// FileComponent is a top-level component that allows you to display an uploaded file as an attachment to the message and reference it in the component.
+type FileComponent struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ File UnfurledMediaItem `json:"file"`
+ Spoiler bool `json:"spoiler"`
+}
+
+// Type is a method to get the type of a component.
+func (FileComponent) Type() ComponentType {
+ return FileComponentType
+}
+
+// MarshalJSON is a method for marshaling FileComponent to a JSON object.
+func (f FileComponent) MarshalJSON() ([]byte, error) {
+ type fileComponent FileComponent
+
+ return Marshal(struct {
+ fileComponent
+ Type ComponentType `json:"type"`
+ }{
+ fileComponent: fileComponent(f),
+ Type: f.Type(),
+ })
+}
+
+// SeparatorSpacingSize represents spacing size around the separator.
+type SeparatorSpacingSize uint
+
+// Separator spacing sizes.
+const (
+ SeparatorSpacingSizeSmall SeparatorSpacingSize = 1
+ SeparatorSpacingSizeLarge SeparatorSpacingSize = 2
+)
+
+// Separator is a top-level layout component that adds vertical padding and visual division between other components.
+type Separator struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+
+ Divider *bool `json:"divider,omitempty"`
+ Spacing *SeparatorSpacingSize `json:"spacing,omitempty"`
+}
+
+// Type is a method to get the type of a component.
+func (Separator) Type() ComponentType {
+ return SeparatorComponent
+}
+
+// MarshalJSON is a method for marshaling Separator to a JSON object.
+func (s Separator) MarshalJSON() ([]byte, error) {
+ type separator Separator
+
+ return Marshal(struct {
+ separator
+ Type ComponentType `json:"type"`
+ }{
+ separator: separator(s),
+ Type: s.Type(),
+ })
+}
+
+// Container is a top-level layout component.
+// Containers are visually distinct from surrounding components and have an optional customizable color bar (similar to embeds).
+type Container struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ AccentColor *int `json:"accent_color,omitempty"`
+ Spoiler bool `json:"spoiler"`
+ Components []MessageComponent `json:"components"`
+}
+
+// Type is a method to get the type of a component.
+func (Container) Type() ComponentType {
+ return ContainerComponent
+}
+
+// UnmarshalJSON is a method for unmarshaling Container from JSON
+func (c *Container) UnmarshalJSON(data []byte) error {
+ type container Container
+
+ var v struct {
+ container
+ RawComponents []unmarshalableMessageComponent `json:"components"`
+ }
+
+ err := json.Unmarshal(data, &v)
+ if err != nil {
+ return err
+ }
+
+ *c = Container(v.container)
+ c.Components = make([]MessageComponent, len(v.RawComponents))
+ for i, v := range v.RawComponents {
+ c.Components[i] = v.MessageComponent
+ }
+
+ return nil
+}
+
+// MarshalJSON is a method for marshaling Container to a JSON object.
+func (c Container) MarshalJSON() ([]byte, error) {
+ type container Container
+
+ return Marshal(struct {
+ container
+ Type ComponentType `json:"type"`
+ }{
+ container: container(c),
+ Type: c.Type(),
+ })
+}
+
+// Label is a top-level layout component.
+// Labels wrap modal components with text as a label and optional description.
+type Label struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ Label string `json:"label"`
+ Description string `json:"description,omitempty"`
+ Component MessageComponent `json:"component"`
+}
+
+// Type is a method to get the type of a component.
+func (Label) Type() ComponentType {
+ return LabelComponent
+}
+
+// UnmarshalJSON is a method for unmarshaling Label from JSON
+func (l *Label) UnmarshalJSON(data []byte) error {
+ type label Label
+
+ var v struct {
+ label
+ RawComponent unmarshalableMessageComponent `json:"component"`
+ }
+
+ err := json.Unmarshal(data, &v)
+ if err != nil {
+ return err
+ }
+
+ *l = Label(v.label)
+ l.Component = v.RawComponent.MessageComponent
+
+ return nil
+}
+
+// MarshalJSON is a method for marshaling Label to a JSON object.
+func (l Label) MarshalJSON() ([]byte, error) {
+ type label Label
+
+ return Marshal(struct {
+ label
+ Type ComponentType `json:"type"`
+ }{
+ label: label(l),
+ Type: l.Type(),
+ })
+}
+
+// FileUpload is an interactive component that allows users to upload files in modals.
+// FileUploads are available on modals. They must be placed inside a Label.
+type FileUpload struct {
+ // Unique identifier for the component; auto populated through increment if not provided.
+ ID int `json:"id,omitempty"`
+ CustomID string `json:"custom_id,omitempty"`
+ MinValues *int `json:"min_values,omitempty"`
+ MaxValues int `json:"max_values,omitempty"`
+ Required *bool `json:"required,omitempty"`
+
+ // List of values that is only populated when receiving an interaction response; do not fill this manually.
+ Values []string `json:"values,omitempty"`
+}
+
+// Type is a method to get the type of a component.
+func (FileUpload) Type() ComponentType {
+ return FileUploadComponent
+}
+
+// MarshalJSON is a method for marshaling FileUpload to a JSON object.
+func (f FileUpload) MarshalJSON() ([]byte, error) {
+ type fileUpload FileUpload
+
+ return Marshal(struct {
+ fileUpload
+ Type ComponentType `json:"type"`
+ }{
+ fileUpload: fileUpload(f),
+ Type: f.Type(),
+ })
+}
+
+type ContentInventoryEntry struct {
+ ID int `json:"id,omitempty"`
+ Entry json.RawMessage `json:"content_inventory_entry,omitempty"`
+}
+
+func (ContentInventoryEntry) Type() ComponentType {
+ return ContentInventoryEntryComponent
+}
+
+func (c ContentInventoryEntry) MarshalJSON() ([]byte, error) {
+ type contentInventoryEntry ContentInventoryEntry
+
+ return Marshal(struct {
+ contentInventoryEntry
+ Type ComponentType `json:"type"`
+ }{
+ contentInventoryEntry: contentInventoryEntry(c),
+ Type: c.Type(),
+ })
+}
+
+type CheckpointCard struct {
+ ID int `json:"id,omitempty"`
+ CheckpointData json.RawMessage `json:"checkpoint_data,omitempty"`
+}
+
+func (CheckpointCard) Type() ComponentType {
+ return CheckpointCardComponent
+}
+
+func (c CheckpointCard) MarshalJSON() ([]byte, error) {
+ type checkpointCard CheckpointCard
+
+ return Marshal(struct {
+ checkpointCard
+ Type ComponentType `json:"type"`
+ }{
+ checkpointCard: checkpointCard(c),
+ Type: c.Type(),
+ })
+}
+
+type UnknownComponent struct {
+ ComponentType ComponentType `json:"-"`
+ Raw json.RawMessage `json:"-"`
+}
+
+func (u UnknownComponent) Type() ComponentType {
+ return u.ComponentType
+}
+
+func (u *UnknownComponent) UnmarshalJSON(src []byte) error {
+ var v struct {
+ Type ComponentType `json:"type"`
+ }
+ if err := json.Unmarshal(src, &v); err != nil {
+ return err
+ }
+ u.ComponentType = v.Type
+ u.Raw = append(json.RawMessage(nil), src...)
+ return nil
+}
+
+func (u UnknownComponent) MarshalJSON() ([]byte, error) {
+ if u.Raw == nil {
+ return []byte("null"), nil
+ }
+ return u.Raw, nil
+}
+
+// UnfurledMediaItem represents an unfurled media item.
+type UnfurledMediaItem struct {
+ URL string `json:"url"`
+}
+
+// UnfurledMediaItemLoadingState is the loading state of the unfurled media item.
+type UnfurledMediaItemLoadingState uint
+
+// Unfurled media item loading states.
+const (
+ UnfurledMediaItemLoadingStateUnknown UnfurledMediaItemLoadingState = 0
+ UnfurledMediaItemLoadingStateLoading UnfurledMediaItemLoadingState = 1
+ UnfurledMediaItemLoadingStateLoadingSuccess UnfurledMediaItemLoadingState = 2
+ UnfurledMediaItemLoadingStateLoadedNotFound UnfurledMediaItemLoadingState = 3
+)
+
+// ResolvedUnfurledMediaItem represents a resolved unfurled media item.
+type ResolvedUnfurledMediaItem struct {
+ URL string `json:"url"`
+ ProxyURL string `json:"proxy_url"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ ContentType string `json:"content_type"`
+}
diff --git a/pkg/meowcord/discord.go b/pkg/meowcord/discord.go
new file mode 100644
index 0000000..5dfaca4
--- /dev/null
+++ b/pkg/meowcord/discord.go
@@ -0,0 +1,119 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains high level helper functions and easy entry points for the
+// entire discordgo package. These functions are being developed and are very
+// experimental at this point. They will most likely change so please use the
+// low level functions if that's a problem.
+
+// package meowcord provides Discord binding for Go
+package meowcord
+
+import (
+ "net/http"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// VERSION of DiscordGo, follows Semantic Versioning. (http://semver.org/)
+const VERSION = "0.29.0"
+
+// New creates a new Discord session with provided token.
+// If the token is for a bot, it must be prefixed with "Bot "
+//
+// e.g. "Bot ..."
+//
+// Or if it is an OAuth2 token, it must be prefixed with "Bearer "
+//
+// e.g. "Bearer ..."
+func New(token string) (s *Session, err error) {
+ // Create an empty Session interface.
+ s = &Session{
+ State: NewState(),
+ Ratelimiter: NewRatelimiter(),
+ StateEnabled: true,
+ Compress: true,
+ ShouldReconnectOnError: true,
+ ShouldReconnectVoiceOnSessionError: true,
+ ShouldRetryOnRateLimit: true,
+ ShardID: 0,
+ ShardCount: 1,
+ MaxRestRetries: 3,
+ Client: &http.Client{Timeout: (20 * time.Second)},
+ // GatewayHTTPClient is only used to shake hands with the Discord
+ // Gateway.
+ GatewayHTTPClient: http.DefaultClient,
+ GatewayDialTimeout: 45 * time.Second,
+ UserAgent: "DiscordBot (https://github.com/bwmarrin/discordgo, v" + VERSION + ")",
+ sequence: new(int64),
+ LastHeartbeatAck: time.Now().UTC(),
+ }
+
+ // Initialize the Identify Package with defaults
+ // These can be modified prior to calling Open()
+ s.Identify.Compress = true
+ s.Identify.LargeThreshold = 250
+ s.Identify.Properties = &IdentifyProperties{
+ OS: runtime.GOOS,
+ Browser: "DiscordGo v" + VERSION,
+ }
+ s.Identify.Intents = IntentsAll
+ s.Identify.Token = token
+ s.Token = token
+
+ if token != "" && !strings.HasPrefix(token, "Bot ") {
+ sig, err := NewVanillaSignature()
+ if err != nil {
+ return nil, err
+ }
+
+ s.Identify.Presence.Activities = make([]Activity, 0)
+ s.Identify.Compress = false
+ s.Identify.LargeThreshold = 0
+ s.Identify.Presence.Status = droidStatus
+ s.Identify.Presence.AFK = true
+
+ s.Identify.Capabilities = droidCapabilities
+ s.Identify.ClientState = &ClientState{
+ //HighestLastMessageID: "0",
+ //ReadStateVersion: 0,
+ //UserGuildSettingsVersion: -1,
+ //UserSettingsVersion: -1,
+ //PrivateChannelsVersion: "0",
+ //APICodeVersion: 0,
+ }
+ s.Identify.Intents = 0
+
+ s.UserAgent = DroidBrowserUserAgent
+
+ s.launchSignature = sig
+ s.launchID = uuid.New()
+ s.HeartbeatSession = NewHeartbeatSession()
+ s.UpdateUserHeaders()
+
+ s.IsUser = true
+ }
+
+ return
+}
diff --git a/pkg/meowcord/discord_test.go b/pkg/meowcord/discord_test.go
new file mode 100644
index 0000000..5d97162
--- /dev/null
+++ b/pkg/meowcord/discord_test.go
@@ -0,0 +1,323 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "fmt"
+ "os"
+ "runtime"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// ////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////// VARS NEEDED FOR TESTING
+var (
+ dg *Session // Stores a global discordgo user session
+ dgBot *Session // Stores a global discordgo bot session
+
+ envOAuth2Token = os.Getenv("DG_OAUTH2_TOKEN") // Token to use when authenticating using OAuth2 token
+ envBotToken = os.Getenv("DGB_TOKEN") // Token to use when authenticating the bot account
+ envGuild = os.Getenv("DG_GUILD") // Guild ID to use for tests
+ envChannel = os.Getenv("DG_CHANNEL") // Channel ID to use for tests
+ envVoiceChannel = os.Getenv("DG_VOICE_CHANNEL") // Channel ID to use for tests
+ envAdmin = os.Getenv("DG_ADMIN") // User ID of admin user to use for tests
+)
+
+func TestMain(m *testing.M) {
+ fmt.Println("Init is being called.")
+ if envBotToken != "" {
+ if d, err := New(envBotToken); err == nil {
+ dgBot = d
+ }
+ }
+
+ if envOAuth2Token == "" {
+ envOAuth2Token = os.Getenv("DGU_TOKEN")
+ }
+
+ if envOAuth2Token != "" {
+ if d, err := New(envOAuth2Token); err == nil {
+ dg = d
+ }
+ }
+
+ os.Exit(m.Run())
+}
+
+//////////////////////////////////////////////////////////////////////////////
+/////////////////////////////////////////////////////////////// START OF TESTS
+
+// TestNewToken tests the New() function with a Token.
+func TestNewToken(t *testing.T) {
+
+ if envOAuth2Token == "" {
+ t.Skip("Skipping New(token), DGU_TOKEN not set")
+ }
+
+ d, err := New(envOAuth2Token)
+ if err != nil {
+ t.Fatalf("New(envToken) returned error: %+v", err)
+ }
+
+ if d == nil {
+ t.Fatal("New(envToken), d is nil, should be Session{}")
+ }
+
+ if d.Token == "" {
+ t.Fatal("New(envToken), d.Token is empty, should be a valid Token.")
+ }
+}
+
+func TestOpenClose(t *testing.T) {
+ if envOAuth2Token == "" {
+ t.Skip("Skipping TestClose, DGU_TOKEN not set")
+ }
+
+ d, err := New(envOAuth2Token)
+ if err != nil {
+ t.Fatalf("TestClose, New(envToken) returned error: %+v", err)
+ }
+
+ if err = d.Open(); err != nil {
+ t.Fatalf("TestClose, d.Open failed: %+v", err)
+ }
+
+ // We need a better way to know the session is ready for use,
+ // this is totally gross.
+ start := time.Now()
+ for {
+ d.RLock()
+ if d.DataReady {
+ d.RUnlock()
+ break
+ }
+ d.RUnlock()
+
+ if time.Since(start) > 10*time.Second {
+ t.Fatal("DataReady never became true.yy")
+ }
+ runtime.Gosched()
+ }
+
+ // TODO find a better way
+ // Add a small sleep here to make sure heartbeat and other events
+ // have enough time to get fired. Need a way to actually check
+ // those events.
+ time.Sleep(2 * time.Second)
+
+ // UpdateStatus - maybe we move this into wsapi_test.go but the websocket
+ // created here is needed. This helps tests that the websocket was setup
+ // and it is working.
+ if err = d.UpdateGameStatus(0, time.Now().String()); err != nil {
+ t.Errorf("UpdateStatus error: %+v", err)
+ }
+
+ if err = d.Close(); err != nil {
+ t.Fatalf("TestClose, d.Close failed: %+v", err)
+ }
+}
+
+func TestAddHandler(t *testing.T) {
+
+ testHandlerCalled := int32(0)
+ testHandler := func(s *Session, m *MessageCreate) {
+ atomic.AddInt32(&testHandlerCalled, 1)
+ }
+
+ interfaceHandlerCalled := int32(0)
+ interfaceHandler := func(s *Session, i interface{}) {
+ atomic.AddInt32(&interfaceHandlerCalled, 1)
+ }
+
+ bogusHandlerCalled := int32(0)
+ bogusHandler := func(s *Session, se *Session) {
+ atomic.AddInt32(&bogusHandlerCalled, 1)
+ }
+
+ d := Session{}
+ d.AddHandler(testHandler)
+ d.AddHandler(testHandler)
+
+ d.AddHandler(interfaceHandler)
+ d.AddHandler(bogusHandler)
+
+ d.handleEvent(messageCreateEventType, &MessageCreate{})
+ d.handleEvent(messageDeleteEventType, &MessageDelete{})
+
+ <-time.After(500 * time.Millisecond)
+
+ // testHandler will be called twice because it was added twice.
+ if atomic.LoadInt32(&testHandlerCalled) != 2 {
+ t.Fatalf("testHandler was not called twice.")
+ }
+
+ // interfaceHandler will be called twice, once for each event.
+ if atomic.LoadInt32(&interfaceHandlerCalled) != 2 {
+ t.Fatalf("interfaceHandler was not called twice.")
+ }
+
+ if atomic.LoadInt32(&bogusHandlerCalled) != 0 {
+ t.Fatalf("bogusHandler was called.")
+ }
+}
+
+func TestRemoveHandler(t *testing.T) {
+
+ testHandlerCalled := int32(0)
+ testHandler := func(s *Session, m *MessageCreate) {
+ atomic.AddInt32(&testHandlerCalled, 1)
+ }
+
+ d := Session{}
+ r := d.AddHandler(testHandler)
+
+ d.handleEvent(messageCreateEventType, &MessageCreate{})
+
+ r()
+
+ d.handleEvent(messageCreateEventType, &MessageCreate{})
+
+ <-time.After(500 * time.Millisecond)
+
+ // testHandler will be called once, as it was removed in between calls.
+ if atomic.LoadInt32(&testHandlerCalled) != 1 {
+ t.Fatalf("testHandler was not called once.")
+ }
+}
+
+func TestScheduledEvents(t *testing.T) {
+ if dgBot == nil {
+ t.Skip("Skipping, dgBot not set.")
+ }
+
+ beginAt := time.Now().Add(1 * time.Hour)
+ endAt := time.Now().Add(2 * time.Hour)
+ event, err := dgBot.GuildScheduledEventCreate(envGuild, &GuildScheduledEventParams{
+ Name: "Test Event",
+ PrivacyLevel: GuildScheduledEventPrivacyLevelGuildOnly,
+ ScheduledStartTime: &beginAt,
+ ScheduledEndTime: &endAt,
+ Description: "Awesome Test Event created on livestream",
+ EntityType: GuildScheduledEventEntityTypeExternal,
+ EntityMetadata: &GuildScheduledEventEntityMetadata{
+ Location: "https://discord.com",
+ },
+ })
+ defer dgBot.GuildScheduledEventDelete(envGuild, event.ID)
+
+ if err != nil || event.Name != "Test Event" {
+ t.Fatal(err)
+ }
+
+ events, err := dgBot.GuildScheduledEvents(envGuild, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var foundEvent *GuildScheduledEvent
+ for _, e := range events {
+ if e.ID == event.ID {
+ foundEvent = e
+ break
+ }
+ }
+ if foundEvent.Name != event.Name {
+ t.Fatal("err on GuildScheduledEvents endpoint. Missing Scheduled Event")
+ }
+
+ getEvent, err := dgBot.GuildScheduledEvent(envGuild, event.ID, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if getEvent.Name != event.Name {
+ t.Fatal("err on GuildScheduledEvent endpoint. Mismatched Scheduled Event")
+ }
+
+ eventUpdated, err := dgBot.GuildScheduledEventEdit(envGuild, event.ID, &GuildScheduledEventParams{Name: "Test Event Updated"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if eventUpdated.Name != "Test Event Updated" {
+ t.Fatal("err on GuildScheduledEventUpdate endpoint. Scheduled Event Name mismatch")
+ }
+
+ // Usage of 1 and 1 is just the pseudo data with the purpose to run all branches in the function without crashes.
+ // see https://github.com/bwmarrin/discordgo/pull/1032#discussion_r815438303 for more details.
+ users, err := dgBot.GuildScheduledEventUsers(envGuild, event.ID, 1, true, "1", "1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(users) != 0 {
+ t.Fatal("err on GuildScheduledEventUsers. Mismatch of event maybe occurred")
+ }
+
+ err = dgBot.GuildScheduledEventDelete(envGuild, event.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestComplexScheduledEvents(t *testing.T) {
+ if dgBot == nil {
+ t.Skip("Skipping, dgBot not set.")
+ }
+
+ beginAt := time.Now().Add(1 * time.Hour)
+ endAt := time.Now().Add(2 * time.Hour)
+ event, err := dgBot.GuildScheduledEventCreate(envGuild, &GuildScheduledEventParams{
+ Name: "Test Voice Event",
+ PrivacyLevel: GuildScheduledEventPrivacyLevelGuildOnly,
+ ScheduledStartTime: &beginAt,
+ ScheduledEndTime: &endAt,
+ Description: "Test event on voice channel",
+ EntityType: GuildScheduledEventEntityTypeVoice,
+ ChannelID: envVoiceChannel,
+ })
+ if err != nil || event.Name != "Test Voice Event" {
+ t.Fatal(err)
+ }
+ defer dgBot.GuildScheduledEventDelete(envGuild, event.ID)
+
+ _, err = dgBot.GuildScheduledEventEdit(envGuild, event.ID, &GuildScheduledEventParams{
+ EntityType: GuildScheduledEventEntityTypeExternal,
+ EntityMetadata: &GuildScheduledEventEntityMetadata{
+ Location: "https://discord.com",
+ },
+ })
+
+ if err != nil {
+ t.Fatal("err on GuildScheduledEventEdit. Change of entity type to external failed")
+ }
+
+ _, err = dgBot.GuildScheduledEventEdit(envGuild, event.ID, &GuildScheduledEventParams{
+ ChannelID: envVoiceChannel,
+ EntityType: GuildScheduledEventEntityTypeVoice,
+ EntityMetadata: nil,
+ })
+
+ if err != nil {
+ t.Fatal("err on GuildScheduledEventEdit. Change of entity type to voice failed")
+ }
+}
diff --git a/pkg/meowcord/droid.go b/pkg/meowcord/droid.go
new file mode 100644
index 0000000..c523386
--- /dev/null
+++ b/pkg/meowcord/droid.go
@@ -0,0 +1,346 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/google/uuid"
+)
+
+const (
+ droidOS = "Windows"
+ droidOSVersion = "10"
+ droidBrowser = "Chrome"
+ droidReferrer = "https://discord.com/channels/@me"
+ droidReferringDomain = "discord.com"
+ droidReleaseChannel = "stable"
+ droidStatus = "invisible"
+ droidSystemLocale = "en-US"
+)
+
+var (
+ droidCapabilities = 1734653
+ droidClientBuildNumber = 497254
+ droidGatewayURL = ""
+ mainPageLoaded = false
+)
+
+var mainPageLoadLock sync.Mutex
+
+const (
+ DroidBrowserMajorVersion = "144"
+ DroidBrowserVersion = DroidBrowserMajorVersion + ".0.0.0"
+ DroidBrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + DroidBrowserVersion + " Safari/537.36"
+)
+
+// BaseProperties contains the data common to both the X-Super-Properties header value
+// and the properties sent during IDENTIFY.
+type BaseProperties struct {
+ OS string `json:"os"`
+ Browser string `json:"browser"`
+ Device string `json:"device"`
+ SystemLocale string `json:"system_locale"`
+ HasClientMods bool `json:"has_client_mods"`
+ BrowserUserAgent string `json:"browser_user_agent"`
+ BrowserVersion string `json:"browser_version"`
+ OSVersion string `json:"os_version"`
+ Referrer string `json:"referrer"`
+ ReferringDomain string `json:"referring_domain"`
+ ReferrerCurrent string `json:"referrer_current"`
+ ReferringDomainCurrent string `json:"referring_domain_current"`
+ ReleaseChannel string `json:"release_channel"`
+ ClientBuildNumber int `json:"client_build_number"`
+ ClientEventSource *string `json:"client_event_source"`
+ ClientLaunchID uuid.UUID `json:"client_launch_id"`
+ LaunchSignature LaunchSignature `json:"launch_signature"`
+ // ClientAppState is either "unfocused" or "focused".
+ ClientAppState string `json:"client_app_state"`
+}
+
+// SuperProperties is sent as the X-Super-Properties header.
+type SuperProperties struct {
+ BaseProperties
+ ClientHeartbeatSessionID uuid.UUID `json:"client_heartbeat_session_id"`
+}
+
+// UserIdentifyProperties is sent when IDENTIFYing to the gateway.
+type UserIdentifyProperties struct {
+ BaseProperties
+ IsFastConnect bool `json:"is_fast_connect"`
+ GatewayConnectReasons string `json:"gateway_connect_reasons"`
+}
+
+type ClientState struct {
+ GuildVersions struct{} `json:"guild_versions"`
+ HighestLastMessageID string `json:"highest_last_message_id,omitempty"`
+ ReadStateVersion int `json:"read_state_version,omitempty"`
+ UserGuildSettingsVersion int `json:"user_guild_settings_version,omitempty"`
+ UserSettingsVersion int `json:"user_settings_version,omitempty"`
+ PrivateChannelsVersion string `json:"private_channels_version,omitempty"`
+ APICodeVersion int `json:"api_code_version,omitempty"`
+}
+
+func mustMarshalJSON(data interface{}) string {
+ dat, err := json.Marshal(data)
+ if err != nil {
+ panic(err)
+ }
+ return base64.StdEncoding.EncodeToString(dat)
+}
+
+func basedOn(base map[string]string, additional map[string]string) map[string]string {
+ for k, v := range base {
+ _, exists := additional[k]
+ if !exists {
+ additional[k] = v
+ }
+ }
+ return additional
+}
+
+func (s *Session) UpdateVersion(version, capabilities int) {
+ droidClientBuildNumber = version
+ droidCapabilities = capabilities
+ droidBaseProperties.ClientBuildNumber = version
+ s.UpdateUserHeaders()
+}
+
+func (s *Session) UpdateUserHeaders() {
+ baseProps := *droidBaseProperties
+ baseProps.LaunchSignature = s.launchSignature
+ baseProps.ClientLaunchID = s.launchID
+
+ superProps := SuperProperties{
+ BaseProperties: baseProps,
+ ClientHeartbeatSessionID: s.HeartbeatSession.ID,
+ }
+
+ superPropsHeader := "X-Super-Properties"
+ encodedSuperProps := mustMarshalJSON(superProps)
+ s.fetchHeaders = basedOn(DroidBaseHeaders, map[string]string{
+ "Sec-Fetch-Dest": "empty",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Site": "same-origin",
+ "X-Debug-Options": "bugReporterEnabled",
+ "X-Discord-Locale": droidSystemLocale,
+ "X-Discord-Timezone": "UTC",
+ superPropsHeader: encodedSuperProps,
+ })
+ s.downloadHeaders = basedOn(s.fetchHeaders, map[string]string{
+ "Sec-Fetch-Mode": "no-cors",
+ superPropsHeader: encodedSuperProps,
+ })
+ s.imageHeaders = basedOn(s.downloadHeaders, map[string]string{
+ "Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
+ "Sec-Fetch-Dest": "image",
+ superPropsHeader: encodedSuperProps,
+ })
+
+ identifyProps := UserIdentifyProperties{
+ BaseProperties: baseProps,
+ IsFastConnect: false,
+ GatewayConnectReasons: "AppSkeleton",
+ }
+ s.Identify.Properties = identifyProps
+}
+
+func (s *Session) SetGatewayURL(url string) {
+ s.gateway = url
+ s.noClearGateway = true
+}
+
+var apiVersionRegex = regexp.MustCompile(`"?API_VERSION"?:\s?(\d+),`)
+var gatewayURLRegex = regexp.MustCompile(`"?GATEWAY_ENDPOINT"?:\s?['"](.+?)['"],`)
+var mainJSRegex = regexp.MustCompile(`src="(/assets/web.[a-f0-9]{12,32}.js)"`)
+var buildNumberRegex = regexp.MustCompile(`(?:buildNumber|build_number):\s?['"]?(\d{6,})['"]?`)
+
+func (s *Session) LoadMainPage(ctx context.Context) error {
+ mainPageLoadLock.Lock()
+ defer mainPageLoadLock.Unlock()
+ if mainPageLoaded && droidGatewayURL != "" {
+ s.SetGatewayURL(droidGatewayURL)
+ return nil
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/channels/@me", nil)
+ if err != nil {
+ return fmt.Errorf("failed to prepare request: %w", err)
+ }
+ for name, value := range DroidBaseHeaders {
+ req.Header.Add(name, value)
+ }
+ req.Header.Set("Sec-Fetch-Dest", "document")
+ req.Header.Set("Sec-Fetch-Mode", "navigate")
+ req.Header.Set("Sec-Fetch-Site", "none")
+ req.Header.Set("Sec-Fetch-User", "?1")
+ req.Header.Set("Upgrade-Insecure-Requests", "1")
+ req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
+ resp, err := s.Client.Do(req)
+ if err != nil {
+ return fmt.Errorf("failed to fetch main page: %w", err)
+ }
+ data, err := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if err != nil {
+ return fmt.Errorf("failed to read main page: %w", err)
+ }
+
+ apiVersionMatch := apiVersionRegex.FindSubmatch(data)
+ if apiVersionMatch == nil {
+ return fmt.Errorf("failed to find API version")
+ } else if string(apiVersionMatch[1]) != APIVersion {
+ return fmt.Errorf("API version mismatch: expected %s, got %s", APIVersion, apiVersionMatch[1])
+ }
+ gatewayURLMatch := gatewayURLRegex.FindSubmatch(data)
+ if gatewayURLMatch == nil {
+ return fmt.Errorf("failed to find gateway URL")
+ }
+ droidGatewayURL = string(gatewayURLMatch[1])
+ if !strings.HasSuffix(droidGatewayURL, "/") {
+ droidGatewayURL += "/"
+ }
+ s.log(LogInformational, "Found gateway URL %s and confirmed API version", droidGatewayURL)
+ s.SetGatewayURL(droidGatewayURL)
+ mainJSMatch := mainJSRegex.FindSubmatch(data)
+ if mainJSMatch == nil {
+ return fmt.Errorf("failed to find main JS URL")
+ }
+
+ jsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com"+string(mainJSMatch[1]), nil)
+ if err != nil {
+ return fmt.Errorf("failed to prepare JS request: %w", err)
+ }
+ for name, value := range DroidBaseHeaders {
+ req.Header.Add(name, value)
+ }
+ jsReq.Header.Set("Sec-Fetch-Dest", "script")
+ jsReq.Header.Set("Sec-Fetch-Mode", "no-cors")
+ jsReq.Header.Set("Sec-Fetch-Site", "same-origin")
+ jsReq.Header.Set("Accept", "*/*")
+ jsResp, err := s.Client.Do(jsReq)
+ if err != nil {
+ return fmt.Errorf("failed to fetch JS: %w", err)
+ }
+ jsData, err := io.ReadAll(jsResp.Body)
+ _ = jsResp.Body.Close()
+ if err != nil {
+ return fmt.Errorf("failed to read JS: %w", err)
+ }
+ buildNumberMatch := buildNumberRegex.FindSubmatch(jsData)
+ if buildNumberMatch == nil {
+ return fmt.Errorf("failed to find build number")
+ }
+ buildNumberInt, err := strconv.Atoi(string(buildNumberMatch[1]))
+ if err != nil {
+ return fmt.Errorf("failed to parse build number %s: %w", buildNumberMatch[1], err)
+ }
+ s.log(LogInformational, "Found build number %d from JS file %s", buildNumberInt, string(mainJSMatch[1]))
+ // TODO parse capabilities too?
+ s.UpdateVersion(buildNumberInt, droidCapabilities)
+ mainPageLoaded = true
+
+ return nil
+}
+
+var (
+ droidBaseProperties = &BaseProperties{
+ OS: droidOS,
+ OSVersion: droidOSVersion,
+ Browser: droidBrowser,
+ BrowserVersion: DroidBrowserVersion,
+ BrowserUserAgent: DroidBrowserUserAgent,
+ //Referrer: droidReferrer,
+ //ReferringDomain: droidReferringDomain,
+ ClientBuildNumber: droidClientBuildNumber,
+ ReleaseChannel: droidReleaseChannel,
+ SystemLocale: droidSystemLocale,
+ ClientAppState: "focused",
+ }
+ DroidBaseHeaders = map[string]string{
+ "Sec-Ch-Ua": fmt.Sprintf(`" Not A;Brand";v="99", "Chromium";v="%[1]s", "Google Chrome";v="%[1]s"`, DroidBrowserMajorVersion),
+ "Sec-Ch-Ua-Mobile": "?0",
+ "Sec-Ch-Ua-Platform": `"` + droidOS + `"`,
+
+ "Accept": "*/*",
+ "Origin": "https://discord.com",
+ "Accept-Language": "en-US,en;q=0.9",
+ "User-Agent": DroidBrowserUserAgent,
+ }
+ DroidFetchHeaders = basedOn(DroidBaseHeaders, map[string]string{})
+ DroidDownloadHeaders = basedOn(DroidFetchHeaders, map[string]string{
+ "Sec-Fetch-Mode": "no-cors",
+ })
+
+ DroidWSHeaders = map[string]string{
+ "User-Agent": DroidBrowserUserAgent,
+ "Origin": "https://discord.com",
+ "Accept-Language": "en-US,en;q=0.9",
+ "Pragma": "no-cache",
+ "Cache-Control": "no-cache",
+ "Accept-Encoding": "gzip, deflate, br",
+
+ //"Sec-Fetch-Dest": "websocket",
+ //"Sec-Fetch-Mode": "websocket",
+ //"Sec-Fetch-Site": "cross-site",
+ }
+)
+
+const (
+ ThreadJoinLocationContextMenu = "Context Menu"
+ ThreadJoinLocationToolbarOverflow = "Toolbar Overflow"
+ ThreadJoinLocationSidebarOverflow = "Sidebar Overflow"
+)
+
+const (
+ ReactionLocationHoverBar = "Message Hover Bar"
+ ReactionLocationInlineButton = "Message Inline Button"
+ ReactionLocationPicker = "Message Reaction Picker"
+ ReactionLocationContextMenu = "Message Context Menu"
+)
+
+func (s *Session) MessageReactionAddUser(guildID, channelID, messageID, emojiID string, options ...RequestOption) error {
+ if s.IsUser {
+ options = append(
+ options,
+ WithChannelReferer(guildID, channelID),
+ WithLocationParam(ReactionLocationPicker),
+ WithQueryParam("type", "0"),
+ )
+ }
+ return s.MessageReactionAdd(channelID, messageID, emojiID, options...)
+}
+
+func (s *Session) MessageReactionRemoveUser(guildID, channelID, messageID, emojiID, userID string, options ...RequestOption) error {
+ if s.IsUser {
+ options = append(
+ options,
+ WithChannelReferer(guildID, channelID),
+ WithLocationParam(ReactionLocationInlineButton),
+ WithQueryParam("burst", "false"),
+ )
+ }
+ return s.MessageReactionRemove(channelID, messageID, emojiID, userID, options...)
+}
diff --git a/pkg/meowcord/endpoints.go b/pkg/meowcord/endpoints.go
new file mode 100644
index 0000000..709b323
--- /dev/null
+++ b/pkg/meowcord/endpoints.go
@@ -0,0 +1,340 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains variables for all known Discord end points. All functions
+// throughout the Discordgo package use these variables for all connections
+// to Discord. These are all exported and you may modify them if needed.
+
+package meowcord
+
+import "strconv"
+
+// APIVersion is the Discord API version used for the REST and Websocket API.
+var APIVersion = "9"
+
+// Animated assets are ideally delivered as WebP with ?animated=true. For media
+// natively uploaded as WebP/AVIF, attempting to access them as GIFs will
+// return HTTP 415.
+const animatedSuffix = ".webp?animated=true"
+
+// Known Discord API Endpoints.
+var (
+ EndpointStatus = "https://status.discord.com/api/v2/"
+ EndpointSm = EndpointStatus + "scheduled-maintenances/"
+ EndpointSmActive = EndpointSm + "active.json"
+ EndpointSmUpcoming = EndpointSm + "upcoming.json"
+
+ EndpointDiscord = "https://discord.com/"
+ EndpointAPI = EndpointDiscord + "api/v" + APIVersion + "/"
+ EndpointGuilds = EndpointAPI + "guilds/"
+ EndpointChannels = EndpointAPI + "channels/"
+ EndpointUsers = EndpointAPI + "users/"
+ EndpointGateway = EndpointAPI + "gateway"
+ EndpointGatewayBot = EndpointGateway + "/bot"
+ EndpointWebhooks = EndpointAPI + "webhooks/"
+ EndpointStickers = EndpointAPI + "stickers/"
+ EndpointStageInstances = EndpointAPI + "stage-instances"
+ EndpointSKUs = EndpointAPI + "skus"
+
+ EndpointCDN = "https://cdn.discordapp.com/"
+ EndpointCDNAttachments = EndpointCDN + "attachments/"
+ EndpointCDNAvatars = EndpointCDN + "avatars/"
+ EndpointCDNIcons = EndpointCDN + "icons/"
+ EndpointCDNSplashes = EndpointCDN + "splashes/"
+ EndpointCDNChannelIcons = EndpointCDN + "channel-icons/"
+ EndpointCDNBanners = EndpointCDN + "banners/"
+ EndpointCDNGuilds = EndpointCDN + "guilds/"
+ EndpointCDNStickers = EndpointCDN + "stickers/"
+ EndpointCDNRoleIcons = EndpointCDN + "role-icons/"
+
+ EndpointVoice = EndpointAPI + "/voice/"
+ EndpointVoiceRegions = EndpointVoice + "regions"
+
+ EndpointUser = func(uID string) string { return EndpointUsers + uID }
+ EndpointUserAvatar = func(uID, aID string) string { return EndpointCDNAvatars + uID + "/" + aID + ".png" }
+ EndpointUserAvatarAnimated = func(uID, aID string) string { return EndpointCDNAvatars + uID + "/" + aID + animatedSuffix }
+ EndpointDefaultUserAvatar = func(idx int) string {
+ return EndpointCDN + "embed/avatars/" + strconv.Itoa(idx) + ".png"
+ }
+ EndpointUserBanner = func(uID, cID string) string {
+ return EndpointCDNBanners + uID + "/" + cID + ".png"
+ }
+ EndpointUserBannerAnimated = func(uID, cID string) string {
+ return EndpointCDNBanners + uID + "/" + cID + animatedSuffix
+ }
+
+ EndpointUserGuilds = func(uID string) string { return EndpointUsers + uID + "/guilds" }
+ EndpointUserGuild = func(uID, gID string) string { return EndpointUsers + uID + "/guilds/" + gID }
+ EndpointUserGuildMember = func(uID, gID string) string { return EndpointUserGuild(uID, gID) + "/member" }
+ EndpointUserChannels = func(uID string) string { return EndpointUsers + uID + "/channels" }
+ EndpointUserApplicationRoleConnection = func(aID string) string { return EndpointUsers + "@me/applications/" + aID + "/role-connection" }
+ EndpointUserConnections = func(uID string) string { return EndpointUsers + uID + "/connections" }
+
+ EndpointGuild = func(gID string) string { return EndpointGuilds + gID }
+ EndpointGuildAutoModeration = func(gID string) string { return EndpointGuild(gID) + "/auto-moderation" }
+ EndpointGuildAutoModerationRules = func(gID string) string { return EndpointGuildAutoModeration(gID) + "/rules" }
+ EndpointGuildAutoModerationRule = func(gID, rID string) string { return EndpointGuildAutoModerationRules(gID) + "/" + rID }
+ EndpointGuildThreads = func(gID string) string { return EndpointGuild(gID) + "/threads" }
+ EndpointGuildActiveThreads = func(gID string) string { return EndpointGuildThreads(gID) + "/active" }
+ EndpointGuildPreview = func(gID string) string { return EndpointGuilds + gID + "/preview" }
+ EndpointGuildChannels = func(gID string) string { return EndpointGuilds + gID + "/channels" }
+ EndpointGuildMembers = func(gID string) string { return EndpointGuilds + gID + "/members" }
+ EndpointGuildMembersSearch = func(gID string) string { return EndpointGuildMembers(gID) + "/search" }
+ EndpointGuildMember = func(gID, uID string) string { return EndpointGuilds + gID + "/members/" + uID }
+ EndpointGuildMemberRole = func(gID, uID, rID string) string { return EndpointGuilds + gID + "/members/" + uID + "/roles/" + rID }
+ EndpointGuildBans = func(gID string) string { return EndpointGuilds + gID + "/bans" }
+ EndpointGuildBan = func(gID, uID string) string { return EndpointGuilds + gID + "/bans/" + uID }
+ EndpointGuildIntegrations = func(gID string) string { return EndpointGuilds + gID + "/integrations" }
+ EndpointGuildIntegration = func(gID, iID string) string { return EndpointGuilds + gID + "/integrations/" + iID }
+ EndpointGuildRoles = func(gID string) string { return EndpointGuilds + gID + "/roles" }
+ EndpointGuildRole = func(gID, rID string) string { return EndpointGuilds + gID + "/roles/" + rID }
+ EndpointGuildRoleMemberCounts = func(gID string) string { return EndpointGuildRoles(gID) + "/member-counts" }
+ EndpointGuildInvites = func(gID string) string { return EndpointGuilds + gID + "/invites" }
+ EndpointGuildWidget = func(gID string) string { return EndpointGuilds + gID + "/widget" }
+ EndpointGuildEmbed = EndpointGuildWidget
+ EndpointGuildPrune = func(gID string) string { return EndpointGuilds + gID + "/prune" }
+ EndpointGuildIcon = func(gID, hash string) string { return EndpointCDNIcons + gID + "/" + hash + ".png" }
+ EndpointGuildIconAnimated = func(gID, hash string) string { return EndpointCDNIcons + gID + "/" + hash + animatedSuffix }
+ EndpointGuildSplash = func(gID, hash string) string { return EndpointCDNSplashes + gID + "/" + hash + ".png" }
+ EndpointGuildWebhooks = func(gID string) string { return EndpointGuilds + gID + "/webhooks" }
+ EndpointGuildAuditLogs = func(gID string) string { return EndpointGuilds + gID + "/audit-logs" }
+ EndpointGuildEmojis = func(gID string) string { return EndpointGuilds + gID + "/emojis" }
+ EndpointGuildEmoji = func(gID, eID string) string { return EndpointGuilds + gID + "/emojis/" + eID }
+ EndpointGuildBanner = func(gID, hash string) string { return EndpointCDNBanners + gID + "/" + hash + ".png" }
+ EndpointGuildBannerAnimated = func(gID, hash string) string { return EndpointCDNBanners + gID + "/" + hash + animatedSuffix }
+ EndpointGuildStickers = func(gID string) string { return EndpointGuilds + gID + "/stickers" }
+ EndpointGuildSticker = func(gID, sID string) string { return EndpointGuilds + gID + "/stickers/" + sID }
+ EndpointStageInstance = func(cID string) string { return EndpointStageInstances + "/" + cID }
+ EndpointGuildScheduledEvents = func(gID string) string { return EndpointGuilds + gID + "/scheduled-events" }
+ EndpointGuildScheduledEvent = func(gID, eID string) string { return EndpointGuilds + gID + "/scheduled-events/" + eID }
+ EndpointGuildScheduledEventUsers = func(gID, eID string) string { return EndpointGuildScheduledEvent(gID, eID) + "/users" }
+ EndpointGuildOnboarding = func(gID string) string { return EndpointGuilds + gID + "/onboarding" }
+ EndpointGuildTemplate = func(tID string) string { return EndpointGuilds + "templates/" + tID }
+ EndpointGuildTemplates = func(gID string) string { return EndpointGuilds + gID + "/templates" }
+ EndpointGuildTemplateSync = func(gID, tID string) string { return EndpointGuilds + gID + "/templates/" + tID }
+ EndpointGuildMemberAvatar = func(gId, uID, aID string) string {
+ return EndpointCDNGuilds + gId + "/users/" + uID + "/avatars/" + aID + ".png"
+ }
+ EndpointGuildMemberAvatarAnimated = func(gId, uID, aID string) string {
+ return EndpointCDNGuilds + gId + "/users/" + uID + "/avatars/" + aID + animatedSuffix
+ }
+ EndpointGuildMemberBanner = func(gId, uID, hash string) string {
+ return EndpointCDNGuilds + gId + "/users/" + uID + "/banners/" + hash + ".png"
+ }
+ EndpointGuildMemberBannerAnimated = func(gId, uID, hash string) string {
+ return EndpointCDNGuilds + gId + "/users/" + uID + "/banners/" + hash + animatedSuffix
+ }
+ EndpointGuildMemberVoiceState = func(gID, uID string) string {
+ return EndpointGuild(gID) + "/voice-states/" + uID
+ }
+
+ EndpointRoleIcon = func(rID, hash string) string {
+ return EndpointCDNRoleIcons + rID + "/" + hash + ".png"
+ }
+
+ EndpointChannel = func(cID string) string { return EndpointChannels + cID }
+ EndpointChannelThreads = func(cID string) string { return EndpointChannel(cID) + "/threads" }
+ EndpointChannelActiveThreads = func(cID string) string { return EndpointChannelThreads(cID) + "/active" }
+ EndpointChannelPublicArchivedThreads = func(cID string) string { return EndpointChannelThreads(cID) + "/archived/public" }
+ EndpointChannelPrivateArchivedThreads = func(cID string) string { return EndpointChannelThreads(cID) + "/archived/private" }
+ EndpointChannelJoinedPrivateArchivedThreads = func(cID string) string { return EndpointChannel(cID) + "/users/@me/threads/archived/private" }
+ EndpointChannelPermissions = func(cID string) string { return EndpointChannels + cID + "/permissions" }
+ EndpointChannelPermission = func(cID, tID string) string { return EndpointChannels + cID + "/permissions/" + tID }
+ EndpointChannelInvites = func(cID string) string { return EndpointChannels + cID + "/invites" }
+ EndpointChannelTyping = func(cID string) string { return EndpointChannels + cID + "/typing" }
+ EndpointChannelAttachments = func(cID string) string { return EndpointChannels + cID + "/attachments" }
+ EndpointChannelMessages = func(cID string) string { return EndpointChannels + cID + "/messages" }
+ EndpointChannelMessage = func(cID, mID string) string { return EndpointChannels + cID + "/messages/" + mID }
+ EndpointChannelMessageThread = func(cID, mID string) string { return EndpointChannelMessage(cID, mID) + "/threads" }
+ EndpointChannelMessagesBulkDelete = func(cID string) string { return EndpointChannel(cID) + "/messages/bulk-delete" }
+ EndpointChannelMessagesPins = func(cID string) string { return EndpointChannel(cID) + "/messages/pins" }
+ EndpointChannelMessagePin = func(cID, mID string) string { return EndpointChannel(cID) + "/messages/pins/" + mID }
+ EndpointChannelMessageCrosspost = func(cID, mID string) string { return EndpointChannel(cID) + "/messages/" + mID + "/crosspost" }
+ EndpointChannelFollow = func(cID string) string { return EndpointChannel(cID) + "/followers" }
+ EndpointThreadMembers = func(tID string) string { return EndpointChannel(tID) + "/thread-members" }
+ EndpointThreadMember = func(tID, mID string) string { return EndpointThreadMembers(tID) + "/" + mID }
+
+ EndpointGroupIcon = func(cID, hash string) string { return EndpointCDNChannelIcons + cID + "/" + hash + ".png" }
+
+ EndpointSticker = func(sID string) string { return EndpointStickers + sID }
+ EndpointStickerImage = func(sID string, format StickerFormat) string {
+ var ext string
+ switch format {
+ case StickerFormatTypePNG, StickerFormatTypeAPNG:
+ ext = ".png"
+ case StickerFormatTypeLottie:
+ ext = ".json"
+ case StickerFormatTypeGIF:
+ ext = ".gif"
+ }
+ return EndpointCDNStickers + sID + ext
+ }
+ EndpointNitroStickersPacks = EndpointAPI + "/sticker-packs"
+
+ EndpointChannelWebhooks = func(cID string) string { return EndpointChannel(cID) + "/webhooks" }
+ EndpointWebhook = func(wID string) string { return EndpointWebhooks + wID }
+ EndpointWebhookToken = func(wID, token string) string { return EndpointWebhooks + wID + "/" + token }
+ EndpointWebhookMessage = func(wID, token, messageID string) string {
+ return EndpointWebhookToken(wID, token) + "/messages/" + messageID
+ }
+
+ EndpointMessageReactionsAll = func(cID, mID string) string {
+ return EndpointChannelMessage(cID, mID) + "/reactions"
+ }
+ EndpointMessageReactions = func(cID, mID, eID string) string {
+ return EndpointChannelMessage(cID, mID) + "/reactions/" + eID
+ }
+ EndpointMessageReaction = func(cID, mID, eID, uID string) string {
+ return EndpointMessageReactions(cID, mID, eID) + "/" + uID
+ }
+
+ EndpointPoll = func(cID, mID string) string {
+ return EndpointChannel(cID) + "/polls/" + mID
+ }
+ EndpointPollAnswerVoters = func(cID, mID string, aID int) string {
+ return EndpointPoll(cID, mID) + "/answers/" + strconv.Itoa(aID)
+ }
+ EndpointPollExpire = func(cID, mID string) string {
+ return EndpointPoll(cID, mID) + "/expire"
+ }
+
+ EndpointApplicationSKUs = func(aID string) string {
+ return EndpointApplication(aID) + "/skus"
+ }
+
+ EndpointEntitlements = func(aID string) string {
+ return EndpointApplication(aID) + "/entitlements"
+ }
+ EndpointEntitlement = func(aID, eID string) string {
+ return EndpointEntitlements(aID) + "/" + eID
+ }
+ EndpointEntitlementConsume = func(aID, eID string) string {
+ return EndpointEntitlement(aID, eID) + "/consume"
+ }
+
+ EndpointSubscriptions = func(skuID string) string {
+ return EndpointSKUs + "/" + skuID + "/subscriptions"
+ }
+ EndpointSubscription = func(skuID, subID string) string {
+ return EndpointSubscriptions(skuID) + "/" + subID
+ }
+
+ EndpointApplicationGlobalCommands = func(aID string) string {
+ return EndpointApplication(aID) + "/commands"
+ }
+ EndpointApplicationGlobalCommand = func(aID, cID string) string {
+ return EndpointApplicationGlobalCommands(aID) + "/" + cID
+ }
+
+ EndpointApplicationGuildCommands = func(aID, gID string) string {
+ return EndpointApplication(aID) + "/guilds/" + gID + "/commands"
+ }
+ EndpointApplicationGuildCommand = func(aID, gID, cID string) string {
+ return EndpointApplicationGuildCommands(aID, gID) + "/" + cID
+ }
+ EndpointApplicationCommandPermissions = func(aID, gID, cID string) string {
+ return EndpointApplicationGuildCommand(aID, gID, cID) + "/permissions"
+ }
+ EndpointApplicationCommandsGuildPermissions = func(aID, gID string) string {
+ return EndpointApplicationGuildCommands(aID, gID) + "/permissions"
+ }
+ EndpointInteraction = func(aID, iToken string) string {
+ return EndpointAPI + "interactions/" + aID + "/" + iToken
+ }
+ EndpointInteractionResponse = func(iID, iToken string) string {
+ return EndpointInteraction(iID, iToken) + "/callback"
+ }
+ EndpointInteractionResponseActions = func(aID, iToken string) string {
+ return EndpointWebhookMessage(aID, iToken, "@original")
+ }
+ EndpointFollowupMessage = func(aID, iToken string) string {
+ return EndpointWebhookToken(aID, iToken)
+ }
+ EndpointFollowupMessageActions = func(aID, iToken, mID string) string {
+ return EndpointWebhookMessage(aID, iToken, mID)
+ }
+
+ EndpointGuildCreate = EndpointAPI + "guilds"
+
+ EndpointInvite = func(iID string) string { return EndpointAPI + "invites/" + iID }
+
+ EndpointEmoji = func(eID string) string { return EndpointCDN + "emojis/" + eID + ".png" }
+ EndpointEmojiAnimated = func(eID string) string { return EndpointCDN + "emojis/" + eID + animatedSuffix }
+
+ EndpointApplications = EndpointAPI + "applications"
+ EndpointApplication = func(aID string) string { return EndpointApplications + "/" + aID }
+ EndpointApplicationRoleConnectionMetadata = func(aID string) string { return EndpointApplication(aID) + "/role-connections/metadata" }
+
+ EndpointApplicationEmojis = func(aID string) string { return EndpointApplication(aID) + "/emojis" }
+ EndpointApplicationEmoji = func(aID, eID string) string { return EndpointApplication(aID) + "/emojis/" + eID }
+
+ EndpointOAuth2 = EndpointAPI + "oauth2/"
+ EndpointOAuth2Applications = EndpointOAuth2 + "applications"
+ EndpointOAuth2Application = func(aID string) string { return EndpointOAuth2Applications + "/" + aID }
+ EndpointOAuth2ApplicationsBot = func(aID string) string { return EndpointOAuth2Applications + "/" + aID + "/bot" }
+ EndpointOAuth2ApplicationAssets = func(aID string) string { return EndpointOAuth2Applications + "/" + aID + "/assets" }
+
+ // TODO: Deprecated, remove in the next release
+ EndpointOauth2 = EndpointOAuth2
+ EndpointOauth2Applications = EndpointOAuth2Applications
+ EndpointOauth2Application = EndpointOAuth2Application
+ EndpointOauth2ApplicationsBot = EndpointOAuth2ApplicationsBot
+ EndpointOauth2ApplicationAssets = EndpointOAuth2ApplicationAssets
+
+ // Non-bot endpoints
+ EndpointUserSettings = func(uID string) string { return EndpointUsers + uID + "/settings" }
+ EndpointUserGuildSettings = func(uID, gID string) string { return EndpointUsers + uID + "/guilds/" + gID + "/settings" }
+ EndpointUserDevices = func(uID string) string { return EndpointUsers + uID + "/devices" }
+ EndpointUserNotes = func(uID string) string { return EndpointUsers + "@me/notes/" + uID }
+ EndpointGuildIntegrationSync = func(gID, iID string) string { return EndpointGuilds + gID + "/integrations/" + iID + "/sync" }
+ EndpointChannelMessageAck = func(cID, mID string) string { return EndpointChannels + cID + "/messages/" + mID + "/ack" }
+ EndpointSafetyHub = func() string { return EndpointAPI + "safety-hub/@me" }
+
+ EndpointRelationships = func() string { return EndpointUsers + "@me" + "/relationships" }
+ EndpointRelationship = func(uID string) string { return EndpointRelationships() + "/" + uID }
+ EndpointRelationshipsMutual = func(uID string) string { return EndpointUsers + uID + "/relationships" }
+
+ EndpointIntegrationsJoin = func(iID string) string { return EndpointAPI + "integrations/" + iID + "/join" }
+
+ EndpointAuth = EndpointAPI + "auth/"
+ EndpointLogin = EndpointAuth + "login"
+ EndpointLogout = EndpointAuth + "logout"
+ EndpointVerify = EndpointAuth + "verify"
+ EndpointVerifyResend = EndpointAuth + "verify/resend"
+ EndpointForgotPassword = EndpointAuth + "forgot"
+ EndpointResetPassword = EndpointAuth + "reset"
+ EndpointRegister = EndpointAuth + "register"
+
+ EndpointRemoteAuthLogin = EndpointUsers + "@me/remote-auth/login"
+
+ EndpointVoiceIce = EndpointVoice + "ice"
+
+ EndpointTutorial = EndpointAPI + "tutorial/"
+ EndpointTutorialIndicators = EndpointTutorial + "indicators"
+
+ EndpointTrack = EndpointAPI + "track"
+ EndpointSso = EndpointAPI + "sso"
+ EndpointReport = EndpointAPI + "report"
+ EndpointIntegrations = EndpointAPI + "integrations"
+ EndpointInteractions = EndpointAPI + "interactions"
+
+ EndpointApplicationCommandsSearch = func(cID string) string { return EndpointChannel(cID) + "/application-commands/search" }
+)
diff --git a/pkg/meowcord/event.go b/pkg/meowcord/event.go
new file mode 100644
index 0000000..567b928
--- /dev/null
+++ b/pkg/meowcord/event.go
@@ -0,0 +1,337 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+// EventHandler is an interface for Discord events.
+type EventHandler interface {
+ // Type returns the type of event this handler belongs to.
+ Type() string
+
+ // Handle is called whenever an event of Type() happens.
+ // It is the receivers responsibility to type assert that the interface
+ // is the expected struct.
+ Handle(*Session, interface{})
+}
+
+// EventInterfaceProvider is an interface for providing empty interfaces for
+// Discord events.
+type EventInterfaceProvider interface {
+ // Type is the type of event this handler belongs to.
+ Type() string
+
+ // New returns a new instance of the struct this event handler handles.
+ // This is called once per event.
+ // The struct is provided to all handlers of the same Type().
+ New() interface{}
+}
+
+// interfaceEventType is the event handler type for interface{} events.
+const interfaceEventType = "__INTERFACE__"
+
+// interfaceEventHandler is an event handler for interface{} events.
+type interfaceEventHandler func(*Session, interface{})
+
+// Type returns the event type for interface{} events.
+func (eh interfaceEventHandler) Type() string {
+ return interfaceEventType
+}
+
+// Handle is the handler for an interface{} event.
+func (eh interfaceEventHandler) Handle(s *Session, i interface{}) {
+ eh(s, i)
+}
+
+var registeredInterfaceProviders = map[string]EventInterfaceProvider{}
+
+// registerInterfaceProvider registers a provider so that DiscordGo can
+// access it's New() method.
+func registerInterfaceProvider(eh EventInterfaceProvider) {
+ if _, ok := registeredInterfaceProviders[eh.Type()]; ok {
+ return
+ // XXX:
+ // if we should error here, we need to do something with it.
+ // fmt.Errorf("event %s already registered", eh.Type())
+ }
+ registeredInterfaceProviders[eh.Type()] = eh
+}
+
+// eventHandlerInstance is a wrapper around an event handler, as functions
+// cannot be compared directly.
+type eventHandlerInstance struct {
+ eventHandler EventHandler
+}
+
+// addEventHandler adds an event handler that will be fired anytime
+// the Discord WSAPI matching eventHandler.Type() fires.
+func (s *Session) addEventHandler(eventHandler EventHandler) func() {
+ s.handlersMu.Lock()
+ defer s.handlersMu.Unlock()
+
+ if s.handlers == nil {
+ s.handlers = map[string][]*eventHandlerInstance{}
+ }
+
+ ehi := &eventHandlerInstance{eventHandler}
+ s.handlers[eventHandler.Type()] = append(s.handlers[eventHandler.Type()], ehi)
+
+ return func() {
+ s.removeEventHandlerInstance(eventHandler.Type(), ehi)
+ }
+}
+
+// addEventHandler adds an event handler that will be fired the next time
+// the Discord WSAPI matching eventHandler.Type() fires.
+func (s *Session) addEventHandlerOnce(eventHandler EventHandler) func() {
+ s.handlersMu.Lock()
+ defer s.handlersMu.Unlock()
+
+ if s.onceHandlers == nil {
+ s.onceHandlers = map[string][]*eventHandlerInstance{}
+ }
+
+ ehi := &eventHandlerInstance{eventHandler}
+ s.onceHandlers[eventHandler.Type()] = append(s.onceHandlers[eventHandler.Type()], ehi)
+
+ return func() {
+ s.removeEventHandlerInstance(eventHandler.Type(), ehi)
+ }
+}
+
+// AddHandler allows you to add an event handler that will be fired anytime
+// the Discord WSAPI event that matches the function fires.
+// The first parameter is a *Session, and the second parameter is a pointer
+// to a struct corresponding to the event for which you want to listen.
+//
+// eg:
+//
+// Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
+// })
+//
+// or:
+//
+// Session.AddHandler(func(s *discordgo.Session, m *discordgo.PresenceUpdate) {
+// })
+//
+// List of events can be found at this page, with corresponding names in the
+// library for each event: https://discord.com/developers/docs/topics/gateway#event-names
+// There are also synthetic events fired by the library internally which are
+// available for handling, like Connect, Disconnect, and RateLimit.
+// events.go contains all of the Discord WSAPI and synthetic events that can be handled.
+//
+// The return value of this method is a function, that when called will remove the
+// event handler.
+func (s *Session) AddHandler(handler interface{}) func() {
+ eh := handlerForInterface(handler)
+
+ if eh == nil {
+ s.log(LogError, "Invalid handler type, handler will never be called")
+ return func() {}
+ }
+
+ return s.addEventHandler(eh)
+}
+
+// AddHandlerOnce allows you to add an event handler that will be fired the next time
+// the Discord WSAPI event that matches the function fires.
+// See AddHandler for more details.
+func (s *Session) AddHandlerOnce(handler interface{}) func() {
+ eh := handlerForInterface(handler)
+
+ if eh == nil {
+ s.log(LogError, "Invalid handler type, handler will never be called")
+ return func() {}
+ }
+
+ return s.addEventHandlerOnce(eh)
+}
+
+// removeEventHandler instance removes an event handler instance.
+func (s *Session) removeEventHandlerInstance(t string, ehi *eventHandlerInstance) {
+ s.handlersMu.Lock()
+ defer s.handlersMu.Unlock()
+
+ handlers := s.handlers[t]
+ for i := range handlers {
+ if handlers[i] == ehi {
+ s.handlers[t] = append(handlers[:i], handlers[i+1:]...)
+ }
+ }
+
+ onceHandlers := s.onceHandlers[t]
+ for i := range onceHandlers {
+ if onceHandlers[i] == ehi {
+ s.onceHandlers[t] = append(onceHandlers[:i], onceHandlers[i+1:]...)
+ }
+ }
+}
+
+// Handles calling permanent and once handlers for an event type.
+/*
+func (s *Session) handle(t string, i interface{}) {
+ for _, eh := range s.handlers[t] {
+ if s.SyncEvents {
+ eh.eventHandler.Handle(s, i)
+ } else {
+ go eh.eventHandler.Handle(s, i)
+ }
+ }
+
+ if len(s.onceHandlers[t]) > 0 {
+ for _, eh := range s.onceHandlers[t] {
+ if s.SyncEvents {
+ eh.eventHandler.Handle(s, i)
+ } else {
+ go eh.eventHandler.Handle(s, i)
+ }
+ }
+ s.onceHandlers[t] = nil
+ }
+}
+*/
+
+// Handles an event type by calling internal methods, firing handlers and firing the
+// interface{} event.
+func (s *Session) handleEvent(t string, i interface{}) {
+ //s.handlersMu.RLock()
+ //defer s.handlersMu.RUnlock()
+
+ // All events are dispatched internally first.
+ s.onInterface(i)
+
+ if s.EventHandler != nil {
+ s.EventHandler(i)
+ }
+
+ // Then they are dispatched to anyone handling interface{} events.
+ //s.handle(interfaceEventType, i)
+
+ // Finally they are dispatched to any typed handlers.
+ //s.handle(t, i)
+}
+
+// setGuildIds will set the GuildID on all the members of a guild.
+// This is done as event data does not have it set.
+func setGuildIds(g *Guild) {
+ for _, c := range g.Channels {
+ c.GuildID = g.ID
+ }
+
+ for _, m := range g.Members {
+ m.GuildID = g.ID
+ }
+
+ for _, vs := range g.VoiceStates {
+ vs.GuildID = g.ID
+ }
+}
+
+func setPrivateChannelMembers(r *Ready) {
+ users := make(map[string]*User)
+ for _, user := range r.Users {
+ users[user.ID] = user
+ }
+ for _, ch := range r.PrivateChannels {
+ if ch.Recipients != nil || len(ch.RecipientIDs) == 0 {
+ continue
+ }
+ ch.Recipients = make([]*User, len(ch.RecipientIDs))
+ for index, id := range ch.RecipientIDs {
+ ch.Recipients[index] = users[id]
+ }
+ }
+}
+
+func copyGuildProperties(g *Guild) {
+ if g.Properties == nil {
+ return
+ }
+ g.AfkChannelID = g.Properties.AfkChannelID
+ g.AfkTimeout = g.Properties.AfkTimeout
+ g.ApplicationID = g.Properties.ApplicationID
+ g.Banner = g.Properties.Banner
+ g.DefaultMessageNotifications = g.Properties.DefaultMessageNotifications
+ g.Description = g.Properties.Description
+ g.DiscoverySplash = g.Properties.DiscoverySplash
+ g.ExplicitContentFilter = g.Properties.ExplicitContentFilter
+ g.Features = g.Properties.Features
+ //g.HomeHeader = g.Properties.HomeHeader
+ //g.HubType = g.Properties.HubType
+ g.Icon = g.Properties.Icon
+ //g.LatestOnboardingQuestionID = g.Properties.LatestOnboardingQuestionID
+ g.MaxMembers = g.Properties.MaxMembers
+ //g.MaxStageVideoChannelUsers = g.Properties.MaxStageVideoChannelUsers
+ g.MaxVideoChannelUsers = g.Properties.MaxVideoChannelUsers
+ g.MfaLevel = g.Properties.MfaLevel
+ g.Name = g.Properties.Name
+ //g.NSFW = g.Properties.NSFW
+ g.NSFWLevel = g.Properties.NSFWLevel
+ g.OwnerID = g.Properties.OwnerID
+ g.PreferredLocale = g.Properties.PreferredLocale
+ //g.PremiumProgressBarEnabled = g.Properties.PremiumProgressBarEnabled
+ g.PremiumTier = g.Properties.PremiumTier
+ g.PublicUpdatesChannelID = g.Properties.PublicUpdatesChannelID
+ g.RulesChannelID = g.Properties.RulesChannelID
+ //g.SafetyAlertsChannelID = g.Properties.SafetyAlertsChannelID
+ g.Splash = g.Properties.Splash
+ g.SystemChannelFlags = g.Properties.SystemChannelFlags
+ g.SystemChannelID = g.Properties.SystemChannelID
+ g.VanityURLCode = g.Properties.VanityURLCode
+ g.VerificationLevel = g.Properties.VerificationLevel
+ g.Properties = nil
+}
+
+// onInterface handles all internal events and routes them to the appropriate internal handler.
+func (s *Session) onInterface(i interface{}) {
+ switch t := i.(type) {
+ case *Ready:
+ for _, g := range t.Guilds {
+ copyGuildProperties(g)
+ setGuildIds(g)
+ }
+ setPrivateChannelMembers(t)
+ s.onReady(t)
+ case *GuildCreate:
+ copyGuildProperties(t.Guild)
+ setGuildIds(t.Guild)
+ case *GuildUpdate:
+ copyGuildProperties(t.Guild)
+ setGuildIds(t.Guild)
+ case *VoiceServerUpdate:
+ go s.onVoiceServerUpdate(t)
+ case *VoiceStateUpdate:
+ go s.onVoiceStateUpdate(t)
+ }
+ err := s.State.OnInterface(s, i)
+ if err != nil {
+ s.log(LogDebug, "error dispatching internal event, %s", err)
+ }
+}
+
+// onReady handles the ready event.
+func (s *Session) onReady(r *Ready) {
+
+ // Store the SessionID within the Session struct.
+ s.sessionID = r.SessionID
+
+ // Store the ResumeGatewayURL within the Session struct.
+ s.resumeGatewayURL = r.ResumeGatewayURL
+}
diff --git a/pkg/meowcord/eventhandlers.go b/pkg/meowcord/eventhandlers.go
new file mode 100644
index 0000000..b907771
--- /dev/null
+++ b/pkg/meowcord/eventhandlers.go
@@ -0,0 +1,2080 @@
+// Code generated by \"eventhandlers\"; DO NOT EDIT
+// See events.go
+
+package meowcord
+
+// Following are all the event types.
+// Event type values are used to match the events returned by Discord.
+// EventTypes surrounded by __ are synthetic and are internal to DiscordGo.
+const (
+ applicationCommandPermissionsUpdateEventType = "APPLICATION_COMMAND_PERMISSIONS_UPDATE"
+ autoModerationActionExecutionEventType = "AUTO_MODERATION_ACTION_EXECUTION"
+ autoModerationRuleCreateEventType = "AUTO_MODERATION_RULE_CREATE"
+ autoModerationRuleDeleteEventType = "AUTO_MODERATION_RULE_DELETE"
+ autoModerationRuleUpdateEventType = "AUTO_MODERATION_RULE_UPDATE"
+ channelCreateEventType = "CHANNEL_CREATE"
+ channelDeleteEventType = "CHANNEL_DELETE"
+ channelPinsUpdateEventType = "CHANNEL_PINS_UPDATE"
+ channelRecipientAddEventType = "CHANNEL_RECIPIENT_ADD"
+ channelRecipientRemoveEventType = "CHANNEL_RECIPIENT_REMOVE"
+ channelUpdateEventType = "CHANNEL_UPDATE"
+ connectEventType = "__CONNECT__"
+ disconnectEventType = "__DISCONNECT__"
+ entitlementCreateEventType = "ENTITLEMENT_CREATE"
+ entitlementDeleteEventType = "ENTITLEMENT_DELETE"
+ entitlementUpdateEventType = "ENTITLEMENT_UPDATE"
+ eventEventType = "__EVENT__"
+ guildAuditLogEntryCreateEventType = "GUILD_AUDIT_LOG_ENTRY_CREATE"
+ guildBanAddEventType = "GUILD_BAN_ADD"
+ guildBanRemoveEventType = "GUILD_BAN_REMOVE"
+ guildCreateEventType = "GUILD_CREATE"
+ guildDeleteEventType = "GUILD_DELETE"
+ guildEmojisUpdateEventType = "GUILD_EMOJIS_UPDATE"
+ guildIntegrationsUpdateEventType = "GUILD_INTEGRATIONS_UPDATE"
+ guildMemberAddEventType = "GUILD_MEMBER_ADD"
+ guildMemberRemoveEventType = "GUILD_MEMBER_REMOVE"
+ guildMemberUpdateEventType = "GUILD_MEMBER_UPDATE"
+ guildMembersChunkEventType = "GUILD_MEMBERS_CHUNK"
+ guildRoleCreateEventType = "GUILD_ROLE_CREATE"
+ guildRoleDeleteEventType = "GUILD_ROLE_DELETE"
+ guildRoleUpdateEventType = "GUILD_ROLE_UPDATE"
+ guildScheduledEventCreateEventType = "GUILD_SCHEDULED_EVENT_CREATE"
+ guildScheduledEventDeleteEventType = "GUILD_SCHEDULED_EVENT_DELETE"
+ guildScheduledEventUpdateEventType = "GUILD_SCHEDULED_EVENT_UPDATE"
+ guildScheduledEventUserAddEventType = "GUILD_SCHEDULED_EVENT_USER_ADD"
+ guildScheduledEventUserRemoveEventType = "GUILD_SCHEDULED_EVENT_USER_REMOVE"
+ guildStickersUpdateEventType = "GUILD_STICKERS_UPDATE"
+ guildUpdateEventType = "GUILD_UPDATE"
+ integrationCreateEventType = "INTEGRATION_CREATE"
+ integrationDeleteEventType = "INTEGRATION_DELETE"
+ integrationUpdateEventType = "INTEGRATION_UPDATE"
+ interactionCreateEventType = "INTERACTION_CREATE"
+ interactionSuccessEventType = "INTERACTION_SUCCESS"
+ invalidAuthEventType = "__INVALID_AUTH__"
+ inviteCreateEventType = "INVITE_CREATE"
+ inviteDeleteEventType = "INVITE_DELETE"
+ messageAckEventType = "MESSAGE_ACK"
+ messageCreateEventType = "MESSAGE_CREATE"
+ messageDeleteEventType = "MESSAGE_DELETE"
+ messageDeleteBulkEventType = "MESSAGE_DELETE_BULK"
+ messagePollVoteAddEventType = "MESSAGE_POLL_VOTE_ADD"
+ messagePollVoteRemoveEventType = "MESSAGE_POLL_VOTE_REMOVE"
+ messageReactionAddEventType = "MESSAGE_REACTION_ADD"
+ messageReactionRemoveEventType = "MESSAGE_REACTION_REMOVE"
+ messageReactionRemoveAllEventType = "MESSAGE_REACTION_REMOVE_ALL"
+ messageReactionRemoveEmojiEventType = "MESSAGE_REACTION_REMOVE_EMOJI"
+ messageUpdateEventType = "MESSAGE_UPDATE"
+ presenceUpdateEventType = "PRESENCE_UPDATE"
+ presencesReplaceEventType = "PRESENCES_REPLACE"
+ rateLimitEventType = "__RATE_LIMIT__"
+ readyEventType = "READY"
+ readySupplementalEventType = "READY_SUPPLEMENTAL"
+ relationshipAddEventType = "RELATIONSHIP_ADD"
+ relationshipRemoveEventType = "RELATIONSHIP_REMOVE"
+ relationshipUpdateEventType = "RELATIONSHIP_UPDATE"
+ resumedEventType = "RESUMED"
+ stageInstanceEventCreateEventType = "STAGE_INSTANCE_EVENT_CREATE"
+ stageInstanceEventDeleteEventType = "STAGE_INSTANCE_EVENT_DELETE"
+ stageInstanceEventUpdateEventType = "STAGE_INSTANCE_EVENT_UPDATE"
+ subscriptionCreateEventType = "SUBSCRIPTION_CREATE"
+ subscriptionDeleteEventType = "SUBSCRIPTION_DELETE"
+ subscriptionUpdateEventType = "SUBSCRIPTION_UPDATE"
+ threadCreateEventType = "THREAD_CREATE"
+ threadDeleteEventType = "THREAD_DELETE"
+ threadListSyncEventType = "THREAD_LIST_SYNC"
+ threadMemberUpdateEventType = "THREAD_MEMBER_UPDATE"
+ threadMembersUpdateEventType = "THREAD_MEMBERS_UPDATE"
+ threadUpdateEventType = "THREAD_UPDATE"
+ typingStartEventType = "TYPING_START"
+ userGuildSettingsUpdateEventType = "USER_GUILD_SETTINGS_UPDATE"
+ userNoteUpdateEventType = "USER_NOTE_UPDATE"
+ userRequiredActionUpdateEventType = "USER_REQUIRED_ACTION_UPDATE"
+ userSettingsUpdateEventType = "USER_SETTINGS_UPDATE"
+ userUpdateEventType = "USER_UPDATE"
+ voiceServerUpdateEventType = "VOICE_SERVER_UPDATE"
+ voiceStateUpdateEventType = "VOICE_STATE_UPDATE"
+ webhooksUpdateEventType = "WEBHOOKS_UPDATE"
+)
+
+// applicationCommandPermissionsUpdateEventHandler is an event handler for ApplicationCommandPermissionsUpdate events.
+type applicationCommandPermissionsUpdateEventHandler func(*Session, *ApplicationCommandPermissionsUpdate)
+
+// Type returns the event type for ApplicationCommandPermissionsUpdate events.
+func (eh applicationCommandPermissionsUpdateEventHandler) Type() string {
+ return applicationCommandPermissionsUpdateEventType
+}
+
+// New returns a new instance of ApplicationCommandPermissionsUpdate.
+func (eh applicationCommandPermissionsUpdateEventHandler) New() interface{} {
+ return &ApplicationCommandPermissionsUpdate{}
+}
+
+// Handle is the handler for ApplicationCommandPermissionsUpdate events.
+func (eh applicationCommandPermissionsUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ApplicationCommandPermissionsUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// autoModerationActionExecutionEventHandler is an event handler for AutoModerationActionExecution events.
+type autoModerationActionExecutionEventHandler func(*Session, *AutoModerationActionExecution)
+
+// Type returns the event type for AutoModerationActionExecution events.
+func (eh autoModerationActionExecutionEventHandler) Type() string {
+ return autoModerationActionExecutionEventType
+}
+
+// New returns a new instance of AutoModerationActionExecution.
+func (eh autoModerationActionExecutionEventHandler) New() interface{} {
+ return &AutoModerationActionExecution{}
+}
+
+// Handle is the handler for AutoModerationActionExecution events.
+func (eh autoModerationActionExecutionEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*AutoModerationActionExecution); ok {
+ eh(s, t)
+ }
+}
+
+// autoModerationRuleCreateEventHandler is an event handler for AutoModerationRuleCreate events.
+type autoModerationRuleCreateEventHandler func(*Session, *AutoModerationRuleCreate)
+
+// Type returns the event type for AutoModerationRuleCreate events.
+func (eh autoModerationRuleCreateEventHandler) Type() string {
+ return autoModerationRuleCreateEventType
+}
+
+// New returns a new instance of AutoModerationRuleCreate.
+func (eh autoModerationRuleCreateEventHandler) New() interface{} {
+ return &AutoModerationRuleCreate{}
+}
+
+// Handle is the handler for AutoModerationRuleCreate events.
+func (eh autoModerationRuleCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*AutoModerationRuleCreate); ok {
+ eh(s, t)
+ }
+}
+
+// autoModerationRuleDeleteEventHandler is an event handler for AutoModerationRuleDelete events.
+type autoModerationRuleDeleteEventHandler func(*Session, *AutoModerationRuleDelete)
+
+// Type returns the event type for AutoModerationRuleDelete events.
+func (eh autoModerationRuleDeleteEventHandler) Type() string {
+ return autoModerationRuleDeleteEventType
+}
+
+// New returns a new instance of AutoModerationRuleDelete.
+func (eh autoModerationRuleDeleteEventHandler) New() interface{} {
+ return &AutoModerationRuleDelete{}
+}
+
+// Handle is the handler for AutoModerationRuleDelete events.
+func (eh autoModerationRuleDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*AutoModerationRuleDelete); ok {
+ eh(s, t)
+ }
+}
+
+// autoModerationRuleUpdateEventHandler is an event handler for AutoModerationRuleUpdate events.
+type autoModerationRuleUpdateEventHandler func(*Session, *AutoModerationRuleUpdate)
+
+// Type returns the event type for AutoModerationRuleUpdate events.
+func (eh autoModerationRuleUpdateEventHandler) Type() string {
+ return autoModerationRuleUpdateEventType
+}
+
+// New returns a new instance of AutoModerationRuleUpdate.
+func (eh autoModerationRuleUpdateEventHandler) New() interface{} {
+ return &AutoModerationRuleUpdate{}
+}
+
+// Handle is the handler for AutoModerationRuleUpdate events.
+func (eh autoModerationRuleUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*AutoModerationRuleUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// channelCreateEventHandler is an event handler for ChannelCreate events.
+type channelCreateEventHandler func(*Session, *ChannelCreate)
+
+// Type returns the event type for ChannelCreate events.
+func (eh channelCreateEventHandler) Type() string {
+ return channelCreateEventType
+}
+
+// New returns a new instance of ChannelCreate.
+func (eh channelCreateEventHandler) New() interface{} {
+ return &ChannelCreate{}
+}
+
+// Handle is the handler for ChannelCreate events.
+func (eh channelCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ChannelCreate); ok {
+ eh(s, t)
+ }
+}
+
+// channelDeleteEventHandler is an event handler for ChannelDelete events.
+type channelDeleteEventHandler func(*Session, *ChannelDelete)
+
+// Type returns the event type for ChannelDelete events.
+func (eh channelDeleteEventHandler) Type() string {
+ return channelDeleteEventType
+}
+
+// New returns a new instance of ChannelDelete.
+func (eh channelDeleteEventHandler) New() interface{} {
+ return &ChannelDelete{}
+}
+
+// Handle is the handler for ChannelDelete events.
+func (eh channelDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ChannelDelete); ok {
+ eh(s, t)
+ }
+}
+
+// channelPinsUpdateEventHandler is an event handler for ChannelPinsUpdate events.
+type channelPinsUpdateEventHandler func(*Session, *ChannelPinsUpdate)
+
+// Type returns the event type for ChannelPinsUpdate events.
+func (eh channelPinsUpdateEventHandler) Type() string {
+ return channelPinsUpdateEventType
+}
+
+// New returns a new instance of ChannelPinsUpdate.
+func (eh channelPinsUpdateEventHandler) New() interface{} {
+ return &ChannelPinsUpdate{}
+}
+
+// Handle is the handler for ChannelPinsUpdate events.
+func (eh channelPinsUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ChannelPinsUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// channelRecipientAddEventHandler is an event handler for ChannelRecipientAdd events.
+type channelRecipientAddEventHandler func(*Session, *ChannelRecipientAdd)
+
+// Type returns the event type for ChannelRecipientAdd events.
+func (eh channelRecipientAddEventHandler) Type() string {
+ return channelRecipientAddEventType
+}
+
+// New returns a new instance of ChannelRecipientAdd.
+func (eh channelRecipientAddEventHandler) New() interface{} {
+ return &ChannelRecipientAdd{}
+}
+
+// Handle is the handler for ChannelRecipientAdd events.
+func (eh channelRecipientAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ChannelRecipientAdd); ok {
+ eh(s, t)
+ }
+}
+
+// channelRecipientRemoveEventHandler is an event handler for ChannelRecipientRemove events.
+type channelRecipientRemoveEventHandler func(*Session, *ChannelRecipientRemove)
+
+// Type returns the event type for ChannelRecipientRemove events.
+func (eh channelRecipientRemoveEventHandler) Type() string {
+ return channelRecipientRemoveEventType
+}
+
+// New returns a new instance of ChannelRecipientRemove.
+func (eh channelRecipientRemoveEventHandler) New() interface{} {
+ return &ChannelRecipientRemove{}
+}
+
+// Handle is the handler for ChannelRecipientRemove events.
+func (eh channelRecipientRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ChannelRecipientRemove); ok {
+ eh(s, t)
+ }
+}
+
+// channelUpdateEventHandler is an event handler for ChannelUpdate events.
+type channelUpdateEventHandler func(*Session, *ChannelUpdate)
+
+// Type returns the event type for ChannelUpdate events.
+func (eh channelUpdateEventHandler) Type() string {
+ return channelUpdateEventType
+}
+
+// New returns a new instance of ChannelUpdate.
+func (eh channelUpdateEventHandler) New() interface{} {
+ return &ChannelUpdate{}
+}
+
+// Handle is the handler for ChannelUpdate events.
+func (eh channelUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ChannelUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// connectEventHandler is an event handler for Connect events.
+type connectEventHandler func(*Session, *Connect)
+
+// Type returns the event type for Connect events.
+func (eh connectEventHandler) Type() string {
+ return connectEventType
+}
+
+// Handle is the handler for Connect events.
+func (eh connectEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*Connect); ok {
+ eh(s, t)
+ }
+}
+
+// disconnectEventHandler is an event handler for Disconnect events.
+type disconnectEventHandler func(*Session, *Disconnect)
+
+// Type returns the event type for Disconnect events.
+func (eh disconnectEventHandler) Type() string {
+ return disconnectEventType
+}
+
+// Handle is the handler for Disconnect events.
+func (eh disconnectEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*Disconnect); ok {
+ eh(s, t)
+ }
+}
+
+// entitlementCreateEventHandler is an event handler for EntitlementCreate events.
+type entitlementCreateEventHandler func(*Session, *EntitlementCreate)
+
+// Type returns the event type for EntitlementCreate events.
+func (eh entitlementCreateEventHandler) Type() string {
+ return entitlementCreateEventType
+}
+
+// New returns a new instance of EntitlementCreate.
+func (eh entitlementCreateEventHandler) New() interface{} {
+ return &EntitlementCreate{}
+}
+
+// Handle is the handler for EntitlementCreate events.
+func (eh entitlementCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*EntitlementCreate); ok {
+ eh(s, t)
+ }
+}
+
+// entitlementDeleteEventHandler is an event handler for EntitlementDelete events.
+type entitlementDeleteEventHandler func(*Session, *EntitlementDelete)
+
+// Type returns the event type for EntitlementDelete events.
+func (eh entitlementDeleteEventHandler) Type() string {
+ return entitlementDeleteEventType
+}
+
+// New returns a new instance of EntitlementDelete.
+func (eh entitlementDeleteEventHandler) New() interface{} {
+ return &EntitlementDelete{}
+}
+
+// Handle is the handler for EntitlementDelete events.
+func (eh entitlementDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*EntitlementDelete); ok {
+ eh(s, t)
+ }
+}
+
+// entitlementUpdateEventHandler is an event handler for EntitlementUpdate events.
+type entitlementUpdateEventHandler func(*Session, *EntitlementUpdate)
+
+// Type returns the event type for EntitlementUpdate events.
+func (eh entitlementUpdateEventHandler) Type() string {
+ return entitlementUpdateEventType
+}
+
+// New returns a new instance of EntitlementUpdate.
+func (eh entitlementUpdateEventHandler) New() interface{} {
+ return &EntitlementUpdate{}
+}
+
+// Handle is the handler for EntitlementUpdate events.
+func (eh entitlementUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*EntitlementUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// eventEventHandler is an event handler for Event events.
+type eventEventHandler func(*Session, *Event)
+
+// Type returns the event type for Event events.
+func (eh eventEventHandler) Type() string {
+ return eventEventType
+}
+
+// Handle is the handler for Event events.
+func (eh eventEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*Event); ok {
+ eh(s, t)
+ }
+}
+
+// guildAuditLogEntryCreateEventHandler is an event handler for GuildAuditLogEntryCreate events.
+type guildAuditLogEntryCreateEventHandler func(*Session, *GuildAuditLogEntryCreate)
+
+// Type returns the event type for GuildAuditLogEntryCreate events.
+func (eh guildAuditLogEntryCreateEventHandler) Type() string {
+ return guildAuditLogEntryCreateEventType
+}
+
+// New returns a new instance of GuildAuditLogEntryCreate.
+func (eh guildAuditLogEntryCreateEventHandler) New() interface{} {
+ return &GuildAuditLogEntryCreate{}
+}
+
+// Handle is the handler for GuildAuditLogEntryCreate events.
+func (eh guildAuditLogEntryCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildAuditLogEntryCreate); ok {
+ eh(s, t)
+ }
+}
+
+// guildBanAddEventHandler is an event handler for GuildBanAdd events.
+type guildBanAddEventHandler func(*Session, *GuildBanAdd)
+
+// Type returns the event type for GuildBanAdd events.
+func (eh guildBanAddEventHandler) Type() string {
+ return guildBanAddEventType
+}
+
+// New returns a new instance of GuildBanAdd.
+func (eh guildBanAddEventHandler) New() interface{} {
+ return &GuildBanAdd{}
+}
+
+// Handle is the handler for GuildBanAdd events.
+func (eh guildBanAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildBanAdd); ok {
+ eh(s, t)
+ }
+}
+
+// guildBanRemoveEventHandler is an event handler for GuildBanRemove events.
+type guildBanRemoveEventHandler func(*Session, *GuildBanRemove)
+
+// Type returns the event type for GuildBanRemove events.
+func (eh guildBanRemoveEventHandler) Type() string {
+ return guildBanRemoveEventType
+}
+
+// New returns a new instance of GuildBanRemove.
+func (eh guildBanRemoveEventHandler) New() interface{} {
+ return &GuildBanRemove{}
+}
+
+// Handle is the handler for GuildBanRemove events.
+func (eh guildBanRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildBanRemove); ok {
+ eh(s, t)
+ }
+}
+
+// guildCreateEventHandler is an event handler for GuildCreate events.
+type guildCreateEventHandler func(*Session, *GuildCreate)
+
+// Type returns the event type for GuildCreate events.
+func (eh guildCreateEventHandler) Type() string {
+ return guildCreateEventType
+}
+
+// New returns a new instance of GuildCreate.
+func (eh guildCreateEventHandler) New() interface{} {
+ return &GuildCreate{}
+}
+
+// Handle is the handler for GuildCreate events.
+func (eh guildCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildCreate); ok {
+ eh(s, t)
+ }
+}
+
+// guildDeleteEventHandler is an event handler for GuildDelete events.
+type guildDeleteEventHandler func(*Session, *GuildDelete)
+
+// Type returns the event type for GuildDelete events.
+func (eh guildDeleteEventHandler) Type() string {
+ return guildDeleteEventType
+}
+
+// New returns a new instance of GuildDelete.
+func (eh guildDeleteEventHandler) New() interface{} {
+ return &GuildDelete{}
+}
+
+// Handle is the handler for GuildDelete events.
+func (eh guildDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildDelete); ok {
+ eh(s, t)
+ }
+}
+
+// guildEmojisUpdateEventHandler is an event handler for GuildEmojisUpdate events.
+type guildEmojisUpdateEventHandler func(*Session, *GuildEmojisUpdate)
+
+// Type returns the event type for GuildEmojisUpdate events.
+func (eh guildEmojisUpdateEventHandler) Type() string {
+ return guildEmojisUpdateEventType
+}
+
+// New returns a new instance of GuildEmojisUpdate.
+func (eh guildEmojisUpdateEventHandler) New() interface{} {
+ return &GuildEmojisUpdate{}
+}
+
+// Handle is the handler for GuildEmojisUpdate events.
+func (eh guildEmojisUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildEmojisUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// guildIntegrationsUpdateEventHandler is an event handler for GuildIntegrationsUpdate events.
+type guildIntegrationsUpdateEventHandler func(*Session, *GuildIntegrationsUpdate)
+
+// Type returns the event type for GuildIntegrationsUpdate events.
+func (eh guildIntegrationsUpdateEventHandler) Type() string {
+ return guildIntegrationsUpdateEventType
+}
+
+// New returns a new instance of GuildIntegrationsUpdate.
+func (eh guildIntegrationsUpdateEventHandler) New() interface{} {
+ return &GuildIntegrationsUpdate{}
+}
+
+// Handle is the handler for GuildIntegrationsUpdate events.
+func (eh guildIntegrationsUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildIntegrationsUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// guildMemberAddEventHandler is an event handler for GuildMemberAdd events.
+type guildMemberAddEventHandler func(*Session, *GuildMemberAdd)
+
+// Type returns the event type for GuildMemberAdd events.
+func (eh guildMemberAddEventHandler) Type() string {
+ return guildMemberAddEventType
+}
+
+// New returns a new instance of GuildMemberAdd.
+func (eh guildMemberAddEventHandler) New() interface{} {
+ return &GuildMemberAdd{}
+}
+
+// Handle is the handler for GuildMemberAdd events.
+func (eh guildMemberAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildMemberAdd); ok {
+ eh(s, t)
+ }
+}
+
+// guildMemberRemoveEventHandler is an event handler for GuildMemberRemove events.
+type guildMemberRemoveEventHandler func(*Session, *GuildMemberRemove)
+
+// Type returns the event type for GuildMemberRemove events.
+func (eh guildMemberRemoveEventHandler) Type() string {
+ return guildMemberRemoveEventType
+}
+
+// New returns a new instance of GuildMemberRemove.
+func (eh guildMemberRemoveEventHandler) New() interface{} {
+ return &GuildMemberRemove{}
+}
+
+// Handle is the handler for GuildMemberRemove events.
+func (eh guildMemberRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildMemberRemove); ok {
+ eh(s, t)
+ }
+}
+
+// guildMemberUpdateEventHandler is an event handler for GuildMemberUpdate events.
+type guildMemberUpdateEventHandler func(*Session, *GuildMemberUpdate)
+
+// Type returns the event type for GuildMemberUpdate events.
+func (eh guildMemberUpdateEventHandler) Type() string {
+ return guildMemberUpdateEventType
+}
+
+// New returns a new instance of GuildMemberUpdate.
+func (eh guildMemberUpdateEventHandler) New() interface{} {
+ return &GuildMemberUpdate{}
+}
+
+// Handle is the handler for GuildMemberUpdate events.
+func (eh guildMemberUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildMemberUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// guildMembersChunkEventHandler is an event handler for GuildMembersChunk events.
+type guildMembersChunkEventHandler func(*Session, *GuildMembersChunk)
+
+// Type returns the event type for GuildMembersChunk events.
+func (eh guildMembersChunkEventHandler) Type() string {
+ return guildMembersChunkEventType
+}
+
+// New returns a new instance of GuildMembersChunk.
+func (eh guildMembersChunkEventHandler) New() interface{} {
+ return &GuildMembersChunk{}
+}
+
+// Handle is the handler for GuildMembersChunk events.
+func (eh guildMembersChunkEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildMembersChunk); ok {
+ eh(s, t)
+ }
+}
+
+// guildRoleCreateEventHandler is an event handler for GuildRoleCreate events.
+type guildRoleCreateEventHandler func(*Session, *GuildRoleCreate)
+
+// Type returns the event type for GuildRoleCreate events.
+func (eh guildRoleCreateEventHandler) Type() string {
+ return guildRoleCreateEventType
+}
+
+// New returns a new instance of GuildRoleCreate.
+func (eh guildRoleCreateEventHandler) New() interface{} {
+ return &GuildRoleCreate{}
+}
+
+// Handle is the handler for GuildRoleCreate events.
+func (eh guildRoleCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildRoleCreate); ok {
+ eh(s, t)
+ }
+}
+
+// guildRoleDeleteEventHandler is an event handler for GuildRoleDelete events.
+type guildRoleDeleteEventHandler func(*Session, *GuildRoleDelete)
+
+// Type returns the event type for GuildRoleDelete events.
+func (eh guildRoleDeleteEventHandler) Type() string {
+ return guildRoleDeleteEventType
+}
+
+// New returns a new instance of GuildRoleDelete.
+func (eh guildRoleDeleteEventHandler) New() interface{} {
+ return &GuildRoleDelete{}
+}
+
+// Handle is the handler for GuildRoleDelete events.
+func (eh guildRoleDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildRoleDelete); ok {
+ eh(s, t)
+ }
+}
+
+// guildRoleUpdateEventHandler is an event handler for GuildRoleUpdate events.
+type guildRoleUpdateEventHandler func(*Session, *GuildRoleUpdate)
+
+// Type returns the event type for GuildRoleUpdate events.
+func (eh guildRoleUpdateEventHandler) Type() string {
+ return guildRoleUpdateEventType
+}
+
+// New returns a new instance of GuildRoleUpdate.
+func (eh guildRoleUpdateEventHandler) New() interface{} {
+ return &GuildRoleUpdate{}
+}
+
+// Handle is the handler for GuildRoleUpdate events.
+func (eh guildRoleUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildRoleUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// guildScheduledEventCreateEventHandler is an event handler for GuildScheduledEventCreate events.
+type guildScheduledEventCreateEventHandler func(*Session, *GuildScheduledEventCreate)
+
+// Type returns the event type for GuildScheduledEventCreate events.
+func (eh guildScheduledEventCreateEventHandler) Type() string {
+ return guildScheduledEventCreateEventType
+}
+
+// New returns a new instance of GuildScheduledEventCreate.
+func (eh guildScheduledEventCreateEventHandler) New() interface{} {
+ return &GuildScheduledEventCreate{}
+}
+
+// Handle is the handler for GuildScheduledEventCreate events.
+func (eh guildScheduledEventCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildScheduledEventCreate); ok {
+ eh(s, t)
+ }
+}
+
+// guildScheduledEventDeleteEventHandler is an event handler for GuildScheduledEventDelete events.
+type guildScheduledEventDeleteEventHandler func(*Session, *GuildScheduledEventDelete)
+
+// Type returns the event type for GuildScheduledEventDelete events.
+func (eh guildScheduledEventDeleteEventHandler) Type() string {
+ return guildScheduledEventDeleteEventType
+}
+
+// New returns a new instance of GuildScheduledEventDelete.
+func (eh guildScheduledEventDeleteEventHandler) New() interface{} {
+ return &GuildScheduledEventDelete{}
+}
+
+// Handle is the handler for GuildScheduledEventDelete events.
+func (eh guildScheduledEventDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildScheduledEventDelete); ok {
+ eh(s, t)
+ }
+}
+
+// guildScheduledEventUpdateEventHandler is an event handler for GuildScheduledEventUpdate events.
+type guildScheduledEventUpdateEventHandler func(*Session, *GuildScheduledEventUpdate)
+
+// Type returns the event type for GuildScheduledEventUpdate events.
+func (eh guildScheduledEventUpdateEventHandler) Type() string {
+ return guildScheduledEventUpdateEventType
+}
+
+// New returns a new instance of GuildScheduledEventUpdate.
+func (eh guildScheduledEventUpdateEventHandler) New() interface{} {
+ return &GuildScheduledEventUpdate{}
+}
+
+// Handle is the handler for GuildScheduledEventUpdate events.
+func (eh guildScheduledEventUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildScheduledEventUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// guildScheduledEventUserAddEventHandler is an event handler for GuildScheduledEventUserAdd events.
+type guildScheduledEventUserAddEventHandler func(*Session, *GuildScheduledEventUserAdd)
+
+// Type returns the event type for GuildScheduledEventUserAdd events.
+func (eh guildScheduledEventUserAddEventHandler) Type() string {
+ return guildScheduledEventUserAddEventType
+}
+
+// New returns a new instance of GuildScheduledEventUserAdd.
+func (eh guildScheduledEventUserAddEventHandler) New() interface{} {
+ return &GuildScheduledEventUserAdd{}
+}
+
+// Handle is the handler for GuildScheduledEventUserAdd events.
+func (eh guildScheduledEventUserAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildScheduledEventUserAdd); ok {
+ eh(s, t)
+ }
+}
+
+// guildScheduledEventUserRemoveEventHandler is an event handler for GuildScheduledEventUserRemove events.
+type guildScheduledEventUserRemoveEventHandler func(*Session, *GuildScheduledEventUserRemove)
+
+// Type returns the event type for GuildScheduledEventUserRemove events.
+func (eh guildScheduledEventUserRemoveEventHandler) Type() string {
+ return guildScheduledEventUserRemoveEventType
+}
+
+// New returns a new instance of GuildScheduledEventUserRemove.
+func (eh guildScheduledEventUserRemoveEventHandler) New() interface{} {
+ return &GuildScheduledEventUserRemove{}
+}
+
+// Handle is the handler for GuildScheduledEventUserRemove events.
+func (eh guildScheduledEventUserRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildScheduledEventUserRemove); ok {
+ eh(s, t)
+ }
+}
+
+// guildStickersUpdateEventHandler is an event handler for GuildStickersUpdate events.
+type guildStickersUpdateEventHandler func(*Session, *GuildStickersUpdate)
+
+// Type returns the event type for GuildStickersUpdate events.
+func (eh guildStickersUpdateEventHandler) Type() string {
+ return guildStickersUpdateEventType
+}
+
+// New returns a new instance of GuildStickersUpdate.
+func (eh guildStickersUpdateEventHandler) New() interface{} {
+ return &GuildStickersUpdate{}
+}
+
+// Handle is the handler for GuildStickersUpdate events.
+func (eh guildStickersUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildStickersUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// guildUpdateEventHandler is an event handler for GuildUpdate events.
+type guildUpdateEventHandler func(*Session, *GuildUpdate)
+
+// Type returns the event type for GuildUpdate events.
+func (eh guildUpdateEventHandler) Type() string {
+ return guildUpdateEventType
+}
+
+// New returns a new instance of GuildUpdate.
+func (eh guildUpdateEventHandler) New() interface{} {
+ return &GuildUpdate{}
+}
+
+// Handle is the handler for GuildUpdate events.
+func (eh guildUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*GuildUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// integrationCreateEventHandler is an event handler for IntegrationCreate events.
+type integrationCreateEventHandler func(*Session, *IntegrationCreate)
+
+// Type returns the event type for IntegrationCreate events.
+func (eh integrationCreateEventHandler) Type() string {
+ return integrationCreateEventType
+}
+
+// New returns a new instance of IntegrationCreate.
+func (eh integrationCreateEventHandler) New() interface{} {
+ return &IntegrationCreate{}
+}
+
+// Handle is the handler for IntegrationCreate events.
+func (eh integrationCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*IntegrationCreate); ok {
+ eh(s, t)
+ }
+}
+
+// integrationDeleteEventHandler is an event handler for IntegrationDelete events.
+type integrationDeleteEventHandler func(*Session, *IntegrationDelete)
+
+// Type returns the event type for IntegrationDelete events.
+func (eh integrationDeleteEventHandler) Type() string {
+ return integrationDeleteEventType
+}
+
+// New returns a new instance of IntegrationDelete.
+func (eh integrationDeleteEventHandler) New() interface{} {
+ return &IntegrationDelete{}
+}
+
+// Handle is the handler for IntegrationDelete events.
+func (eh integrationDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*IntegrationDelete); ok {
+ eh(s, t)
+ }
+}
+
+// integrationUpdateEventHandler is an event handler for IntegrationUpdate events.
+type integrationUpdateEventHandler func(*Session, *IntegrationUpdate)
+
+// Type returns the event type for IntegrationUpdate events.
+func (eh integrationUpdateEventHandler) Type() string {
+ return integrationUpdateEventType
+}
+
+// New returns a new instance of IntegrationUpdate.
+func (eh integrationUpdateEventHandler) New() interface{} {
+ return &IntegrationUpdate{}
+}
+
+// Handle is the handler for IntegrationUpdate events.
+func (eh integrationUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*IntegrationUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// interactionCreateEventHandler is an event handler for InteractionCreate events.
+type interactionCreateEventHandler func(*Session, *InteractionCreate)
+
+// Type returns the event type for InteractionCreate events.
+func (eh interactionCreateEventHandler) Type() string {
+ return interactionCreateEventType
+}
+
+// New returns a new instance of InteractionCreate.
+func (eh interactionCreateEventHandler) New() interface{} {
+ return &InteractionCreate{}
+}
+
+// Handle is the handler for InteractionCreate events.
+func (eh interactionCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*InteractionCreate); ok {
+ eh(s, t)
+ }
+}
+
+// interactionSuccessEventHandler is an event handler for InteractionSuccess events.
+type interactionSuccessEventHandler func(*Session, *InteractionSuccess)
+
+// Type returns the event type for InteractionSuccess events.
+func (eh interactionSuccessEventHandler) Type() string {
+ return interactionSuccessEventType
+}
+
+// New returns a new instance of InteractionSuccess.
+func (eh interactionSuccessEventHandler) New() interface{} {
+ return &InteractionSuccess{}
+}
+
+// Handle is the handler for InteractionSuccess events.
+func (eh interactionSuccessEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*InteractionSuccess); ok {
+ eh(s, t)
+ }
+}
+
+// invalidAuthEventHandler is an event handler for InvalidAuth events.
+type invalidAuthEventHandler func(*Session, *InvalidAuth)
+
+// Type returns the event type for InvalidAuth events.
+func (eh invalidAuthEventHandler) Type() string {
+ return invalidAuthEventType
+}
+
+// Handle is the handler for InvalidAuth events.
+func (eh invalidAuthEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*InvalidAuth); ok {
+ eh(s, t)
+ }
+}
+
+// inviteCreateEventHandler is an event handler for InviteCreate events.
+type inviteCreateEventHandler func(*Session, *InviteCreate)
+
+// Type returns the event type for InviteCreate events.
+func (eh inviteCreateEventHandler) Type() string {
+ return inviteCreateEventType
+}
+
+// New returns a new instance of InviteCreate.
+func (eh inviteCreateEventHandler) New() interface{} {
+ return &InviteCreate{}
+}
+
+// Handle is the handler for InviteCreate events.
+func (eh inviteCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*InviteCreate); ok {
+ eh(s, t)
+ }
+}
+
+// inviteDeleteEventHandler is an event handler for InviteDelete events.
+type inviteDeleteEventHandler func(*Session, *InviteDelete)
+
+// Type returns the event type for InviteDelete events.
+func (eh inviteDeleteEventHandler) Type() string {
+ return inviteDeleteEventType
+}
+
+// New returns a new instance of InviteDelete.
+func (eh inviteDeleteEventHandler) New() interface{} {
+ return &InviteDelete{}
+}
+
+// Handle is the handler for InviteDelete events.
+func (eh inviteDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*InviteDelete); ok {
+ eh(s, t)
+ }
+}
+
+// messageAckEventHandler is an event handler for MessageAck events.
+type messageAckEventHandler func(*Session, *MessageAck)
+
+// Type returns the event type for MessageAck events.
+func (eh messageAckEventHandler) Type() string {
+ return messageAckEventType
+}
+
+// New returns a new instance of MessageAck.
+func (eh messageAckEventHandler) New() interface{} {
+ return &MessageAck{}
+}
+
+// Handle is the handler for MessageAck events.
+func (eh messageAckEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageAck); ok {
+ eh(s, t)
+ }
+}
+
+// messageCreateEventHandler is an event handler for MessageCreate events.
+type messageCreateEventHandler func(*Session, *MessageCreate)
+
+// Type returns the event type for MessageCreate events.
+func (eh messageCreateEventHandler) Type() string {
+ return messageCreateEventType
+}
+
+// New returns a new instance of MessageCreate.
+func (eh messageCreateEventHandler) New() interface{} {
+ return &MessageCreate{}
+}
+
+// Handle is the handler for MessageCreate events.
+func (eh messageCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageCreate); ok {
+ eh(s, t)
+ }
+}
+
+// messageDeleteEventHandler is an event handler for MessageDelete events.
+type messageDeleteEventHandler func(*Session, *MessageDelete)
+
+// Type returns the event type for MessageDelete events.
+func (eh messageDeleteEventHandler) Type() string {
+ return messageDeleteEventType
+}
+
+// New returns a new instance of MessageDelete.
+func (eh messageDeleteEventHandler) New() interface{} {
+ return &MessageDelete{}
+}
+
+// Handle is the handler for MessageDelete events.
+func (eh messageDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageDelete); ok {
+ eh(s, t)
+ }
+}
+
+// messageDeleteBulkEventHandler is an event handler for MessageDeleteBulk events.
+type messageDeleteBulkEventHandler func(*Session, *MessageDeleteBulk)
+
+// Type returns the event type for MessageDeleteBulk events.
+func (eh messageDeleteBulkEventHandler) Type() string {
+ return messageDeleteBulkEventType
+}
+
+// New returns a new instance of MessageDeleteBulk.
+func (eh messageDeleteBulkEventHandler) New() interface{} {
+ return &MessageDeleteBulk{}
+}
+
+// Handle is the handler for MessageDeleteBulk events.
+func (eh messageDeleteBulkEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageDeleteBulk); ok {
+ eh(s, t)
+ }
+}
+
+// messagePollVoteAddEventHandler is an event handler for MessagePollVoteAdd events.
+type messagePollVoteAddEventHandler func(*Session, *MessagePollVoteAdd)
+
+// Type returns the event type for MessagePollVoteAdd events.
+func (eh messagePollVoteAddEventHandler) Type() string {
+ return messagePollVoteAddEventType
+}
+
+// New returns a new instance of MessagePollVoteAdd.
+func (eh messagePollVoteAddEventHandler) New() interface{} {
+ return &MessagePollVoteAdd{}
+}
+
+// Handle is the handler for MessagePollVoteAdd events.
+func (eh messagePollVoteAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessagePollVoteAdd); ok {
+ eh(s, t)
+ }
+}
+
+// messagePollVoteRemoveEventHandler is an event handler for MessagePollVoteRemove events.
+type messagePollVoteRemoveEventHandler func(*Session, *MessagePollVoteRemove)
+
+// Type returns the event type for MessagePollVoteRemove events.
+func (eh messagePollVoteRemoveEventHandler) Type() string {
+ return messagePollVoteRemoveEventType
+}
+
+// New returns a new instance of MessagePollVoteRemove.
+func (eh messagePollVoteRemoveEventHandler) New() interface{} {
+ return &MessagePollVoteRemove{}
+}
+
+// Handle is the handler for MessagePollVoteRemove events.
+func (eh messagePollVoteRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessagePollVoteRemove); ok {
+ eh(s, t)
+ }
+}
+
+// messageReactionAddEventHandler is an event handler for MessageReactionAdd events.
+type messageReactionAddEventHandler func(*Session, *MessageReactionAdd)
+
+// Type returns the event type for MessageReactionAdd events.
+func (eh messageReactionAddEventHandler) Type() string {
+ return messageReactionAddEventType
+}
+
+// New returns a new instance of MessageReactionAdd.
+func (eh messageReactionAddEventHandler) New() interface{} {
+ return &MessageReactionAdd{}
+}
+
+// Handle is the handler for MessageReactionAdd events.
+func (eh messageReactionAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageReactionAdd); ok {
+ eh(s, t)
+ }
+}
+
+// messageReactionRemoveEventHandler is an event handler for MessageReactionRemove events.
+type messageReactionRemoveEventHandler func(*Session, *MessageReactionRemove)
+
+// Type returns the event type for MessageReactionRemove events.
+func (eh messageReactionRemoveEventHandler) Type() string {
+ return messageReactionRemoveEventType
+}
+
+// New returns a new instance of MessageReactionRemove.
+func (eh messageReactionRemoveEventHandler) New() interface{} {
+ return &MessageReactionRemove{}
+}
+
+// Handle is the handler for MessageReactionRemove events.
+func (eh messageReactionRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageReactionRemove); ok {
+ eh(s, t)
+ }
+}
+
+// messageReactionRemoveAllEventHandler is an event handler for MessageReactionRemoveAll events.
+type messageReactionRemoveAllEventHandler func(*Session, *MessageReactionRemoveAll)
+
+// Type returns the event type for MessageReactionRemoveAll events.
+func (eh messageReactionRemoveAllEventHandler) Type() string {
+ return messageReactionRemoveAllEventType
+}
+
+// New returns a new instance of MessageReactionRemoveAll.
+func (eh messageReactionRemoveAllEventHandler) New() interface{} {
+ return &MessageReactionRemoveAll{}
+}
+
+// Handle is the handler for MessageReactionRemoveAll events.
+func (eh messageReactionRemoveAllEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageReactionRemoveAll); ok {
+ eh(s, t)
+ }
+}
+
+// messageReactionRemoveEmojiEventHandler is an event handler for MessageReactionRemoveEmoji events.
+type messageReactionRemoveEmojiEventHandler func(*Session, *MessageReactionRemoveEmoji)
+
+// Type returns the event type for MessageReactionRemoveEmoji events.
+func (eh messageReactionRemoveEmojiEventHandler) Type() string {
+ return messageReactionRemoveEmojiEventType
+}
+
+// New returns a new instance of MessageReactionRemoveEmoji.
+func (eh messageReactionRemoveEmojiEventHandler) New() interface{} {
+ return &MessageReactionRemoveEmoji{}
+}
+
+// Handle is the handler for MessageReactionRemoveEmoji events.
+func (eh messageReactionRemoveEmojiEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageReactionRemoveEmoji); ok {
+ eh(s, t)
+ }
+}
+
+// messageUpdateEventHandler is an event handler for MessageUpdate events.
+type messageUpdateEventHandler func(*Session, *MessageUpdate)
+
+// Type returns the event type for MessageUpdate events.
+func (eh messageUpdateEventHandler) Type() string {
+ return messageUpdateEventType
+}
+
+// New returns a new instance of MessageUpdate.
+func (eh messageUpdateEventHandler) New() interface{} {
+ return &MessageUpdate{}
+}
+
+// Handle is the handler for MessageUpdate events.
+func (eh messageUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*MessageUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// presenceUpdateEventHandler is an event handler for PresenceUpdate events.
+type presenceUpdateEventHandler func(*Session, *PresenceUpdate)
+
+// Type returns the event type for PresenceUpdate events.
+func (eh presenceUpdateEventHandler) Type() string {
+ return presenceUpdateEventType
+}
+
+// New returns a new instance of PresenceUpdate.
+func (eh presenceUpdateEventHandler) New() interface{} {
+ return &PresenceUpdate{}
+}
+
+// Handle is the handler for PresenceUpdate events.
+func (eh presenceUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*PresenceUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// presencesReplaceEventHandler is an event handler for PresencesReplace events.
+type presencesReplaceEventHandler func(*Session, *PresencesReplace)
+
+// Type returns the event type for PresencesReplace events.
+func (eh presencesReplaceEventHandler) Type() string {
+ return presencesReplaceEventType
+}
+
+// New returns a new instance of PresencesReplace.
+func (eh presencesReplaceEventHandler) New() interface{} {
+ return &PresencesReplace{}
+}
+
+// Handle is the handler for PresencesReplace events.
+func (eh presencesReplaceEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*PresencesReplace); ok {
+ eh(s, t)
+ }
+}
+
+// rateLimitEventHandler is an event handler for RateLimit events.
+type rateLimitEventHandler func(*Session, *RateLimit)
+
+// Type returns the event type for RateLimit events.
+func (eh rateLimitEventHandler) Type() string {
+ return rateLimitEventType
+}
+
+// Handle is the handler for RateLimit events.
+func (eh rateLimitEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*RateLimit); ok {
+ eh(s, t)
+ }
+}
+
+// readyEventHandler is an event handler for Ready events.
+type readyEventHandler func(*Session, *Ready)
+
+// Type returns the event type for Ready events.
+func (eh readyEventHandler) Type() string {
+ return readyEventType
+}
+
+// New returns a new instance of Ready.
+func (eh readyEventHandler) New() interface{} {
+ return &Ready{}
+}
+
+// Handle is the handler for Ready events.
+func (eh readyEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*Ready); ok {
+ eh(s, t)
+ }
+}
+
+// readySupplementalEventHandler is an event handler for ReadySupplemental events.
+type readySupplementalEventHandler func(*Session, *ReadySupplemental)
+
+// Type returns the event type for ReadySupplemental events.
+func (eh readySupplementalEventHandler) Type() string {
+ return readySupplementalEventType
+}
+
+// New returns a new instance of ReadySupplemental.
+func (eh readySupplementalEventHandler) New() interface{} {
+ return &ReadySupplemental{}
+}
+
+// Handle is the handler for ReadySupplemental events.
+func (eh readySupplementalEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ReadySupplemental); ok {
+ eh(s, t)
+ }
+}
+
+// relationshipAddEventHandler is an event handler for RelationshipAdd events.
+type relationshipAddEventHandler func(*Session, *RelationshipAdd)
+
+// Type returns the event type for RelationshipAdd events.
+func (eh relationshipAddEventHandler) Type() string {
+ return relationshipAddEventType
+}
+
+// New returns a new instance of RelationshipAdd.
+func (eh relationshipAddEventHandler) New() interface{} {
+ return &RelationshipAdd{}
+}
+
+// Handle is the handler for RelationshipAdd events.
+func (eh relationshipAddEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*RelationshipAdd); ok {
+ eh(s, t)
+ }
+}
+
+// relationshipRemoveEventHandler is an event handler for RelationshipRemove events.
+type relationshipRemoveEventHandler func(*Session, *RelationshipRemove)
+
+// Type returns the event type for RelationshipRemove events.
+func (eh relationshipRemoveEventHandler) Type() string {
+ return relationshipRemoveEventType
+}
+
+// New returns a new instance of RelationshipRemove.
+func (eh relationshipRemoveEventHandler) New() interface{} {
+ return &RelationshipRemove{}
+}
+
+// Handle is the handler for RelationshipRemove events.
+func (eh relationshipRemoveEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*RelationshipRemove); ok {
+ eh(s, t)
+ }
+}
+
+// relationshipUpdateEventHandler is an event handler for RelationshipUpdate events.
+type relationshipUpdateEventHandler func(*Session, *RelationshipUpdate)
+
+// Type returns the event type for RelationshipUpdate events.
+func (eh relationshipUpdateEventHandler) Type() string {
+ return relationshipUpdateEventType
+}
+
+// New returns a new instance of RelationshipUpdate.
+func (eh relationshipUpdateEventHandler) New() interface{} {
+ return &RelationshipUpdate{}
+}
+
+// Handle is the handler for RelationshipUpdate events.
+func (eh relationshipUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*RelationshipUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// resumedEventHandler is an event handler for Resumed events.
+type resumedEventHandler func(*Session, *Resumed)
+
+// Type returns the event type for Resumed events.
+func (eh resumedEventHandler) Type() string {
+ return resumedEventType
+}
+
+// New returns a new instance of Resumed.
+func (eh resumedEventHandler) New() interface{} {
+ return &Resumed{}
+}
+
+// Handle is the handler for Resumed events.
+func (eh resumedEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*Resumed); ok {
+ eh(s, t)
+ }
+}
+
+// stageInstanceEventCreateEventHandler is an event handler for StageInstanceEventCreate events.
+type stageInstanceEventCreateEventHandler func(*Session, *StageInstanceEventCreate)
+
+// Type returns the event type for StageInstanceEventCreate events.
+func (eh stageInstanceEventCreateEventHandler) Type() string {
+ return stageInstanceEventCreateEventType
+}
+
+// New returns a new instance of StageInstanceEventCreate.
+func (eh stageInstanceEventCreateEventHandler) New() interface{} {
+ return &StageInstanceEventCreate{}
+}
+
+// Handle is the handler for StageInstanceEventCreate events.
+func (eh stageInstanceEventCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*StageInstanceEventCreate); ok {
+ eh(s, t)
+ }
+}
+
+// stageInstanceEventDeleteEventHandler is an event handler for StageInstanceEventDelete events.
+type stageInstanceEventDeleteEventHandler func(*Session, *StageInstanceEventDelete)
+
+// Type returns the event type for StageInstanceEventDelete events.
+func (eh stageInstanceEventDeleteEventHandler) Type() string {
+ return stageInstanceEventDeleteEventType
+}
+
+// New returns a new instance of StageInstanceEventDelete.
+func (eh stageInstanceEventDeleteEventHandler) New() interface{} {
+ return &StageInstanceEventDelete{}
+}
+
+// Handle is the handler for StageInstanceEventDelete events.
+func (eh stageInstanceEventDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*StageInstanceEventDelete); ok {
+ eh(s, t)
+ }
+}
+
+// stageInstanceEventUpdateEventHandler is an event handler for StageInstanceEventUpdate events.
+type stageInstanceEventUpdateEventHandler func(*Session, *StageInstanceEventUpdate)
+
+// Type returns the event type for StageInstanceEventUpdate events.
+func (eh stageInstanceEventUpdateEventHandler) Type() string {
+ return stageInstanceEventUpdateEventType
+}
+
+// New returns a new instance of StageInstanceEventUpdate.
+func (eh stageInstanceEventUpdateEventHandler) New() interface{} {
+ return &StageInstanceEventUpdate{}
+}
+
+// Handle is the handler for StageInstanceEventUpdate events.
+func (eh stageInstanceEventUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*StageInstanceEventUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// subscriptionCreateEventHandler is an event handler for SubscriptionCreate events.
+type subscriptionCreateEventHandler func(*Session, *SubscriptionCreate)
+
+// Type returns the event type for SubscriptionCreate events.
+func (eh subscriptionCreateEventHandler) Type() string {
+ return subscriptionCreateEventType
+}
+
+// New returns a new instance of SubscriptionCreate.
+func (eh subscriptionCreateEventHandler) New() interface{} {
+ return &SubscriptionCreate{}
+}
+
+// Handle is the handler for SubscriptionCreate events.
+func (eh subscriptionCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*SubscriptionCreate); ok {
+ eh(s, t)
+ }
+}
+
+// subscriptionDeleteEventHandler is an event handler for SubscriptionDelete events.
+type subscriptionDeleteEventHandler func(*Session, *SubscriptionDelete)
+
+// Type returns the event type for SubscriptionDelete events.
+func (eh subscriptionDeleteEventHandler) Type() string {
+ return subscriptionDeleteEventType
+}
+
+// New returns a new instance of SubscriptionDelete.
+func (eh subscriptionDeleteEventHandler) New() interface{} {
+ return &SubscriptionDelete{}
+}
+
+// Handle is the handler for SubscriptionDelete events.
+func (eh subscriptionDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*SubscriptionDelete); ok {
+ eh(s, t)
+ }
+}
+
+// subscriptionUpdateEventHandler is an event handler for SubscriptionUpdate events.
+type subscriptionUpdateEventHandler func(*Session, *SubscriptionUpdate)
+
+// Type returns the event type for SubscriptionUpdate events.
+func (eh subscriptionUpdateEventHandler) Type() string {
+ return subscriptionUpdateEventType
+}
+
+// New returns a new instance of SubscriptionUpdate.
+func (eh subscriptionUpdateEventHandler) New() interface{} {
+ return &SubscriptionUpdate{}
+}
+
+// Handle is the handler for SubscriptionUpdate events.
+func (eh subscriptionUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*SubscriptionUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// threadCreateEventHandler is an event handler for ThreadCreate events.
+type threadCreateEventHandler func(*Session, *ThreadCreate)
+
+// Type returns the event type for ThreadCreate events.
+func (eh threadCreateEventHandler) Type() string {
+ return threadCreateEventType
+}
+
+// New returns a new instance of ThreadCreate.
+func (eh threadCreateEventHandler) New() interface{} {
+ return &ThreadCreate{}
+}
+
+// Handle is the handler for ThreadCreate events.
+func (eh threadCreateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ThreadCreate); ok {
+ eh(s, t)
+ }
+}
+
+// threadDeleteEventHandler is an event handler for ThreadDelete events.
+type threadDeleteEventHandler func(*Session, *ThreadDelete)
+
+// Type returns the event type for ThreadDelete events.
+func (eh threadDeleteEventHandler) Type() string {
+ return threadDeleteEventType
+}
+
+// New returns a new instance of ThreadDelete.
+func (eh threadDeleteEventHandler) New() interface{} {
+ return &ThreadDelete{}
+}
+
+// Handle is the handler for ThreadDelete events.
+func (eh threadDeleteEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ThreadDelete); ok {
+ eh(s, t)
+ }
+}
+
+// threadListSyncEventHandler is an event handler for ThreadListSync events.
+type threadListSyncEventHandler func(*Session, *ThreadListSync)
+
+// Type returns the event type for ThreadListSync events.
+func (eh threadListSyncEventHandler) Type() string {
+ return threadListSyncEventType
+}
+
+// New returns a new instance of ThreadListSync.
+func (eh threadListSyncEventHandler) New() interface{} {
+ return &ThreadListSync{}
+}
+
+// Handle is the handler for ThreadListSync events.
+func (eh threadListSyncEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ThreadListSync); ok {
+ eh(s, t)
+ }
+}
+
+// threadMemberUpdateEventHandler is an event handler for ThreadMemberUpdate events.
+type threadMemberUpdateEventHandler func(*Session, *ThreadMemberUpdate)
+
+// Type returns the event type for ThreadMemberUpdate events.
+func (eh threadMemberUpdateEventHandler) Type() string {
+ return threadMemberUpdateEventType
+}
+
+// New returns a new instance of ThreadMemberUpdate.
+func (eh threadMemberUpdateEventHandler) New() interface{} {
+ return &ThreadMemberUpdate{}
+}
+
+// Handle is the handler for ThreadMemberUpdate events.
+func (eh threadMemberUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ThreadMemberUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// threadMembersUpdateEventHandler is an event handler for ThreadMembersUpdate events.
+type threadMembersUpdateEventHandler func(*Session, *ThreadMembersUpdate)
+
+// Type returns the event type for ThreadMembersUpdate events.
+func (eh threadMembersUpdateEventHandler) Type() string {
+ return threadMembersUpdateEventType
+}
+
+// New returns a new instance of ThreadMembersUpdate.
+func (eh threadMembersUpdateEventHandler) New() interface{} {
+ return &ThreadMembersUpdate{}
+}
+
+// Handle is the handler for ThreadMembersUpdate events.
+func (eh threadMembersUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ThreadMembersUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// threadUpdateEventHandler is an event handler for ThreadUpdate events.
+type threadUpdateEventHandler func(*Session, *ThreadUpdate)
+
+// Type returns the event type for ThreadUpdate events.
+func (eh threadUpdateEventHandler) Type() string {
+ return threadUpdateEventType
+}
+
+// New returns a new instance of ThreadUpdate.
+func (eh threadUpdateEventHandler) New() interface{} {
+ return &ThreadUpdate{}
+}
+
+// Handle is the handler for ThreadUpdate events.
+func (eh threadUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*ThreadUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// typingStartEventHandler is an event handler for TypingStart events.
+type typingStartEventHandler func(*Session, *TypingStart)
+
+// Type returns the event type for TypingStart events.
+func (eh typingStartEventHandler) Type() string {
+ return typingStartEventType
+}
+
+// New returns a new instance of TypingStart.
+func (eh typingStartEventHandler) New() interface{} {
+ return &TypingStart{}
+}
+
+// Handle is the handler for TypingStart events.
+func (eh typingStartEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*TypingStart); ok {
+ eh(s, t)
+ }
+}
+
+// userGuildSettingsUpdateEventHandler is an event handler for UserGuildSettingsUpdate events.
+type userGuildSettingsUpdateEventHandler func(*Session, *UserGuildSettingsUpdate)
+
+// Type returns the event type for UserGuildSettingsUpdate events.
+func (eh userGuildSettingsUpdateEventHandler) Type() string {
+ return userGuildSettingsUpdateEventType
+}
+
+// New returns a new instance of UserGuildSettingsUpdate.
+func (eh userGuildSettingsUpdateEventHandler) New() interface{} {
+ return &UserGuildSettingsUpdate{}
+}
+
+// Handle is the handler for UserGuildSettingsUpdate events.
+func (eh userGuildSettingsUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*UserGuildSettingsUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// userNoteUpdateEventHandler is an event handler for UserNoteUpdate events.
+type userNoteUpdateEventHandler func(*Session, *UserNoteUpdate)
+
+// Type returns the event type for UserNoteUpdate events.
+func (eh userNoteUpdateEventHandler) Type() string {
+ return userNoteUpdateEventType
+}
+
+// New returns a new instance of UserNoteUpdate.
+func (eh userNoteUpdateEventHandler) New() interface{} {
+ return &UserNoteUpdate{}
+}
+
+// Handle is the handler for UserNoteUpdate events.
+func (eh userNoteUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*UserNoteUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// userRequiredActionUpdateEventHandler is an event handler for UserRequiredActionUpdate events.
+type userRequiredActionUpdateEventHandler func(*Session, *UserRequiredActionUpdate)
+
+// Type returns the event type for UserRequiredActionUpdate events.
+func (eh userRequiredActionUpdateEventHandler) Type() string {
+ return userRequiredActionUpdateEventType
+}
+
+// New returns a new instance of UserRequiredActionUpdate.
+func (eh userRequiredActionUpdateEventHandler) New() interface{} {
+ return &UserRequiredActionUpdate{}
+}
+
+// Handle is the handler for UserRequiredActionUpdate events.
+func (eh userRequiredActionUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*UserRequiredActionUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// userSettingsUpdateEventHandler is an event handler for UserSettingsUpdate events.
+type userSettingsUpdateEventHandler func(*Session, *UserSettingsUpdate)
+
+// Type returns the event type for UserSettingsUpdate events.
+func (eh userSettingsUpdateEventHandler) Type() string {
+ return userSettingsUpdateEventType
+}
+
+// New returns a new instance of UserSettingsUpdate.
+func (eh userSettingsUpdateEventHandler) New() interface{} {
+ return &UserSettingsUpdate{}
+}
+
+// Handle is the handler for UserSettingsUpdate events.
+func (eh userSettingsUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*UserSettingsUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// userUpdateEventHandler is an event handler for UserUpdate events.
+type userUpdateEventHandler func(*Session, *UserUpdate)
+
+// Type returns the event type for UserUpdate events.
+func (eh userUpdateEventHandler) Type() string {
+ return userUpdateEventType
+}
+
+// New returns a new instance of UserUpdate.
+func (eh userUpdateEventHandler) New() interface{} {
+ return &UserUpdate{}
+}
+
+// Handle is the handler for UserUpdate events.
+func (eh userUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*UserUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// voiceServerUpdateEventHandler is an event handler for VoiceServerUpdate events.
+type voiceServerUpdateEventHandler func(*Session, *VoiceServerUpdate)
+
+// Type returns the event type for VoiceServerUpdate events.
+func (eh voiceServerUpdateEventHandler) Type() string {
+ return voiceServerUpdateEventType
+}
+
+// New returns a new instance of VoiceServerUpdate.
+func (eh voiceServerUpdateEventHandler) New() interface{} {
+ return &VoiceServerUpdate{}
+}
+
+// Handle is the handler for VoiceServerUpdate events.
+func (eh voiceServerUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*VoiceServerUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// voiceStateUpdateEventHandler is an event handler for VoiceStateUpdate events.
+type voiceStateUpdateEventHandler func(*Session, *VoiceStateUpdate)
+
+// Type returns the event type for VoiceStateUpdate events.
+func (eh voiceStateUpdateEventHandler) Type() string {
+ return voiceStateUpdateEventType
+}
+
+// New returns a new instance of VoiceStateUpdate.
+func (eh voiceStateUpdateEventHandler) New() interface{} {
+ return &VoiceStateUpdate{}
+}
+
+// Handle is the handler for VoiceStateUpdate events.
+func (eh voiceStateUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*VoiceStateUpdate); ok {
+ eh(s, t)
+ }
+}
+
+// webhooksUpdateEventHandler is an event handler for WebhooksUpdate events.
+type webhooksUpdateEventHandler func(*Session, *WebhooksUpdate)
+
+// Type returns the event type for WebhooksUpdate events.
+func (eh webhooksUpdateEventHandler) Type() string {
+ return webhooksUpdateEventType
+}
+
+// New returns a new instance of WebhooksUpdate.
+func (eh webhooksUpdateEventHandler) New() interface{} {
+ return &WebhooksUpdate{}
+}
+
+// Handle is the handler for WebhooksUpdate events.
+func (eh webhooksUpdateEventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*WebhooksUpdate); ok {
+ eh(s, t)
+ }
+}
+
+func handlerForInterface(handler interface{}) EventHandler {
+ switch v := handler.(type) {
+ case func(*Session, interface{}):
+ return interfaceEventHandler(v)
+ case func(*Session, *ApplicationCommandPermissionsUpdate):
+ return applicationCommandPermissionsUpdateEventHandler(v)
+ case func(*Session, *AutoModerationActionExecution):
+ return autoModerationActionExecutionEventHandler(v)
+ case func(*Session, *AutoModerationRuleCreate):
+ return autoModerationRuleCreateEventHandler(v)
+ case func(*Session, *AutoModerationRuleDelete):
+ return autoModerationRuleDeleteEventHandler(v)
+ case func(*Session, *AutoModerationRuleUpdate):
+ return autoModerationRuleUpdateEventHandler(v)
+ case func(*Session, *ChannelCreate):
+ return channelCreateEventHandler(v)
+ case func(*Session, *ChannelDelete):
+ return channelDeleteEventHandler(v)
+ case func(*Session, *ChannelPinsUpdate):
+ return channelPinsUpdateEventHandler(v)
+ case func(*Session, *ChannelRecipientAdd):
+ return channelRecipientAddEventHandler(v)
+ case func(*Session, *ChannelRecipientRemove):
+ return channelRecipientRemoveEventHandler(v)
+ case func(*Session, *ChannelUpdate):
+ return channelUpdateEventHandler(v)
+ case func(*Session, *Connect):
+ return connectEventHandler(v)
+ case func(*Session, *Disconnect):
+ return disconnectEventHandler(v)
+ case func(*Session, *EntitlementCreate):
+ return entitlementCreateEventHandler(v)
+ case func(*Session, *EntitlementDelete):
+ return entitlementDeleteEventHandler(v)
+ case func(*Session, *EntitlementUpdate):
+ return entitlementUpdateEventHandler(v)
+ case func(*Session, *Event):
+ return eventEventHandler(v)
+ case func(*Session, *GuildAuditLogEntryCreate):
+ return guildAuditLogEntryCreateEventHandler(v)
+ case func(*Session, *GuildBanAdd):
+ return guildBanAddEventHandler(v)
+ case func(*Session, *GuildBanRemove):
+ return guildBanRemoveEventHandler(v)
+ case func(*Session, *GuildCreate):
+ return guildCreateEventHandler(v)
+ case func(*Session, *GuildDelete):
+ return guildDeleteEventHandler(v)
+ case func(*Session, *GuildEmojisUpdate):
+ return guildEmojisUpdateEventHandler(v)
+ case func(*Session, *GuildIntegrationsUpdate):
+ return guildIntegrationsUpdateEventHandler(v)
+ case func(*Session, *GuildMemberAdd):
+ return guildMemberAddEventHandler(v)
+ case func(*Session, *GuildMemberRemove):
+ return guildMemberRemoveEventHandler(v)
+ case func(*Session, *GuildMemberUpdate):
+ return guildMemberUpdateEventHandler(v)
+ case func(*Session, *GuildMembersChunk):
+ return guildMembersChunkEventHandler(v)
+ case func(*Session, *GuildRoleCreate):
+ return guildRoleCreateEventHandler(v)
+ case func(*Session, *GuildRoleDelete):
+ return guildRoleDeleteEventHandler(v)
+ case func(*Session, *GuildRoleUpdate):
+ return guildRoleUpdateEventHandler(v)
+ case func(*Session, *GuildScheduledEventCreate):
+ return guildScheduledEventCreateEventHandler(v)
+ case func(*Session, *GuildScheduledEventDelete):
+ return guildScheduledEventDeleteEventHandler(v)
+ case func(*Session, *GuildScheduledEventUpdate):
+ return guildScheduledEventUpdateEventHandler(v)
+ case func(*Session, *GuildScheduledEventUserAdd):
+ return guildScheduledEventUserAddEventHandler(v)
+ case func(*Session, *GuildScheduledEventUserRemove):
+ return guildScheduledEventUserRemoveEventHandler(v)
+ case func(*Session, *GuildStickersUpdate):
+ return guildStickersUpdateEventHandler(v)
+ case func(*Session, *GuildUpdate):
+ return guildUpdateEventHandler(v)
+ case func(*Session, *IntegrationCreate):
+ return integrationCreateEventHandler(v)
+ case func(*Session, *IntegrationDelete):
+ return integrationDeleteEventHandler(v)
+ case func(*Session, *IntegrationUpdate):
+ return integrationUpdateEventHandler(v)
+ case func(*Session, *InteractionCreate):
+ return interactionCreateEventHandler(v)
+ case func(*Session, *InteractionSuccess):
+ return interactionSuccessEventHandler(v)
+ case func(*Session, *InvalidAuth):
+ return invalidAuthEventHandler(v)
+ case func(*Session, *InviteCreate):
+ return inviteCreateEventHandler(v)
+ case func(*Session, *InviteDelete):
+ return inviteDeleteEventHandler(v)
+ case func(*Session, *MessageAck):
+ return messageAckEventHandler(v)
+ case func(*Session, *MessageCreate):
+ return messageCreateEventHandler(v)
+ case func(*Session, *MessageDelete):
+ return messageDeleteEventHandler(v)
+ case func(*Session, *MessageDeleteBulk):
+ return messageDeleteBulkEventHandler(v)
+ case func(*Session, *MessagePollVoteAdd):
+ return messagePollVoteAddEventHandler(v)
+ case func(*Session, *MessagePollVoteRemove):
+ return messagePollVoteRemoveEventHandler(v)
+ case func(*Session, *MessageReactionAdd):
+ return messageReactionAddEventHandler(v)
+ case func(*Session, *MessageReactionRemove):
+ return messageReactionRemoveEventHandler(v)
+ case func(*Session, *MessageReactionRemoveAll):
+ return messageReactionRemoveAllEventHandler(v)
+ case func(*Session, *MessageReactionRemoveEmoji):
+ return messageReactionRemoveEmojiEventHandler(v)
+ case func(*Session, *MessageUpdate):
+ return messageUpdateEventHandler(v)
+ case func(*Session, *PresenceUpdate):
+ return presenceUpdateEventHandler(v)
+ case func(*Session, *PresencesReplace):
+ return presencesReplaceEventHandler(v)
+ case func(*Session, *RateLimit):
+ return rateLimitEventHandler(v)
+ case func(*Session, *Ready):
+ return readyEventHandler(v)
+ case func(*Session, *ReadySupplemental):
+ return readySupplementalEventHandler(v)
+ case func(*Session, *RelationshipAdd):
+ return relationshipAddEventHandler(v)
+ case func(*Session, *RelationshipRemove):
+ return relationshipRemoveEventHandler(v)
+ case func(*Session, *RelationshipUpdate):
+ return relationshipUpdateEventHandler(v)
+ case func(*Session, *Resumed):
+ return resumedEventHandler(v)
+ case func(*Session, *StageInstanceEventCreate):
+ return stageInstanceEventCreateEventHandler(v)
+ case func(*Session, *StageInstanceEventDelete):
+ return stageInstanceEventDeleteEventHandler(v)
+ case func(*Session, *StageInstanceEventUpdate):
+ return stageInstanceEventUpdateEventHandler(v)
+ case func(*Session, *SubscriptionCreate):
+ return subscriptionCreateEventHandler(v)
+ case func(*Session, *SubscriptionDelete):
+ return subscriptionDeleteEventHandler(v)
+ case func(*Session, *SubscriptionUpdate):
+ return subscriptionUpdateEventHandler(v)
+ case func(*Session, *ThreadCreate):
+ return threadCreateEventHandler(v)
+ case func(*Session, *ThreadDelete):
+ return threadDeleteEventHandler(v)
+ case func(*Session, *ThreadListSync):
+ return threadListSyncEventHandler(v)
+ case func(*Session, *ThreadMemberUpdate):
+ return threadMemberUpdateEventHandler(v)
+ case func(*Session, *ThreadMembersUpdate):
+ return threadMembersUpdateEventHandler(v)
+ case func(*Session, *ThreadUpdate):
+ return threadUpdateEventHandler(v)
+ case func(*Session, *TypingStart):
+ return typingStartEventHandler(v)
+ case func(*Session, *UserGuildSettingsUpdate):
+ return userGuildSettingsUpdateEventHandler(v)
+ case func(*Session, *UserNoteUpdate):
+ return userNoteUpdateEventHandler(v)
+ case func(*Session, *UserRequiredActionUpdate):
+ return userRequiredActionUpdateEventHandler(v)
+ case func(*Session, *UserSettingsUpdate):
+ return userSettingsUpdateEventHandler(v)
+ case func(*Session, *UserUpdate):
+ return userUpdateEventHandler(v)
+ case func(*Session, *VoiceServerUpdate):
+ return voiceServerUpdateEventHandler(v)
+ case func(*Session, *VoiceStateUpdate):
+ return voiceStateUpdateEventHandler(v)
+ case func(*Session, *WebhooksUpdate):
+ return webhooksUpdateEventHandler(v)
+ }
+
+ return nil
+}
+
+func init() {
+ registerInterfaceProvider(applicationCommandPermissionsUpdateEventHandler(nil))
+ registerInterfaceProvider(autoModerationActionExecutionEventHandler(nil))
+ registerInterfaceProvider(autoModerationRuleCreateEventHandler(nil))
+ registerInterfaceProvider(autoModerationRuleDeleteEventHandler(nil))
+ registerInterfaceProvider(autoModerationRuleUpdateEventHandler(nil))
+ registerInterfaceProvider(channelCreateEventHandler(nil))
+ registerInterfaceProvider(channelDeleteEventHandler(nil))
+ registerInterfaceProvider(channelPinsUpdateEventHandler(nil))
+ registerInterfaceProvider(channelRecipientAddEventHandler(nil))
+ registerInterfaceProvider(channelRecipientRemoveEventHandler(nil))
+ registerInterfaceProvider(channelUpdateEventHandler(nil))
+ registerInterfaceProvider(entitlementCreateEventHandler(nil))
+ registerInterfaceProvider(entitlementDeleteEventHandler(nil))
+ registerInterfaceProvider(entitlementUpdateEventHandler(nil))
+ registerInterfaceProvider(guildAuditLogEntryCreateEventHandler(nil))
+ registerInterfaceProvider(guildBanAddEventHandler(nil))
+ registerInterfaceProvider(guildBanRemoveEventHandler(nil))
+ registerInterfaceProvider(guildCreateEventHandler(nil))
+ registerInterfaceProvider(guildDeleteEventHandler(nil))
+ registerInterfaceProvider(guildEmojisUpdateEventHandler(nil))
+ registerInterfaceProvider(guildIntegrationsUpdateEventHandler(nil))
+ registerInterfaceProvider(guildMemberAddEventHandler(nil))
+ registerInterfaceProvider(guildMemberRemoveEventHandler(nil))
+ registerInterfaceProvider(guildMemberUpdateEventHandler(nil))
+ registerInterfaceProvider(guildMembersChunkEventHandler(nil))
+ registerInterfaceProvider(guildRoleCreateEventHandler(nil))
+ registerInterfaceProvider(guildRoleDeleteEventHandler(nil))
+ registerInterfaceProvider(guildRoleUpdateEventHandler(nil))
+ registerInterfaceProvider(guildScheduledEventCreateEventHandler(nil))
+ registerInterfaceProvider(guildScheduledEventDeleteEventHandler(nil))
+ registerInterfaceProvider(guildScheduledEventUpdateEventHandler(nil))
+ registerInterfaceProvider(guildScheduledEventUserAddEventHandler(nil))
+ registerInterfaceProvider(guildScheduledEventUserRemoveEventHandler(nil))
+ registerInterfaceProvider(guildStickersUpdateEventHandler(nil))
+ registerInterfaceProvider(guildUpdateEventHandler(nil))
+ registerInterfaceProvider(integrationCreateEventHandler(nil))
+ registerInterfaceProvider(integrationDeleteEventHandler(nil))
+ registerInterfaceProvider(integrationUpdateEventHandler(nil))
+ registerInterfaceProvider(interactionCreateEventHandler(nil))
+ registerInterfaceProvider(interactionSuccessEventHandler(nil))
+ registerInterfaceProvider(inviteCreateEventHandler(nil))
+ registerInterfaceProvider(inviteDeleteEventHandler(nil))
+ registerInterfaceProvider(messageAckEventHandler(nil))
+ registerInterfaceProvider(messageCreateEventHandler(nil))
+ registerInterfaceProvider(messageDeleteEventHandler(nil))
+ registerInterfaceProvider(messageDeleteBulkEventHandler(nil))
+ registerInterfaceProvider(messagePollVoteAddEventHandler(nil))
+ registerInterfaceProvider(messagePollVoteRemoveEventHandler(nil))
+ registerInterfaceProvider(messageReactionAddEventHandler(nil))
+ registerInterfaceProvider(messageReactionRemoveEventHandler(nil))
+ registerInterfaceProvider(messageReactionRemoveAllEventHandler(nil))
+ registerInterfaceProvider(messageReactionRemoveEmojiEventHandler(nil))
+ registerInterfaceProvider(messageUpdateEventHandler(nil))
+ registerInterfaceProvider(presenceUpdateEventHandler(nil))
+ registerInterfaceProvider(presencesReplaceEventHandler(nil))
+ registerInterfaceProvider(readyEventHandler(nil))
+ registerInterfaceProvider(readySupplementalEventHandler(nil))
+ registerInterfaceProvider(relationshipAddEventHandler(nil))
+ registerInterfaceProvider(relationshipRemoveEventHandler(nil))
+ registerInterfaceProvider(relationshipUpdateEventHandler(nil))
+ registerInterfaceProvider(resumedEventHandler(nil))
+ registerInterfaceProvider(stageInstanceEventCreateEventHandler(nil))
+ registerInterfaceProvider(stageInstanceEventDeleteEventHandler(nil))
+ registerInterfaceProvider(stageInstanceEventUpdateEventHandler(nil))
+ registerInterfaceProvider(subscriptionCreateEventHandler(nil))
+ registerInterfaceProvider(subscriptionDeleteEventHandler(nil))
+ registerInterfaceProvider(subscriptionUpdateEventHandler(nil))
+ registerInterfaceProvider(threadCreateEventHandler(nil))
+ registerInterfaceProvider(threadDeleteEventHandler(nil))
+ registerInterfaceProvider(threadListSyncEventHandler(nil))
+ registerInterfaceProvider(threadMemberUpdateEventHandler(nil))
+ registerInterfaceProvider(threadMembersUpdateEventHandler(nil))
+ registerInterfaceProvider(threadUpdateEventHandler(nil))
+ registerInterfaceProvider(typingStartEventHandler(nil))
+ registerInterfaceProvider(userGuildSettingsUpdateEventHandler(nil))
+ registerInterfaceProvider(userNoteUpdateEventHandler(nil))
+ registerInterfaceProvider(userRequiredActionUpdateEventHandler(nil))
+ registerInterfaceProvider(userSettingsUpdateEventHandler(nil))
+ registerInterfaceProvider(userUpdateEventHandler(nil))
+ registerInterfaceProvider(voiceServerUpdateEventHandler(nil))
+ registerInterfaceProvider(voiceStateUpdateEventHandler(nil))
+ registerInterfaceProvider(webhooksUpdateEventHandler(nil))
+}
diff --git a/pkg/meowcord/events.go b/pkg/meowcord/events.go
new file mode 100644
index 0000000..9e372a6
--- /dev/null
+++ b/pkg/meowcord/events.go
@@ -0,0 +1,600 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "encoding/json"
+)
+
+// This file contains all the possible structs that can be
+// handled by AddHandler/EventHandler.
+// DO NOT ADD ANYTHING BUT EVENT HANDLER STRUCTS TO THIS FILE.
+//go:generate go run tools/cmd/eventhandlers/main.go
+
+// Connect is the data for a Connect event.
+// This is a synthetic event and is not dispatched by Discord.
+type Connect struct{}
+
+// Disconnect is the data for a Disconnect event.
+// This is a synthetic event and is not dispatched by Discord.
+type Disconnect struct{}
+
+// InvalidAuth is the data for a InvalidAuth event.
+// This is a synthetic event and is not dispatched by Discord.
+type InvalidAuth struct{}
+
+// RateLimit is the data for a RateLimit event.
+// This is a synthetic event and is not dispatched by Discord.
+type RateLimit struct {
+ *TooManyRequests
+ URL string
+}
+
+// Event provides a basic initial struct for all websocket events.
+type Event struct {
+ Operation int `json:"op"`
+ Sequence int64 `json:"s"`
+ Type string `json:"t"`
+ RawData json.RawMessage `json:"d"`
+ // Struct contains one of the other types in this file.
+ Struct interface{} `json:"-"`
+}
+
+// A Ready stores all data for the websocket READY event.
+type Ready struct {
+ Version int `json:"v"`
+ SessionID string `json:"session_id"`
+ User *User `json:"user"`
+ Shard *[2]int `json:"shard"`
+ Application *Application `json:"application"`
+ PrivateChannels []*Channel `json:"private_channels"`
+ Guilds []*Guild `json:"guilds"`
+ ResumeGatewayURL string `json:"resume_gateway_url"`
+
+ // Undocumented fields
+ ReadState *ReadStateList `json:"read_state"`
+ Settings *Settings `json:"user_settings"`
+ UserGuildSettings *UserGuildSettingsList `json:"user_guild_settings"`
+ Relationships []*Relationship `json:"relationships"`
+ Presences []*Presence `json:"presences"`
+ Notes map[string]string `json:"notes"`
+ MergedMembers [][]*Member `json:"merged_members"`
+ RequiredAction RequiredAction `json:"required_action"`
+
+ Users []*User `json:"users"`
+}
+
+type ReadySupplemental struct {
+ MergedMembers [][]*Member `json:"merged_members"`
+ Guilds []*MinimalGuild `json:"guilds"`
+ Disclose []string `json:"disclose"`
+ LazyPrivateChannels []*Channel `json:"lazy_private_channels"`
+ // Also has merged_presences
+}
+
+// ChannelCreate is the data for a ChannelCreate event.
+type ChannelCreate struct {
+ *Channel
+}
+
+// ChannelUpdate is the data for a ChannelUpdate event.
+type ChannelUpdate struct {
+ *Channel
+ BeforeUpdate *Channel `json:"-"`
+}
+
+// ChannelDelete is the data for a ChannelDelete event.
+type ChannelDelete struct {
+ *Channel
+ BeforeDelete *Channel `json:"-"`
+}
+
+// ChannelPinsUpdate stores data for a ChannelPinsUpdate event.
+type ChannelPinsUpdate struct {
+ LastPinTimestamp string `json:"last_pin_timestamp"`
+ ChannelID string `json:"channel_id"`
+ GuildID string `json:"guild_id,omitempty"`
+}
+
+// ThreadCreate is the data for a ThreadCreate event.
+type ThreadCreate struct {
+ *Channel
+ NewlyCreated bool `json:"newly_created"`
+}
+
+// ThreadUpdate is the data for a ThreadUpdate event.
+type ThreadUpdate struct {
+ *Channel
+ BeforeUpdate *Channel `json:"-"`
+}
+
+// ThreadDelete is the data for a ThreadDelete event.
+type ThreadDelete struct {
+ *Channel
+ BeforeDelete *Channel `json:"-"`
+}
+
+// ThreadListSync is the data for a ThreadListSync event.
+type ThreadListSync struct {
+ // The id of the guild
+ GuildID string `json:"guild_id"`
+ // The parent channel ids whose threads are being synced.
+ // If omitted, then threads were synced for the entire guild.
+ // This array may contain channel_ids that have no active threads as well, so you know to clear that data.
+ ChannelIDs []string `json:"channel_ids"`
+ // All active threads in the given channels that the current user can access
+ Threads []*Channel `json:"threads"`
+ // All thread member objects from the synced threads for the current user,
+ // indicating which threads the current user has been added to
+ Members []*ThreadMember `json:"members"`
+}
+
+// ThreadMemberUpdate is the data for a ThreadMemberUpdate event.
+type ThreadMemberUpdate struct {
+ *ThreadMember
+ GuildID string `json:"guild_id"`
+}
+
+// ThreadMembersUpdate is the data for a ThreadMembersUpdate event.
+type ThreadMembersUpdate struct {
+ ID string `json:"id"`
+ GuildID string `json:"guild_id"`
+ MemberCount int `json:"member_count"`
+ AddedMembers []AddedThreadMember `json:"added_members"`
+ RemovedMembers []string `json:"removed_member_ids"`
+}
+
+// GuildCreate is the data for a GuildCreate event.
+type GuildCreate struct {
+ *Guild
+}
+
+// GuildUpdate is the data for a GuildUpdate event.
+type GuildUpdate struct {
+ *Guild
+}
+
+// GuildDelete is the data for a GuildDelete event.
+type GuildDelete struct {
+ *Guild
+ BeforeDelete *Guild `json:"-"`
+}
+
+// GuildBanAdd is the data for a GuildBanAdd event.
+type GuildBanAdd struct {
+ User *User `json:"user"`
+ GuildID string `json:"guild_id"`
+}
+
+// GuildBanRemove is the data for a GuildBanRemove event.
+type GuildBanRemove struct {
+ User *User `json:"user"`
+ GuildID string `json:"guild_id"`
+}
+
+// GuildMemberAdd is the data for a GuildMemberAdd event.
+type GuildMemberAdd struct {
+ *Member
+}
+
+// GuildMemberUpdate is the data for a GuildMemberUpdate event.
+type GuildMemberUpdate struct {
+ *Member
+ BeforeUpdate *Member `json:"-"`
+}
+
+// GuildMemberRemove is the data for a GuildMemberRemove event.
+type GuildMemberRemove struct {
+ *Member
+ BeforeDelete *Member `json:"-"`
+}
+
+// GuildRoleCreate is the data for a GuildRoleCreate event.
+type GuildRoleCreate struct {
+ *GuildRole
+}
+
+// GuildRoleUpdate is the data for a GuildRoleUpdate event.
+type GuildRoleUpdate struct {
+ *GuildRole
+ BeforeUpdate *Role `json:"-"`
+}
+
+// A GuildRoleDelete is the data for a GuildRoleDelete event.
+type GuildRoleDelete struct {
+ RoleID string `json:"role_id"`
+ GuildID string `json:"guild_id"`
+ BeforeDelete *Role `json:"-"`
+}
+
+// A GuildEmojisUpdate is the data for a guild emoji update event.
+type GuildEmojisUpdate struct {
+ GuildID string `json:"guild_id"`
+ Emojis []*Emoji `json:"emojis"`
+}
+
+// A GuildStickersUpdate is the data for a GuildStickersUpdate event.
+type GuildStickersUpdate struct {
+ GuildID string `json:"guild_id"`
+ Stickers []*Sticker `json:"stickers"`
+}
+
+// A GuildMembersChunk is the data for a GuildMembersChunk event.
+type GuildMembersChunk struct {
+ GuildID string `json:"guild_id"`
+ Members []*Member `json:"members"`
+ ChunkIndex int `json:"chunk_index"`
+ ChunkCount int `json:"chunk_count"`
+ NotFound []string `json:"not_found,omitempty"`
+ Presences []*Presence `json:"presences,omitempty"`
+ Nonce string `json:"nonce,omitempty"`
+}
+
+// GuildIntegrationsUpdate is the data for a GuildIntegrationsUpdate event.
+type GuildIntegrationsUpdate struct {
+ GuildID string `json:"guild_id"`
+}
+
+// StageInstanceEventCreate is the data for a StageInstanceEventCreate event.
+type StageInstanceEventCreate struct {
+ *StageInstance
+}
+
+// StageInstanceEventUpdate is the data for a StageInstanceEventUpdate event.
+type StageInstanceEventUpdate struct {
+ *StageInstance
+}
+
+// StageInstanceEventDelete is the data for a StageInstanceEventDelete event.
+type StageInstanceEventDelete struct {
+ *StageInstance
+}
+
+// GuildScheduledEventCreate is the data for a GuildScheduledEventCreate event.
+type GuildScheduledEventCreate struct {
+ *GuildScheduledEvent
+}
+
+// GuildScheduledEventUpdate is the data for a GuildScheduledEventUpdate event.
+type GuildScheduledEventUpdate struct {
+ *GuildScheduledEvent
+}
+
+// GuildScheduledEventDelete is the data for a GuildScheduledEventDelete event.
+type GuildScheduledEventDelete struct {
+ *GuildScheduledEvent
+}
+
+// GuildScheduledEventUserAdd is the data for a GuildScheduledEventUserAdd event.
+type GuildScheduledEventUserAdd struct {
+ GuildScheduledEventID string `json:"guild_scheduled_event_id"`
+ UserID string `json:"user_id"`
+ GuildID string `json:"guild_id"`
+}
+
+// GuildScheduledEventUserRemove is the data for a GuildScheduledEventUserRemove event.
+type GuildScheduledEventUserRemove struct {
+ GuildScheduledEventID string `json:"guild_scheduled_event_id"`
+ UserID string `json:"user_id"`
+ GuildID string `json:"guild_id"`
+}
+
+// MessageAck is the data for a MessageAck event.
+type MessageAck struct {
+ Version int `json:"version"`
+ MessageID string `json:"message_id"`
+ ChannelID string `json:"channel_id"`
+}
+
+// IntegrationCreate is the data for a IntegrationCreate event.
+type IntegrationCreate struct {
+ *Integration
+ GuildID string `json:"guild_id"`
+}
+
+// IntegrationUpdate is the data for a IntegrationUpdate event.
+type IntegrationUpdate struct {
+ *Integration
+ GuildID string `json:"guild_id"`
+}
+
+// IntegrationDelete is the data for a IntegrationDelete event.
+type IntegrationDelete struct {
+ ID string `json:"id"`
+ GuildID string `json:"guild_id"`
+ ApplicationID string `json:"application_id,omitempty"`
+}
+
+// MessageCreate is the data for a MessageCreate event.
+type MessageCreate struct {
+ *Message
+}
+
+// UnmarshalJSON is a helper function to unmarshal MessageCreate object.
+func (m *MessageCreate) UnmarshalJSON(b []byte) error {
+ return json.Unmarshal(b, &m.Message)
+}
+
+// MessageUpdate is the data for a MessageUpdate event.
+type MessageUpdate struct {
+ *Message
+ // BeforeUpdate will be nil if the Message was not previously cached in the state cache.
+ BeforeUpdate *Message `json:"-"`
+}
+
+// UnmarshalJSON is a helper function to unmarshal MessageUpdate object.
+func (m *MessageUpdate) UnmarshalJSON(b []byte) error {
+ return json.Unmarshal(b, &m.Message)
+}
+
+// MessageDelete is the data for a MessageDelete event.
+type MessageDelete struct {
+ *Message
+ BeforeDelete *Message `json:"-"`
+}
+
+// UnmarshalJSON is a helper function to unmarshal MessageDelete object.
+func (m *MessageDelete) UnmarshalJSON(b []byte) error {
+ return json.Unmarshal(b, &m.Message)
+}
+
+// MessageReactionAdd is the data for a MessageReactionAdd event.
+type MessageReactionAdd struct {
+ *MessageReaction
+ Member *Member `json:"member,omitempty"`
+}
+
+// MessageReactionRemove is the data for a MessageReactionRemove event.
+type MessageReactionRemove struct {
+ *MessageReaction
+}
+
+// MessageReactionRemoveAll is the data for a MessageReactionRemoveAll event.
+type MessageReactionRemoveAll struct {
+ *MessageReaction
+}
+
+// MessageReactionRemoveEmoji is the data for a MessageReactionRemoveEmoji event.
+type MessageReactionRemoveEmoji struct {
+ *MessageReaction
+}
+
+// PresencesReplace is the data for a PresencesReplace event.
+type PresencesReplace []*Presence
+
+// PresenceUpdate is the data for a PresenceUpdate event.
+type PresenceUpdate struct {
+ Presence
+ GuildID string `json:"guild_id"`
+}
+
+// Resumed is the data for a Resumed event.
+type Resumed struct {
+ Trace []string `json:"_trace"`
+}
+
+// RelationshipAdd is the data for a RelationshipAdd event.
+type RelationshipAdd struct {
+ *Relationship
+}
+
+// RelationshipRemove is the data for a RelationshipRemove event.
+type RelationshipRemove struct {
+ *Relationship
+}
+
+// RelationshipUpdate is the data for a RelationshipUpdate event.
+type RelationshipUpdate struct {
+ *Relationship
+}
+
+type ChannelRecipientRemove struct {
+ ChannelID string `json:"channel_id"`
+ User *User `json:"user"`
+}
+
+type ChannelRecipientAdd struct {
+ ChannelID string `json:"channel_id"`
+ User *User `json:"user"`
+}
+
+// TypingStart is the data for a TypingStart event.
+type TypingStart struct {
+ UserID string `json:"user_id"`
+ ChannelID string `json:"channel_id"`
+ GuildID string `json:"guild_id,omitempty"`
+ Timestamp int `json:"timestamp"`
+}
+
+// UserUpdate is the data for a UserUpdate event.
+type UserUpdate struct {
+ *User
+}
+
+// UserSettingsUpdate is the data for a UserSettingsUpdate event.
+type UserSettingsUpdate map[string]interface{}
+
+// UserGuildSettingsUpdate is the data for a UserGuildSettingsUpdate event.
+type UserGuildSettingsUpdate struct {
+ *UserGuildSettings
+}
+
+// UserNoteUpdate is the data for a UserNoteUpdate event.
+type UserNoteUpdate struct {
+ ID string `json:"id"`
+ Note string `json:"note"`
+}
+
+// VoiceServerUpdate is the data for a VoiceServerUpdate event.
+type VoiceServerUpdate struct {
+ Token string `json:"token"`
+ GuildID string `json:"guild_id"`
+ Endpoint string `json:"endpoint"`
+}
+
+// VoiceStateUpdate is the data for a VoiceStateUpdate event.
+type VoiceStateUpdate struct {
+ *VoiceState
+ // BeforeUpdate will be nil if the VoiceState was not previously cached in the state cache.
+ BeforeUpdate *VoiceState `json:"-"`
+}
+
+// MessageDeleteBulk is the data for a MessageDeleteBulk event
+type MessageDeleteBulk struct {
+ Messages []string `json:"ids"`
+ ChannelID string `json:"channel_id"`
+ GuildID string `json:"guild_id"`
+}
+
+// WebhooksUpdate is the data for a WebhooksUpdate event
+type WebhooksUpdate struct {
+ GuildID string `json:"guild_id"`
+ ChannelID string `json:"channel_id"`
+}
+
+// InteractionCreate is the data for a InteractionCreate event
+type InteractionCreate struct {
+ *Interaction
+}
+
+// UnmarshalJSON is a helper function to unmarshal Interaction object.
+func (i *InteractionCreate) UnmarshalJSON(b []byte) error {
+ return json.Unmarshal(b, &i.Interaction)
+}
+
+type InteractionSuccess struct {
+ Nonce string `json:"nonce"`
+ ID string `json:"id"`
+}
+
+// InviteCreate is the data for a InviteCreate event
+type InviteCreate struct {
+ *Invite
+ ChannelID string `json:"channel_id"`
+ GuildID string `json:"guild_id"`
+}
+
+// InviteDelete is the data for a InviteDelete event
+type InviteDelete struct {
+ ChannelID string `json:"channel_id"`
+ GuildID string `json:"guild_id"`
+ Code string `json:"code"`
+}
+
+// ApplicationCommandPermissionsUpdate is the data for an ApplicationCommandPermissionsUpdate event
+type ApplicationCommandPermissionsUpdate struct {
+ *GuildApplicationCommandPermissions
+}
+
+// AutoModerationRuleCreate is the data for an AutoModerationRuleCreate event.
+type AutoModerationRuleCreate struct {
+ *AutoModerationRule
+}
+
+// AutoModerationRuleUpdate is the data for an AutoModerationRuleUpdate event.
+type AutoModerationRuleUpdate struct {
+ *AutoModerationRule
+}
+
+// AutoModerationRuleDelete is the data for an AutoModerationRuleDelete event.
+type AutoModerationRuleDelete struct {
+ *AutoModerationRule
+}
+
+// AutoModerationActionExecution is the data for an AutoModerationActionExecution event.
+type AutoModerationActionExecution struct {
+ GuildID string `json:"guild_id"`
+ Action AutoModerationAction `json:"action"`
+ RuleID string `json:"rule_id"`
+ RuleTriggerType AutoModerationRuleTriggerType `json:"rule_trigger_type"`
+ UserID string `json:"user_id"`
+ ChannelID string `json:"channel_id"`
+ MessageID string `json:"message_id"`
+ AlertSystemMessageID string `json:"alert_system_message_id"`
+ Content string `json:"content"`
+ MatchedKeyword string `json:"matched_keyword"`
+ MatchedContent string `json:"matched_content"`
+}
+
+// GuildAuditLogEntryCreate is the data for a GuildAuditLogEntryCreate event.
+type GuildAuditLogEntryCreate struct {
+ *AuditLogEntry
+ GuildID string `json:"guild_id"`
+}
+
+// MessagePollVoteAdd is the data for a MessagePollVoteAdd event.
+type MessagePollVoteAdd struct {
+ UserID string `json:"user_id"`
+ ChannelID string `json:"channel_id"`
+ MessageID string `json:"message_id"`
+ GuildID string `json:"guild_id,omitempty"`
+ AnswerID int `json:"answer_id"`
+}
+
+// MessagePollVoteRemove is the data for a MessagePollVoteRemove event.
+type MessagePollVoteRemove struct {
+ UserID string `json:"user_id"`
+ ChannelID string `json:"channel_id"`
+ MessageID string `json:"message_id"`
+ GuildID string `json:"guild_id,omitempty"`
+ AnswerID int `json:"answer_id"`
+}
+
+// EntitlementCreate is the data for an EntitlementCreate event.
+type EntitlementCreate struct {
+ *Entitlement
+}
+
+// EntitlementUpdate is the data for an EntitlementUpdate event.
+type EntitlementUpdate struct {
+ *Entitlement
+}
+
+// EntitlementDelete is the data for an EntitlementDelete event.
+// NOTE: Entitlements are not deleted when they expire.
+type EntitlementDelete struct {
+ *Entitlement
+}
+
+// SubscriptionCreate is the data for an SubscriptionCreate event.
+// https://discord.com/developers/docs/monetization/implementing-app-subscriptions#using-subscription-events-for-the-subscription-lifecycle
+type SubscriptionCreate struct {
+ *Subscription
+}
+
+// SubscriptionUpdate is the data for an SubscriptionUpdate event.
+// https://discord.com/developers/docs/monetization/implementing-app-subscriptions#using-subscription-events-for-the-subscription-lifecycle
+type SubscriptionUpdate struct {
+ *Subscription
+}
+
+// SubscriptionDelete is the data for an SubscriptionDelete event.
+// https://discord.com/developers/docs/monetization/implementing-app-subscriptions#using-subscription-events-for-the-subscription-lifecycle
+type SubscriptionDelete struct {
+ *Subscription
+}
+
+// UserRequiredActionUpdate contains an updated [RequiredAction] for the user
+// account.
+type UserRequiredActionUpdate struct {
+ RequiredAction RequiredAction `json:"required_action"`
+}
diff --git a/pkg/meowcord/heartbeatsession.go b/pkg/meowcord/heartbeatsession.go
new file mode 100644
index 0000000..af092a1
--- /dev/null
+++ b/pkg/meowcord/heartbeatsession.go
@@ -0,0 +1,56 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type HeartbeatSession struct {
+ CreatedAt time.Time `json:"createdAtTimestamp"`
+ LastUsedTimestamp time.Time `json:"lastUsedTimestamp"`
+ ID uuid.UUID `json:"uuid"`
+ // "version": 1
+}
+
+func NewHeartbeatSession() HeartbeatSession {
+ now := time.Now()
+ return HeartbeatSession{
+ CreatedAt: now,
+ LastUsedTimestamp: now,
+ ID: uuid.New(),
+ }
+}
+
+// BumpLastUsed updates the last used timestamp to the current time.
+func (hbs *HeartbeatSession) BumpLastUsed() {
+ if hbs == nil {
+ return
+ }
+ hbs.LastUsedTimestamp = time.Now()
+}
+
+// IsExpired reports whether the heartbeat session should be discarded in favor
+// of a new one.
+func (hbs *HeartbeatSession) IsExpired() bool {
+ if hbs == nil {
+ return true
+ }
+ return time.Since(hbs.LastUsedTimestamp) >= (time.Minute * 30)
+}
diff --git a/pkg/meowcord/interactions.go b/pkg/meowcord/interactions.go
new file mode 100644
index 0000000..73ee60f
--- /dev/null
+++ b/pkg/meowcord/interactions.go
@@ -0,0 +1,684 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// InteractionDeadline is the time allowed to respond to an interaction.
+const InteractionDeadline = time.Second * 3
+
+// ApplicationCommandType represents the type of application command.
+type ApplicationCommandType uint8
+
+// Application command types
+const (
+ // ChatApplicationCommand is default command type. They are slash commands (i.e. called directly from the chat).
+ ChatApplicationCommand ApplicationCommandType = 1
+ // UserApplicationCommand adds command to user context menu.
+ UserApplicationCommand ApplicationCommandType = 2
+ // MessageApplicationCommand adds command to message context menu.
+ MessageApplicationCommand ApplicationCommandType = 3
+)
+
+// ApplicationCommand represents an application's slash command.
+type ApplicationCommand struct {
+ ID string `json:"id,omitempty"`
+ ApplicationID string `json:"application_id,omitempty"`
+ GuildID string `json:"guild_id,omitempty"`
+ Version string `json:"version,omitempty"`
+ Type ApplicationCommandType `json:"type,omitempty"`
+ Name string `json:"name"`
+ NameLocalizations *map[Locale]string `json:"name_localizations,omitempty"`
+
+ // NOTE: DefaultPermission will be soon deprecated. Use DefaultMemberPermissions and Contexts instead.
+ DefaultPermission *bool `json:"default_permission,omitempty"`
+ DefaultMemberPermissions *int64 `json:"default_member_permissions,string,omitempty"`
+ NSFW *bool `json:"nsfw,omitempty"`
+
+ // Deprecated: use Contexts instead.
+ DMPermission *bool `json:"dm_permission,omitempty"`
+ Contexts *[]InteractionContextType `json:"contexts,omitempty"`
+ IntegrationTypes *[]ApplicationIntegrationType `json:"integration_types,omitempty"`
+
+ // NOTE: Chat commands only. Otherwise it mustn't be set.
+
+ Description string `json:"description,omitempty"`
+ DescriptionLocalizations *map[Locale]string `json:"description_localizations,omitempty"`
+ Options []*ApplicationCommandOption `json:"options"`
+}
+
+// ApplicationCommandOptionType indicates the type of a slash command's option.
+type ApplicationCommandOptionType uint8
+
+// Application command option types.
+const (
+ ApplicationCommandOptionSubCommand ApplicationCommandOptionType = 1
+ ApplicationCommandOptionSubCommandGroup ApplicationCommandOptionType = 2
+ ApplicationCommandOptionString ApplicationCommandOptionType = 3
+ ApplicationCommandOptionInteger ApplicationCommandOptionType = 4
+ ApplicationCommandOptionBoolean ApplicationCommandOptionType = 5
+ ApplicationCommandOptionUser ApplicationCommandOptionType = 6
+ ApplicationCommandOptionChannel ApplicationCommandOptionType = 7
+ ApplicationCommandOptionRole ApplicationCommandOptionType = 8
+ ApplicationCommandOptionMentionable ApplicationCommandOptionType = 9
+ ApplicationCommandOptionNumber ApplicationCommandOptionType = 10
+ ApplicationCommandOptionAttachment ApplicationCommandOptionType = 11
+)
+
+func (t ApplicationCommandOptionType) String() string {
+ switch t {
+ case ApplicationCommandOptionSubCommand:
+ return "SubCommand"
+ case ApplicationCommandOptionSubCommandGroup:
+ return "SubCommandGroup"
+ case ApplicationCommandOptionString:
+ return "String"
+ case ApplicationCommandOptionInteger:
+ return "Integer"
+ case ApplicationCommandOptionBoolean:
+ return "Boolean"
+ case ApplicationCommandOptionUser:
+ return "User"
+ case ApplicationCommandOptionChannel:
+ return "Channel"
+ case ApplicationCommandOptionRole:
+ return "Role"
+ case ApplicationCommandOptionMentionable:
+ return "Mentionable"
+ case ApplicationCommandOptionNumber:
+ return "Number"
+ case ApplicationCommandOptionAttachment:
+ return "Attachment"
+ }
+ return fmt.Sprintf("ApplicationCommandOptionType(%d)", t)
+}
+
+// ApplicationCommandOption represents an option/subcommand/subcommands group.
+type ApplicationCommandOption struct {
+ Type ApplicationCommandOptionType `json:"type"`
+ Name string `json:"name"`
+ NameLocalizations map[Locale]string `json:"name_localizations,omitempty"`
+ Description string `json:"description,omitempty"`
+ DescriptionLocalizations map[Locale]string `json:"description_localizations,omitempty"`
+ // NOTE: This feature was on the API, but at some point developers decided to remove it.
+ // So I commented it, until it will be officially on the docs.
+ // Default bool `json:"default"`
+
+ ChannelTypes []ChannelType `json:"channel_types,omitempty"`
+ Required bool `json:"required,omitempty"`
+ Options []*ApplicationCommandOption `json:"options,omitempty"`
+
+ // NOTE: mutually exclusive with Choices.
+ Autocomplete bool `json:"autocomplete,omitempty"`
+ Choices []*ApplicationCommandOptionChoice `json:"choices,omitempty"`
+ // Minimal value of number/integer option.
+ MinValue *float64 `json:"min_value,omitempty"`
+ // Maximum value of number/integer option.
+ MaxValue float64 `json:"max_value,omitempty"`
+ // Minimum length of string option.
+ MinLength *int `json:"min_length,omitempty"`
+ // Maximum length of string option.
+ MaxLength int `json:"max_length,omitempty"`
+}
+
+// ApplicationCommandOptionChoice represents a slash command option choice.
+type ApplicationCommandOptionChoice struct {
+ Name string `json:"name"`
+ NameLocalizations map[Locale]string `json:"name_localizations,omitempty"`
+ Value interface{} `json:"value"`
+}
+
+// ApplicationCommandPermissions represents a single user or role permission for a command.
+type ApplicationCommandPermissions struct {
+ ID string `json:"id"`
+ Type ApplicationCommandPermissionType `json:"type"`
+ Permission bool `json:"permission"`
+}
+
+// GuildAllChannelsID is a helper function which returns guild_id-1.
+// It is used in ApplicationCommandPermissions to target all the channels within a guild.
+func GuildAllChannelsID(guild string) (id string, err error) {
+ var v uint64
+ v, err = strconv.ParseUint(guild, 10, 64)
+ if err != nil {
+ return
+ }
+
+ return strconv.FormatUint(v-1, 10), nil
+}
+
+// ApplicationCommandPermissionsList represents a list of ApplicationCommandPermissions, needed for serializing to JSON.
+type ApplicationCommandPermissionsList struct {
+ Permissions []*ApplicationCommandPermissions `json:"permissions"`
+}
+
+// GuildApplicationCommandPermissions represents all permissions for a single guild command.
+type GuildApplicationCommandPermissions struct {
+ ID string `json:"id"`
+ ApplicationID string `json:"application_id"`
+ GuildID string `json:"guild_id"`
+ Permissions []*ApplicationCommandPermissions `json:"permissions"`
+}
+
+// ApplicationCommandPermissionType indicates whether a permission is user or role based.
+type ApplicationCommandPermissionType uint8
+
+// Application command permission types.
+const (
+ ApplicationCommandPermissionTypeRole ApplicationCommandPermissionType = 1
+ ApplicationCommandPermissionTypeUser ApplicationCommandPermissionType = 2
+ ApplicationCommandPermissionTypeChannel ApplicationCommandPermissionType = 3
+)
+
+// InteractionType indicates the type of an interaction event.
+type InteractionType uint8
+
+// Interaction types
+const (
+ InteractionPing InteractionType = 1
+ InteractionApplicationCommand InteractionType = 2
+ InteractionMessageComponent InteractionType = 3
+ InteractionApplicationCommandAutocomplete InteractionType = 4
+ InteractionModalSubmit InteractionType = 5
+)
+
+func (t InteractionType) String() string {
+ switch t {
+ case InteractionPing:
+ return "Ping"
+ case InteractionApplicationCommand:
+ return "ApplicationCommand"
+ case InteractionMessageComponent:
+ return "MessageComponent"
+ case InteractionModalSubmit:
+ return "ModalSubmit"
+ }
+ return fmt.Sprintf("InteractionType(%d)", t)
+}
+
+// InteractionContextType represents the context in which interaction can be used or was triggered from.
+type InteractionContextType uint
+
+const (
+ // InteractionContextGuild indicates that interaction can be used within guilds.
+ InteractionContextGuild InteractionContextType = 0
+ // InteractionContextBotDM indicates that interaction can be used within DMs with the bot.
+ InteractionContextBotDM InteractionContextType = 1
+ // InteractionContextPrivateChannel indicates that interaction can be used within group DMs and DMs with other users.
+ InteractionContextPrivateChannel InteractionContextType = 2
+)
+
+// Interaction represents data of an interaction.
+type Interaction struct {
+ ID string `json:"id"`
+ AppID string `json:"application_id"`
+ Type InteractionType `json:"type"`
+ Data InteractionData `json:"data"`
+ GuildID string `json:"guild_id"`
+ ChannelID string `json:"channel_id"`
+
+ // The message on which interaction was used.
+ // NOTE: this field is only filled when a button click triggered the interaction. Otherwise it will be nil.
+ Message *Message `json:"message"`
+
+ // Bitwise set of permissions the app or bot has within the channel the interaction was sent from
+ AppPermissions int64 `json:"app_permissions,string"`
+
+ // The member who invoked this interaction.
+ // NOTE: this field is only filled when the slash command was invoked in a guild;
+ // if it was invoked in a DM, the `User` field will be filled instead.
+ // Make sure to check for `nil` before using this field.
+ Member *Member `json:"member"`
+ // The user who invoked this interaction.
+ // NOTE: this field is only filled when the slash command was invoked in a DM;
+ // if it was invoked in a guild, the `Member` field will be filled instead.
+ // Make sure to check for `nil` before using this field.
+ User *User `json:"user"`
+
+ // The user's discord client locale.
+ Locale Locale `json:"locale"`
+ // The guild's locale. This defaults to EnglishUS
+ // NOTE: this field is only filled when the interaction was invoked in a guild.
+ GuildLocale *Locale `json:"guild_locale"`
+
+ Context InteractionContextType `json:"context"`
+ AuthorizingIntegrationOwners map[ApplicationIntegrationType]string `json:"authorizing_integration_owners"`
+
+ Token string `json:"token"`
+ Version int `json:"version"`
+
+ // Any entitlements for the invoking user, representing access to premium SKUs.
+ // NOTE: this field is only filled in monetized apps
+ Entitlements []*Entitlement `json:"entitlements"`
+}
+
+type interaction Interaction
+
+type rawInteraction struct {
+ interaction
+ Data json.RawMessage `json:"data"`
+}
+
+// UnmarshalJSON is a method for unmarshalling JSON object to Interaction.
+func (i *Interaction) UnmarshalJSON(raw []byte) error {
+ var tmp rawInteraction
+ err := json.Unmarshal(raw, &tmp)
+ if err != nil {
+ return err
+ }
+
+ *i = Interaction(tmp.interaction)
+
+ switch tmp.Type {
+ case InteractionApplicationCommand, InteractionApplicationCommandAutocomplete:
+ v := ApplicationCommandInteractionData{}
+ err = json.Unmarshal(tmp.Data, &v)
+ if err != nil {
+ return err
+ }
+ i.Data = v
+ case InteractionMessageComponent:
+ v := MessageComponentInteractionData{}
+ err = json.Unmarshal(tmp.Data, &v)
+ if err != nil {
+ return err
+ }
+ i.Data = v
+ case InteractionModalSubmit:
+ v := ModalSubmitInteractionData{}
+ err = json.Unmarshal(tmp.Data, &v)
+ if err != nil {
+ return err
+ }
+ i.Data = v
+ }
+ return nil
+}
+
+// MessageComponentData is helper function to assert the inner InteractionData to MessageComponentInteractionData.
+// Make sure to check that the Type of the interaction is InteractionMessageComponent before calling.
+func (i Interaction) MessageComponentData() (data MessageComponentInteractionData) {
+ if i.Type != InteractionMessageComponent {
+ panic("MessageComponentData called on interaction of type " + i.Type.String())
+ }
+ return i.Data.(MessageComponentInteractionData)
+}
+
+// ApplicationCommandData is helper function to assert the inner InteractionData to ApplicationCommandInteractionData.
+// Make sure to check that the Type of the interaction is InteractionApplicationCommand before calling.
+func (i Interaction) ApplicationCommandData() (data ApplicationCommandInteractionData) {
+ if i.Type != InteractionApplicationCommand && i.Type != InteractionApplicationCommandAutocomplete {
+ panic("ApplicationCommandData called on interaction of type " + i.Type.String())
+ }
+ return i.Data.(ApplicationCommandInteractionData)
+}
+
+// ModalSubmitData is helper function to assert the inner InteractionData to ModalSubmitInteractionData.
+// Make sure to check that the Type of the interaction is InteractionModalSubmit before calling.
+func (i Interaction) ModalSubmitData() (data ModalSubmitInteractionData) {
+ if i.Type != InteractionModalSubmit {
+ panic("ModalSubmitData called on interaction of type " + i.Type.String())
+ }
+ return i.Data.(ModalSubmitInteractionData)
+}
+
+// InteractionData is a common interface for all types of interaction data.
+type InteractionData interface {
+ Type() InteractionType
+}
+
+// ApplicationCommandInteractionData contains the data of application command interaction.
+type ApplicationCommandInteractionData struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CommandType ApplicationCommandType `json:"type"`
+ Resolved *ApplicationCommandInteractionDataResolved `json:"resolved"`
+
+ // Slash command options
+ Options []*ApplicationCommandInteractionDataOption `json:"options"`
+ // Target (user/message) id on which context menu command was called.
+ // The details are stored in Resolved according to command type.
+ TargetID string `json:"target_id"`
+}
+
+// GetOption finds and returns an application command option by its name.
+func (d ApplicationCommandInteractionData) GetOption(name string) (option *ApplicationCommandInteractionDataOption) {
+ for _, opt := range d.Options {
+ if opt.Name == name {
+ option = opt
+ break
+ }
+ }
+
+ return
+}
+
+// ApplicationCommandInteractionDataResolved contains resolved data of command execution.
+// Partial Member objects are missing user, deaf and mute fields.
+// Partial Channel objects only have id, name, type and permissions fields.
+type ApplicationCommandInteractionDataResolved struct {
+ Users map[string]*User `json:"users"`
+ Members map[string]*Member `json:"members"`
+ Roles map[string]*Role `json:"roles"`
+ Channels map[string]*Channel `json:"channels"`
+ Messages map[string]*Message `json:"messages"`
+ Attachments map[string]*MessageAttachment `json:"attachments"`
+}
+
+// Type returns the type of interaction data.
+func (ApplicationCommandInteractionData) Type() InteractionType {
+ return InteractionApplicationCommand
+}
+
+// MessageComponentInteractionData contains the data of message component interaction.
+type MessageComponentInteractionData struct {
+ CustomID string `json:"custom_id"`
+ ComponentType ComponentType `json:"component_type"`
+ Resolved ComponentInteractionDataResolved `json:"resolved"`
+
+ // NOTE: Only filled when ComponentType is SelectMenuComponent (3). Otherwise is nil.
+ Values []string `json:"values"`
+}
+
+// ComponentInteractionDataResolved contains the resolved data of selected option.
+type ComponentInteractionDataResolved struct {
+ Users map[string]*User `json:"users"`
+ Members map[string]*Member `json:"members"`
+ Roles map[string]*Role `json:"roles"`
+ Channels map[string]*Channel `json:"channels"`
+ Attachments map[string]*MessageAttachment `json:"attachments"`
+}
+
+// Type returns the type of interaction data.
+func (MessageComponentInteractionData) Type() InteractionType {
+ return InteractionMessageComponent
+}
+
+// ModalSubmitInteractionData contains the data of modal submit interaction.
+type ModalSubmitInteractionData struct {
+ CustomID string `json:"custom_id"`
+ Components []MessageComponent `json:"-"`
+ Resolved ComponentInteractionDataResolved `json:"resolved"`
+}
+
+// Type returns the type of interaction data.
+func (ModalSubmitInteractionData) Type() InteractionType {
+ return InteractionModalSubmit
+}
+
+// UnmarshalJSON is a helper function to correctly unmarshal Components.
+func (d *ModalSubmitInteractionData) UnmarshalJSON(data []byte) error {
+ type modalSubmitInteractionData ModalSubmitInteractionData
+ var v struct {
+ modalSubmitInteractionData
+ RawComponents []unmarshalableMessageComponent `json:"components"`
+ }
+ err := json.Unmarshal(data, &v)
+ if err != nil {
+ return err
+ }
+ *d = ModalSubmitInteractionData(v.modalSubmitInteractionData)
+ d.Components = make([]MessageComponent, len(v.RawComponents))
+ for i, v := range v.RawComponents {
+ d.Components[i] = v.MessageComponent
+ }
+ return err
+}
+
+// ApplicationCommandInteractionDataOption represents an option of a slash command.
+type ApplicationCommandInteractionDataOption struct {
+ Name string `json:"name"`
+ Type ApplicationCommandOptionType `json:"type"`
+ // NOTE: Contains the value specified by Type.
+ Value interface{} `json:"value,omitempty"`
+ Options []*ApplicationCommandInteractionDataOption `json:"options,omitempty"`
+
+ // NOTE: autocomplete interaction only.
+ Focused bool `json:"focused,omitempty"`
+}
+
+// GetOption finds and returns an application command option by its name.
+func (o ApplicationCommandInteractionDataOption) GetOption(name string) (option *ApplicationCommandInteractionDataOption) {
+ for _, opt := range o.Options {
+ if opt.Name == name {
+ option = opt
+ break
+ }
+ }
+
+ return
+}
+
+// IntValue is a utility function for casting option value to integer
+func (o ApplicationCommandInteractionDataOption) IntValue() int64 {
+ if o.Type != ApplicationCommandOptionInteger {
+ panic("IntValue called on data option of type " + o.Type.String())
+ }
+ return int64(o.Value.(float64))
+}
+
+// UintValue is a utility function for casting option value to unsigned integer
+func (o ApplicationCommandInteractionDataOption) UintValue() uint64 {
+ if o.Type != ApplicationCommandOptionInteger {
+ panic("UintValue called on data option of type " + o.Type.String())
+ }
+ return uint64(o.Value.(float64))
+}
+
+// FloatValue is a utility function for casting option value to float
+func (o ApplicationCommandInteractionDataOption) FloatValue() float64 {
+ if o.Type != ApplicationCommandOptionNumber {
+ panic("FloatValue called on data option of type " + o.Type.String())
+ }
+ return o.Value.(float64)
+}
+
+// StringValue is a utility function for casting option value to string
+func (o ApplicationCommandInteractionDataOption) StringValue() string {
+ if o.Type != ApplicationCommandOptionString {
+ panic("StringValue called on data option of type " + o.Type.String())
+ }
+ return o.Value.(string)
+}
+
+// BoolValue is a utility function for casting option value to bool
+func (o ApplicationCommandInteractionDataOption) BoolValue() bool {
+ if o.Type != ApplicationCommandOptionBoolean {
+ panic("BoolValue called on data option of type " + o.Type.String())
+ }
+ return o.Value.(bool)
+}
+
+// ChannelValue is a utility function for casting option value to channel object.
+// s : Session object, if not nil, function additionally fetches all channel's data
+func (o ApplicationCommandInteractionDataOption) ChannelValue(s *Session) *Channel {
+ if o.Type != ApplicationCommandOptionChannel {
+ panic("ChannelValue called on data option of type " + o.Type.String())
+ }
+ chanID := o.Value.(string)
+
+ if s == nil {
+ return &Channel{ID: chanID}
+ }
+
+ ch, err := s.State.Channel(chanID)
+ if err != nil {
+ ch, err = s.Channel(chanID)
+ if err != nil {
+ return &Channel{ID: chanID}
+ }
+ }
+
+ return ch
+}
+
+// RoleValue is a utility function for casting option value to role object.
+// s : Session object, if not nil, function additionally fetches all role's data
+func (o ApplicationCommandInteractionDataOption) RoleValue(s *Session, gID string) *Role {
+ if o.Type != ApplicationCommandOptionRole && o.Type != ApplicationCommandOptionMentionable {
+ panic("RoleValue called on data option of type " + o.Type.String())
+ }
+ roleID := o.Value.(string)
+
+ if s == nil || gID == "" {
+ return &Role{ID: roleID}
+ }
+
+ r, err := s.State.Role(gID, roleID)
+ if err != nil {
+ roles, err := s.GuildRoles(gID)
+ if err == nil {
+ for _, r = range roles {
+ if r.ID == roleID {
+ return r
+ }
+ }
+ }
+ return &Role{ID: roleID}
+ }
+
+ return r
+}
+
+// UserValue is a utility function for casting option value to user object.
+// s : Session object, if not nil, function additionally fetches all user's data
+func (o ApplicationCommandInteractionDataOption) UserValue(s *Session) *User {
+ if o.Type != ApplicationCommandOptionUser && o.Type != ApplicationCommandOptionMentionable {
+ panic("UserValue called on data option of type " + o.Type.String())
+ }
+ userID := o.Value.(string)
+
+ if s == nil {
+ return &User{ID: userID}
+ }
+
+ u, err := s.User(userID)
+ if err != nil {
+ return &User{ID: userID}
+ }
+
+ return u
+}
+
+// InteractionResponseType is type of interaction response.
+type InteractionResponseType uint8
+
+// Interaction response types.
+const (
+ // InteractionResponsePong is for ACK ping event.
+ InteractionResponsePong InteractionResponseType = 1
+ // InteractionResponseChannelMessageWithSource is for responding with a message, showing the user's input.
+ InteractionResponseChannelMessageWithSource InteractionResponseType = 4
+ // InteractionResponseDeferredChannelMessageWithSource acknowledges that the event was received, and that a follow-up will come later.
+ InteractionResponseDeferredChannelMessageWithSource InteractionResponseType = 5
+ // InteractionResponseDeferredMessageUpdate acknowledges that the message component interaction event was received, and message will be updated later.
+ InteractionResponseDeferredMessageUpdate InteractionResponseType = 6
+ // InteractionResponseUpdateMessage is for updating the message to which message component was attached.
+ InteractionResponseUpdateMessage InteractionResponseType = 7
+ // InteractionApplicationCommandAutocompleteResult shows autocompletion results. Autocomplete interaction only.
+ InteractionApplicationCommandAutocompleteResult InteractionResponseType = 8
+ // InteractionResponseModal is for responding to an interaction with a modal window.
+ InteractionResponseModal InteractionResponseType = 9
+)
+
+// InteractionResponse represents a response for an interaction event.
+type InteractionResponse struct {
+ Type InteractionResponseType `json:"type,omitempty"`
+ Data *InteractionResponseData `json:"data,omitempty"`
+}
+
+// InteractionResponseData is response data for an interaction.
+type InteractionResponseData struct {
+ TTS bool `json:"tts"`
+ Content string `json:"content"`
+ Components []MessageComponent `json:"components"`
+ Embeds []*MessageEmbed `json:"embeds"`
+ AllowedMentions *MessageAllowedMentions `json:"allowed_mentions,omitempty"`
+ Files []*File `json:"-"`
+ Attachments *[]*MessageAttachment `json:"attachments,omitempty"`
+ Poll *Poll `json:"poll,omitempty"`
+
+ // NOTE: only MessageFlagsSuppressEmbeds and MessageFlagsEphemeral can be set.
+ Flags MessageFlags `json:"flags,omitempty"`
+
+ // NOTE: autocomplete interaction only.
+ Choices []*ApplicationCommandOptionChoice `json:"choices,omitempty"`
+
+ // NOTE: modal interaction only.
+
+ CustomID string `json:"custom_id,omitempty"`
+ Title string `json:"title,omitempty"`
+}
+
+// VerifyInteraction implements message verification of the discord interactions api
+// signing algorithm, as documented here:
+// https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization
+func VerifyInteraction(r *http.Request, key ed25519.PublicKey) bool {
+ var msg bytes.Buffer
+
+ signature := r.Header.Get("X-Signature-Ed25519")
+ if signature == "" {
+ return false
+ }
+
+ sig, err := hex.DecodeString(signature)
+ if err != nil {
+ return false
+ }
+
+ if len(sig) != ed25519.SignatureSize {
+ return false
+ }
+
+ timestamp := r.Header.Get("X-Signature-Timestamp")
+ if timestamp == "" {
+ return false
+ }
+
+ msg.WriteString(timestamp)
+
+ defer r.Body.Close()
+ var body bytes.Buffer
+
+ // at the end of the function, copy the original body back into the request
+ defer func() {
+ r.Body = io.NopCloser(&body)
+ }()
+
+ // copy body into buffers
+ _, err = io.Copy(&msg, io.TeeReader(r.Body, &body))
+ if err != nil {
+ return false
+ }
+
+ return ed25519.Verify(key, msg.Bytes(), sig)
+}
diff --git a/pkg/meowcord/interactions_test.go b/pkg/meowcord/interactions_test.go
new file mode 100644
index 0000000..823dc7a
--- /dev/null
+++ b/pkg/meowcord/interactions_test.go
@@ -0,0 +1,89 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "encoding/hex"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestVerifyInteraction(t *testing.T) {
+ pubkey, privkey, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Errorf("error generating signing keypair: %s", err)
+ }
+ timestamp := "1608597133"
+
+ t.Run("success", func(t *testing.T) {
+ body := "body"
+ request := httptest.NewRequest("POST", "http://localhost/interaction", strings.NewReader(body))
+ request.Header.Set("X-Signature-Timestamp", timestamp)
+
+ var msg bytes.Buffer
+ msg.WriteString(timestamp)
+ msg.WriteString(body)
+ signature := ed25519.Sign(privkey, msg.Bytes())
+ request.Header.Set("X-Signature-Ed25519", hex.EncodeToString(signature[:ed25519.SignatureSize]))
+
+ if !VerifyInteraction(request, pubkey) {
+ t.Error("expected true, got false")
+ }
+ })
+
+ t.Run("failure/modified body", func(t *testing.T) {
+ body := "body"
+ request := httptest.NewRequest("POST", "http://localhost/interaction", strings.NewReader("WRONG"))
+ request.Header.Set("X-Signature-Timestamp", timestamp)
+
+ var msg bytes.Buffer
+ msg.WriteString(timestamp)
+ msg.WriteString(body)
+ signature := ed25519.Sign(privkey, msg.Bytes())
+ request.Header.Set("X-Signature-Ed25519", hex.EncodeToString(signature[:ed25519.SignatureSize]))
+
+ if VerifyInteraction(request, pubkey) {
+ t.Error("expected false, got true")
+ }
+ })
+
+ t.Run("failure/modified timestamp", func(t *testing.T) {
+ body := "body"
+ request := httptest.NewRequest("POST", "http://localhost/interaction", strings.NewReader("WRONG"))
+ request.Header.Set("X-Signature-Timestamp", strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10))
+
+ var msg bytes.Buffer
+ msg.WriteString(timestamp)
+ msg.WriteString(body)
+ signature := ed25519.Sign(privkey, msg.Bytes())
+ request.Header.Set("X-Signature-Ed25519", hex.EncodeToString(signature[:ed25519.SignatureSize]))
+
+ if VerifyInteraction(request, pubkey) {
+ t.Error("expected false, got true")
+ }
+ })
+}
diff --git a/pkg/meowcord/launchsig.go b/pkg/meowcord/launchsig.go
new file mode 100644
index 0000000..d86dcec
--- /dev/null
+++ b/pkg/meowcord/launchsig.go
@@ -0,0 +1,61 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "encoding"
+ "fmt"
+
+ "github.com/google/uuid"
+)
+
+const uuidv4ByteSize = 16
+
+// A LaunchSignature is a valid version 4 UUID that doubles as a bitfield denoting the presence
+// of certain properties on the JavaScript window object.
+//
+// In the first-party client, launch signatures are generated by libdiscore.
+type LaunchSignature uuid.UUID
+
+// A bitmask that, when ANDed with the bytes of a version 4 UUID, produces a launch signature reflecting
+// no detected properties.
+//
+// Note that this incidentally includes the bits that must be zeroed in a valid version 4 UUID.
+var vanillaBitmask = [uuidv4ByteSize]byte{0xff, 0x7f, 0xef, 0xef, 0xf7, 0xef, 0x47, 0xff, 0x9f, 0x7e, 0xff, 0xbf, 0xfe, 0xff, 0xf7, 0xff}
+
+// NewVanillaSignature randomly generates a valid launch signature corresponding to a completely unmodified
+// client, i.e. a browser environment that does not trip any of the property checks.
+func NewVanillaSignature() (LaunchSignature, error) {
+ uuid := uuid.New()
+
+ for i := 0; i < uuidv4ByteSize; i++ {
+ uuid[i] &= vanillaBitmask[i]
+ }
+
+ return LaunchSignature(uuid), nil
+}
+
+var _ encoding.TextMarshaler = (*LaunchSignature)(nil)
+var _ fmt.Stringer = (*LaunchSignature)(nil)
+
+func (l LaunchSignature) MarshalText() (text []byte, err error) {
+ return uuid.UUID(l).MarshalText()
+}
+
+func (l LaunchSignature) String() string {
+ return uuid.UUID(l).String()
+}
diff --git a/pkg/meowcord/launchsig_test.go b/pkg/meowcord/launchsig_test.go
new file mode 100644
index 0000000..c9a05fe
--- /dev/null
+++ b/pkg/meowcord/launchsig_test.go
@@ -0,0 +1,50 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+// TestLaunchSignatureValidUUID ensures that launch signatures double as valid
+// version 4 UUIDs.
+func TestLaunchSignatureValidUUID(t *testing.T) {
+ sig, err := NewVanillaSignature()
+ if err != nil {
+ t.Error(err)
+ }
+ uuid.MustParse(sig.String())
+}
+
+func TestLaunchSignatureMarshal(t *testing.T) {
+ sig, err := NewVanillaSignature()
+ if err != nil {
+ t.Error(err)
+ }
+ j, err := json.Marshal(sig)
+ if err != nil {
+ t.Error(err)
+ }
+
+ if string(j) != fmt.Sprintf("\"%s\"", sig.String()) {
+ t.Errorf("launch signature didn't serialize as UUID; got %v", string(j))
+ }
+}
diff --git a/pkg/meowcord/locales.go b/pkg/meowcord/locales.go
new file mode 100644
index 0000000..6f00abb
--- /dev/null
+++ b/pkg/meowcord/locales.go
@@ -0,0 +1,106 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+// Locale represents the accepted languages for Discord.
+// https://discord.com/developers/docs/reference#locales
+type Locale string
+
+// String returns the human-readable string of the locale
+func (l Locale) String() string {
+ if name, ok := Locales[l]; ok {
+ return name
+ }
+ return Unknown.String()
+}
+
+// All defined locales in Discord
+const (
+ EnglishUS Locale = "en-US"
+ EnglishGB Locale = "en-GB"
+ Bulgarian Locale = "bg"
+ ChineseCN Locale = "zh-CN"
+ ChineseTW Locale = "zh-TW"
+ Croatian Locale = "hr"
+ Czech Locale = "cs"
+ Danish Locale = "da"
+ Dutch Locale = "nl"
+ Finnish Locale = "fi"
+ French Locale = "fr"
+ German Locale = "de"
+ Greek Locale = "el"
+ Hindi Locale = "hi"
+ Hungarian Locale = "hu"
+ Italian Locale = "it"
+ Japanese Locale = "ja"
+ Korean Locale = "ko"
+ Lithuanian Locale = "lt"
+ Norwegian Locale = "no"
+ Polish Locale = "pl"
+ PortugueseBR Locale = "pt-BR"
+ Romanian Locale = "ro"
+ Russian Locale = "ru"
+ SpanishES Locale = "es-ES"
+ SpanishLATAM Locale = "es-419"
+ Swedish Locale = "sv-SE"
+ Thai Locale = "th"
+ Turkish Locale = "tr"
+ Ukrainian Locale = "uk"
+ Vietnamese Locale = "vi"
+ Unknown Locale = ""
+)
+
+// Locales is a map of all the languages codes to their names.
+var Locales = map[Locale]string{
+ EnglishUS: "English (United States)",
+ EnglishGB: "English (Great Britain)",
+ Bulgarian: "Bulgarian",
+ ChineseCN: "Chinese (China)",
+ ChineseTW: "Chinese (Taiwan)",
+ Croatian: "Croatian",
+ Czech: "Czech",
+ Danish: "Danish",
+ Dutch: "Dutch",
+ Finnish: "Finnish",
+ French: "French",
+ German: "German",
+ Greek: "Greek",
+ Hindi: "Hindi",
+ Hungarian: "Hungarian",
+ Italian: "Italian",
+ Japanese: "Japanese",
+ Korean: "Korean",
+ Lithuanian: "Lithuanian",
+ Norwegian: "Norwegian",
+ Polish: "Polish",
+ PortugueseBR: "Portuguese (Brazil)",
+ Romanian: "Romanian",
+ Russian: "Russian",
+ SpanishES: "Spanish (Spain)",
+ SpanishLATAM: "Spanish (LATAM)",
+ Swedish: "Swedish",
+ Thai: "Thai",
+ Turkish: "Turkish",
+ Ukrainian: "Ukrainian",
+ Vietnamese: "Vietnamese",
+ Unknown: "unknown",
+}
diff --git a/pkg/meowcord/logging.go b/pkg/meowcord/logging.go
new file mode 100644
index 0000000..d342986
--- /dev/null
+++ b/pkg/meowcord/logging.go
@@ -0,0 +1,123 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains code related to discordgo package logging
+
+package meowcord
+
+import (
+ "fmt"
+ "log"
+ "runtime"
+ "strings"
+)
+
+const (
+
+ // LogError level is used for critical errors that could lead to data loss
+ // or panic that would not be returned to a calling function.
+ LogError int = iota
+
+ // LogWarning level is used for very abnormal events and errors that are
+ // also returned to a calling function.
+ LogWarning
+
+ // LogInformational level is used for normal non-error activity
+ LogInformational
+
+ // LogDebug level is for very detailed non-error activity. This is
+ // very spammy and will impact performance.
+ LogDebug
+)
+
+// Logger can be used to replace the standard logging for discordgo
+var Logger func(msgL, caller int, format string, a ...interface{})
+
+// msglog provides package wide logging consistency for discordgo
+// the format, a... portion this command follows that of fmt.Printf
+//
+// msgL : LogLevel of the message
+// caller : 1 + the number of callers away from the message source
+// format : Printf style message format
+// a ... : comma separated list of values to pass
+func msglog(msgL, caller int, format string, a ...interface{}) {
+
+ if Logger != nil {
+ Logger(msgL, caller, format, a...)
+ } else {
+
+ pc, file, line, _ := runtime.Caller(caller)
+
+ files := strings.Split(file, "/")
+ file = files[len(files)-1]
+
+ name := runtime.FuncForPC(pc).Name()
+ fns := strings.Split(name, ".")
+ name = fns[len(fns)-1]
+
+ msg := fmt.Sprintf(format, a...)
+
+ log.Printf("[DG%d] %s:%d:%s() %s\n", msgL, file, line, name, msg)
+ }
+}
+
+// helper function that wraps msglog for the Session struct
+// This adds a check to insure the message is only logged
+// if the session log level is equal or higher than the
+// message log level
+func (s *Session) log(msgL int, format string, a ...interface{}) {
+
+ if msgL > s.LogLevel {
+ return
+ }
+
+ if s.Logger != nil {
+ s.Logger(msgL, 1, format, a...)
+ return
+ }
+
+ msglog(msgL, 2, format, a...)
+}
+
+// helper function that wraps msglog for the VoiceConnection struct
+// This adds a check to insure the message is only logged
+// if the voice connection log level is equal or higher than the
+// message log level
+func (v *VoiceConnection) log(msgL int, format string, a ...interface{}) {
+
+ if msgL > v.LogLevel {
+ return
+ }
+
+ msglog(msgL, 2, format, a...)
+}
+
+// printJSON is a helper function to display JSON data in an easy to read format.
+/* NOT USED ATM
+func printJSON(body []byte) {
+ var prettyJSON bytes.Buffer
+ error := json.Indent(&prettyJSON, body, "", "\t")
+ if error != nil {
+ log.Print("JSON parse error: ", error)
+ }
+ log.Println(string(prettyJSON.Bytes()))
+}
+*/
diff --git a/pkg/meowcord/message.go b/pkg/meowcord/message.go
new file mode 100644
index 0000000..f459b28
--- /dev/null
+++ b/pkg/meowcord/message.go
@@ -0,0 +1,696 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains code related to the Message struct
+
+package meowcord
+
+import (
+ "encoding/json"
+ "io"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// MessageType is the type of Message
+// https://discord.com/developers/docs/resources/channel#message-object-message-types
+type MessageType int
+
+// Block contains the valid known MessageType values
+const (
+ MessageTypeDefault MessageType = 0
+ MessageTypeRecipientAdd MessageType = 1
+ MessageTypeRecipientRemove MessageType = 2
+ MessageTypeCall MessageType = 3
+ MessageTypeChannelNameChange MessageType = 4
+ MessageTypeChannelIconChange MessageType = 5
+ MessageTypeChannelPinnedMessage MessageType = 6
+ MessageTypeGuildMemberJoin MessageType = 7
+ MessageTypeUserPremiumGuildSubscription MessageType = 8
+ MessageTypeUserPremiumGuildSubscriptionTierOne MessageType = 9
+ MessageTypeUserPremiumGuildSubscriptionTierTwo MessageType = 10
+ MessageTypeUserPremiumGuildSubscriptionTierThree MessageType = 11
+ MessageTypeChannelFollowAdd MessageType = 12
+ MessageTypeGuildDiscoveryDisqualified MessageType = 14
+ MessageTypeGuildDiscoveryRequalified MessageType = 15
+ MessageTypeThreadCreated MessageType = 18
+ MessageTypeReply MessageType = 19
+ MessageTypeChatInputCommand MessageType = 20
+ MessageTypeThreadStarterMessage MessageType = 21
+ MessageTypeContextMenuCommand MessageType = 23
+)
+
+// A Message stores all data related to a specific Discord message.
+type Message struct {
+ // The ID of the message.
+ ID string `json:"id"`
+
+ // The ID of the channel in which the message was sent.
+ ChannelID string `json:"channel_id"`
+
+ // The ID of the guild in which the message was sent.
+ GuildID string `json:"guild_id,omitempty"`
+
+ // The content of the message.
+ Content string `json:"content"`
+
+ // The time at which the messsage was sent.
+ // CAUTION: this field may be removed in a
+ // future API version; it is safer to calculate
+ // the creation time via the ID.
+ Timestamp time.Time `json:"timestamp"`
+
+ // The time at which the last edit of the message
+ // occurred, if it has been edited.
+ EditedTimestamp *time.Time `json:"edited_timestamp"`
+
+ // The roles mentioned in the message.
+ MentionRoles []string `json:"mention_roles"`
+
+ // Whether the message is text-to-speech.
+ TTS bool `json:"tts"`
+
+ // Whether the message mentions everyone.
+ MentionEveryone bool `json:"mention_everyone"`
+
+ // The author of the message. This is not guaranteed to be a
+ // valid user (webhook-sent messages do not possess a full author).
+ Author *User `json:"author"`
+
+ // A list of attachments present in the message.
+ Attachments []*MessageAttachment `json:"attachments"`
+
+ // A list of components attached to the message.
+ Components []MessageComponent `json:"-"`
+
+ // A list of embeds present in the message.
+ Embeds []*MessageEmbed `json:"embeds"`
+
+ // A list of users mentioned in the message.
+ Mentions []*User `json:"mentions"`
+
+ // A list of reactions to the message.
+ Reactions []*MessageReactions `json:"reactions"`
+
+ // Whether the message is pinned or not.
+ Pinned bool `json:"pinned"`
+
+ // The type of the message.
+ Type MessageType `json:"type"`
+
+ // The webhook ID of the message, if it was generated by a webhook
+ WebhookID string `json:"webhook_id"`
+
+ ApplicationID string `json:"application_id"`
+
+ // Member properties for this message's author,
+ // contains only partial information
+ Member *Member `json:"member"`
+
+ // Channels specifically mentioned in this message
+ // Not all channel mentions in a message will appear in mention_channels.
+ // Only textual channels that are visible to everyone in a lurkable guild will ever be included.
+ // Only crossposted messages (via Channel Following) currently include mention_channels at all.
+ // If no mentions in the message meet these requirements, this field will not be sent.
+ MentionChannels []*Channel `json:"mention_channels"`
+
+ // Is sent with Rich Presence-related chat embeds
+ Activity *MessageActivity `json:"activity"`
+
+ // Is sent with Rich Presence-related chat embeds
+ Application *MessageApplication `json:"application"`
+
+ // MessageReference contains reference data sent with crossposted or reply messages.
+ // This does not contain the reference *to* this message; this is for when *this* message references another.
+ // To generate a reference to this message, use (*Message).Reference().
+ MessageReference *MessageReference `json:"message_reference"`
+
+ // The message associated with the message_reference
+ // NOTE: This field is only returned for messages with a type of 19 (REPLY) or 21 (THREAD_STARTER_MESSAGE).
+ // If the message is a reply but the referenced_message field is not present,
+ // the backend did not attempt to fetch the message that was being replied to, so its state is unknown.
+ // If the field exists but is null, the referenced message was deleted.
+ ReferencedMessage *Message `json:"referenced_message"`
+
+ // The message associated with the message_reference.
+ // This is a minimal subset of fields in a message (e.g. Author is excluded)
+ // NOTE: This field is only returned when referenced when MessageReference.Type is MessageReferenceTypeForward.
+ MessageSnapshots []MessageSnapshot `json:"message_snapshots"`
+
+ // Deprecated, use InteractionMetadata.
+ // Is sent when the message is a response to an Interaction, without an existing message.
+ // This means responses to message component interactions do not include this property,
+ // instead including a MessageReference, as components exist on preexisting messages.
+ Interaction *MessageInteraction `json:"interaction"`
+
+ InteractionMetadata *MessageInteractionMetadata `json:"interaction_metadata"`
+
+ // The flags of the message, which describe extra features of a message.
+ // This is a combination of bit masks; the presence of a certain permission can
+ // be checked by performing a bitwise AND between this int and the flag.
+ Flags MessageFlags `json:"flags"`
+
+ // The thread that was started from this message, includes thread member object
+ Thread *Channel `json:"thread,omitempty"`
+
+ // An array of StickerItem objects, representing sent stickers, if there were any.
+ StickerItems []*StickerItem `json:"sticker_items"`
+
+ // A poll object.
+ Poll *Poll `json:"poll"`
+
+ // Nonce is used for optimistic message sending. Only present in MESSAGE_CREATE
+ // events to confirm delivery of a message with a matching nonce.
+ Nonce StringOrInt `json:"nonce,omitempty"`
+}
+
+// UnmarshalJSON is a helper function to unmarshal the Message.
+func (m *Message) UnmarshalJSON(data []byte) error {
+ type message Message
+ var v struct {
+ message
+ RawComponents []unmarshalableMessageComponent `json:"components"`
+ }
+ err := json.Unmarshal(data, &v)
+ if err != nil {
+ return err
+ }
+ *m = Message(v.message)
+ m.Components = make([]MessageComponent, len(v.RawComponents))
+ for i, v := range v.RawComponents {
+ m.Components[i] = v.MessageComponent
+ }
+ return err
+}
+
+// GetCustomEmojis pulls out all the custom (Non-unicode) emojis from a message and returns a Slice of the Emoji struct.
+func (m *Message) GetCustomEmojis() []*Emoji {
+ var toReturn []*Emoji
+ emojis := EmojiRegex.FindAllString(m.Content, -1)
+ if len(emojis) < 1 {
+ return toReturn
+ }
+ for _, em := range emojis {
+ parts := strings.Split(em, ":")
+ toReturn = append(toReturn, &Emoji{
+ ID: parts[2][:len(parts[2])-1],
+ Name: parts[1],
+ Animated: strings.HasPrefix(em, " mentions with the
+// username of the mention.
+func (m *Message) ContentWithMentionsReplaced() (content string) {
+ content = m.Content
+
+ for _, user := range m.Mentions {
+ content = strings.NewReplacer(
+ "<@"+user.ID+">", "@"+user.Username,
+ "<@!"+user.ID+">", "@"+user.Username,
+ ).Replace(content)
+ }
+ return
+}
+
+var patternChannels = regexp.MustCompile("<#[^>]*>")
+
+// ContentWithMoreMentionsReplaced will replace all @ mentions with the
+// username of the mention, but also role IDs and more.
+func (m *Message) ContentWithMoreMentionsReplaced(s *Session) (content string, err error) {
+ content = m.Content
+
+ if !s.StateEnabled {
+ content = m.ContentWithMentionsReplaced()
+ return
+ }
+
+ channel, err := s.State.Channel(m.ChannelID)
+ if err != nil {
+ content = m.ContentWithMentionsReplaced()
+ return
+ }
+
+ for _, user := range m.Mentions {
+ nick := user.Username
+
+ member, err := s.State.Member(channel.GuildID, user.ID)
+ if err == nil && member.Nick != "" {
+ nick = member.Nick
+ }
+
+ content = strings.NewReplacer(
+ "<@"+user.ID+">", "@"+user.Username,
+ "<@!"+user.ID+">", "@"+nick,
+ ).Replace(content)
+ }
+ for _, roleID := range m.MentionRoles {
+ role, err := s.State.Role(channel.GuildID, roleID)
+ if err != nil || !role.Mentionable {
+ continue
+ }
+
+ content = strings.Replace(content, "<@&"+role.ID+">", "@"+role.Name, -1)
+ }
+
+ content = patternChannels.ReplaceAllStringFunc(content, func(mention string) string {
+ channel, err := s.State.Channel(mention[2 : len(mention)-1])
+ if err != nil || channel.Type == ChannelTypeGuildVoice {
+ return mention
+ }
+
+ return "#" + channel.Name
+ })
+ return
+}
+
+// MessageInteraction contains information about the application command interaction which generated the message.
+type MessageInteraction struct {
+ ID string `json:"id"`
+ Type InteractionType `json:"type"`
+ Name string `json:"name"`
+ User *User `json:"user"`
+
+ // Member is only present when the interaction is from a guild.
+ Member *Member `json:"member"`
+}
+
+// MessageInteractionMetadata contains metadata of an interaction, including relevant user info.
+type MessageInteractionMetadata struct {
+ // ID of the interaction.
+ ID string `json:"id"`
+ // Type of the interaction.
+ Type InteractionType `json:"type"`
+ // User who triggered the interaction.
+ User *User `json:"user"`
+ // IDs for installation context(s) related to an interaction.
+ AuthorizingIntegrationOwners map[ApplicationIntegrationType]string `json:"authorizing_integration_owners"`
+ // ID of the original response message.
+ // NOTE: present only on followup messages.
+ OriginalResponseMessageID string `json:"original_response_message_id,omitempty"`
+ // ID of the message that contained interactive component.
+ // NOTE: present only on message component interactions.
+ InteractedMessageID string `json:"interacted_message_id,omitempty"`
+ // Metadata for interaction that was used to open a modal.
+ // NOTE: present only on modal submit interactions.
+ TriggeringInteractionMetadata *MessageInteractionMetadata `json:"triggering_interaction_metadata,omitempty"`
+}
diff --git a/pkg/meowcord/message_test.go b/pkg/meowcord/message_test.go
new file mode 100644
index 0000000..dc1046f
--- /dev/null
+++ b/pkg/meowcord/message_test.go
@@ -0,0 +1,132 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "testing"
+)
+
+func TestContentWithMoreMentionsReplaced(t *testing.T) {
+ s := &Session{StateEnabled: true, State: NewState()}
+
+ user := &User{
+ ID: "user",
+ Username: "User Name",
+ }
+
+ s.State.GuildAdd(&Guild{ID: "guild"})
+ s.State.RoleAdd("guild", &Role{
+ ID: "role",
+ Name: "Role Name",
+ Mentionable: true,
+ })
+ s.State.MemberAdd(&Member{
+ User: user,
+ Nick: "User Nick",
+ GuildID: "guild",
+ })
+ s.State.ChannelAdd(&Channel{
+ Name: "Channel Name",
+ GuildID: "guild",
+ ID: "channel",
+ })
+ m := &Message{
+ Content: "<@&role> <@!user> <@user> <#channel>",
+ ChannelID: "channel",
+ MentionRoles: []string{"role"},
+ Mentions: []*User{user},
+ }
+ if result, _ := m.ContentWithMoreMentionsReplaced(s); result != "@Role Name @User Nick @User Name #Channel Name" {
+ t.Error(result)
+ }
+}
+func TestGettingEmojisFromMessage(t *testing.T) {
+ msg := "test test <:kitty14:811736565172011058> <:kitty4:811736468812595260>"
+ m := &Message{
+ Content: msg,
+ }
+ emojis := m.GetCustomEmojis()
+ if len(emojis) < 1 {
+ t.Error("No emojis found.")
+ return
+ }
+
+}
+
+func TestMessage_Reference(t *testing.T) {
+ m := &Message{
+ ID: "811736565172011001",
+ GuildID: "811736565172011002",
+ ChannelID: "811736565172011003",
+ }
+
+ ref := m.Reference()
+
+ if ref.Type != 0 {
+ t.Error("Default reference type should be 0")
+ }
+
+ if ref.MessageID != m.ID {
+ t.Error("Message ID should be the same")
+ }
+
+ if ref.GuildID != m.GuildID {
+ t.Error("Guild ID should be the same")
+ }
+
+ if ref.ChannelID != m.ChannelID {
+ t.Error("Channel ID should be the same")
+ }
+}
+
+func TestMessage_Forward(t *testing.T) {
+ m := &Message{
+ ID: "811736565172011001",
+ GuildID: "811736565172011002",
+ ChannelID: "811736565172011003",
+ }
+
+ ref := m.Forward()
+
+ if ref.Type != MessageReferenceTypeForward {
+ t.Error("Reference type should be 1 (forward)")
+ }
+
+ if ref.MessageID != m.ID {
+ t.Error("Message ID should be the same")
+ }
+
+ if ref.GuildID != m.GuildID {
+ t.Error("Guild ID should be the same")
+ }
+
+ if ref.ChannelID != m.ChannelID {
+ t.Error("Channel ID should be the same")
+ }
+}
+
+func TestMessageReference_DefaultTypeIsDefault(t *testing.T) {
+ r := MessageReference{}
+ if r.Type != MessageReferenceTypeDefault {
+ t.Error("Default message type should be MessageReferenceTypeDefault")
+ }
+}
diff --git a/pkg/meowcord/oauth2.go b/pkg/meowcord/oauth2.go
new file mode 100644
index 0000000..1ea008c
--- /dev/null
+++ b/pkg/meowcord/oauth2.go
@@ -0,0 +1,172 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains functions related to Discord OAuth2 endpoints
+
+package meowcord
+
+// ------------------------------------------------------------------------------------------------
+// Code specific to Discord OAuth2 Applications
+// ------------------------------------------------------------------------------------------------
+
+// The MembershipState represents whether the user is in the team or has been invited into it
+type MembershipState int
+
+// Constants for the different stages of the MembershipState
+const (
+ MembershipStateInvited MembershipState = 1
+ MembershipStateAccepted MembershipState = 2
+)
+
+// A TeamMember struct stores values for a single Team Member, extending the normal User data - note that the user field is partial
+type TeamMember struct {
+ User *User `json:"user"`
+ TeamID string `json:"team_id"`
+ MembershipState MembershipState `json:"membership_state"`
+ Permissions []string `json:"permissions"`
+}
+
+// A Team struct stores the members of a Discord Developer Team as well as some metadata about it
+type Team struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Icon string `json:"icon"`
+ OwnerID string `json:"owner_user_id"`
+ Members []*TeamMember `json:"members"`
+}
+
+// Application returns an Application structure of a specific Application
+//
+// appID : The ID of an Application
+func (s *Session) Application(appID string) (st *Application, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointOAuth2Application(appID), nil, EndpointOAuth2Application(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// Applications returns all applications for the authenticated user
+func (s *Session) Applications() (st []*Application, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointOAuth2Applications, nil, EndpointOAuth2Applications)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ApplicationCreate creates a new Application
+//
+// name : Name of Application / Bot
+// uris : Redirect URIs (Not required)
+func (s *Session) ApplicationCreate(ap *Application) (st *Application, err error) {
+
+ data := struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ }{ap.Name, ap.Description}
+
+ body, err := s.RequestWithBucketID("POST", EndpointOAuth2Applications, data, EndpointOAuth2Applications)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ApplicationUpdate updates an existing Application
+//
+// var : desc
+func (s *Session) ApplicationUpdate(appID string, ap *Application) (st *Application, err error) {
+
+ data := struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ }{ap.Name, ap.Description}
+
+ body, err := s.RequestWithBucketID("PUT", EndpointOAuth2Application(appID), data, EndpointOAuth2Application(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ApplicationDelete deletes an existing Application
+//
+// appID : The ID of an Application
+func (s *Session) ApplicationDelete(appID string) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointOAuth2Application(appID), nil, EndpointOAuth2Application(""))
+ if err != nil {
+ return
+ }
+
+ return
+}
+
+// Asset struct stores values for an asset of an application
+type Asset struct {
+ Type int `json:"type"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// ApplicationAssets returns an application's assets
+func (s *Session) ApplicationAssets(appID string) (ass []*Asset, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointOAuth2ApplicationAssets(appID), nil, EndpointOAuth2ApplicationAssets(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &ass)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Code specific to Discord OAuth2 Application Bots
+// ------------------------------------------------------------------------------------------------
+
+// ApplicationBotCreate creates an Application Bot Account
+//
+// appID : The ID of an Application
+//
+// NOTE: func name may change, if I can think up something better.
+func (s *Session) ApplicationBotCreate(appID string) (st *User, err error) {
+
+ body, err := s.RequestWithBucketID("POST", EndpointOAuth2ApplicationsBot(appID), nil, EndpointOAuth2ApplicationsBot(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
diff --git a/pkg/meowcord/oauth2_test.go b/pkg/meowcord/oauth2_test.go
new file mode 100644
index 0000000..6d46248
--- /dev/null
+++ b/pkg/meowcord/oauth2_test.go
@@ -0,0 +1,76 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord_test
+
+import (
+ "log"
+ "os"
+
+ "go.mau.fi/mautrix-discord/pkg/meowcord"
+)
+
+func ExampleApplication() {
+
+ // Authentication Token pulled from environment variable DGU_TOKEN
+ Token := os.Getenv("DGU_TOKEN")
+ if Token == "" {
+ return
+ }
+
+ // Create a new Discordgo session
+ dg, err := meowcord.New(Token)
+ if err != nil {
+ log.Println(err)
+ return
+ }
+
+ // Create an new Application
+ ap := &meowcord.Application{}
+ ap.Name = "TestApp"
+ ap.Description = "TestDesc"
+ ap, err = dg.ApplicationCreate(ap)
+ log.Printf("ApplicationCreate: err: %+v, app: %+v\n", err, ap)
+
+ // Get a specific Application by it's ID
+ ap, err = dg.Application(ap.ID)
+ log.Printf("Application: err: %+v, app: %+v\n", err, ap)
+
+ // Update an existing Application with new values
+ ap.Description = "Whooooa"
+ ap, err = dg.ApplicationUpdate(ap.ID, ap)
+ log.Printf("ApplicationUpdate: err: %+v, app: %+v\n", err, ap)
+
+ // create a new bot account for this application
+ bot, err := dg.ApplicationBotCreate(ap.ID)
+ log.Printf("BotCreate: err: %+v, bot: %+v\n", err, bot)
+
+ // Get a list of all applications for the authenticated user
+ apps, err := dg.Applications()
+ log.Printf("Applications: err: %+v, apps : %+v\n", err, apps)
+ for k, v := range apps {
+ log.Printf("Applications: %d : %+v\n", k, v)
+ }
+
+ // Delete the application we created.
+ err = dg.ApplicationDelete(ap.ID)
+ log.Printf("Delete: err: %+v\n", err)
+}
diff --git a/pkg/meowcord/ratelimit.go b/pkg/meowcord/ratelimit.go
new file mode 100644
index 0000000..9477922
--- /dev/null
+++ b/pkg/meowcord/ratelimit.go
@@ -0,0 +1,216 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// customRateLimit holds information for defining a custom rate limit
+type customRateLimit struct {
+ suffix string
+ requests int
+ reset time.Duration
+}
+
+// RateLimiter holds all ratelimit buckets
+type RateLimiter struct {
+ sync.Mutex
+ global *int64
+ buckets map[string]*Bucket
+ customRateLimits []*customRateLimit
+}
+
+// NewRatelimiter returns a new RateLimiter
+func NewRatelimiter() *RateLimiter {
+
+ return &RateLimiter{
+ buckets: make(map[string]*Bucket),
+ global: new(int64),
+ customRateLimits: []*customRateLimit{
+ {
+ suffix: "//reactions//",
+ requests: 1,
+ reset: 200 * time.Millisecond,
+ },
+ },
+ }
+}
+
+// GetBucket retrieves or creates a bucket
+func (r *RateLimiter) GetBucket(key string) *Bucket {
+ r.Lock()
+ defer r.Unlock()
+
+ if bucket, ok := r.buckets[key]; ok {
+ return bucket
+ }
+
+ b := &Bucket{
+ Remaining: 1,
+ Key: key,
+ global: r.global,
+ }
+
+ // Check if there is a custom ratelimit set for this bucket ID.
+ for _, rl := range r.customRateLimits {
+ if strings.HasSuffix(b.Key, rl.suffix) {
+ b.customRateLimit = rl
+ break
+ }
+ }
+
+ r.buckets[key] = b
+ return b
+}
+
+// GetWaitTime returns the duration you should wait for a Bucket
+func (r *RateLimiter) GetWaitTime(b *Bucket, minRemaining int) time.Duration {
+ // If we ran out of calls and the reset time is still ahead of us
+ // then we need to take it easy and relax a little
+ if b.Remaining < minRemaining && b.reset.After(time.Now()) {
+ return time.Until(b.reset)
+ }
+
+ // Check for global ratelimits
+ sleepTo := time.Unix(0, atomic.LoadInt64(r.global))
+ if now := time.Now(); now.Before(sleepTo) {
+ return sleepTo.Sub(now)
+ }
+
+ return 0
+}
+
+// LockBucket Locks until a request can be made
+func (r *RateLimiter) LockBucket(bucketID string) *Bucket {
+ return r.LockBucketObject(r.GetBucket(bucketID))
+}
+
+// LockBucketObject Locks an already resolved bucket until a request can be made
+func (r *RateLimiter) LockBucketObject(b *Bucket) *Bucket {
+ b.Lock()
+
+ if wait := r.GetWaitTime(b, 1); wait > 0 {
+ time.Sleep(wait)
+ }
+
+ b.Remaining--
+ return b
+}
+
+// Bucket represents a ratelimit bucket, each bucket gets ratelimited individually (-global ratelimits)
+type Bucket struct {
+ sync.Mutex
+ Key string
+ Remaining int
+ reset time.Time
+ global *int64
+
+ lastReset time.Time
+ customRateLimit *customRateLimit
+ Userdata interface{}
+}
+
+// Release unlocks the bucket and reads the headers to update the buckets ratelimit info
+// and locks up the whole thing in case if there's a global ratelimit.
+func (b *Bucket) Release(headers http.Header) error {
+ defer b.Unlock()
+
+ // Check if the bucket uses a custom ratelimiter
+ if rl := b.customRateLimit; rl != nil {
+ if time.Since(b.lastReset) >= rl.reset {
+ b.Remaining = rl.requests - 1
+ b.lastReset = time.Now()
+ }
+ if b.Remaining < 1 {
+ b.reset = time.Now().Add(rl.reset)
+ }
+ return nil
+ }
+
+ if headers == nil {
+ return nil
+ }
+
+ remaining := headers.Get("X-RateLimit-Remaining")
+ reset := headers.Get("X-RateLimit-Reset")
+ global := headers.Get("X-RateLimit-Global")
+ resetAfter := headers.Get("X-RateLimit-Reset-After")
+
+ // Update global and per bucket reset time if the proper headers are available
+ // If global is set, then it will block all buckets until after Retry-After
+ // If Retry-After without global is provided it will use that for the new reset
+ // time since it's more accurate than X-RateLimit-Reset.
+ // If Retry-After after is not proided, it will update the reset time from X-RateLimit-Reset
+ if resetAfter != "" {
+ parsedAfter, err := strconv.ParseFloat(resetAfter, 64)
+ if err != nil {
+ return err
+ }
+
+ whole, frac := math.Modf(parsedAfter)
+ resetAt := time.Now().Add(time.Duration(whole) * time.Second).Add(time.Duration(frac*1000) * time.Millisecond)
+
+ // Lock either this single bucket or all buckets
+ if global != "" {
+ atomic.StoreInt64(b.global, resetAt.UnixNano())
+ } else {
+ b.reset = resetAt
+ }
+ } else if reset != "" {
+ // Calculate the reset time by using the date header returned from discord
+ discordTime, err := http.ParseTime(headers.Get("Date"))
+ if err != nil {
+ return err
+ }
+
+ unix, err := strconv.ParseFloat(reset, 64)
+ if err != nil {
+ return err
+ }
+
+ // Calculate the time until reset and add it to the current local time
+ // some extra time is added because without it i still encountered 429's.
+ // The added amount is the lowest amount that gave no 429's
+ // in 1k requests
+ whole, frac := math.Modf(unix)
+ delta := time.Unix(int64(whole), 0).Add(time.Duration(frac*1000)*time.Millisecond).Sub(discordTime) + time.Millisecond*250
+ b.reset = time.Now().Add(delta)
+ }
+
+ // Udpate remaining if header is present
+ if remaining != "" {
+ parsedRemaining, err := strconv.ParseInt(remaining, 10, 32)
+ if err != nil {
+ return err
+ }
+ b.Remaining = int(parsedRemaining)
+ }
+
+ return nil
+}
diff --git a/pkg/meowcord/ratelimit_test.go b/pkg/meowcord/ratelimit_test.go
new file mode 100644
index 0000000..8732ba7
--- /dev/null
+++ b/pkg/meowcord/ratelimit_test.go
@@ -0,0 +1,134 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// This test takes ~2 seconds to run
+func TestRatelimitReset(t *testing.T) {
+ rl := NewRatelimiter()
+
+ sendReq := func(endpoint string) {
+ bucket := rl.LockBucket(endpoint)
+
+ headers := http.Header(make(map[string][]string))
+
+ headers.Set("X-RateLimit-Remaining", "0")
+ // Reset for approx 2 seconds from now
+ headers.Set("X-RateLimit-Reset", fmt.Sprint(float64(time.Now().Add(time.Second*2).UnixNano())/1e9))
+ headers.Set("Date", time.Now().Format(time.RFC850))
+
+ err := bucket.Release(headers)
+ if err != nil {
+ t.Errorf("Release returned error: %v", err)
+ }
+ }
+
+ sent := time.Now()
+ sendReq("/guilds/99/channels")
+ sendReq("/guilds/55/channels")
+ sendReq("/guilds/66/channels")
+
+ sendReq("/guilds/99/channels")
+ sendReq("/guilds/55/channels")
+ sendReq("/guilds/66/channels")
+
+ // We hit the same endpoint 2 times, so we should only be ratelimited 2 second
+ // And always less than 4 seconds (unless you're on a stoneage computer or using swap or something...)
+ if time.Since(sent) >= time.Second && time.Since(sent) < time.Second*4 {
+ t.Log("OK", time.Since(sent))
+ } else {
+ t.Error("Did not ratelimit correctly, got:", time.Since(sent))
+ }
+}
+
+// This test takes ~1 seconds to run
+func TestRatelimitGlobal(t *testing.T) {
+ rl := NewRatelimiter()
+
+ sendReq := func(endpoint string) {
+ bucket := rl.LockBucket(endpoint)
+
+ headers := http.Header(make(map[string][]string))
+
+ headers.Set("X-RateLimit-Global", "1")
+ // Reset for approx 1 seconds from now
+ headers.Set("X-RateLimit-Reset-After", "1")
+
+ err := bucket.Release(headers)
+ if err != nil {
+ t.Errorf("Release returned error: %v", err)
+ }
+ }
+
+ sent := time.Now()
+
+ // This should trigger a global ratelimit
+ sendReq("/guilds/99/channels")
+ time.Sleep(time.Millisecond * 100)
+
+ // This shouldn't go through in less than 1 second
+ sendReq("/guilds/55/channels")
+
+ if time.Since(sent) >= time.Second && time.Since(sent) < time.Second*2 {
+ t.Log("OK", time.Since(sent))
+ } else {
+ t.Error("Did not ratelimit correctly, got:", time.Since(sent))
+ }
+}
+
+func BenchmarkRatelimitSingleEndpoint(b *testing.B) {
+ rl := NewRatelimiter()
+ for i := 0; i < b.N; i++ {
+ sendBenchReq("/guilds/99/channels", rl)
+ }
+}
+
+func BenchmarkRatelimitParallelMultiEndpoints(b *testing.B) {
+ rl := NewRatelimiter()
+ b.RunParallel(func(pb *testing.PB) {
+ i := 0
+ for pb.Next() {
+ sendBenchReq("/guilds/"+strconv.Itoa(i)+"/channels", rl)
+ i++
+ }
+ })
+}
+
+// Does not actually send requests, but locks the bucket and releases it with made-up headers
+func sendBenchReq(endpoint string, rl *RateLimiter) {
+ bucket := rl.LockBucket(endpoint)
+
+ headers := http.Header(make(map[string][]string))
+
+ headers.Set("X-RateLimit-Remaining", "10")
+ headers.Set("X-RateLimit-Reset", fmt.Sprint(float64(time.Now().UnixNano())/1e9))
+ headers.Set("Date", time.Now().Format(time.RFC850))
+
+ bucket.Release(headers)
+}
diff --git a/pkg/meowcord/restapi.go b/pkg/meowcord/restapi.go
new file mode 100644
index 0000000..e757820
--- /dev/null
+++ b/pkg/meowcord/restapi.go
@@ -0,0 +1,4285 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains functions for interacting with the Discord REST/JSON API
+// at the lowest level.
+
+package meowcord
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "image"
+ _ "image/jpeg" // For JPEG decoding
+ _ "image/png" // For PNG decoding
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// All error constants
+var (
+ ErrJSONUnmarshal = errors.New("json unmarshal")
+ ErrStatusOffline = errors.New("you can't set your Status to offline")
+ ErrVerificationLevelBounds = errors.New("VerificationLevel out of bounds, should be between 0 and 3")
+ ErrPruneDaysBounds = errors.New("the number of days should be more than or equal to 1")
+ ErrGuildNoIcon = errors.New("guild does not have an icon set")
+ ErrGuildNoSplash = errors.New("guild does not have a splash set")
+ ErrUnauthorized = errors.New("HTTP request was unauthorized. This could be because the provided token was not a bot token. Please add \"Bot \" to the start of your token. https://discord.com/developers/docs/reference#authentication-example-bot-token-authorization-header")
+ ErrImmediateDisconnect = errors.New("got op7 reconnect while connecting")
+ ErrInvalidSessionOnConnect = errors.New("got op9 invalid session while connecting")
+)
+
+var (
+ // Marshal defines function used to encode JSON payloads
+ Marshal func(v interface{}) ([]byte, error) = json.Marshal
+ // Unmarshal defines function used to decode JSON payloads
+ Unmarshal func(src []byte, v interface{}) error = json.Unmarshal
+)
+
+// RESTError stores error information about a request with a bad response code.
+// Message is not always present, there are cases where api calls can fail
+// without returning a json message.
+type RESTError struct {
+ Request *http.Request
+ Response *http.Response
+ ResponseBody []byte
+
+ Message *APIErrorMessage // Message may be nil.
+}
+
+// newRestError returns a new REST API error.
+func newRestError(req *http.Request, resp *http.Response, body []byte) *RESTError {
+ restErr := &RESTError{
+ Request: req,
+ Response: resp,
+ ResponseBody: body,
+ }
+
+ // Attempt to decode the error and assume no message was provided if it fails
+ var msg *APIErrorMessage
+ err := Unmarshal(body, &msg)
+ if err == nil {
+ restErr.Message = msg
+ }
+
+ return restErr
+}
+
+// Error returns a Rest API Error with its status code and body.
+func (r RESTError) Error() string {
+ return "HTTP " + r.Response.Status + ", " + string(r.ResponseBody)
+}
+
+// RateLimitError is returned when a request exceeds a rate limit
+// and ShouldRetryOnRateLimit is false. The request may be manually
+// retried after waiting the duration specified by RetryAfter.
+type RateLimitError struct {
+ *RateLimit
+}
+
+// Error returns a rate limit error with rate limited endpoint and retry time.
+func (e RateLimitError) Error() string {
+ return "Rate limit exceeded on " + e.URL + ", retry after " + e.RetryAfter.String()
+}
+
+// RequestConfig is an HTTP request configuration.
+type RequestConfig struct {
+ Request *http.Request
+ ShouldRetryOnRateLimit bool
+ MaxRestRetries int
+ Client *http.Client
+}
+
+// newRequestConfig returns a new HTTP request configuration based on parameters in Session.
+func newRequestConfig(s *Session, req *http.Request) *RequestConfig {
+ return &RequestConfig{
+ ShouldRetryOnRateLimit: s.ShouldRetryOnRateLimit,
+ MaxRestRetries: s.MaxRestRetries,
+ Client: s.Client,
+ Request: req,
+ }
+}
+
+// RequestOption is a function which mutates request configuration.
+// It can be supplied as an argument to any REST method.
+type RequestOption func(cfg *RequestConfig)
+
+// WithClient changes the HTTP client used for the request.
+func WithClient(client *http.Client) RequestOption {
+ return func(cfg *RequestConfig) {
+ if client != nil {
+ cfg.Client = client
+ }
+ }
+}
+
+// WithRetryOnRatelimit controls whether session will retry the request on rate limit.
+func WithRetryOnRatelimit(retry bool) RequestOption {
+ return func(cfg *RequestConfig) {
+ cfg.ShouldRetryOnRateLimit = retry
+ }
+}
+
+// WithRestRetries changes maximum amount of retries if request fails.
+func WithRestRetries(max int) RequestOption {
+ return func(cfg *RequestConfig) {
+ cfg.MaxRestRetries = max
+ }
+}
+
+// WithHeader sets a header in the request.
+func WithHeader(key, value string) RequestOption {
+ return func(cfg *RequestConfig) {
+ cfg.Request.Header.Set(key, value)
+ }
+}
+
+// WithAuditLogReason changes audit log reason associated with the request.
+func WithAuditLogReason(reason string) RequestOption {
+ return WithHeader("X-Audit-Log-Reason", reason)
+}
+
+// WithLocale changes accepted locale of the request.
+func WithLocale(locale Locale) RequestOption {
+ return WithHeader("X-Discord-Locale", string(locale))
+}
+
+// WithContextProperties changes the X-Context-Properties header sent with the request.
+func WithContextProperties(location string) RequestOption {
+ jsonText, err := json.Marshal(map[string]string{
+ "location": location,
+ })
+ if err != nil {
+ panic(err)
+ }
+ return WithHeader("X-Context-Properties", base64.StdEncoding.EncodeToString(jsonText))
+}
+
+func WithReferer(referer string, args ...any) RequestOption {
+ if len(args) > 0 {
+ referer = fmt.Sprintf(referer, args...)
+ }
+ return WithHeader("Referer", referer)
+}
+
+func WithChannelReferer(guildID, channelID string) RequestOption {
+ if guildID == "" {
+ guildID = "@me"
+ }
+ return WithReferer("https://discord.com/channels/%s/%s", guildID, channelID)
+}
+
+func WithThreadReferer(guildID, channelID, threadID string) RequestOption {
+ return WithReferer("https://discord.com/channels/%s/%s/threads/%s", guildID, channelID, threadID)
+}
+
+func WithQueryParam(key, value string) RequestOption {
+ return func(cfg *RequestConfig) {
+ q := cfg.Request.URL.Query()
+ q.Add(key, value)
+ cfg.Request.URL.RawQuery = q.Encode()
+ }
+}
+
+func WithLocationParam(loc string) RequestOption {
+ return WithQueryParam("location", loc)
+}
+
+// WithContext changes context of the request.
+func WithContext(ctx context.Context) RequestOption {
+ return func(cfg *RequestConfig) {
+ cfg.Request = cfg.Request.WithContext(ctx)
+ }
+}
+
+// Request is the same as RequestWithBucketID but the bucket id is the same as the urlStr
+func (s *Session) Request(method, urlStr string, data interface{}, options ...RequestOption) (response []byte, err error) {
+ return s.RequestWithBucketID(method, urlStr, data, strings.SplitN(urlStr, "?", 2)[0], options...)
+}
+
+// RequestWithBucketID makes a (GET/POST/...) Requests to Discord REST API with JSON data.
+func (s *Session) RequestWithBucketID(method, urlStr string, data interface{}, bucketID string, options ...RequestOption) (response []byte, err error) {
+ var body []byte
+ if data != nil {
+ body, err = Marshal(data)
+ if err != nil {
+ return
+ }
+ }
+
+ return s.RequestRaw(method, urlStr, "application/json", body, bucketID, 0, options...)
+}
+
+// RequestRaw makes a (GET/POST/...) Requests to Discord REST API.
+// Preferably use the other Request* methods but this lets you send JSON directly if that's what you have.
+// Sequence is the sequence number, if it fails with a 502 it will
+// retry with sequence+1 until it either succeeds or sequence >= session.MaxRestRetries
+func (s *Session) RequestRaw(method, urlStr, contentType string, b []byte, bucketID string, sequence int, options ...RequestOption) (response []byte, err error) {
+ if bucketID == "" {
+ bucketID = strings.SplitN(urlStr, "?", 2)[0]
+ }
+ return s.RequestWithLockedBucket(method, urlStr, contentType, b, s.Ratelimiter.LockBucket(bucketID), sequence, options...)
+}
+
+// RequestWithLockedBucket makes a request using a bucket that's already been locked
+func (s *Session) RequestWithLockedBucket(method, urlStr, contentType string, b []byte, bucket *Bucket, sequence int, options ...RequestOption) (response []byte, err error) {
+ if s.Debug {
+ log.Printf("API REQUEST %8s :: %s\n", method, urlStr)
+ log.Printf("API REQUEST PAYLOAD :: [%s]\n", string(b))
+ }
+
+ req, err := http.NewRequest(method, urlStr, bytes.NewBuffer(b))
+ if err != nil {
+ bucket.Release(nil)
+ return
+ }
+
+ // Not used on initial login..
+ // TODO: Verify if a login, otherwise complain about no-token
+ if s.Token != "" {
+ req.Header.Set("authorization", s.Token)
+ }
+
+ // Discord's API returns a 400 Bad Request is Content-Type is set, but the
+ // request body is empty.
+ if b != nil {
+ req.Header.Set("Content-Type", contentType)
+ }
+
+ // TODO: Make a configurable static variable.
+ req.Header.Set("User-Agent", s.UserAgent)
+
+ if s.IsUser {
+ headers := s.fetchHeaders
+ if strings.HasPrefix(urlStr, "https://cdn.discordapp.com") {
+ headers = s.downloadHeaders
+ }
+ for key, value := range headers {
+ req.Header.Set(key, value)
+ }
+ }
+
+ cfg := newRequestConfig(s, req)
+ for _, opt := range options {
+ opt(cfg)
+ }
+ req = cfg.Request
+
+ if s.Debug {
+ for k, v := range req.Header {
+ log.Printf("API REQUEST HEADER :: [%s] = %+v\n", k, v)
+ }
+ }
+
+ logBody := b
+ if contentType != "application/json" {
+ logBody = fmt.Appendf(nil, "%d bytes of %s", len(logBody), contentType)
+ }
+ s.log(LogDebug, "Requesting %s %s with %s", req.Method, req.URL.String(), logBody)
+ resp, err := cfg.Client.Do(req)
+ if err != nil {
+ bucket.Release(nil)
+ return
+ }
+ defer func() {
+ err2 := resp.Body.Close()
+ if s.Debug && err2 != nil {
+ log.Println("error closing resp body")
+ }
+ }()
+
+ err = bucket.Release(resp.Header)
+ if err != nil {
+ return
+ }
+
+ response, err = io.ReadAll(resp.Body)
+ if s.RESTResponseHook != nil {
+ s.RESTResponseHook(req, resp, response)
+ }
+ if err != nil {
+ return
+ }
+ s.log(LogDebug, "Response to %s %s: %d / %s", req.Method, req.URL.String(), resp.StatusCode, response)
+
+ if s.Debug {
+
+ log.Printf("API RESPONSE STATUS :: %s\n", resp.Status)
+ for k, v := range resp.Header {
+ log.Printf("API RESPONSE HEADER :: [%s] = %+v\n", k, v)
+ }
+ log.Printf("API RESPONSE BODY :: [%s]\n\n\n", response)
+ }
+
+ switch resp.StatusCode {
+ case http.StatusOK:
+ case http.StatusCreated:
+ case http.StatusNoContent:
+ case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
+ // Retry sending request if possible
+ if sequence < cfg.MaxRestRetries {
+
+ s.log(LogInformational, "%s Failed (%s), Retrying...", urlStr, resp.Status)
+ response, err = s.RequestWithLockedBucket(method, urlStr, contentType, b, s.Ratelimiter.LockBucketObject(bucket), sequence+1, options...)
+ } else {
+ err = newRestError(req, resp, response)
+ }
+ case http.StatusTooManyRequests:
+ rl := TooManyRequests{}
+ err = Unmarshal(response, &rl)
+ if err != nil {
+ s.log(LogError, "rate limit unmarshal error, %s", err)
+ return
+ }
+
+ if cfg.ShouldRetryOnRateLimit {
+ s.log(LogInformational, "Rate Limiting %s, retry in %v", urlStr, rl.RetryAfter)
+ s.handleEvent(rateLimitEventType, &RateLimit{TooManyRequests: &rl, URL: urlStr})
+
+ time.Sleep(rl.RetryAfter)
+ // we can make the above smarter
+ // this method can cause longer delays than required
+
+ response, err = s.RequestWithLockedBucket(method, urlStr, contentType, b, s.Ratelimiter.LockBucketObject(bucket), sequence, options...)
+ } else {
+ err = &RateLimitError{&RateLimit{TooManyRequests: &rl, URL: urlStr}}
+ }
+ case http.StatusUnauthorized:
+ if strings.Index(s.Token, "Bot ") != 0 {
+ s.log(LogInformational, "%s", ErrUnauthorized.Error())
+ err = ErrUnauthorized
+ }
+ fallthrough
+ default: // Error condition
+ err = newRestError(req, resp, response)
+ }
+
+ return
+}
+
+func unmarshal(data []byte, v interface{}) error {
+ err := Unmarshal(data, v)
+ if err != nil {
+ return fmt.Errorf("%w: %s", ErrJSONUnmarshal, err)
+ }
+
+ return nil
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Sessions
+// ------------------------------------------------------------------------------------------------
+
+// Login asks the Discord server for an authentication token.
+//
+// NOTE: While email/pass authentication is supported by DiscordGo it is
+// HIGHLY DISCOURAGED by Discord. Please only use email/pass to obtain a token
+// and then use that authentication token for all future connections.
+// Also, doing any form of automation with a user (non Bot) account may result
+// in that account being permanently banned from Discord.
+func (s *Session) Login(email, password string) (err error) {
+
+ data := struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }{email, password}
+
+ response, err := s.RequestWithBucketID("POST", EndpointLogin, data, EndpointLogin)
+ if err != nil {
+ return
+ }
+
+ temp := struct {
+ Token string `json:"token"`
+ MFA bool `json:"mfa"`
+ }{}
+
+ err = unmarshal(response, &temp)
+ if err != nil {
+ return
+ }
+
+ s.Token = temp.Token
+ s.MFA = temp.MFA
+ return
+}
+
+func (s *Session) RemoteAuthLogin(ticket string) (encryptedToken string, err error) {
+
+ data := struct {
+ Ticket string `json:"ticket"`
+ }{ticket}
+
+ response, err := s.RequestWithBucketID("POST", EndpointRemoteAuthLogin, data, EndpointRemoteAuthLogin)
+ if err != nil {
+ return
+ }
+
+ temp := struct {
+ EncryptedToken string `json:"encrypted_token"`
+ }{}
+
+ err = unmarshal(response, &temp)
+ if err != nil {
+ return
+ }
+
+ encryptedToken = temp.EncryptedToken
+ return
+}
+
+// Register sends a Register request to Discord, and returns the authentication token
+// Note that this account is temporary and should be verified for future use.
+// Another option is to save the authentication token external, but this isn't recommended.
+func (s *Session) Register(username string) (token string, err error) {
+
+ data := struct {
+ Username string `json:"username"`
+ }{username}
+
+ response, err := s.RequestWithBucketID("POST", EndpointRegister, data, EndpointRegister)
+ if err != nil {
+ return
+ }
+
+ temp := struct {
+ Token string `json:"token"`
+ }{}
+
+ err = unmarshal(response, &temp)
+ if err != nil {
+ return
+ }
+
+ token = temp.Token
+ return
+}
+
+// Logout sends a logout request to Discord.
+// This does not seem to actually invalidate the token. So you can still
+// make API calls even after a Logout. So, it seems almost pointless to
+// even use.
+func (s *Session) Logout() (err error) {
+
+ // _, err = s.Request("POST", LOGOUT, `{"token": "` + s.Token + `"}`)
+
+ if s.Token == "" {
+ return
+ }
+
+ data := struct {
+ Token string `json:"token"`
+ }{s.Token}
+
+ _, err = s.RequestWithBucketID("POST", EndpointLogout, data, EndpointLogout)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Users
+// ------------------------------------------------------------------------------------------------
+
+// User returns the user details of the given userID
+// userID : A user ID or "@me" which is a shortcut of current user ID
+func (s *Session) User(userID string, options ...RequestOption) (st *User, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointUser(userID), nil, EndpointUsers, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserAvatar is deprecated. Please use UserAvatarDecode
+// userID : A user ID or "@me" which is a shortcut of current user ID
+func (s *Session) UserAvatar(userID string, options ...RequestOption) (img image.Image, err error) {
+ u, err := s.User(userID, options...)
+ if err != nil {
+ return
+ }
+ img, err = s.UserAvatarDecode(u, options...)
+ return
+}
+
+// UserAvatarDecode returns an image.Image of a user's Avatar
+// user : The user which avatar should be retrieved
+func (s *Session) UserAvatarDecode(u *User, options ...RequestOption) (img image.Image, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointUserAvatar(u.ID, u.Avatar), nil, EndpointUserAvatar("", ""), options...)
+ if err != nil {
+ return
+ }
+
+ img, _, err = image.Decode(bytes.NewReader(body))
+ return
+}
+
+// UserUpdate updates current user settings.
+func (s *Session) UserUpdate(email, password, username, avatar, banner, newPassword string, options ...RequestOption) (st *User, err error) {
+
+ // NOTE: Avatar must be either the hash/id of existing Avatar or
+ // data:image/png;base64,BASE64_STRING_OF_NEW_AVATAR_PNG
+ // to set a new avatar.
+ // If left blank, avatar will be set to null/blank
+
+ data := struct {
+ Email string `json:"email,omitempty"`
+ Password string `json:"password,omitempty"`
+ Username string `json:"username,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+ Banner string `json:"banner,omitempty"`
+ NewPassword string `json:"new_password,omitempty"`
+ }{email, password, username, avatar, banner, newPassword}
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointUser("@me"), data, EndpointUsers, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserSettings returns the settings for a given user
+func (s *Session) UserSettings() (st *Settings, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointUserSettings("@me"), nil, EndpointUserSettings(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserUpdateStatus update the user status
+// status : The new status (Actual valid status are 'online','idle','dnd','invisible')
+func (s *Session) UserUpdateStatus(status Status) (st *Settings, err error) {
+ if status == StatusOffline {
+ err = ErrStatusOffline
+ return
+ }
+
+ data := struct {
+ Status Status `json:"status"`
+ }{status}
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointUserSettings("@me"), data, EndpointUserSettings(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+func (s *Session) SafetyHub(options ...RequestOption) (sh *SafetyHub, err error) {
+ response, err := s.Request("GET", EndpointSafetyHub(), nil, options...)
+ if err != nil {
+ return nil, err
+ }
+ err = unmarshal(response, &sh)
+ return
+}
+
+// UserConnections returns the user's connections
+func (s *Session) UserConnections(options ...RequestOption) (conn []*UserConnection, err error) {
+ response, err := s.RequestWithBucketID("GET", EndpointUserConnections("@me"), nil, EndpointUserConnections("@me"), options...)
+ if err != nil {
+ return nil, err
+ }
+
+ err = unmarshal(response, &conn)
+ if err != nil {
+ return
+ }
+
+ return
+}
+
+// UserChannels returns an array of Channel structures for all private
+// channels.
+func (s *Session) UserChannels() (st []*Channel, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointUserChannels("@me"), nil, EndpointUserChannels(""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserChannelCreate creates a new User (Private) Channel with another User
+// recipientID : A user ID for the user to which this channel is opened with.
+func (s *Session) UserChannelCreate(recipientID string, options ...RequestOption) (st *Channel, err error) {
+
+ data := struct {
+ RecipientID string `json:"recipient_id"`
+ }{recipientID}
+
+ body, err := s.RequestWithBucketID("POST", EndpointUserChannels("@me"), data, EndpointUserChannels(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserGuildMember returns a guild member object for the current user in the given Guild.
+// guildID : ID of the guild
+func (s *Session) UserGuildMember(guildID string, options ...RequestOption) (st *Member, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointUserGuildMember("@me", guildID), nil, EndpointUserGuildMember("@me", guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserGuilds returns an array of UserGuild structures for all guilds.
+// limit : The number guilds that can be returned. (max 200)
+// beforeID : If provided all guilds returned will be before given ID.
+// afterID : If provided all guilds returned will be after given ID.
+// withCounts : Whether to include approximate member and presence counts or not.
+func (s *Session) UserGuilds(limit int, beforeID, afterID string, withCounts bool, options ...RequestOption) (st []*UserGuild, err error) {
+
+ v := url.Values{}
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+ if afterID != "" {
+ v.Set("after", afterID)
+ }
+ if beforeID != "" {
+ v.Set("before", beforeID)
+ }
+ if withCounts {
+ v.Set("with_counts", "true")
+ }
+
+ uri := EndpointUserGuilds("@me")
+
+ if len(v) > 0 {
+ uri += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointUserGuilds(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserGuildSettingsEdit Edits the users notification settings for a guild
+// guildID : The ID of the guild to edit the settings on
+// settings : The settings to update
+func (s *Session) UserGuildSettingsEdit(guildID string, settings *UserGuildSettingsEdit) (st *UserGuildSettings, err error) {
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointUserGuildSettings("@me", guildID), settings, EndpointUserGuildSettings("", guildID))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserChannelPermissions returns the permission of a user in a channel.
+// userID : The ID of the user to calculate permissions for.
+// channelID : The ID of the channel to calculate permission for.
+// fetchOptions : Options used to fetch guild, member or channel if they are not present in state.
+//
+// NOTE: This function is now deprecated and will be removed in the future.
+// Please see the same function inside state.go
+func (s *Session) UserChannelPermissions(userID, channelID string, fetchOptions ...RequestOption) (apermissions int64, err error) {
+ // Try to just get permissions from state.
+ apermissions, err = s.State.UserChannelPermissions(userID, channelID)
+ if err == nil {
+ return
+ }
+
+ // Otherwise try get as much data from state as possible, falling back to the network.
+ channel, err := s.State.Channel(channelID)
+ if err != nil || channel == nil {
+ channel, err = s.Channel(channelID, fetchOptions...)
+ if err != nil {
+ return
+ }
+ }
+
+ guild, err := s.State.Guild(channel.GuildID)
+ if err != nil || guild == nil {
+ guild, err = s.Guild(channel.GuildID, fetchOptions...)
+ if err != nil {
+ return
+ }
+ }
+
+ if userID == guild.OwnerID {
+ apermissions = PermissionAll
+ return
+ }
+
+ member, err := s.State.Member(guild.ID, userID)
+ if err != nil || member == nil {
+ member, err = s.GuildMember(guild.ID, userID, fetchOptions...)
+ if err != nil {
+ return
+ }
+ }
+
+ return memberPermissions(guild, channel, userID, member.Roles), nil
+}
+
+// Calculates the permissions for a member.
+// https://support.discord.com/hc/en-us/articles/206141927-How-is-the-permission-hierarchy-structured-
+func memberPermissions(guild *Guild, channel *Channel, userID string, roles []string) (apermissions int64) {
+ if userID == guild.OwnerID {
+ apermissions = PermissionAll
+ return
+ }
+
+ for _, role := range guild.Roles {
+ if role.ID == guild.ID {
+ apermissions |= role.Permissions
+ break
+ }
+ }
+
+ for _, role := range guild.Roles {
+ for _, roleID := range roles {
+ if role.ID == roleID {
+ apermissions |= role.Permissions
+ break
+ }
+ }
+ }
+
+ if apermissions&PermissionAdministrator == PermissionAdministrator {
+ apermissions |= PermissionAll
+ }
+
+ // Apply @everyone overrides from the channel.
+ for _, overwrite := range channel.PermissionOverwrites {
+ if guild.ID == overwrite.ID {
+ apermissions &= ^overwrite.Deny
+ apermissions |= overwrite.Allow
+ break
+ }
+ }
+
+ var denies, allows int64
+ // Member overwrites can override role overrides, so do two passes
+ for _, overwrite := range channel.PermissionOverwrites {
+ for _, roleID := range roles {
+ if overwrite.Type == PermissionOverwriteTypeRole && roleID == overwrite.ID {
+ denies |= overwrite.Deny
+ allows |= overwrite.Allow
+ break
+ }
+ }
+ }
+
+ apermissions &= ^denies
+ apermissions |= allows
+
+ for _, overwrite := range channel.PermissionOverwrites {
+ if overwrite.Type == PermissionOverwriteTypeMember && overwrite.ID == userID {
+ apermissions &= ^overwrite.Deny
+ apermissions |= overwrite.Allow
+ break
+ }
+ }
+
+ if apermissions&PermissionAdministrator == PermissionAdministrator {
+ apermissions |= PermissionAllChannel
+ }
+
+ return apermissions
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Guilds
+// ------------------------------------------------------------------------------------------------
+
+// Guild returns a Guild structure of a specific Guild.
+// guildID : The ID of a Guild
+func (s *Session) Guild(guildID string, options ...RequestOption) (st *Guild, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointGuild(guildID), nil, EndpointGuild(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildWithCounts returns a Guild structure of a specific Guild with approximate member and presence counts.
+// guildID : The ID of a Guild
+func (s *Session) GuildWithCounts(guildID string, options ...RequestOption) (st *Guild, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuild(guildID)+"?with_counts=true", nil, EndpointGuild(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildPreview returns a GuildPreview structure of a specific public Guild.
+// guildID : The ID of a Guild
+func (s *Session) GuildPreview(guildID string, options ...RequestOption) (st *GuildPreview, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointGuildPreview(guildID), nil, EndpointGuildPreview(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildCreate creates a new Guild
+// name : A name for the Guild (2-100 characters)
+func (s *Session) GuildCreate(name string, options ...RequestOption) (st *Guild, err error) {
+
+ data := struct {
+ Name string `json:"name"`
+ }{name}
+
+ body, err := s.RequestWithBucketID("POST", EndpointGuildCreate, data, EndpointGuildCreate, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildEdit edits a new Guild
+// guildID : The ID of a Guild
+// g : A GuildParams struct with the values Name, Region and VerificationLevel defined.
+func (s *Session) GuildEdit(guildID string, g *GuildParams, options ...RequestOption) (st *Guild, err error) {
+
+ // Bounds checking for VerificationLevel, interval: [0, 4]
+ if g.VerificationLevel != nil {
+ val := *g.VerificationLevel
+ if val < 0 || val > 4 {
+ err = ErrVerificationLevelBounds
+ return
+ }
+ }
+
+ // Bounds checking for regions
+ if g.Region != "" {
+ isValid := false
+ regions, _ := s.VoiceRegions(options...)
+ for _, r := range regions {
+ if g.Region == r.ID {
+ isValid = true
+ }
+ }
+ if !isValid {
+ var valid []string
+ for _, r := range regions {
+ valid = append(valid, r.ID)
+ }
+ err = fmt.Errorf("region not a valid region (%q)", valid)
+ return
+ }
+ }
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointGuild(guildID), g, EndpointGuild(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildDelete deletes a Guild.
+// guildID : The ID of a Guild
+func (s *Session) GuildDelete(guildID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuild(guildID), nil, EndpointGuild(guildID), options...)
+ return
+}
+
+// GuildLeave leaves a Guild.
+// guildID : The ID of a Guild
+func (s *Session) GuildLeave(guildID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointUserGuild("@me", guildID), nil, EndpointUserGuild("", guildID), options...)
+ return
+}
+
+// GuildBans returns an array of GuildBan structures for bans in the given guild.
+// guildID : The ID of a Guild
+// limit : Max number of bans to return (max 1000)
+// beforeID : If not empty all returned users will be after the given id
+// afterID : If not empty all returned users will be before the given id
+func (s *Session) GuildBans(guildID string, limit int, beforeID, afterID string, options ...RequestOption) (st []*GuildBan, err error) {
+ uri := EndpointGuildBans(guildID)
+
+ v := url.Values{}
+ if limit != 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+ if beforeID != "" {
+ v.Set("before", beforeID)
+ }
+ if afterID != "" {
+ v.Set("after", afterID)
+ }
+
+ if len(v) > 0 {
+ uri += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildBans(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildBanCreate bans the given user from the given guild.
+// guildID : The ID of a Guild.
+// userID : The ID of a User
+// days : The number of days of previous comments to delete.
+func (s *Session) GuildBanCreate(guildID, userID string, days int, options ...RequestOption) (err error) {
+ return s.GuildBanCreateWithReason(guildID, userID, "", days, options...)
+}
+
+// GuildBan finds ban by given guild and user id and returns GuildBan structure
+func (s *Session) GuildBan(guildID, userID string, options ...RequestOption) (st *GuildBan, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildBan(guildID, userID), nil, EndpointGuildBan(guildID, userID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildBanCreateWithReason bans the given user from the given guild also providing a reaso.
+// guildID : The ID of a Guild.
+// userID : The ID of a User
+// reason : The reason for this ban
+// days : The number of days of previous comments to delete.
+func (s *Session) GuildBanCreateWithReason(guildID, userID, reason string, days int, options ...RequestOption) (err error) {
+
+ uri := EndpointGuildBan(guildID, userID)
+
+ queryParams := url.Values{}
+ if days > 0 {
+ queryParams.Set("delete_message_days", strconv.Itoa(days))
+ }
+ if reason != "" {
+ queryParams.Set("reason", reason)
+ }
+
+ if len(queryParams) > 0 {
+ uri += "?" + queryParams.Encode()
+ }
+
+ _, err = s.RequestWithBucketID("PUT", uri, nil, EndpointGuildBan(guildID, ""), options...)
+ return
+}
+
+// GuildBanDelete removes the given user from the guild bans
+// guildID : The ID of a Guild.
+// userID : The ID of a User
+func (s *Session) GuildBanDelete(guildID, userID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildBan(guildID, userID), nil, EndpointGuildBan(guildID, ""), options...)
+ return
+}
+
+// GuildMembers returns a list of members for a guild.
+// guildID : The ID of a Guild.
+// after : The id of the member to return members after
+// limit : max number of members to return (max 1000)
+func (s *Session) GuildMembers(guildID string, after string, limit int, options ...RequestOption) (st []*Member, err error) {
+
+ uri := EndpointGuildMembers(guildID)
+
+ v := url.Values{}
+
+ if after != "" {
+ v.Set("after", after)
+ }
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+
+ if len(v) > 0 {
+ uri += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildMembers(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ // The returned objects don't have the GuildID attribute so we will set it here.
+ for _, member := range st {
+ member.GuildID = guildID
+ }
+ return
+}
+
+// GuildMembersSearch returns a list of guild member objects whose username or nickname starts with a provided string
+// guildID : The ID of a Guild
+// query : Query string to match username(s) and nickname(s) against
+// limit : Max number of members to return (default 1, min 1, max 1000)
+func (s *Session) GuildMembersSearch(guildID, query string, limit int, options ...RequestOption) (st []*Member, err error) {
+
+ uri := EndpointGuildMembersSearch(guildID)
+
+ queryParams := url.Values{}
+ queryParams.Set("query", query)
+ if limit > 1 {
+ queryParams.Set("limit", strconv.Itoa(limit))
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri+"?"+queryParams.Encode(), nil, uri, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildMember returns a member of a guild.
+// guildID : The ID of a Guild.
+// userID : The ID of a User
+func (s *Session) GuildMember(guildID, userID string, options ...RequestOption) (st *Member, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildMember(guildID, userID), nil, EndpointGuildMember(guildID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ // The returned object doesn't have the GuildID attribute so we will set it here.
+ st.GuildID = guildID
+ return
+}
+
+// GuildMemberAdd force joins a user to the guild.
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// data : Parameters of the user to add.
+func (s *Session) GuildMemberAdd(guildID, userID string, data *GuildMemberAddParams, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("PUT", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ if err != nil {
+ return err
+ }
+
+ return err
+}
+
+// GuildMemberDelete removes the given user from the given guild.
+// guildID : The ID of a Guild.
+// userID : The ID of a User
+func (s *Session) GuildMemberDelete(guildID, userID string, options ...RequestOption) (err error) {
+
+ return s.GuildMemberDeleteWithReason(guildID, userID, "", options...)
+}
+
+// GuildMemberDeleteWithReason removes the given user from the given guild.
+// guildID : The ID of a Guild.
+// userID : The ID of a User
+// reason : The reason for the kick
+func (s *Session) GuildMemberDeleteWithReason(guildID, userID, reason string, options ...RequestOption) (err error) {
+
+ uri := EndpointGuildMember(guildID, userID)
+ if reason != "" {
+ uri += "?reason=" + url.QueryEscape(reason)
+ }
+
+ _, err = s.RequestWithBucketID("DELETE", uri, nil, EndpointGuildMember(guildID, ""), options...)
+ return
+}
+
+// GuildMemberEdit edits and returns updated member.
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// data : Updated GuildMember data.
+func (s *Session) GuildMemberEdit(guildID, userID string, data *GuildMemberParams, options ...RequestOption) (st *Member, err error) {
+ var body []byte
+ body, err = s.RequestWithBucketID("PATCH", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ if err != nil {
+ return nil, err
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildMemberEditComplex edits the nickname and roles of a member.
+// NOTE: deprecated, use GuildMemberEdit instead.
+//
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// data : A GuildMemberEditData struct with the new nickname and roles
+func (s *Session) GuildMemberEditComplex(guildID, userID string, data *GuildMemberParams, options ...RequestOption) (st *Member, err error) {
+ return s.GuildMemberEdit(guildID, userID, data, options...)
+}
+
+// GuildMemberMove moves a guild member from one voice channel to another/none
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// channelID : The ID of a channel to move user to or nil to remove from voice channel
+//
+// NOTE : I am not entirely set on the name of this function and it may change
+// prior to the final 1.0.0 release of Discordgo
+func (s *Session) GuildMemberMove(guildID string, userID string, channelID *string, options ...RequestOption) (err error) {
+ data := struct {
+ ChannelID *string `json:"channel_id"`
+ }{channelID}
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ return
+}
+
+// GuildMemberNickname updates the nickname of a guild member
+// guildID : The ID of a guild
+// userID : The ID of a user
+// userID : The ID of a user or "@me" which is a shortcut of the current user ID
+// nickname : The nickname of the member, "" will reset their nickname
+func (s *Session) GuildMemberNickname(guildID, userID, nickname string, options ...RequestOption) (err error) {
+
+ data := struct {
+ Nick string `json:"nick"`
+ }{nickname}
+
+ if userID == "@me" {
+ userID += "/nick"
+ }
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ return
+}
+
+// GuildMemberMute server mutes a guild member
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// mute : boolean value for if the user should be muted
+func (s *Session) GuildMemberMute(guildID string, userID string, mute bool, options ...RequestOption) (err error) {
+ data := struct {
+ Mute bool `json:"mute"`
+ }{mute}
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ return
+}
+
+// GuildMemberTimeout times out a guild member
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// until : The timestamp for how long a member should be timed out. Set to nil to remove timeout.
+func (s *Session) GuildMemberTimeout(guildID string, userID string, until *time.Time, options ...RequestOption) (err error) {
+ data := struct {
+ CommunicationDisabledUntil *time.Time `json:"communication_disabled_until"`
+ }{until}
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ return
+}
+
+// GuildMemberDeafen server deafens a guild member
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// deaf : boolean value for if the user should be deafened
+func (s *Session) GuildMemberDeafen(guildID string, userID string, deaf bool, options ...RequestOption) (err error) {
+ data := struct {
+ Deaf bool `json:"deaf"`
+ }{deaf}
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildMember(guildID, userID), data, EndpointGuildMember(guildID, ""), options...)
+ return
+}
+
+// GuildMemberRoleAdd adds the specified role to a given member
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// roleID : The ID of a Role to be assigned to the user.
+func (s *Session) GuildMemberRoleAdd(guildID, userID, roleID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("PUT", EndpointGuildMemberRole(guildID, userID, roleID), nil, EndpointGuildMemberRole(guildID, "", ""), options...)
+
+ return
+}
+
+// GuildMemberRoleRemove removes the specified role to a given member
+// guildID : The ID of a Guild.
+// userID : The ID of a User.
+// roleID : The ID of a Role to be removed from the user.
+func (s *Session) GuildMemberRoleRemove(guildID, userID, roleID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildMemberRole(guildID, userID, roleID), nil, EndpointGuildMemberRole(guildID, "", ""), options...)
+
+ return
+}
+
+// GuildChannels returns an array of Channel structures for all channels of a
+// given guild.
+// guildID : The ID of a Guild.
+func (s *Session) GuildChannels(guildID string, options ...RequestOption) (st []*Channel, err error) {
+
+ body, err := s.RequestRaw("GET", EndpointGuildChannels(guildID), "", nil, EndpointGuildChannels(guildID), 0, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildChannelCreateData is provided to GuildChannelCreateComplex
+type GuildChannelCreateData struct {
+ Name string `json:"name"`
+ Type ChannelType `json:"type"`
+ Topic string `json:"topic,omitempty"`
+ Bitrate int `json:"bitrate,omitempty"`
+ UserLimit int `json:"user_limit,omitempty"`
+ RateLimitPerUser int `json:"rate_limit_per_user,omitempty"`
+ Position int `json:"position,omitempty"`
+ PermissionOverwrites []*PermissionOverwrite `json:"permission_overwrites,omitempty"`
+ ParentID string `json:"parent_id,omitempty"`
+ NSFW bool `json:"nsfw,omitempty"`
+}
+
+// GuildChannelCreateComplex creates a new channel in the given guild
+// guildID : The ID of a Guild
+// data : A data struct describing the new Channel, Name and Type are mandatory, other fields depending on the type
+func (s *Session) GuildChannelCreateComplex(guildID string, data GuildChannelCreateData, options ...RequestOption) (st *Channel, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointGuildChannels(guildID), data, EndpointGuildChannels(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildChannelCreate creates a new channel in the given guild
+// guildID : The ID of a Guild.
+// name : Name of the channel (2-100 chars length)
+// ctype : Type of the channel
+func (s *Session) GuildChannelCreate(guildID, name string, ctype ChannelType, options ...RequestOption) (st *Channel, err error) {
+ return s.GuildChannelCreateComplex(guildID, GuildChannelCreateData{
+ Name: name,
+ Type: ctype,
+ }, options...)
+}
+
+// GuildChannelsReorder updates the order of channels in a guild
+// guildID : The ID of a Guild.
+// channels : Updated channels.
+func (s *Session) GuildChannelsReorder(guildID string, channels []*Channel, options ...RequestOption) (err error) {
+
+ data := make([]struct {
+ ID string `json:"id"`
+ Position int `json:"position"`
+ }, len(channels))
+
+ for i, c := range channels {
+ data[i].ID = c.ID
+ data[i].Position = c.Position
+ }
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildChannels(guildID), data, EndpointGuildChannels(guildID), options...)
+ return
+}
+
+// GuildInvites returns an array of Invite structures for the given guild
+// guildID : The ID of a Guild.
+func (s *Session) GuildInvites(guildID string, options ...RequestOption) (st []*Invite, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointGuildInvites(guildID), nil, EndpointGuildInvites(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildRoles returns all roles for a given guild.
+// guildID : The ID of a Guild.
+func (s *Session) GuildRoles(guildID string, options ...RequestOption) (st []*Role, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildRoles(guildID), nil, EndpointGuildRoles(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return // TODO return pointer
+}
+
+// GuildRole returns a specific role for a given guild.
+// guildID : The ID of a Guild.
+// roleID : The ID of a Role.
+func (s *Session) GuildRole(guildID, roleID string, options ...RequestOption) (st *Role, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointGuildRole(guildID, roleID), nil, EndpointGuildRole(guildID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildRoleCreate creates a new Guild Role and returns it.
+// guildID : The ID of a Guild.
+// data : New Role parameters.
+func (s *Session) GuildRoleCreate(guildID string, data *RoleParams, options ...RequestOption) (st *Role, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointGuildRoles(guildID), data, EndpointGuildRoles(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildRoleEdit updates an existing Guild Role and returns updated Role data.
+// guildID : The ID of a Guild.
+// roleID : The ID of a Role.
+// data : Updated Role data.
+func (s *Session) GuildRoleEdit(guildID, roleID string, data *RoleParams, options ...RequestOption) (st *Role, err error) {
+
+ // Prevent sending a color int that is too big.
+ if data.Color != nil && *data.Color > 0xFFFFFF {
+ return nil, fmt.Errorf("color value cannot be larger than 0xFFFFFF")
+ }
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointGuildRole(guildID, roleID), data, EndpointGuildRole(guildID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildRoleReorder reoders guild roles
+// guildID : The ID of a Guild.
+// roles : A list of ordered roles.
+func (s *Session) GuildRoleReorder(guildID string, roles []*Role, options ...RequestOption) (st []*Role, err error) {
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointGuildRoles(guildID), roles, EndpointGuildRoles(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildRoleDelete deletes an existing role.
+// guildID : The ID of a Guild.
+// roleID : The ID of a Role.
+func (s *Session) GuildRoleDelete(guildID, roleID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildRole(guildID, roleID), nil, EndpointGuildRole(guildID, ""), options...)
+
+ return
+}
+
+// GuildRoleMemberCounts returns a map of role ID to the number of members that have that role.
+//
+// guildID : The ID of a Guild.
+//
+// Does not include the @everyone role.
+func (s *Session) GuildRoleMemberCounts(guildID string, options ...RequestOption) (memberCounts map[string]uint64, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointGuildRoleMemberCounts(guildID), nil, EndpointGuildRoleMemberCounts(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &memberCounts)
+ return
+}
+
+// GuildPruneCount Returns the number of members that would be removed in a prune operation.
+// Requires 'KICK_MEMBER' permission.
+// guildID : The ID of a Guild.
+// days : The number of days to count prune for (1 or more).
+func (s *Session) GuildPruneCount(guildID string, days uint32, options ...RequestOption) (count uint32, err error) {
+ count = 0
+
+ if days <= 0 {
+ err = ErrPruneDaysBounds
+ return
+ }
+
+ p := struct {
+ Pruned uint32 `json:"pruned"`
+ }{}
+
+ uri := EndpointGuildPrune(guildID) + "?days=" + strconv.FormatUint(uint64(days), 10)
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildPrune(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &p)
+ if err != nil {
+ return
+ }
+
+ count = p.Pruned
+
+ return
+}
+
+// GuildPrune Begin as prune operation. Requires the 'KICK_MEMBERS' permission.
+// Returns an object with one 'pruned' key indicating the number of members that were removed in the prune operation.
+// guildID : The ID of a Guild.
+// days : The number of days to count prune for (1 or more).
+func (s *Session) GuildPrune(guildID string, days uint32, options ...RequestOption) (count uint32, err error) {
+
+ count = 0
+
+ if days <= 0 {
+ err = ErrPruneDaysBounds
+ return
+ }
+
+ data := struct {
+ days uint32
+ }{days}
+
+ p := struct {
+ Pruned uint32 `json:"pruned"`
+ }{}
+
+ body, err := s.RequestWithBucketID("POST", EndpointGuildPrune(guildID), data, EndpointGuildPrune(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &p)
+ if err != nil {
+ return
+ }
+
+ count = p.Pruned
+
+ return
+}
+
+// GuildIntegrations returns an array of Integrations for a guild.
+// guildID : The ID of a Guild.
+func (s *Session) GuildIntegrations(guildID string, options ...RequestOption) (st []*Integration, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildIntegrations(guildID), nil, EndpointGuildIntegrations(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildIntegrationCreate creates a Guild Integration.
+// guildID : The ID of a Guild.
+// integrationType : The Integration type.
+// integrationID : The ID of an integration.
+func (s *Session) GuildIntegrationCreate(guildID, integrationType, integrationID string, options ...RequestOption) (err error) {
+
+ data := struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ }{integrationType, integrationID}
+
+ _, err = s.RequestWithBucketID("POST", EndpointGuildIntegrations(guildID), data, EndpointGuildIntegrations(guildID), options...)
+ return
+}
+
+// GuildIntegrationEdit edits a Guild Integration.
+// guildID : The ID of a Guild.
+// integrationType : The Integration type.
+// integrationID : The ID of an integration.
+// expireBehavior : The behavior when an integration subscription lapses (see the integration object documentation).
+// expireGracePeriod : Period (in seconds) where the integration will ignore lapsed subscriptions.
+// enableEmoticons : Whether emoticons should be synced for this integration (twitch only currently).
+func (s *Session) GuildIntegrationEdit(guildID, integrationID string, expireBehavior, expireGracePeriod int, enableEmoticons bool, options ...RequestOption) (err error) {
+
+ data := struct {
+ ExpireBehavior int `json:"expire_behavior"`
+ ExpireGracePeriod int `json:"expire_grace_period"`
+ EnableEmoticons bool `json:"enable_emoticons"`
+ }{expireBehavior, expireGracePeriod, enableEmoticons}
+
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildIntegration(guildID, integrationID), data, EndpointGuildIntegration(guildID, ""), options...)
+ return
+}
+
+// GuildIntegrationDelete removes the given integration from the Guild.
+// guildID : The ID of a Guild.
+// integrationID : The ID of an integration.
+func (s *Session) GuildIntegrationDelete(guildID, integrationID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildIntegration(guildID, integrationID), nil, EndpointGuildIntegration(guildID, ""), options...)
+ return
+}
+
+// GuildIntegrationSync syncs an integration.
+// guildID : The ID of a Guild.
+// integrationID : The ID of an integration.
+func (s *Session) GuildIntegrationSync(guildID, integrationID string) (err error) {
+
+ _, err = s.RequestWithBucketID("POST", EndpointGuildIntegrationSync(guildID, integrationID), nil, EndpointGuildIntegration(guildID, ""))
+ return
+}
+
+// GuildIcon returns an image.Image of a guild icon.
+// guildID : The ID of a Guild.
+func (s *Session) GuildIcon(guildID string, options ...RequestOption) (img image.Image, err error) {
+ g, err := s.Guild(guildID, options...)
+ if err != nil {
+ return
+ }
+
+ if g.Icon == "" {
+ err = ErrGuildNoIcon
+ return
+ }
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildIcon(guildID, g.Icon), nil, EndpointGuildIcon(guildID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ img, _, err = image.Decode(bytes.NewReader(body))
+ return
+}
+
+// GuildSplash returns an image.Image of a guild splash image.
+// guildID : The ID of a Guild.
+func (s *Session) GuildSplash(guildID string, options ...RequestOption) (img image.Image, err error) {
+ g, err := s.Guild(guildID, options...)
+ if err != nil {
+ return
+ }
+
+ if g.Splash == "" {
+ err = ErrGuildNoSplash
+ return
+ }
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildSplash(guildID, g.Splash), nil, EndpointGuildSplash(guildID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ img, _, err = image.Decode(bytes.NewReader(body))
+ return
+}
+
+// GuildEmbed returns the embed for a Guild.
+// guildID : The ID of a Guild.
+func (s *Session) GuildEmbed(guildID string, options ...RequestOption) (st *GuildEmbed, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildEmbed(guildID), nil, EndpointGuildEmbed(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildEmbedEdit edits the embed of a Guild.
+// guildID : The ID of a Guild.
+// data : New GuildEmbed data.
+func (s *Session) GuildEmbedEdit(guildID string, data *GuildEmbed, options ...RequestOption) (err error) {
+ _, err = s.RequestWithBucketID("PATCH", EndpointGuildEmbed(guildID), data, EndpointGuildEmbed(guildID), options...)
+ return
+}
+
+// GuildAuditLog returns the audit log for a Guild.
+// guildID : The ID of a Guild.
+// userID : If provided the log will be filtered for the given ID.
+// beforeID : If provided all log entries returned will be before the given ID.
+// actionType : If provided the log will be filtered for the given Action Type.
+// limit : The number messages that can be returned. (default 50, min 1, max 100)
+func (s *Session) GuildAuditLog(guildID, userID, beforeID string, actionType, limit int, options ...RequestOption) (st *GuildAuditLog, err error) {
+
+ uri := EndpointGuildAuditLogs(guildID)
+
+ v := url.Values{}
+ if userID != "" {
+ v.Set("user_id", userID)
+ }
+ if beforeID != "" {
+ v.Set("before", beforeID)
+ }
+ if actionType > 0 {
+ v.Set("action_type", strconv.Itoa(actionType))
+ }
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+ if len(v) > 0 {
+ uri = fmt.Sprintf("%s?%s", uri, v.Encode())
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildAuditLogs(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildEmojis returns all emoji
+// guildID : The ID of a Guild.
+func (s *Session) GuildEmojis(guildID string, options ...RequestOption) (emoji []*Emoji, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildEmojis(guildID), nil, EndpointGuildEmojis(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// GuildEmoji returns specified emoji.
+// guildID : The ID of a Guild
+// emojiID : The ID of an Emoji to retrieve
+func (s *Session) GuildEmoji(guildID, emojiID string, options ...RequestOption) (emoji *Emoji, err error) {
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", EndpointGuildEmoji(guildID, emojiID), nil, EndpointGuildEmoji(guildID, emojiID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// GuildEmojiCreate creates a new Emoji.
+// guildID : The ID of a Guild.
+// data : New Emoji data.
+func (s *Session) GuildEmojiCreate(guildID string, data *EmojiParams, options ...RequestOption) (emoji *Emoji, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointGuildEmojis(guildID), data, EndpointGuildEmojis(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// GuildEmojiEdit modifies and returns updated Emoji.
+// guildID : The ID of a Guild.
+// emojiID : The ID of an Emoji.
+// data : Updated Emoji data.
+func (s *Session) GuildEmojiEdit(guildID, emojiID string, data *EmojiParams, options ...RequestOption) (emoji *Emoji, err error) {
+ body, err := s.RequestWithBucketID("PATCH", EndpointGuildEmoji(guildID, emojiID), data, EndpointGuildEmojis(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// GuildEmojiDelete deletes an Emoji.
+// guildID : The ID of a Guild.
+// emojiID : The ID of an Emoji.
+func (s *Session) GuildEmojiDelete(guildID, emojiID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildEmoji(guildID, emojiID), nil, EndpointGuildEmojis(guildID), options...)
+ return
+}
+
+// ApplicationEmojis returns all emojis for the given application
+// appID : ID of the application
+func (s *Session) ApplicationEmojis(appID string, options ...RequestOption) (emojis []*Emoji, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointApplicationEmojis(appID), nil, EndpointApplicationEmojis(appID), options...)
+ if err != nil {
+ return
+ }
+
+ var temp struct {
+ Items []*Emoji `json:"items"`
+ }
+
+ err = unmarshal(body, &temp)
+ if err != nil {
+ return
+ }
+
+ emojis = temp.Items
+ return
+}
+
+// ApplicationEmoji returns the emoji for the given application.
+// appID : ID of the application
+// emojiID : ID of an Emoji to retrieve
+func (s *Session) ApplicationEmoji(appID, emojiID string, options ...RequestOption) (emoji *Emoji, err error) {
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", EndpointApplicationEmoji(appID, emojiID), nil, EndpointApplicationEmoji(appID, emojiID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// ApplicationEmojiCreate creates a new Emoji for the given application.
+// appID : ID of the application
+// data : New Emoji data
+func (s *Session) ApplicationEmojiCreate(appID string, data *EmojiParams, options ...RequestOption) (emoji *Emoji, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointApplicationEmojis(appID), data, EndpointApplicationEmojis(appID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// ApplicationEmojiEdit modifies and returns updated Emoji for the given application.
+// appID : ID of the application
+// emojiID : ID of an Emoji
+// data : Updated Emoji data
+func (s *Session) ApplicationEmojiEdit(appID string, emojiID string, data *EmojiParams, options ...RequestOption) (emoji *Emoji, err error) {
+ body, err := s.RequestWithBucketID("PATCH", EndpointApplicationEmoji(appID, emojiID), data, EndpointApplicationEmojis(appID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &emoji)
+ return
+}
+
+// ApplicationEmojiDelete deletes an Emoji for the given application.
+// appID : ID of the application
+// emojiID : ID of an Emoji
+func (s *Session) ApplicationEmojiDelete(appID, emojiID string, options ...RequestOption) (err error) {
+ _, err = s.RequestWithBucketID("DELETE", EndpointApplicationEmoji(appID, emojiID), nil, EndpointApplicationEmojis(appID), options...)
+ return
+}
+
+// GuildTemplate returns a GuildTemplate for the given code
+// templateCode: The Code of a GuildTemplate
+func (s *Session) GuildTemplate(templateCode string, options ...RequestOption) (st *GuildTemplate, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildTemplate(templateCode), nil, EndpointGuildTemplate(templateCode), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildCreateWithTemplate creates a guild based on a GuildTemplate
+// templateCode: The Code of a GuildTemplate
+// name: The name of the guild (2-100) characters
+// icon: base64 encoded 128x128 image for the guild icon
+func (s *Session) GuildCreateWithTemplate(templateCode, name, icon string, options ...RequestOption) (st *Guild, err error) {
+
+ data := struct {
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ }{name, icon}
+
+ body, err := s.RequestWithBucketID("POST", EndpointGuildTemplate(templateCode), data, EndpointGuildTemplate(templateCode), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildTemplates returns all of GuildTemplates
+// guildID: The ID of the guild
+func (s *Session) GuildTemplates(guildID string, options ...RequestOption) (st []*GuildTemplate, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildTemplates(guildID), nil, EndpointGuildTemplates(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildTemplateCreate creates a template for the guild
+// guildID : The ID of the guild
+// data : Template metadata
+func (s *Session) GuildTemplateCreate(guildID string, data *GuildTemplateParams, options ...RequestOption) (st *GuildTemplate, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointGuildTemplates(guildID), data, EndpointGuildTemplates(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildTemplateSync syncs the template to the guild's current state
+// guildID: The ID of the guild
+// templateCode: The code of the template
+func (s *Session) GuildTemplateSync(guildID, templateCode string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("PUT", EndpointGuildTemplateSync(guildID, templateCode), nil, EndpointGuildTemplateSync(guildID, ""), options...)
+ return
+}
+
+// GuildTemplateEdit modifies the template's metadata
+// guildID : The ID of the guild
+// templateCode : The code of the template
+// data : New template metadata
+func (s *Session) GuildTemplateEdit(guildID, templateCode string, data *GuildTemplateParams, options ...RequestOption) (st *GuildTemplate, err error) {
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointGuildTemplateSync(guildID, templateCode), data, EndpointGuildTemplateSync(guildID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildTemplateDelete deletes the template
+// guildID: The ID of the guild
+// templateCode: The code of the template
+func (s *Session) GuildTemplateDelete(guildID, templateCode string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildTemplateSync(guildID, templateCode), nil, EndpointGuildTemplateSync(guildID, ""), options...)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Channels
+// ------------------------------------------------------------------------------------------------
+
+// Channel returns a Channel structure of a specific Channel.
+// channelID : The ID of the Channel you want returned.
+func (s *Session) Channel(channelID string, options ...RequestOption) (st *Channel, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointChannel(channelID), nil, EndpointChannel(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelEdit edits the given channel and returns the updated Channel data.
+// channelID : The ID of a Channel.
+// data : New Channel data.
+func (s *Session) ChannelEdit(channelID string, data *ChannelEdit, options ...RequestOption) (st *Channel, err error) {
+ body, err := s.RequestWithBucketID("PATCH", EndpointChannel(channelID), data, EndpointChannel(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+
+}
+
+// ChannelEditComplex edits an existing channel, replacing the parameters entirely with ChannelEdit struct
+// NOTE: deprecated, use ChannelEdit instead
+// channelID : The ID of a Channel
+// data : The channel struct to send
+func (s *Session) ChannelEditComplex(channelID string, data *ChannelEdit, options ...RequestOption) (st *Channel, err error) {
+ return s.ChannelEdit(channelID, data, options...)
+}
+
+// ChannelDelete deletes the given channel
+// channelID : The ID of a Channel
+func (s *Session) ChannelDelete(channelID string, options ...RequestOption) (st *Channel, err error) {
+
+ body, err := s.RequestWithBucketID("DELETE", EndpointChannel(channelID), nil, EndpointChannel(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelTyping broadcasts to all members that authenticated user is typing in
+// the given channel.
+// channelID : The ID of a Channel
+func (s *Session) ChannelTyping(channelID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("POST", EndpointChannelTyping(channelID), nil, EndpointChannelTyping(channelID), options...)
+ return
+}
+
+// ChannelMessages returns an array of Message structures for messages within
+// a given channel.
+// channelID : The ID of a Channel.
+// limit : The number messages that can be returned. (max 100)
+// beforeID : If provided all messages returned will be before given ID.
+// afterID : If provided all messages returned will be after given ID.
+// aroundID : If provided all messages returned will be around given ID.
+func (s *Session) ChannelMessages(channelID string, limit int, beforeID, afterID, aroundID string, options ...RequestOption) (st []*Message, err error) {
+
+ uri := EndpointChannelMessages(channelID)
+
+ v := url.Values{}
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+ if afterID != "" {
+ v.Set("after", afterID)
+ }
+ if beforeID != "" {
+ v.Set("before", beforeID)
+ }
+ if aroundID != "" {
+ v.Set("around", aroundID)
+ }
+ if len(v) > 0 {
+ uri += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointChannelMessages(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelMessage gets a single message by ID from a given channel.
+// channeld : The ID of a Channel
+// messageID : the ID of a Message
+func (s *Session) ChannelMessage(channelID, messageID string, options ...RequestOption) (st *Message, err error) {
+
+ response, err := s.RequestWithBucketID("GET", EndpointChannelMessage(channelID, messageID), nil, EndpointChannelMessage(channelID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(response, &st)
+ return
+}
+
+// ChannelMessageAck acknowledges and marks the given message as read
+// channeld : The ID of a Channel
+// messageID : the ID of a Message
+// lastToken : token returned by last ack
+func (s *Session) ChannelMessageAck(channelID, messageID, lastToken string) (st *Ack, err error) {
+
+ body, err := s.RequestWithBucketID("POST", EndpointChannelMessageAck(channelID, messageID), &Ack{Token: lastToken}, EndpointChannelMessageAck(channelID, ""))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelMessageAckNoToken acknowledges and marks the given message as read without a token
+// channeld : The ID of a Channel
+// messageID : the ID of a Message
+func (s *Session) ChannelMessageAckNoToken(channelID, messageID string, options ...RequestOption) (st *PtrAck, err error) {
+
+ body, err := s.RequestWithBucketID("POST", EndpointChannelMessageAck(channelID, messageID), &PtrAck{}, EndpointChannelMessageAck(channelID, ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelMessageSend sends a message to the given channel.
+// channelID : The ID of a Channel.
+// content : The message to send.
+func (s *Session) ChannelMessageSend(channelID string, content string, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{
+ Content: content,
+ }, options...)
+}
+
+var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
+
+// ChannelMessageSendComplex sends a message to the given channel.
+// channelID : The ID of a Channel.
+// data : The message struct to send.
+func (s *Session) ChannelMessageSendComplex(channelID string, data *MessageSend, options ...RequestOption) (st *Message, err error) {
+ // TODO: Remove this when compatibility is not required.
+ if data.Embed != nil {
+ if data.Embeds == nil {
+ data.Embeds = []*MessageEmbed{data.Embed}
+ } else {
+ err = fmt.Errorf("cannot specify both Embed and Embeds")
+ return
+ }
+ }
+
+ for _, embed := range data.Embeds {
+ if embed.Type == "" {
+ embed.Type = "rich"
+ }
+ }
+ endpoint := EndpointChannelMessages(channelID)
+
+ // TODO: Remove this when compatibility is not required.
+ files := data.Files
+ if data.File != nil {
+ if files == nil {
+ files = []*File{data.File}
+ } else {
+ err = fmt.Errorf("cannot specify both File and Files")
+ return
+ }
+ }
+
+ if data.StickerIDs != nil {
+ if len(*data.StickerIDs) > 3 {
+ err = fmt.Errorf("cannot send more than 3 stickers")
+ return
+ }
+ }
+
+ var response []byte
+ if len(files) > 0 {
+ contentType, body, encodeErr := MultipartBodyWithJSON(data, files)
+ if encodeErr != nil {
+ return st, encodeErr
+ }
+ response, err = s.RequestRaw("POST", endpoint, contentType, body, endpoint, 0, options...)
+ } else {
+ if s.IsUser {
+ if data.Attachments != nil {
+ zero := 0
+ data.Type = &zero
+ if data.StickerIDs == nil {
+ emptyArr := make([]string, 0)
+ data.StickerIDs = &emptyArr
+ }
+ } else {
+ var zero MessageFlags
+ data.Flags = &zero
+ data.MobileNetworkType = "unknown"
+ if data.TTS == nil {
+ var falseVal bool
+ data.TTS = &falseVal
+ }
+ }
+ options = append(options, WithContextProperties("chat_input"))
+ }
+ response, err = s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ }
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(response, &st)
+ return
+}
+
+func (s *Session) ChannelAttachmentCreate(channelID string, data *ReqPrepareAttachments, options ...RequestOption) (st *RespPrepareAttachments, err error) {
+ endpoint := EndpointChannelAttachments(channelID)
+
+ var response []byte
+ response, err = s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(response, &st)
+ return
+}
+
+// ChannelMessageSendTTS sends a message to the given channel with Text to Speech.
+// channelID : The ID of a Channel.
+// content : The message to send.
+func (s *Session) ChannelMessageSendTTS(channelID string, content string, options ...RequestOption) (*Message, error) {
+ trueVal := true
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{
+ Content: content,
+ TTS: &trueVal,
+ }, options...)
+}
+
+// ChannelMessageSendEmbed sends a message to the given channel with embedded data.
+// channelID : The ID of a Channel.
+// embed : The embed data to send.
+func (s *Session) ChannelMessageSendEmbed(channelID string, embed *MessageEmbed, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageSendEmbeds(channelID, []*MessageEmbed{embed}, options...)
+}
+
+// ChannelMessageSendEmbeds sends a message to the given channel with multiple embedded data.
+// channelID : The ID of a Channel.
+// embeds : The embeds data to send.
+func (s *Session) ChannelMessageSendEmbeds(channelID string, embeds []*MessageEmbed, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{
+ Embeds: embeds,
+ }, options...)
+}
+
+// ChannelMessageSendReply sends a message to the given channel with reference data.
+// channelID : The ID of a Channel.
+// content : The message to send.
+// reference : The message reference to send.
+func (s *Session) ChannelMessageSendReply(channelID string, content string, reference *MessageReference, options ...RequestOption) (*Message, error) {
+ if reference == nil {
+ return nil, fmt.Errorf("reply attempted with nil message reference")
+ }
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{
+ Content: content,
+ Reference: reference,
+ }, options...)
+}
+
+// ChannelMessageSendEmbedReply sends a message to the given channel with reference data and embedded data.
+// channelID : The ID of a Channel.
+// embed : The embed data to send.
+// reference : The message reference to send.
+func (s *Session) ChannelMessageSendEmbedReply(channelID string, embed *MessageEmbed, reference *MessageReference, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageSendEmbedsReply(channelID, []*MessageEmbed{embed}, reference, options...)
+}
+
+// ChannelMessageSendEmbedsReply sends a message to the given channel with reference data and multiple embedded data.
+// channelID : The ID of a Channel.
+// embeds : The embeds data to send.
+// reference : The message reference to send.
+func (s *Session) ChannelMessageSendEmbedsReply(channelID string, embeds []*MessageEmbed, reference *MessageReference, options ...RequestOption) (*Message, error) {
+ if reference == nil {
+ return nil, fmt.Errorf("reply attempted with nil message reference")
+ }
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{
+ Embeds: embeds,
+ Reference: reference,
+ }, options...)
+}
+
+// ChannelMessageEdit edits an existing message, replacing it entirely with
+// the given content.
+// channelID : The ID of a Channel
+// messageID : The ID of a Message
+// content : The contents of the message
+func (s *Session) ChannelMessageEdit(channelID, messageID, content string, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageEditComplex(NewMessageEdit(channelID, messageID).SetContent(content), options...)
+}
+
+// ChannelMessageEditComplex edits an existing message, replacing it entirely with
+// the given MessageEdit struct
+func (s *Session) ChannelMessageEditComplex(m *MessageEdit, options ...RequestOption) (st *Message, err error) {
+ // TODO: Remove this when compatibility is not required.
+ if m.Embed != nil {
+ if m.Embeds == nil {
+ m.Embeds = &[]*MessageEmbed{m.Embed}
+ } else {
+ err = fmt.Errorf("cannot specify both Embed and Embeds")
+ return
+ }
+ }
+
+ if m.Embeds != nil {
+ for _, embed := range *m.Embeds {
+ if embed.Type == "" {
+ embed.Type = "rich"
+ }
+ }
+ }
+
+ endpoint := EndpointChannelMessage(m.Channel, m.ID)
+
+ var response []byte
+ if len(m.Files) > 0 {
+ contentType, body, encodeErr := MultipartBodyWithJSON(m, m.Files)
+ if encodeErr != nil {
+ return st, encodeErr
+ }
+ response, err = s.RequestRaw("PATCH", endpoint, contentType, body, EndpointChannelMessage(m.Channel, ""), 0, options...)
+ } else {
+ response, err = s.RequestWithBucketID("PATCH", endpoint, m, EndpointChannelMessage(m.Channel, ""), options...)
+ }
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(response, &st)
+ return
+}
+
+// ChannelMessageEditEmbed edits an existing message with embedded data.
+// channelID : The ID of a Channel
+// messageID : The ID of a Message
+// embed : The embed data to send
+func (s *Session) ChannelMessageEditEmbed(channelID, messageID string, embed *MessageEmbed, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageEditEmbeds(channelID, messageID, []*MessageEmbed{embed}, options...)
+}
+
+// ChannelMessageEditEmbeds edits an existing message with multiple embedded data.
+// channelID : The ID of a Channel
+// messageID : The ID of a Message
+// embeds : The embeds data to send
+func (s *Session) ChannelMessageEditEmbeds(channelID, messageID string, embeds []*MessageEmbed, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageEditComplex(NewMessageEdit(channelID, messageID).SetEmbeds(embeds), options...)
+}
+
+// ChannelMessageDelete deletes a message from the Channel.
+func (s *Session) ChannelMessageDelete(channelID, messageID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointChannelMessage(channelID, messageID), nil, EndpointChannelMessage(channelID, ""), options...)
+ return
+}
+
+// ChannelMessagesBulkDelete bulk deletes the messages from the channel for the provided messageIDs.
+// If only one messageID is in the slice call channelMessageDelete function.
+// If the slice is empty do nothing.
+// channelID : The ID of the channel for the messages to delete.
+// messages : The IDs of the messages to be deleted. A slice of string IDs. A maximum of 100 messages.
+func (s *Session) ChannelMessagesBulkDelete(channelID string, messages []string, options ...RequestOption) (err error) {
+
+ if len(messages) == 0 {
+ return
+ }
+
+ if len(messages) == 1 {
+ err = s.ChannelMessageDelete(channelID, messages[0], options...)
+ return
+ }
+
+ if len(messages) > 100 {
+ messages = messages[:100]
+ }
+
+ data := struct {
+ Messages []string `json:"messages"`
+ }{messages}
+
+ _, err = s.RequestWithBucketID("POST", EndpointChannelMessagesBulkDelete(channelID), data, EndpointChannelMessagesBulkDelete(channelID), options...)
+ return
+}
+
+// ChannelMessagePin pins a message within a given channel.
+// channelID: The ID of a channel.
+// messageID: The ID of a message.
+func (s *Session) ChannelMessagePin(channelID, messageID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("PUT", EndpointChannelMessagePin(channelID, messageID), nil, EndpointChannelMessagePin(channelID, ""), options...)
+ return
+}
+
+// ChannelMessageUnpin unpins a message within a given channel.
+// channelID: The ID of a channel.
+// messageID: The ID of a message.
+func (s *Session) ChannelMessageUnpin(channelID, messageID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointChannelMessagePin(channelID, messageID), nil, EndpointChannelMessagePin(channelID, ""), options...)
+ return
+}
+
+// ChannelMessagesPinned returns an array of Message structures for pinned messages
+// within a given channel
+// channelID : The ID of a Channel.
+// before : If specified returns only pinned messages before the timestamp
+// limit : Optional maximum amount of pinned messages to return.
+func (s *Session) ChannelMessagesPinned(channelID string, before *time.Time, limit int, options ...RequestOption) (pinnedMessages *ChannelMessagePinsList, err error) {
+ uri := EndpointChannelMessagesPins(channelID)
+
+ v := url.Values{}
+
+ if before != nil {
+ v.Set("before", before.Format(time.RFC3339))
+ }
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+
+ if len(v) > 0 {
+ uri += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, uri, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &pinnedMessages)
+ return
+}
+
+// ChannelFileSend sends a file to the given channel.
+// channelID : The ID of a Channel.
+// name: The name of the file.
+// io.Reader : A reader for the file contents.
+func (s *Session) ChannelFileSend(channelID, name string, r io.Reader, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{File: &File{Name: name, Reader: r}}, options...)
+}
+
+// ChannelFileSendWithMessage sends a file to the given channel with an message.
+// DEPRECATED. Use ChannelMessageSendComplex instead.
+// channelID : The ID of a Channel.
+// content: Optional Message content.
+// name: The name of the file.
+// io.Reader : A reader for the file contents.
+func (s *Session) ChannelFileSendWithMessage(channelID, content string, name string, r io.Reader, options ...RequestOption) (*Message, error) {
+ return s.ChannelMessageSendComplex(channelID, &MessageSend{File: &File{Name: name, Reader: r}, Content: content}, options...)
+}
+
+// ChannelInvites returns an array of Invite structures for the given channel
+// channelID : The ID of a Channel
+func (s *Session) ChannelInvites(channelID string, options ...RequestOption) (st []*Invite, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointChannelInvites(channelID), nil, EndpointChannelInvites(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelInviteCreate creates a new invite for the given channel.
+// channelID : The ID of a Channel
+// i : An Invite struct with the values MaxAge, MaxUses and Temporary defined.
+func (s *Session) ChannelInviteCreate(channelID string, i Invite, options ...RequestOption) (st *Invite, err error) {
+
+ data := struct {
+ MaxAge int `json:"max_age"`
+ MaxUses int `json:"max_uses"`
+ Temporary bool `json:"temporary"`
+ Unique bool `json:"unique"`
+ }{i.MaxAge, i.MaxUses, i.Temporary, i.Unique}
+
+ body, err := s.RequestWithBucketID("POST", EndpointChannelInvites(channelID), data, EndpointChannelInvites(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelPermissionSet creates a Permission Override for the given channel.
+// NOTE: This func name may changed. Using Set instead of Create because
+// you can both create a new override or update an override with this function.
+func (s *Session) ChannelPermissionSet(channelID, targetID string, targetType PermissionOverwriteType, allow, deny int64, options ...RequestOption) (err error) {
+
+ data := struct {
+ ID string `json:"id"`
+ Type PermissionOverwriteType `json:"type"`
+ Allow int64 `json:"allow,string"`
+ Deny int64 `json:"deny,string"`
+ }{targetID, targetType, allow, deny}
+
+ _, err = s.RequestWithBucketID("PUT", EndpointChannelPermission(channelID, targetID), data, EndpointChannelPermission(channelID, ""), options...)
+ return
+}
+
+// ChannelPermissionDelete deletes a specific permission override for the given channel.
+// NOTE: Name of this func may change.
+func (s *Session) ChannelPermissionDelete(channelID, targetID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointChannelPermission(channelID, targetID), nil, EndpointChannelPermission(channelID, ""), options...)
+ return
+}
+
+// ChannelMessageCrosspost cross posts a message in a news channel to followers
+// of the channel
+// channelID : The ID of a Channel
+// messageID : The ID of a Message
+func (s *Session) ChannelMessageCrosspost(channelID, messageID string, options ...RequestOption) (st *Message, err error) {
+
+ endpoint := EndpointChannelMessageCrosspost(channelID, messageID)
+
+ body, err := s.RequestWithBucketID("POST", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ChannelNewsFollow follows a news channel in the targetID
+// channelID : The ID of a News Channel
+// targetID : The ID of a Channel where the News Channel should post to
+func (s *Session) ChannelNewsFollow(channelID, targetID string, options ...RequestOption) (st *ChannelFollow, err error) {
+
+ endpoint := EndpointChannelFollow(channelID)
+
+ data := struct {
+ WebhookChannelID string `json:"webhook_channel_id"`
+ }{targetID}
+
+ body, err := s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Invites
+// ------------------------------------------------------------------------------------------------
+
+// Invite returns an Invite structure of the given invite
+// inviteID : The invite code
+func (s *Session) Invite(inviteID string, options ...RequestOption) (st *Invite, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointInvite(inviteID), nil, EndpointInvite(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// InviteWithCounts returns an Invite structure of the given invite including approximate member counts
+// inviteID : The invite code
+func (s *Session) InviteWithCounts(inviteID string, options ...RequestOption) (st *Invite, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointInvite(inviteID)+"?with_counts=true", nil, EndpointInvite(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// InviteComplex returns an Invite structure of the given invite including specified fields.
+// inviteID : The invite code
+// guildScheduledEventID : If specified, includes specified guild scheduled event.
+// withCounts : Whether to include approximate member counts or not
+// withExpiration : Whether to include expiration time or not
+func (s *Session) InviteComplex(inviteID, guildScheduledEventID string, withCounts, withExpiration bool, options ...RequestOption) (st *Invite, err error) {
+ endpoint := EndpointInvite(inviteID)
+ v := url.Values{}
+ if guildScheduledEventID != "" {
+ v.Set("guild_scheduled_event_id", guildScheduledEventID)
+ }
+ if withCounts {
+ v.Set("with_counts", "true")
+ }
+ if withExpiration {
+ v.Set("with_expiration", "true")
+ }
+
+ if len(v) != 0 {
+ endpoint += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", endpoint, nil, EndpointInvite(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// InviteDelete deletes an existing invite
+// inviteID : the code of an invite
+func (s *Session) InviteDelete(inviteID string, options ...RequestOption) (st *Invite, err error) {
+
+ body, err := s.RequestWithBucketID("DELETE", EndpointInvite(inviteID), nil, EndpointInvite(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// InviteAccept accepts an Invite to a Guild or Channel
+// inviteID : The invite code
+func (s *Session) InviteAccept(inviteID string, options ...RequestOption) (st *Invite, err error) {
+
+ body, err := s.RequestWithBucketID("POST", EndpointInvite(inviteID), nil, EndpointInvite(""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Voice
+// ------------------------------------------------------------------------------------------------
+
+// VoiceRegions returns the voice server regions
+func (s *Session) VoiceRegions(options ...RequestOption) (st []*VoiceRegion, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointVoiceRegions, nil, EndpointVoiceRegions, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// VoiceICE returns the voice server ICE information
+func (s *Session) VoiceICE() (st *VoiceICE, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointVoiceIce, nil, EndpointVoiceIce)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Websockets
+// ------------------------------------------------------------------------------------------------
+
+// Gateway returns the websocket Gateway address
+func (s *Session) Gateway(options ...RequestOption) (gateway string, err error) {
+
+ response, err := s.RequestWithBucketID("GET", EndpointGateway, nil, EndpointGateway, options...)
+ if err != nil {
+ return
+ }
+
+ temp := struct {
+ URL string `json:"url"`
+ }{}
+
+ err = unmarshal(response, &temp)
+ if err != nil {
+ return
+ }
+
+ gateway = temp.URL
+
+ // Ensure the gateway always has a trailing slash.
+ // MacOS will fail to connect if we add query params without a trailing slash on the base domain.
+ if !strings.HasSuffix(gateway, "/") {
+ gateway += "/"
+ }
+
+ return
+}
+
+// GatewayBot returns the websocket Gateway address and the recommended number of shards
+func (s *Session) GatewayBot(options ...RequestOption) (st *GatewayBotResponse, err error) {
+
+ response, err := s.RequestWithBucketID("GET", EndpointGatewayBot, nil, EndpointGatewayBot, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(response, &st)
+ if err != nil {
+ return
+ }
+
+ // Ensure the gateway always has a trailing slash.
+ // MacOS will fail to connect if we add query params without a trailing slash on the base domain.
+ if !strings.HasSuffix(st.URL, "/") {
+ st.URL += "/"
+ }
+
+ return
+}
+
+// Functions specific to Webhooks
+
+// WebhookCreate returns a new Webhook.
+// channelID: The ID of a Channel.
+// name : The name of the webhook.
+// avatar : The avatar of the webhook.
+func (s *Session) WebhookCreate(channelID, name, avatar string, options ...RequestOption) (st *Webhook, err error) {
+
+ data := struct {
+ Name string `json:"name"`
+ Avatar string `json:"avatar,omitempty"`
+ }{name, avatar}
+
+ body, err := s.RequestWithBucketID("POST", EndpointChannelWebhooks(channelID), data, EndpointChannelWebhooks(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// ChannelWebhooks returns all webhooks for a given channel.
+// channelID: The ID of a channel.
+func (s *Session) ChannelWebhooks(channelID string, options ...RequestOption) (st []*Webhook, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointChannelWebhooks(channelID), nil, EndpointChannelWebhooks(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// GuildWebhooks returns all webhooks for a given guild.
+// guildID: The ID of a Guild.
+func (s *Session) GuildWebhooks(guildID string, options ...RequestOption) (st []*Webhook, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointGuildWebhooks(guildID), nil, EndpointGuildWebhooks(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// Webhook returns a webhook for a given ID
+// webhookID: The ID of a webhook.
+func (s *Session) Webhook(webhookID string, options ...RequestOption) (st *Webhook, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointWebhook(webhookID), nil, EndpointWebhooks, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// WebhookWithToken returns a webhook for a given ID
+// webhookID: The ID of a webhook.
+// token : The auth token for the webhook.
+func (s *Session) WebhookWithToken(webhookID, token string, options ...RequestOption) (st *Webhook, err error) {
+
+ body, err := s.RequestWithBucketID("GET", EndpointWebhookToken(webhookID, token), nil, EndpointWebhookToken("", ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// WebhookEdit updates an existing Webhook.
+// webhookID: The ID of a webhook.
+// name : The name of the webhook.
+// avatar : The avatar of the webhook.
+func (s *Session) WebhookEdit(webhookID, name, avatar, channelID string, options ...RequestOption) (st *Webhook, err error) {
+
+ data := struct {
+ Name string `json:"name,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+ ChannelID string `json:"channel_id,omitempty"`
+ }{name, avatar, channelID}
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointWebhook(webhookID), data, EndpointWebhooks, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// WebhookEditWithToken updates an existing Webhook with an auth token.
+// webhookID: The ID of a webhook.
+// token : The auth token for the webhook.
+// name : The name of the webhook.
+// avatar : The avatar of the webhook.
+func (s *Session) WebhookEditWithToken(webhookID, token, name, avatar string, options ...RequestOption) (st *Webhook, err error) {
+
+ data := struct {
+ Name string `json:"name,omitempty"`
+ Avatar string `json:"avatar,omitempty"`
+ }{name, avatar}
+
+ var body []byte
+ body, err = s.RequestWithBucketID("PATCH", EndpointWebhookToken(webhookID, token), data, EndpointWebhookToken("", ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+
+ return
+}
+
+// WebhookDelete deletes a webhook for a given ID
+// webhookID: The ID of a webhook.
+func (s *Session) WebhookDelete(webhookID string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointWebhook(webhookID), nil, EndpointWebhooks, options...)
+
+ return
+}
+
+// WebhookDeleteWithToken deletes a webhook for a given ID with an auth token.
+// webhookID: The ID of a webhook.
+// token : The auth token for the webhook.
+func (s *Session) WebhookDeleteWithToken(webhookID, token string, options ...RequestOption) (err error) {
+
+ _, err = s.RequestWithBucketID("DELETE", EndpointWebhookToken(webhookID, token), nil, EndpointWebhookToken("", ""), options...)
+
+ return
+}
+
+func (s *Session) webhookExecute(webhookID, token string, wait bool, threadID string, data *WebhookParams, options ...RequestOption) (st *Message, err error) {
+ uri := EndpointWebhookToken(webhookID, token)
+
+ v := url.Values{}
+ if wait {
+ v.Set("wait", "true")
+ }
+
+ if threadID != "" {
+ v.Set("thread_id", threadID)
+ }
+ if len(v) != 0 {
+ uri += "?" + v.Encode()
+ }
+
+ var response []byte
+ if len(data.Files) > 0 {
+ contentType, body, encodeErr := MultipartBodyWithJSON(data, data.Files)
+ if encodeErr != nil {
+ return st, encodeErr
+ }
+
+ response, err = s.RequestRaw("POST", uri, contentType, body, uri, 0, options...)
+ } else {
+ response, err = s.RequestWithBucketID("POST", uri, data, uri, options...)
+ }
+ if !wait || err != nil {
+ return
+ }
+
+ err = unmarshal(response, &st)
+ return
+}
+
+// WebhookExecute executes a webhook.
+// webhookID: The ID of a webhook.
+// token : The auth token for the webhook
+// wait : Waits for server confirmation of message send and ensures that the return struct is populated (it is nil otherwise)
+func (s *Session) WebhookExecute(webhookID, token string, wait bool, data *WebhookParams, options ...RequestOption) (st *Message, err error) {
+ return s.webhookExecute(webhookID, token, wait, "", data, options...)
+}
+
+// WebhookThreadExecute executes a webhook in a thread.
+// webhookID: The ID of a webhook.
+// token : The auth token for the webhook
+// wait : Waits for server confirmation of message send and ensures that the return struct is populated (it is nil otherwise)
+// threadID : Sends a message to the specified thread within a webhook's channel. The thread will automatically be unarchived.
+func (s *Session) WebhookThreadExecute(webhookID, token string, wait bool, threadID string, data *WebhookParams, options ...RequestOption) (st *Message, err error) {
+ return s.webhookExecute(webhookID, token, wait, threadID, data, options...)
+}
+
+// WebhookMessage gets a webhook message.
+// webhookID : The ID of a webhook
+// token : The auth token for the webhook
+// messageID : The ID of message to get
+func (s *Session) WebhookMessage(webhookID, token, messageID string, options ...RequestOption) (message *Message, err error) {
+ uri := EndpointWebhookMessage(webhookID, token, messageID)
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointWebhookToken("", ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = Unmarshal(body, &message)
+
+ return
+}
+
+// WebhookMessageEdit edits a webhook message and returns a new one.
+// webhookID : The ID of a webhook
+// token : The auth token for the webhook
+// messageID : The ID of message to edit
+func (s *Session) WebhookMessageEdit(webhookID, token, messageID string, data *WebhookEdit, options ...RequestOption) (st *Message, err error) {
+ uri := EndpointWebhookMessage(webhookID, token, messageID)
+
+ var response []byte
+ if len(data.Files) > 0 {
+ contentType, body, err := MultipartBodyWithJSON(data, data.Files)
+ if err != nil {
+ return nil, err
+ }
+
+ response, err = s.RequestRaw("PATCH", uri, contentType, body, uri, 0, options...)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ response, err = s.RequestWithBucketID("PATCH", uri, data, EndpointWebhookToken("", ""), options...)
+
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ err = unmarshal(response, &st)
+ return
+}
+
+// WebhookMessageDelete deletes a webhook message.
+// webhookID : The ID of a webhook
+// token : The auth token for the webhook
+// messageID : The ID of a message to edit
+func (s *Session) WebhookMessageDelete(webhookID, token, messageID string, options ...RequestOption) (err error) {
+ uri := EndpointWebhookMessage(webhookID, token, messageID)
+
+ _, err = s.RequestWithBucketID("DELETE", uri, nil, EndpointWebhookToken("", ""), options...)
+ return
+}
+
+// MessageReactionAdd creates an emoji reaction to a message.
+// channelID : The channel ID.
+// messageID : The message ID.
+// emojiID : Either the unicode emoji for the reaction, or a guild emoji identifier in name:id format (e.g. "hello:1234567654321")
+func (s *Session) MessageReactionAdd(channelID, messageID, emojiID string, options ...RequestOption) error {
+
+ // emoji such as #⃣ need to have # escaped
+ emojiID = strings.Replace(emojiID, "#", "%23", -1)
+ _, err := s.RequestWithBucketID("PUT", EndpointMessageReaction(channelID, messageID, emojiID, "@me"), nil, EndpointMessageReaction(channelID, "", "", ""), options...)
+
+ return err
+}
+
+// MessageReactionRemove deletes an emoji reaction to a message.
+// channelID : The channel ID.
+// messageID : The message ID.
+// emojiID : Either the unicode emoji for the reaction, or a guild emoji identifier.
+// userID : @me or ID of the user to delete the reaction for.
+func (s *Session) MessageReactionRemove(channelID, messageID, emojiID, userID string, options ...RequestOption) error {
+
+ // emoji such as #⃣ need to have # escaped
+ emojiID = strings.Replace(emojiID, "#", "%23", -1)
+ _, err := s.RequestWithBucketID("DELETE", EndpointMessageReaction(channelID, messageID, emojiID, userID), nil, EndpointMessageReaction(channelID, "", "", ""), options...)
+
+ return err
+}
+
+// MessageReactionsRemoveAll deletes all reactions from a message
+// channelID : The channel ID
+// messageID : The message ID.
+func (s *Session) MessageReactionsRemoveAll(channelID, messageID string, options ...RequestOption) error {
+
+ _, err := s.RequestWithBucketID("DELETE", EndpointMessageReactionsAll(channelID, messageID), nil, EndpointMessageReactionsAll(channelID, messageID), options...)
+
+ return err
+}
+
+// MessageReactionsRemoveEmoji deletes all reactions of a certain emoji from a message
+// channelID : The channel ID
+// messageID : The message ID
+// emojiID : The emoji ID
+func (s *Session) MessageReactionsRemoveEmoji(channelID, messageID, emojiID string, options ...RequestOption) error {
+
+ // emoji such as #⃣ need to have # escaped
+ emojiID = strings.Replace(emojiID, "#", "%23", -1)
+ _, err := s.RequestWithBucketID("DELETE", EndpointMessageReactions(channelID, messageID, emojiID), nil, EndpointMessageReactions(channelID, messageID, emojiID), options...)
+
+ return err
+}
+
+// MessageReactions gets all the users reactions for a specific emoji.
+// channelID : The channel ID.
+// messageID : The message ID.
+// emojiID : Either the unicode emoji for the reaction, or a guild emoji identifier.
+// limit : max number of users to return (max 100)
+// beforeID : If provided all reactions returned will be before given ID.
+// afterID : If provided all reactions returned will be after given ID.
+func (s *Session) MessageReactions(channelID, messageID, emojiID string, limit int, beforeID, afterID string, options ...RequestOption) (st []*User, err error) {
+ // emoji such as #⃣ need to have # escaped
+ emojiID = strings.Replace(emojiID, "#", "%23", -1)
+ uri := EndpointMessageReactions(channelID, messageID, emojiID)
+
+ v := url.Values{}
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+
+ if afterID != "" {
+ v.Set("after", afterID)
+ }
+ if beforeID != "" {
+ v.Set("before", beforeID)
+ }
+
+ if len(v) > 0 {
+ uri += "?" + v.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointMessageReaction(channelID, "", "", ""), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to threads
+// ------------------------------------------------------------------------------------------------
+
+// MessageThreadStartComplex creates a new thread from an existing message.
+// channelID : Channel to create thread in
+// messageID : Message to start thread from
+// data : Parameters of the thread
+func (s *Session) MessageThreadStartComplex(channelID, messageID string, data *ThreadStart, options ...RequestOption) (ch *Channel, err error) {
+ endpoint := EndpointChannelMessageThread(channelID, messageID)
+ var body []byte
+ body, err = s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &ch)
+ return
+}
+
+// MessageThreadStart creates a new thread from an existing message.
+// channelID : Channel to create thread in
+// messageID : Message to start thread from
+// name : Name of the thread
+// archiveDuration : Auto archive duration (in minutes)
+func (s *Session) MessageThreadStart(channelID, messageID string, name string, archiveDuration int, options ...RequestOption) (ch *Channel, err error) {
+ return s.MessageThreadStartComplex(channelID, messageID, &ThreadStart{
+ Name: name,
+ AutoArchiveDuration: archiveDuration,
+ }, options...)
+}
+
+// ThreadStartComplex creates a new thread.
+// channelID : Channel to create thread in
+// data : Parameters of the thread
+func (s *Session) ThreadStartComplex(channelID string, data *ThreadStart, options ...RequestOption) (ch *Channel, err error) {
+ endpoint := EndpointChannelThreads(channelID)
+ var body []byte
+ body, err = s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &ch)
+ return
+}
+
+// ThreadStart creates a new thread.
+// channelID : Channel to create thread in
+// name : Name of the thread
+// archiveDuration : Auto archive duration (in minutes)
+func (s *Session) ThreadStart(channelID, name string, typ ChannelType, archiveDuration int, options ...RequestOption) (ch *Channel, err error) {
+ return s.ThreadStartComplex(channelID, &ThreadStart{
+ Name: name,
+ Type: typ,
+ AutoArchiveDuration: archiveDuration,
+ }, options...)
+}
+
+// ForumThreadStartComplex starts a new thread (creates a post) in a forum channel.
+// channelID : Channel to create thread in.
+// threadData : Parameters of the thread.
+// messageData : Parameters of the starting message.
+func (s *Session) ForumThreadStartComplex(channelID string, threadData *ThreadStart, messageData *MessageSend, options ...RequestOption) (th *Channel, err error) {
+ endpoint := EndpointChannelThreads(channelID)
+
+ // TODO: Remove this when compatibility is not required.
+ if messageData.Embed != nil {
+ if messageData.Embeds == nil {
+ messageData.Embeds = []*MessageEmbed{messageData.Embed}
+ } else {
+ err = fmt.Errorf("cannot specify both Embed and Embeds")
+ return
+ }
+ }
+
+ for _, embed := range messageData.Embeds {
+ if embed.Type == "" {
+ embed.Type = "rich"
+ }
+ }
+
+ // TODO: Remove this when compatibility is not required.
+ files := messageData.Files
+ if messageData.File != nil {
+ if files == nil {
+ files = []*File{messageData.File}
+ } else {
+ err = fmt.Errorf("cannot specify both File and Files")
+ return
+ }
+ }
+
+ data := struct {
+ *ThreadStart
+ Message *MessageSend `json:"message"`
+ }{ThreadStart: threadData, Message: messageData}
+
+ var response []byte
+ if len(files) > 0 {
+ contentType, body, encodeErr := MultipartBodyWithJSON(data, files)
+ if encodeErr != nil {
+ return th, encodeErr
+ }
+
+ response, err = s.RequestRaw("POST", endpoint, contentType, body, endpoint, 0, options...)
+ } else {
+ response, err = s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ }
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(response, &th)
+ return
+}
+
+// ForumThreadStart starts a new thread (post) in a forum channel.
+// channelID : Channel to create thread in.
+// name : Name of the thread.
+// archiveDuration : Auto archive duration.
+// content : Content of the starting message.
+func (s *Session) ForumThreadStart(channelID, name string, archiveDuration int, content string, options ...RequestOption) (th *Channel, err error) {
+ return s.ForumThreadStartComplex(channelID, &ThreadStart{
+ Name: name,
+ AutoArchiveDuration: archiveDuration,
+ }, &MessageSend{Content: content}, options...)
+}
+
+// ForumThreadStartEmbed starts a new thread (post) in a forum channel.
+// channelID : Channel to create thread in.
+// name : Name of the thread.
+// archiveDuration : Auto archive duration.
+// embed : Embed data of the starting message.
+func (s *Session) ForumThreadStartEmbed(channelID, name string, archiveDuration int, embed *MessageEmbed, options ...RequestOption) (th *Channel, err error) {
+ return s.ForumThreadStartComplex(channelID, &ThreadStart{
+ Name: name,
+ AutoArchiveDuration: archiveDuration,
+ }, &MessageSend{Embeds: []*MessageEmbed{embed}}, options...)
+}
+
+// ForumThreadStartEmbeds starts a new thread (post) in a forum channel.
+// channelID : Channel to create thread in.
+// name : Name of the thread.
+// archiveDuration : Auto archive duration.
+// embeds : Embeds data of the starting message.
+func (s *Session) ForumThreadStartEmbeds(channelID, name string, archiveDuration int, embeds []*MessageEmbed, options ...RequestOption) (th *Channel, err error) {
+ return s.ForumThreadStartComplex(channelID, &ThreadStart{
+ Name: name,
+ AutoArchiveDuration: archiveDuration,
+ }, &MessageSend{Embeds: embeds}, options...)
+}
+
+// ThreadJoin adds current user to a thread
+func (s *Session) ThreadJoin(id string, options ...RequestOption) error {
+ endpoint := EndpointThreadMember(id, "@me")
+ _, err := s.RequestWithBucketID("PUT", endpoint, nil, endpoint, options...)
+ return err
+}
+
+// ThreadLeave removes current user to a thread
+func (s *Session) ThreadLeave(id string, options ...RequestOption) error {
+ endpoint := EndpointThreadMember(id, "@me")
+ _, err := s.RequestWithBucketID("DELETE", endpoint, nil, endpoint, options...)
+ return err
+}
+
+// ThreadMemberAdd adds another member to a thread
+func (s *Session) ThreadMemberAdd(threadID, memberID string, options ...RequestOption) error {
+ endpoint := EndpointThreadMember(threadID, memberID)
+ _, err := s.RequestWithBucketID("PUT", endpoint, nil, endpoint, options...)
+ return err
+}
+
+// ThreadMemberRemove removes another member from a thread
+func (s *Session) ThreadMemberRemove(threadID, memberID string, options ...RequestOption) error {
+ endpoint := EndpointThreadMember(threadID, memberID)
+ _, err := s.RequestWithBucketID("DELETE", endpoint, nil, endpoint, options...)
+ return err
+}
+
+// ThreadMember returns thread member object for the specified member of a thread.
+// withMember : Whether to include a guild member object.
+func (s *Session) ThreadMember(threadID, memberID string, withMember bool, options ...RequestOption) (member *ThreadMember, err error) {
+ uri := EndpointThreadMember(threadID, memberID)
+
+ queryParams := url.Values{}
+ if withMember {
+ queryParams.Set("with_member", "true")
+ }
+
+ if len(queryParams) > 0 {
+ uri += "?" + queryParams.Encode()
+ }
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", uri, nil, uri, options...)
+
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &member)
+ return
+}
+
+// ThreadMembers returns all members of specified thread.
+// limit : Max number of thread members to return (1-100). Defaults to 100.
+// afterID : Get thread members after this user ID.
+// withMember : Whether to include a guild member object for each thread member.
+func (s *Session) ThreadMembers(threadID string, limit int, withMember bool, afterID string, options ...RequestOption) (members []*ThreadMember, err error) {
+ uri := EndpointThreadMembers(threadID)
+
+ queryParams := url.Values{}
+ if withMember {
+ queryParams.Set("with_member", "true")
+ }
+ if limit > 0 {
+ queryParams.Set("limit", strconv.Itoa(limit))
+ }
+ if afterID != "" {
+ queryParams.Set("after", afterID)
+ }
+
+ if len(queryParams) > 0 {
+ uri += "?" + queryParams.Encode()
+ }
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", uri, nil, uri, options...)
+
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &members)
+ return
+}
+
+// ThreadsActive returns all active threads for specified channel.
+func (s *Session) ThreadsActive(channelID string, options ...RequestOption) (threads *ThreadsList, err error) {
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", EndpointChannelActiveThreads(channelID), nil, EndpointChannelActiveThreads(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &threads)
+ return
+}
+
+// GuildThreadsActive returns all active threads for specified guild.
+func (s *Session) GuildThreadsActive(guildID string, options ...RequestOption) (threads *ThreadsList, err error) {
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", EndpointGuildActiveThreads(guildID), nil, EndpointGuildActiveThreads(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &threads)
+ return
+}
+
+// ThreadsArchived returns archived threads for specified channel.
+// before : If specified returns only threads before the timestamp
+// limit : Optional maximum amount of threads to return.
+func (s *Session) ThreadsArchived(channelID string, before *time.Time, limit int, options ...RequestOption) (threads *ThreadsList, err error) {
+ endpoint := EndpointChannelPublicArchivedThreads(channelID)
+ v := url.Values{}
+ if before != nil {
+ v.Set("before", before.Format(time.RFC3339))
+ }
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+
+ if len(v) > 0 {
+ endpoint += "?" + v.Encode()
+ }
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &threads)
+ return
+}
+
+// ThreadsPrivateArchived returns archived private threads for specified channel.
+// before : If specified returns only threads before the timestamp
+// limit : Optional maximum amount of threads to return.
+func (s *Session) ThreadsPrivateArchived(channelID string, before *time.Time, limit int, options ...RequestOption) (threads *ThreadsList, err error) {
+ endpoint := EndpointChannelPrivateArchivedThreads(channelID)
+ v := url.Values{}
+ if before != nil {
+ v.Set("before", before.Format(time.RFC3339))
+ }
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+
+ if len(v) > 0 {
+ endpoint += "?" + v.Encode()
+ }
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &threads)
+ return
+}
+
+// ThreadsPrivateJoinedArchived returns archived joined private threads for specified channel.
+// before : If specified returns only threads before the timestamp
+// limit : Optional maximum amount of threads to return.
+func (s *Session) ThreadsPrivateJoinedArchived(channelID string, before *time.Time, limit int, options ...RequestOption) (threads *ThreadsList, err error) {
+ endpoint := EndpointChannelJoinedPrivateArchivedThreads(channelID)
+ v := url.Values{}
+ if before != nil {
+ v.Set("before", before.Format(time.RFC3339))
+ }
+
+ if limit > 0 {
+ v.Set("limit", strconv.Itoa(limit))
+ }
+
+ if len(v) > 0 {
+ endpoint += "?" + v.Encode()
+ }
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &threads)
+ return
+}
+
+// Functions specific to user notes
+// ------------------------------------------------------------------------------------------------
+
+// UserNoteSet sets the note for a specific user.
+func (s *Session) UserNoteSet(userID string, message string) (err error) {
+ data := struct {
+ Note string `json:"note"`
+ }{message}
+
+ _, err = s.RequestWithBucketID("PUT", EndpointUserNotes(userID), data, EndpointUserNotes(""))
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to Discord Relationships (Friends list)
+// ------------------------------------------------------------------------------------------------
+
+// RelationshipsGet returns an array of all the relationships of the user.
+func (s *Session) RelationshipsGet() (r []*Relationship, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointRelationships(), nil, EndpointRelationships())
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &r)
+ return
+}
+
+// relationshipCreate creates a new relationship. (I.e. send or accept a friend request, block a user.)
+// relationshipType : 1 = friend, 2 = blocked, 3 = incoming friend req, 4 = sent friend req
+func (s *Session) relationshipCreate(userID string, relationshipType int) (err error) {
+ data := struct {
+ Type int `json:"type"`
+ }{relationshipType}
+
+ _, err = s.RequestWithBucketID("PUT", EndpointRelationship(userID), data, EndpointRelationships())
+ return
+}
+
+// RelationshipFriendRequestSend sends a friend request to a user.
+// userID: ID of the user.
+func (s *Session) RelationshipFriendRequestSend(userID string) (err error) {
+ err = s.relationshipCreate(userID, 4)
+ return
+}
+
+// RelationshipFriendRequestAccept accepts a friend request from a user.
+// userID: ID of the user.
+func (s *Session) RelationshipFriendRequestAccept(userID string) (err error) {
+ err = s.relationshipCreate(userID, 1)
+ return
+}
+
+// RelationshipUserBlock blocks a user.
+// userID: ID of the user.
+func (s *Session) RelationshipUserBlock(userID string) (err error) {
+ err = s.relationshipCreate(userID, 2)
+ return
+}
+
+// RelationshipDelete removes the relationship with a user.
+// userID: ID of the user.
+func (s *Session) RelationshipDelete(userID string) (err error) {
+ _, err = s.RequestWithBucketID("DELETE", EndpointRelationship(userID), nil, EndpointRelationships())
+ return
+}
+
+// RelationshipsMutualGet returns an array of all the users both @me and the given user is friends with.
+// userID: ID of the user.
+func (s *Session) RelationshipsMutualGet(userID string) (mf []*User, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointRelationshipsMutual(userID), nil, EndpointRelationshipsMutual(userID))
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &mf)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to application (slash) commands
+// ------------------------------------------------------------------------------------------------
+
+// ApplicationCommandCreate creates a global application command and returns it.
+// appID : The application ID.
+// guildID : Guild ID to create guild-specific application command. If empty - creates global application command.
+// cmd : New application command data.
+func (s *Session) ApplicationCommandCreate(appID string, guildID string, cmd *ApplicationCommand, options ...RequestOption) (ccmd *ApplicationCommand, err error) {
+ endpoint := EndpointApplicationGlobalCommands(appID)
+ if guildID != "" {
+ endpoint = EndpointApplicationGuildCommands(appID, guildID)
+ }
+
+ body, err := s.RequestWithBucketID("POST", endpoint, *cmd, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &ccmd)
+
+ return
+}
+
+// ApplicationCommandEdit edits application command and returns new command data.
+// appID : The application ID.
+// cmdID : Application command ID to edit.
+// guildID : Guild ID to edit guild-specific application command. If empty - edits global application command.
+// cmd : Updated application command data.
+func (s *Session) ApplicationCommandEdit(appID, guildID, cmdID string, cmd *ApplicationCommand, options ...RequestOption) (updated *ApplicationCommand, err error) {
+ endpoint := EndpointApplicationGlobalCommand(appID, cmdID)
+ if guildID != "" {
+ endpoint = EndpointApplicationGuildCommand(appID, guildID, cmdID)
+ }
+
+ body, err := s.RequestWithBucketID("PATCH", endpoint, *cmd, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &updated)
+
+ return
+}
+
+// ApplicationCommandBulkOverwrite Creates commands overwriting existing commands. Returns a list of commands.
+// appID : The application ID.
+// commands : The commands to create.
+func (s *Session) ApplicationCommandBulkOverwrite(appID string, guildID string, commands []*ApplicationCommand, options ...RequestOption) (createdCommands []*ApplicationCommand, err error) {
+ endpoint := EndpointApplicationGlobalCommands(appID)
+ if guildID != "" {
+ endpoint = EndpointApplicationGuildCommands(appID, guildID)
+ }
+
+ body, err := s.RequestWithBucketID("PUT", endpoint, commands, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &createdCommands)
+
+ return
+}
+
+// ApplicationCommandDelete deletes application command by ID.
+// appID : The application ID.
+// cmdID : Application command ID to delete.
+// guildID : Guild ID to delete guild-specific application command. If empty - deletes global application command.
+func (s *Session) ApplicationCommandDelete(appID, guildID, cmdID string, options ...RequestOption) error {
+ endpoint := EndpointApplicationGlobalCommand(appID, cmdID)
+ if guildID != "" {
+ endpoint = EndpointApplicationGuildCommand(appID, guildID, cmdID)
+ }
+
+ _, err := s.RequestWithBucketID("DELETE", endpoint, nil, endpoint, options...)
+
+ return err
+}
+
+// ApplicationCommand retrieves an application command by given ID.
+// appID : The application ID.
+// cmdID : Application command ID.
+// guildID : Guild ID to retrieve guild-specific application command. If empty - retrieves global application command.
+func (s *Session) ApplicationCommand(appID, guildID, cmdID string, options ...RequestOption) (cmd *ApplicationCommand, err error) {
+ endpoint := EndpointApplicationGlobalCommand(appID, cmdID)
+ if guildID != "" {
+ endpoint = EndpointApplicationGuildCommand(appID, guildID, cmdID)
+ }
+
+ body, err := s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &cmd)
+
+ return
+}
+
+// ApplicationCommands retrieves all commands in application.
+// appID : The application ID.
+// guildID : Guild ID to retrieve all guild-specific application commands. If empty - retrieves global application commands.
+func (s *Session) ApplicationCommands(appID, guildID string, options ...RequestOption) (cmd []*ApplicationCommand, err error) {
+ endpoint := EndpointApplicationGlobalCommands(appID)
+ if guildID != "" {
+ endpoint = EndpointApplicationGuildCommands(appID, guildID)
+ }
+
+ body, err := s.RequestWithBucketID("GET", endpoint+"?with_localizations=true", nil, "GET "+endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &cmd)
+
+ return
+}
+
+// GuildApplicationCommandsPermissions returns permissions for application commands in a guild.
+// appID : The application ID
+// guildID : Guild ID to retrieve application commands permissions for.
+func (s *Session) GuildApplicationCommandsPermissions(appID, guildID string, options ...RequestOption) (permissions []*GuildApplicationCommandPermissions, err error) {
+ endpoint := EndpointApplicationCommandsGuildPermissions(appID, guildID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &permissions)
+ return
+}
+
+// ApplicationCommandPermissions returns all permissions of an application command
+// appID : The Application ID
+// guildID : The guild ID containing the application command
+// cmdID : The command ID to retrieve the permissions of
+func (s *Session) ApplicationCommandPermissions(appID, guildID, cmdID string, options ...RequestOption) (permissions *GuildApplicationCommandPermissions, err error) {
+ endpoint := EndpointApplicationCommandPermissions(appID, guildID, cmdID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &permissions)
+ return
+}
+
+// ApplicationCommandPermissionsEdit edits the permissions of an application command
+// appID : The Application ID
+// guildID : The guild ID containing the application command
+// cmdID : The command ID to edit the permissions of
+// permissions : An object containing a list of permissions for the application command
+//
+// NOTE: Requires OAuth2 token with applications.commands.permissions.update scope
+func (s *Session) ApplicationCommandPermissionsEdit(appID, guildID, cmdID string, permissions *ApplicationCommandPermissionsList, options ...RequestOption) (err error) {
+ endpoint := EndpointApplicationCommandPermissions(appID, guildID, cmdID)
+
+ _, err = s.RequestWithBucketID("PUT", endpoint, permissions, endpoint, options...)
+ return
+}
+
+// ApplicationCommandPermissionsBatchEdit edits the permissions of a batch of commands
+// appID : The Application ID
+// guildID : The guild ID to batch edit commands of
+// permissions : A list of permissions paired with a command ID, guild ID, and application ID per application command
+//
+// NOTE: This endpoint has been disabled with updates to command permissions (Permissions v2). Please use ApplicationCommandPermissionsEdit instead.
+func (s *Session) ApplicationCommandPermissionsBatchEdit(appID, guildID string, permissions []*GuildApplicationCommandPermissions, options ...RequestOption) (err error) {
+ endpoint := EndpointApplicationCommandsGuildPermissions(appID, guildID)
+
+ _, err = s.RequestWithBucketID("PUT", endpoint, permissions, endpoint, options...)
+ return
+}
+
+// InteractionRespond creates the response to an interaction.
+// interaction : Interaction instance.
+// resp : Response message data.
+func (s *Session) InteractionRespond(interaction *Interaction, resp *InteractionResponse, options ...RequestOption) error {
+ endpoint := EndpointInteractionResponse(interaction.ID, interaction.Token)
+
+ if resp.Data != nil && len(resp.Data.Files) > 0 {
+ contentType, body, err := MultipartBodyWithJSON(resp, resp.Data.Files)
+ if err != nil {
+ return err
+ }
+
+ _, err = s.RequestRaw("POST", endpoint, contentType, body, endpoint, 0, options...)
+ return err
+ }
+
+ _, err := s.RequestWithBucketID("POST", endpoint, *resp, endpoint, options...)
+ return err
+}
+
+// InteractionResponse gets the response to an interaction.
+// interaction : Interaction instance.
+func (s *Session) InteractionResponse(interaction *Interaction, options ...RequestOption) (*Message, error) {
+ return s.WebhookMessage(interaction.AppID, interaction.Token, "@original", options...)
+}
+
+// InteractionResponseEdit edits the response to an interaction.
+// interaction : Interaction instance.
+// newresp : Updated response message data.
+func (s *Session) InteractionResponseEdit(interaction *Interaction, newresp *WebhookEdit, options ...RequestOption) (*Message, error) {
+ return s.WebhookMessageEdit(interaction.AppID, interaction.Token, "@original", newresp, options...)
+}
+
+// InteractionResponseDelete deletes the response to an interaction.
+// interaction : Interaction instance.
+func (s *Session) InteractionResponseDelete(interaction *Interaction, options ...RequestOption) error {
+ endpoint := EndpointInteractionResponseActions(interaction.AppID, interaction.Token)
+
+ _, err := s.RequestWithBucketID("DELETE", endpoint, nil, endpoint, options...)
+
+ return err
+}
+
+// FollowupMessageCreate creates the followup message for an interaction.
+// interaction : Interaction instance.
+// wait : Waits for server confirmation of message send and ensures that the return struct is populated (it is nil otherwise)
+// data : Data of the message to send.
+func (s *Session) FollowupMessageCreate(interaction *Interaction, wait bool, data *WebhookParams, options ...RequestOption) (*Message, error) {
+ return s.WebhookExecute(interaction.AppID, interaction.Token, wait, data, options...)
+}
+
+// FollowupMessageEdit edits a followup message of an interaction.
+// interaction : Interaction instance.
+// messageID : The followup message ID.
+// data : Data to update the message
+func (s *Session) FollowupMessageEdit(interaction *Interaction, messageID string, data *WebhookEdit, options ...RequestOption) (*Message, error) {
+ return s.WebhookMessageEdit(interaction.AppID, interaction.Token, messageID, data, options...)
+}
+
+// FollowupMessageDelete deletes a followup message of an interaction.
+// interaction : Interaction instance.
+// messageID : The followup message ID.
+func (s *Session) FollowupMessageDelete(interaction *Interaction, messageID string, options ...RequestOption) error {
+ return s.WebhookMessageDelete(interaction.AppID, interaction.Token, messageID, options...)
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to stage instances
+// ------------------------------------------------------------------------------------------------
+
+// StageInstanceCreate creates and returns a new Stage instance associated to a Stage channel.
+// data : Parameters needed to create a stage instance.
+// data : The data of the Stage instance to create
+func (s *Session) StageInstanceCreate(data *StageInstanceParams, options ...RequestOption) (si *StageInstance, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointStageInstances, data, EndpointStageInstances, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &si)
+ return
+}
+
+// StageInstance will retrieve a Stage instance by ID of the Stage channel.
+// channelID : The ID of the Stage channel
+func (s *Session) StageInstance(channelID string, options ...RequestOption) (si *StageInstance, err error) {
+ body, err := s.RequestWithBucketID("GET", EndpointStageInstance(channelID), nil, EndpointStageInstance(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &si)
+ return
+}
+
+// StageInstanceEdit will edit a Stage instance by ID of the Stage channel.
+// channelID : The ID of the Stage channel
+// data : The data to edit the Stage instance
+func (s *Session) StageInstanceEdit(channelID string, data *StageInstanceParams, options ...RequestOption) (si *StageInstance, err error) {
+
+ body, err := s.RequestWithBucketID("PATCH", EndpointStageInstance(channelID), data, EndpointStageInstance(channelID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &si)
+ return
+}
+
+// StageInstanceDelete will delete a Stage instance by ID of the Stage channel.
+// channelID : The ID of the Stage channel
+func (s *Session) StageInstanceDelete(channelID string, options ...RequestOption) (err error) {
+ _, err = s.RequestWithBucketID("DELETE", EndpointStageInstance(channelID), nil, EndpointStageInstance(channelID), options...)
+ return
+}
+
+// ------------------------------------------------------------------------------------------------
+// Functions specific to guilds scheduled events
+// ------------------------------------------------------------------------------------------------
+
+// GuildScheduledEvents returns an array of GuildScheduledEvent for a guild
+// guildID : The ID of a Guild
+// userCount : Whether to include the user count in the response
+func (s *Session) GuildScheduledEvents(guildID string, userCount bool, options ...RequestOption) (st []*GuildScheduledEvent, err error) {
+ uri := EndpointGuildScheduledEvents(guildID)
+ if userCount {
+ uri += "?with_user_count=true"
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildScheduledEvents(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildScheduledEvent returns a specific GuildScheduledEvent in a guild
+// guildID : The ID of a Guild
+// eventID : The ID of the event
+// userCount : Whether to include the user count in the response
+func (s *Session) GuildScheduledEvent(guildID, eventID string, userCount bool, options ...RequestOption) (st *GuildScheduledEvent, err error) {
+ uri := EndpointGuildScheduledEvent(guildID, eventID)
+ if userCount {
+ uri += "?with_user_count=true"
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildScheduledEvent(guildID, eventID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildScheduledEventCreate creates a GuildScheduledEvent for a guild and returns it
+// guildID : The ID of a Guild
+// eventID : The ID of the event
+func (s *Session) GuildScheduledEventCreate(guildID string, event *GuildScheduledEventParams, options ...RequestOption) (st *GuildScheduledEvent, err error) {
+ body, err := s.RequestWithBucketID("POST", EndpointGuildScheduledEvents(guildID), event, EndpointGuildScheduledEvents(guildID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildScheduledEventEdit updates a specific event for a guild and returns it.
+// guildID : The ID of a Guild
+// eventID : The ID of the event
+func (s *Session) GuildScheduledEventEdit(guildID, eventID string, event *GuildScheduledEventParams, options ...RequestOption) (st *GuildScheduledEvent, err error) {
+ body, err := s.RequestWithBucketID("PATCH", EndpointGuildScheduledEvent(guildID, eventID), event, EndpointGuildScheduledEvent(guildID, eventID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildScheduledEventDelete deletes a specific GuildScheduledEvent in a guild
+// guildID : The ID of a Guild
+// eventID : The ID of the event
+func (s *Session) GuildScheduledEventDelete(guildID, eventID string, options ...RequestOption) (err error) {
+ _, err = s.RequestWithBucketID("DELETE", EndpointGuildScheduledEvent(guildID, eventID), nil, EndpointGuildScheduledEvent(guildID, eventID), options...)
+ return
+}
+
+// GuildScheduledEventUsers returns an array of GuildScheduledEventUser for a particular event in a guild
+// guildID : The ID of a Guild
+// eventID : The ID of the event
+// limit : The maximum number of users to return (Max 100)
+// withMember : Whether to include the member object in the response
+// beforeID : If is not empty all returned users entries will be before the given ID
+// afterID : If is not empty all returned users entries will be after the given ID
+func (s *Session) GuildScheduledEventUsers(guildID, eventID string, limit int, withMember bool, beforeID, afterID string, options ...RequestOption) (st []*GuildScheduledEventUser, err error) {
+ uri := EndpointGuildScheduledEventUsers(guildID, eventID)
+
+ queryParams := url.Values{}
+ if withMember {
+ queryParams.Set("with_member", "true")
+ }
+ if limit > 0 {
+ queryParams.Set("limit", strconv.Itoa(limit))
+ }
+ if beforeID != "" {
+ queryParams.Set("before", beforeID)
+ }
+ if afterID != "" {
+ queryParams.Set("after", afterID)
+ }
+
+ if len(queryParams) > 0 {
+ uri += "?" + queryParams.Encode()
+ }
+
+ body, err := s.RequestWithBucketID("GET", uri, nil, EndpointGuildScheduledEventUsers(guildID, eventID), options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// GuildOnboarding returns onboarding configuration of a guild.
+// guildID : The ID of the guild
+func (s *Session) GuildOnboarding(guildID string, options ...RequestOption) (onboarding *GuildOnboarding, err error) {
+ endpoint := EndpointGuildOnboarding(guildID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &onboarding)
+ return
+}
+
+// GuildOnboardingEdit edits onboarding configuration of a guild.
+// guildID : The ID of the guild
+// o : New GuildOnboarding data
+func (s *Session) GuildOnboardingEdit(guildID string, o *GuildOnboarding, options ...RequestOption) (onboarding *GuildOnboarding, err error) {
+ endpoint := EndpointGuildOnboarding(guildID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("PUT", endpoint, o, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &onboarding)
+ return
+}
+
+// ----------------------------------------------------------------------
+// Functions specific to auto moderation
+// ----------------------------------------------------------------------
+
+// AutoModerationRules returns a list of auto moderation rules.
+// guildID : ID of the guild
+func (s *Session) AutoModerationRules(guildID string, options ...RequestOption) (st []*AutoModerationRule, err error) {
+ endpoint := EndpointGuildAutoModerationRules(guildID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// AutoModerationRule returns an auto moderation rule.
+// guildID : ID of the guild
+// ruleID : ID of the auto moderation rule
+func (s *Session) AutoModerationRule(guildID, ruleID string, options ...RequestOption) (st *AutoModerationRule, err error) {
+ endpoint := EndpointGuildAutoModerationRule(guildID, ruleID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// AutoModerationRuleCreate creates an auto moderation rule with the given data and returns it.
+// guildID : ID of the guild
+// rule : Rule data
+func (s *Session) AutoModerationRuleCreate(guildID string, rule *AutoModerationRule, options ...RequestOption) (st *AutoModerationRule, err error) {
+ endpoint := EndpointGuildAutoModerationRules(guildID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("POST", endpoint, rule, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// AutoModerationRuleEdit edits and returns the updated auto moderation rule.
+// guildID : ID of the guild
+// ruleID : ID of the auto moderation rule
+// rule : New rule data
+func (s *Session) AutoModerationRuleEdit(guildID, ruleID string, rule *AutoModerationRule, options ...RequestOption) (st *AutoModerationRule, err error) {
+ endpoint := EndpointGuildAutoModerationRule(guildID, ruleID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("PATCH", endpoint, rule, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// AutoModerationRuleDelete deletes an auto moderation rule.
+// guildID : ID of the guild
+// ruleID : ID of the auto moderation rule
+func (s *Session) AutoModerationRuleDelete(guildID, ruleID string, options ...RequestOption) (err error) {
+ endpoint := EndpointGuildAutoModerationRule(guildID, ruleID)
+ _, err = s.RequestWithBucketID("DELETE", endpoint, nil, endpoint, options...)
+ return
+}
+
+// ApplicationRoleConnectionMetadata returns application role connection metadata.
+// appID : ID of the application
+func (s *Session) ApplicationRoleConnectionMetadata(appID string) (st []*ApplicationRoleConnectionMetadata, err error) {
+ endpoint := EndpointApplicationRoleConnectionMetadata(appID)
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// ApplicationRoleConnectionMetadataUpdate updates and returns application role connection metadata.
+// appID : ID of the application
+// metadata : New metadata
+func (s *Session) ApplicationRoleConnectionMetadataUpdate(appID string, metadata []*ApplicationRoleConnectionMetadata) (st []*ApplicationRoleConnectionMetadata, err error) {
+ endpoint := EndpointApplicationRoleConnectionMetadata(appID)
+ var body []byte
+ body, err = s.RequestWithBucketID("PUT", endpoint, metadata, endpoint)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+// UserApplicationRoleConnection returns user role connection to the specified application.
+// appID : ID of the application
+func (s *Session) UserApplicationRoleConnection(appID string) (st *ApplicationRoleConnection, err error) {
+ endpoint := EndpointUserApplicationRoleConnection(appID)
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+
+}
+
+// UserApplicationRoleConnectionUpdate updates and returns user role connection to the specified application.
+// appID : ID of the application
+// connection : New ApplicationRoleConnection data
+func (s *Session) UserApplicationRoleConnectionUpdate(appID string, rconn *ApplicationRoleConnection) (st *ApplicationRoleConnection, err error) {
+ endpoint := EndpointUserApplicationRoleConnection(appID)
+ var body []byte
+ body, err = s.RequestWithBucketID("PUT", endpoint, rconn, endpoint)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &st)
+ return
+}
+
+type cursor struct {
+ Repaired bool `json:"repaired"`
+ Previous string `json:"previous"`
+ Next string `json:"next"`
+}
+
+type respSearchApplicationCommands struct {
+ Applications []*Application `json:"applications"`
+ ApplicationCommands []*ApplicationCommand `json:"application_commands"`
+ Cursor cursor `json:"cursor"`
+}
+
+func (s *Session) ApplicationCommandsSearch(channelID, query string, options ...RequestOption) (st []*ApplicationCommand, err error) {
+ queryParams := url.Values{
+ "type": {"1"},
+ "query": {query},
+ "limit": {"7"},
+ "include_applications": {"false"},
+ }
+ if query == "" {
+ queryParams.Set("limit", "10")
+ queryParams.Set("include_applications", "true")
+ queryParams.Del("query")
+ }
+ endpoint := EndpointApplicationCommandsSearch(channelID) + "?" + queryParams.Encode()
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ var resp respSearchApplicationCommands
+ err = unmarshal(body, &resp)
+ st = resp.ApplicationCommands
+ return
+}
+
+type ApplicationCommandOptionInput struct {
+ Type ApplicationCommandOptionType `json:"type"`
+ Name string `json:"name"`
+ Value interface{} `json:"value,omitempty"`
+ Options []*ApplicationCommandOptionInput `json:"options,omitempty"`
+}
+
+type interactionData struct {
+ Version string `json:"version"`
+ ID string `json:"id"`
+ ApplicationID string `json:"application_id"`
+ Name string `json:"name"`
+ Type ApplicationCommandType `json:"type"`
+
+ Options []*ApplicationCommandOptionInput `json:"options"`
+ Attachments []interface{} `json:"attachments"`
+
+ ApplicationCommand *ApplicationCommand `json:"application_command"`
+}
+
+type reqSendInteraction struct {
+ Type int `json:"type"`
+ ApplicationID string `json:"application_id"`
+ GuildID string `json:"guild_id,omitempty"`
+ ChannelID string `json:"channel_id"`
+ SessionID string `json:"session_id"`
+ Data interactionData `json:"data"`
+ Nonce string `json:"nonce"`
+}
+
+func (s *Session) SendInteractions(guildID, channelID string, cmd *ApplicationCommand, options []*ApplicationCommandOptionInput, nonce string, reqOptions ...RequestOption) error {
+ if options == nil {
+ options = make([]*ApplicationCommandOptionInput, 0)
+ }
+ req := &reqSendInteraction{
+ Type: 2,
+ ApplicationID: cmd.ApplicationID,
+ GuildID: guildID,
+ ChannelID: channelID,
+ SessionID: s.sessionID,
+ Data: interactionData{
+ Version: cmd.Version,
+ ApplicationID: cmd.ApplicationID,
+ ID: cmd.ID,
+ Name: cmd.Name,
+ Type: cmd.Type,
+ Options: options,
+ Attachments: []interface{}{},
+ ApplicationCommand: cmd,
+ },
+ Nonce: nonce,
+ }
+ contentType, body, encodeErr := MultipartBodyWithJSON(req, nil)
+ if encodeErr != nil {
+ return encodeErr
+ }
+
+ endpoint := EndpointInteractions
+ _, err := s.RequestRaw("POST", endpoint, contentType, body, endpoint, 0, reqOptions...)
+ return err
+}
+
+// ----------------------------------------------------------------------
+// Functions specific to polls
+// ----------------------------------------------------------------------
+
+// PollAnswerVoters returns users who voted for a particular answer in a poll on the specified message.
+// channelID : ID of the channel.
+// messageID : ID of the message.
+// answerID : ID of the answer.
+func (s *Session) PollAnswerVoters(channelID, messageID string, answerID int) (voters []*User, err error) {
+ endpoint := EndpointPollAnswerVoters(channelID, messageID, answerID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("GET", endpoint, nil, endpoint)
+ if err != nil {
+ return
+ }
+
+ var r struct {
+ Users []*User `json:"users"`
+ }
+
+ err = unmarshal(body, &r)
+ if err != nil {
+ return
+ }
+
+ voters = r.Users
+ return
+}
+
+// PollExpire expires poll on the specified message.
+// channelID : ID of the channel.
+// messageID : ID of the message.
+func (s *Session) PollExpire(channelID, messageID string) (msg *Message, err error) {
+ endpoint := EndpointPollExpire(channelID, messageID)
+
+ var body []byte
+ body, err = s.RequestWithBucketID("POST", endpoint, nil, endpoint)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &msg)
+ return
+}
+
+// ----------------------------------------------------------------------
+// Functions specific to monetization
+// ----------------------------------------------------------------------
+
+// SKUs returns all SKUs for a given application.
+// appID : The ID of the application.
+func (s *Session) SKUs(appID string) (skus []*SKU, err error) {
+ endpoint := EndpointApplicationSKUs(appID)
+
+ body, err := s.RequestWithBucketID("GET", endpoint, nil, endpoint)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &skus)
+ return
+}
+
+// Entitlements returns all Entitlements for a given app, active and expired.
+// appID : The ID of the application.
+// filterOptions : Optional filter options; otherwise set it to nil.
+func (s *Session) Entitlements(appID string, filterOptions *EntitlementFilterOptions, options ...RequestOption) (entitlements []*Entitlement, err error) {
+ endpoint := EndpointEntitlements(appID)
+
+ queryParams := url.Values{}
+ if filterOptions != nil {
+ if filterOptions.UserID != "" {
+ queryParams.Set("user_id", filterOptions.UserID)
+ }
+ if len(filterOptions.SkuIDs) > 0 {
+ queryParams.Set("sku_ids", strings.Join(filterOptions.SkuIDs, ","))
+ }
+ if filterOptions.Before != nil {
+ queryParams.Set("before", filterOptions.Before.Format(time.RFC3339))
+ }
+ if filterOptions.After != nil {
+ queryParams.Set("after", filterOptions.After.Format(time.RFC3339))
+ }
+ if filterOptions.Limit > 0 {
+ queryParams.Set("limit", strconv.Itoa(filterOptions.Limit))
+ }
+ if filterOptions.GuildID != "" {
+ queryParams.Set("guild_id", filterOptions.GuildID)
+ }
+ if filterOptions.ExcludeEnded {
+ queryParams.Set("exclude_ended", "true")
+ }
+ }
+
+ body, err := s.RequestWithBucketID("GET", endpoint+"?"+queryParams.Encode(), nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &entitlements)
+ return
+}
+
+// EntitlementConsume marks a given One-Time Purchase for the user as consumed.
+func (s *Session) EntitlementConsume(appID, entitlementID string, options ...RequestOption) (err error) {
+ _, err = s.RequestWithBucketID("POST", EndpointEntitlementConsume(appID, entitlementID), nil, EndpointEntitlementConsume(appID, ""), options...)
+ return
+}
+
+// EntitlementTestCreate creates a test entitlement to a given SKU for a given guild or user.
+// Discord will act as though that user or guild has entitlement to your premium offering.
+func (s *Session) EntitlementTestCreate(appID string, data *EntitlementTest, options ...RequestOption) (err error) {
+ endpoint := EndpointEntitlements(appID)
+
+ _, err = s.RequestWithBucketID("POST", endpoint, data, endpoint, options...)
+ return
+}
+
+// EntitlementTestDelete deletes a currently-active test entitlement. Discord will act as though
+// that user or guild no longer has entitlement to your premium offering.
+func (s *Session) EntitlementTestDelete(appID, entitlementID string, options ...RequestOption) (err error) {
+ _, err = s.RequestWithBucketID("DELETE", EndpointEntitlement(appID, entitlementID), nil, EndpointEntitlement(appID, ""), options...)
+ return
+}
+
+// Subscriptions returns all subscriptions containing the SKU.
+// skuID : The ID of the SKU.
+// userID : User ID for which to return subscriptions. Required except for OAuth queries.
+// before : Optional timestamp to retrieve subscriptions before this time.
+// after : Optional timestamp to retrieve subscriptions after this time.
+// limit : Optional maximum number of subscriptions to return (1-100, default 50).
+func (s *Session) Subscriptions(skuID string, userID string, before, after *time.Time, limit int, options ...RequestOption) (subscriptions []*Subscription, err error) {
+ endpoint := EndpointSubscriptions(skuID)
+
+ queryParams := url.Values{}
+ if before != nil {
+ queryParams.Set("before", before.Format(time.RFC3339))
+ }
+ if after != nil {
+ queryParams.Set("after", after.Format(time.RFC3339))
+ }
+ if userID != "" {
+ queryParams.Set("user_id", userID)
+ }
+ if limit > 0 {
+ queryParams.Set("limit", strconv.Itoa(limit))
+ }
+
+ body, err := s.RequestWithBucketID("GET", endpoint+"?"+queryParams.Encode(), nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &subscriptions)
+ return
+}
+
+// Subscription returns a subscription by its SKU and subscription ID.
+// skuID : The ID of the SKU.
+// subscriptionID : The ID of the subscription.
+// userID : User ID for which to return the subscription. Required except for OAuth queries.
+func (s *Session) Subscription(skuID, subscriptionID, userID string, options ...RequestOption) (subscription *Subscription, err error) {
+ endpoint := EndpointSubscription(skuID, subscriptionID)
+
+ queryParams := url.Values{}
+ if userID != "" {
+ // Unlike stated in the documentation, the user_id parameter is required here.
+ queryParams.Set("user_id", userID)
+ }
+
+ body, err := s.RequestWithBucketID("GET", endpoint+"?"+queryParams.Encode(), nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &subscription)
+ return
+}
+
+// UserVoiceState returns the voice state of the current user (the bot) in a guild.
+// guildID : The ID of the guild.
+// userID : The ID of the user.
+// Note: Using @me will return the bot's voice state for the given guild.
+func (s *Session) UserVoiceState(guildID string, userID string, options ...RequestOption) (state *VoiceState, err error) {
+ endpoint := EndpointGuildMemberVoiceState(guildID, userID)
+
+ body, err := s.RequestWithBucketID("GET", endpoint, nil, endpoint, options...)
+ if err != nil {
+ return
+ }
+
+ err = unmarshal(body, &state)
+ return
+}
diff --git a/pkg/meowcord/restapi_test.go b/pkg/meowcord/restapi_test.go
new file mode 100644
index 0000000..4432d51
--- /dev/null
+++ b/pkg/meowcord/restapi_test.go
@@ -0,0 +1,356 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "testing"
+)
+
+//////////////////////////////////////////////////////////////////////////////
+/////////////////////////////////////////////////////////////// START OF TESTS
+
+// TestChannelMessageSend tests the ChannelMessageSend() function. This should not return an error.
+func TestChannelMessageSend(t *testing.T) {
+
+ if envChannel == "" {
+ t.Skip("Skipping, DG_CHANNEL not set.")
+ }
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.ChannelMessageSend(envChannel, "Running REST API Tests!")
+ if err != nil {
+ t.Errorf("ChannelMessageSend returned error: %+v", err)
+ }
+}
+
+/*
+// removed for now, only works on BOT accounts now
+func TestUserAvatar(t *testing.T) {
+
+ if dg == nil {
+ t.Skip("Cannot TestUserAvatar, dg not set.")
+ }
+
+ u, err := dg.User("@me")
+ if err != nil {
+ t.Error("error fetching @me user,", err)
+ }
+
+ a, err := dg.UserAvatar(u.ID)
+ if err != nil {
+ if err.Error() == `HTTP 404 NOT FOUND, {"code": 0, "message": "404: Not Found"}` {
+ t.Skip("Skipped, @me doesn't have an Avatar")
+ }
+ t.Errorf(err.Error())
+ }
+
+ if a == nil {
+ t.Errorf("a == nil, should be image.Image")
+ }
+}
+*/
+
+/* Running this causes an error due to 2/hour rate limit on username changes
+func TestUserUpdate(t *testing.T) {
+ if dg == nil {
+ t.Skip("Cannot test logout, dg not set.")
+ }
+
+ u, err := dg.User("@me")
+ if err != nil {
+ t.Errorf(err.Error())
+ }
+
+ s, err := dg.UserUpdate(envEmail, envPassword, "testname", u.Avatar, "")
+ if err != nil {
+ t.Error(err.Error())
+ }
+ if s.Username != "testname" {
+ t.Error("Username != testname")
+ }
+ s, err = dg.UserUpdate(envEmail, envPassword, u.Username, u.Avatar, "")
+ if err != nil {
+ t.Error(err.Error())
+ }
+ if s.Username != u.Username {
+ t.Error("Username != " + u.Username)
+ }
+}
+*/
+
+//func (s *Session) UserChannelCreate(recipientID string) (st *Channel, err error) {
+
+func TestUserChannelCreate(t *testing.T) {
+ if dg == nil {
+ t.Skip("Cannot TestUserChannelCreate, dg not set.")
+ }
+
+ if envAdmin == "" {
+ t.Skip("Skipped, DG_ADMIN not set.")
+ }
+
+ _, err := dg.UserChannelCreate(envAdmin)
+ if err != nil {
+ t.Error(err)
+ }
+
+ // TODO make sure the channel was added
+}
+
+func TestUserChannels(t *testing.T) {
+ if dg == nil {
+ t.Skip("Cannot TestUserChannels, dg not set.")
+ }
+
+ _, err := dg.UserChannels()
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestUserGuilds(t *testing.T) {
+ if dg == nil {
+ t.Skip("Cannot TestUserGuilds, dg not set.")
+ }
+
+ _, err := dg.UserGuilds(10, "", "", false)
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestUserSettings(t *testing.T) {
+ if dg == nil {
+ t.Skip("Cannot TestUserSettings, dg not set.")
+ }
+
+ _, err := dg.UserSettings()
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+func TestUserUpdateStatus(t *testing.T) {
+ if dg == nil {
+ t.Skip("Cannot TestUserSettings, dg not set.")
+ }
+
+ _, err := dg.UserUpdateStatus(StatusDoNotDisturb)
+ if err != nil {
+ t.Error(err)
+ }
+}
+
+// TestLogout tests the Logout() function. This should not return an error.
+func TestLogout(t *testing.T) {
+
+ if dg == nil {
+ t.Skip("Cannot TestLogout, dg not set.")
+ }
+
+ err := dg.Logout()
+ if err != nil {
+ t.Errorf("Logout() returned error: %+v", err)
+ }
+}
+
+func TestGateway(t *testing.T) {
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+ _, err := dg.Gateway()
+ if err != nil {
+ t.Errorf("Gateway() returned error: %+v", err)
+ }
+}
+
+func TestGatewayBot(t *testing.T) {
+
+ if dgBot == nil {
+ t.Skip("Skipping, dgBot not set.")
+ }
+ _, err := dgBot.GatewayBot()
+ if err != nil {
+ t.Errorf("GatewayBot() returned error: %+v", err)
+ }
+}
+
+func TestVoiceICE(t *testing.T) {
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.VoiceICE()
+ if err != nil {
+ t.Errorf("VoiceICE() returned error: %+v", err)
+ }
+}
+
+func TestVoiceRegions(t *testing.T) {
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.VoiceRegions()
+ if err != nil {
+ t.Errorf("VoiceRegions() returned error: %+v", err)
+ }
+}
+func TestGuildRoles(t *testing.T) {
+
+ if envGuild == "" {
+ t.Skip("Skipping, DG_GUILD not set.")
+ }
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.GuildRoles(envGuild)
+ if err != nil {
+ t.Errorf("GuildRoles(envGuild) returned error: %+v", err)
+ }
+
+}
+
+func TestGuildMemberNickname(t *testing.T) {
+
+ if envGuild == "" {
+ t.Skip("Skipping, DG_GUILD not set.")
+ }
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ err := dg.GuildMemberNickname(envGuild, "@me/nick", "B1nzyRocks")
+ if err != nil {
+ t.Errorf("GuildNickname returned error: %+v", err)
+ }
+}
+
+// TestChannelMessageSend2 tests the ChannelMessageSend() function. This should not return an error.
+func TestChannelMessageSend2(t *testing.T) {
+
+ if envChannel == "" {
+ t.Skip("Skipping, DG_CHANNEL not set.")
+ }
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.ChannelMessageSend(envChannel, "All done running REST API Tests!")
+ if err != nil {
+ t.Errorf("ChannelMessageSend returned error: %+v", err)
+ }
+}
+
+// TestGuildPruneCount tests GuildPruneCount() function. This should not return an error.
+func TestGuildPruneCount(t *testing.T) {
+
+ if envGuild == "" {
+ t.Skip("Skipping, DG_GUILD not set.")
+ }
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.GuildPruneCount(envGuild, 1)
+ if err != nil {
+ t.Errorf("GuildPruneCount returned error: %+v", err)
+ }
+}
+
+/*
+// TestGuildPrune tests GuildPrune() function. This should not return an error.
+func TestGuildPrune(t *testing.T) {
+
+ if envGuild == "" {
+ t.Skip("Skipping, DG_GUILD not set.")
+ }
+
+ if dg == nil {
+ t.Skip("Skipping, dg not set.")
+ }
+
+ _, err := dg.GuildPrune(envGuild, 1)
+ if err != nil {
+ t.Errorf("GuildPrune returned error: %+v", err)
+ }
+}
+*/
+
+func Test_unmarshal(t *testing.T) {
+ err := unmarshal([]byte{}, &struct{}{})
+ if !errors.Is(err, ErrJSONUnmarshal) {
+ t.Errorf("Unexpected error type: %T", err)
+ }
+}
+
+func TestWithContext(t *testing.T) {
+ // Set up a test context.
+ type key struct{}
+ ctx := context.WithValue(context.Background(), key{}, "value")
+
+ // Set up a test client.
+ session, err := New("")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ testErr := errors.New("test")
+
+ // Intercept the request to assert the context.
+ session.Client.Transport = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
+ val, _ := r.Context().Value(key{}).(string)
+ if val != "value" {
+ t.Errorf("missing value in context (got %q, wanted %q)", val, "value")
+ }
+ return nil, testErr
+ })
+
+ // Run any client method using WithContext.
+ _, err = session.User("", WithContext(ctx))
+
+ // Verify that the assertion code was actually run.
+ if !errors.Is(err, testErr) {
+ t.Errorf("unexpected error %v returned from client", err)
+ }
+}
+
+// roundTripperFunc implements http.RoundTripper.
+type roundTripperFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
diff --git a/pkg/meowcord/state.go b/pkg/meowcord/state.go
new file mode 100644
index 0000000..03d53c1
--- /dev/null
+++ b/pkg/meowcord/state.go
@@ -0,0 +1,1398 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains code related to state tracking. If enabled, state
+// tracking will capture the initial READY packet and many other websocket
+// events and maintain an in-memory state of guilds, channels, users, and
+// so forth. This information can be accessed through the Session.State struct.
+
+package meowcord
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "sync"
+)
+
+// ErrNilState is returned when the state is nil.
+var ErrNilState = errors.New("state not instantiated, please use discordgo.New() or assign Session.State")
+
+// ErrStateNotFound is returned when the state cache
+// requested is not found
+var ErrStateNotFound = errors.New("state cache not found")
+
+// ErrMessageIncompletePermissions is returned when the message
+// requested for permissions does not contain enough data to
+// generate the permissions.
+var ErrMessageIncompletePermissions = errors.New("message incomplete, unable to determine permissions")
+
+// A State contains the current known state.
+// As discord sends this in a READY blob, it seems reasonable to simply
+// use that struct as the data store.
+type State struct {
+ sync.RWMutex
+ Ready
+
+ // MaxMessageCount represents how many messages per channel the state will store.
+ MaxMessageCount int
+ TrackChannels bool
+ TrackThreads bool
+ TrackEmojis bool
+ TrackStickers bool
+ TrackMembers bool
+ TrackThreadMembers bool
+ TrackRoles bool
+ TrackVoice bool
+ TrackPresences bool
+
+ guildMap map[string]*Guild
+ channelMap map[string]*Channel
+ memberMap map[string]map[string]*Member
+}
+
+// NewState creates an empty state.
+func NewState() *State {
+ return &State{
+ Ready: Ready{
+ PrivateChannels: []*Channel{},
+ Guilds: []*Guild{},
+ },
+ TrackChannels: true,
+ TrackThreads: true,
+ TrackEmojis: true,
+ TrackStickers: true,
+ TrackMembers: true,
+ TrackThreadMembers: true,
+ TrackRoles: true,
+ TrackVoice: true,
+ TrackPresences: true,
+ guildMap: make(map[string]*Guild),
+ channelMap: make(map[string]*Channel),
+ memberMap: make(map[string]map[string]*Member),
+ }
+}
+
+func (s *State) createMemberMap(guild *Guild) map[string]*Member {
+ members := make(map[string]*Member)
+ for _, m := range guild.Members {
+ members[m.User.ID] = m
+ }
+ s.memberMap[guild.ID] = members
+ return members
+}
+
+// GuildAdd adds a guild to the current world state, or
+// updates it if it already exists.
+func (s *State) GuildAdd(guild *Guild) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ // Update the channels to point to the right guild, adding them to the channelMap as we go
+ for _, c := range guild.Channels {
+ s.channelMap[c.ID] = c
+ }
+
+ // Add all the threads to the state in case of thread sync list.
+ for _, t := range guild.Threads {
+ s.channelMap[t.ID] = t
+ }
+
+ // If this guild contains a new member slice, we must regenerate the member map so the pointers stay valid
+ if guild.Members != nil {
+ s.createMemberMap(guild)
+ } else if _, ok := s.memberMap[guild.ID]; !ok {
+ // Even if we have no new member slice, we still initialize the member map for this guild if it doesn't exist
+ s.memberMap[guild.ID] = make(map[string]*Member)
+ }
+
+ if g, ok := s.guildMap[guild.ID]; ok {
+ // We are about to replace `g` in the state with `guild`, but first we need to
+ // make sure we preserve any fields that the `guild` doesn't contain from `g`.
+ if guild.MemberCount == 0 {
+ guild.MemberCount = g.MemberCount
+ }
+ if guild.Roles == nil {
+ guild.Roles = g.Roles
+ }
+ if guild.Emojis == nil {
+ guild.Emojis = g.Emojis
+ }
+ if guild.Members == nil {
+ guild.Members = g.Members
+ }
+ if guild.Presences == nil {
+ guild.Presences = g.Presences
+ }
+ if guild.Channels == nil {
+ guild.Channels = g.Channels
+ }
+ if guild.Threads == nil {
+ guild.Threads = g.Threads
+ }
+ if guild.VoiceStates == nil {
+ guild.VoiceStates = g.VoiceStates
+ }
+ *g = *guild
+ return nil
+ }
+
+ s.Guilds = append(s.Guilds, guild)
+ s.guildMap[guild.ID] = guild
+
+ return nil
+}
+
+// GuildRemove removes a guild from current world state.
+func (s *State) GuildRemove(guild *Guild) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ _, err := s.Guild(guild.ID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ delete(s.guildMap, guild.ID)
+
+ for i, g := range s.Guilds {
+ if g.ID == guild.ID {
+ s.Guilds = append(s.Guilds[:i], s.Guilds[i+1:]...)
+ return nil
+ }
+ }
+
+ return nil
+}
+
+// Guild gets a guild by ID.
+// Useful for querying if @me is in a guild:
+//
+// _, err := discordgo.Session.State.Guild(guildID)
+// isInGuild := err == nil
+func (s *State) Guild(guildID string) (*Guild, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+
+ if g, ok := s.guildMap[guildID]; ok {
+ return g, nil
+ }
+
+ return nil, ErrStateNotFound
+}
+
+func (s *State) presenceAdd(guildID string, presence *Presence) error {
+ guild, ok := s.guildMap[guildID]
+ if !ok {
+ return ErrStateNotFound
+ }
+
+ for i, p := range guild.Presences {
+ if p.User.ID == presence.User.ID {
+ //guild.Presences[i] = presence
+
+ //Update status
+ guild.Presences[i].Activities = presence.Activities
+ if presence.Status != "" {
+ guild.Presences[i].Status = presence.Status
+ }
+ if presence.ClientStatus.Desktop != "" {
+ guild.Presences[i].ClientStatus.Desktop = presence.ClientStatus.Desktop
+ }
+ if presence.ClientStatus.Mobile != "" {
+ guild.Presences[i].ClientStatus.Mobile = presence.ClientStatus.Mobile
+ }
+ if presence.ClientStatus.Web != "" {
+ guild.Presences[i].ClientStatus.Web = presence.ClientStatus.Web
+ }
+
+ //Update the optionally sent user information
+ //ID Is a mandatory field so you should not need to check if it is empty
+ guild.Presences[i].User.ID = presence.User.ID
+
+ if presence.User.Avatar != "" {
+ guild.Presences[i].User.Avatar = presence.User.Avatar
+ }
+ if presence.User.Discriminator != "" {
+ guild.Presences[i].User.Discriminator = presence.User.Discriminator
+ }
+ if presence.User.Email != "" {
+ guild.Presences[i].User.Email = presence.User.Email
+ }
+ if presence.User.Token != "" {
+ guild.Presences[i].User.Token = presence.User.Token
+ }
+ if presence.User.Username != "" {
+ guild.Presences[i].User.Username = presence.User.Username
+ }
+
+ return nil
+ }
+ }
+
+ guild.Presences = append(guild.Presences, presence)
+ return nil
+}
+
+// PresenceAdd adds a presence to the current world state, or
+// updates it if it already exists.
+func (s *State) PresenceAdd(guildID string, presence *Presence) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ return s.presenceAdd(guildID, presence)
+}
+
+// PresenceRemove removes a presence from the current world state.
+func (s *State) PresenceRemove(guildID string, presence *Presence) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ for i, p := range guild.Presences {
+ if p.User.ID == presence.User.ID {
+ guild.Presences = append(guild.Presences[:i], guild.Presences[i+1:]...)
+ return nil
+ }
+ }
+
+ return ErrStateNotFound
+}
+
+// Presence gets a presence by ID from a guild.
+func (s *State) Presence(guildID, userID string) (*Presence, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, p := range guild.Presences {
+ if p.User.ID == userID {
+ return p, nil
+ }
+ }
+
+ return nil, ErrStateNotFound
+}
+
+// TODO: Consider moving Guild state update methods onto *Guild.
+
+func (s *State) memberAdd(member *Member) error {
+ guild, ok := s.guildMap[member.GuildID]
+ if !ok {
+ return ErrStateNotFound
+ }
+
+ members, ok := s.memberMap[member.GuildID]
+ if !ok {
+ return ErrStateNotFound
+ }
+
+ m, ok := members[member.User.ID]
+ if !ok {
+ members[member.User.ID] = member
+ guild.Members = append(guild.Members, member)
+ } else {
+ // We are about to replace `m` in the state with `member`, but first we need to
+ // make sure we preserve any fields that the `member` doesn't contain from `m`.
+ if member.JoinedAt.IsZero() {
+ member.JoinedAt = m.JoinedAt
+ }
+ *m = *member
+ }
+ return nil
+}
+
+// MemberAdd adds a member to the current world state, or
+// updates it if it already exists.
+func (s *State) MemberAdd(member *Member) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ return s.memberAdd(member)
+}
+
+// MemberRemove removes a member from current world state.
+func (s *State) MemberRemove(member *Member) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ guild, err := s.Guild(member.GuildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ members, ok := s.memberMap[member.GuildID]
+ if !ok {
+ return ErrStateNotFound
+ }
+
+ _, ok = members[member.User.ID]
+ if !ok {
+ return ErrStateNotFound
+ }
+ delete(members, member.User.ID)
+
+ for i, m := range guild.Members {
+ if m.User.ID == member.User.ID {
+ guild.Members = append(guild.Members[:i], guild.Members[i+1:]...)
+ return nil
+ }
+ }
+
+ return ErrStateNotFound
+}
+
+// Member gets a member by ID from a guild.
+func (s *State) Member(guildID, userID string) (*Member, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+
+ members, ok := s.memberMap[guildID]
+ if !ok {
+ return nil, ErrStateNotFound
+ }
+
+ m, ok := members[userID]
+ if ok {
+ return m, nil
+ }
+
+ return nil, ErrStateNotFound
+}
+
+// RoleAdd adds a role to the current world state, or
+// updates it if it already exists.
+func (s *State) RoleAdd(guildID string, role *Role) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ for i, r := range guild.Roles {
+ if r.ID == role.ID {
+ guild.Roles[i] = role
+ return nil
+ }
+ }
+
+ guild.Roles = append(guild.Roles, role)
+ return nil
+}
+
+// RoleRemove removes a role from current world state by ID.
+func (s *State) RoleRemove(guildID, roleID string) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ for i, r := range guild.Roles {
+ if r.ID == roleID {
+ guild.Roles = append(guild.Roles[:i], guild.Roles[i+1:]...)
+ return nil
+ }
+ }
+
+ return ErrStateNotFound
+}
+
+// Role gets a role by ID from a guild.
+func (s *State) Role(guildID, roleID string) (*Role, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return nil, err
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+
+ for _, r := range guild.Roles {
+ if r.ID == roleID {
+ return r, nil
+ }
+ }
+
+ return nil, ErrStateNotFound
+}
+
+// ChannelAdd adds a channel to the current world state, or
+// updates it if it already exists.
+// Channels may exist either as PrivateChannels or inside
+// a guild.
+func (s *State) ChannelAdd(channel *Channel) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ // If the channel exists, replace it
+ if c, ok := s.channelMap[channel.ID]; ok {
+ if channel.Messages == nil {
+ channel.Messages = c.Messages
+ }
+ if channel.PermissionOverwrites == nil {
+ channel.PermissionOverwrites = c.PermissionOverwrites
+ }
+ if channel.ThreadMetadata == nil {
+ channel.ThreadMetadata = c.ThreadMetadata
+ }
+
+ *c = *channel
+ return nil
+ }
+
+ if channel.Type == ChannelTypeDM || channel.Type == ChannelTypeGroupDM {
+ s.PrivateChannels = append(s.PrivateChannels, channel)
+ s.channelMap[channel.ID] = channel
+ return nil
+ }
+
+ guild, ok := s.guildMap[channel.GuildID]
+ if !ok {
+ return ErrStateNotFound
+ }
+
+ if channel.IsThread() {
+ guild.Threads = append(guild.Threads, channel)
+ } else {
+ guild.Channels = append(guild.Channels, channel)
+ }
+
+ s.channelMap[channel.ID] = channel
+
+ return nil
+}
+
+// ChannelRemove removes a channel from current world state.
+func (s *State) ChannelRemove(channel *Channel) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ _, err := s.Channel(channel.ID)
+ if err != nil {
+ return err
+ }
+
+ if channel.Type == ChannelTypeDM || channel.Type == ChannelTypeGroupDM {
+ s.Lock()
+ defer s.Unlock()
+
+ for i, c := range s.PrivateChannels {
+ if c.ID == channel.ID {
+ s.PrivateChannels = append(s.PrivateChannels[:i], s.PrivateChannels[i+1:]...)
+ break
+ }
+ }
+ delete(s.channelMap, channel.ID)
+ return nil
+ }
+
+ guild, err := s.Guild(channel.GuildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ if channel.IsThread() {
+ for i, t := range guild.Threads {
+ if t.ID == channel.ID {
+ guild.Threads = append(guild.Threads[:i], guild.Threads[i+1:]...)
+ break
+ }
+ }
+ } else {
+ for i, c := range guild.Channels {
+ if c.ID == channel.ID {
+ guild.Channels = append(guild.Channels[:i], guild.Channels[i+1:]...)
+ break
+ }
+ }
+ }
+
+ delete(s.channelMap, channel.ID)
+
+ return nil
+}
+
+// ThreadListSync syncs guild threads with provided ones.
+func (s *State) ThreadListSync(tls *ThreadListSync) error {
+ guild, err := s.Guild(tls.GuildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ // This algorithm filters out archived or
+ // threads which are children of channels in channelIDs
+ // and then it adds all synced threads to guild threads and cache
+ index := 0
+outer:
+ for _, t := range guild.Threads {
+ if !t.ThreadMetadata.Archived && tls.ChannelIDs != nil {
+ for _, v := range tls.ChannelIDs {
+ if t.ParentID == v {
+ delete(s.channelMap, t.ID)
+ continue outer
+ }
+ }
+ guild.Threads[index] = t
+ index++
+ } else {
+ delete(s.channelMap, t.ID)
+ }
+ }
+ guild.Threads = guild.Threads[:index]
+ for _, t := range tls.Threads {
+ s.channelMap[t.ID] = t
+ guild.Threads = append(guild.Threads, t)
+ }
+
+ for _, m := range tls.Members {
+ if c, ok := s.channelMap[m.ID]; ok {
+ c.Member = m
+ }
+ }
+
+ return nil
+}
+
+// ThreadMembersUpdate updates thread members list
+func (s *State) ThreadMembersUpdate(tmu *ThreadMembersUpdate) error {
+ thread, err := s.Channel(tmu.ID)
+ if err != nil {
+ return err
+ }
+ s.Lock()
+ defer s.Unlock()
+
+ for idx, member := range thread.Members {
+ for _, removedMember := range tmu.RemovedMembers {
+ if member.ID == removedMember {
+ thread.Members = append(thread.Members[:idx], thread.Members[idx+1:]...)
+ break
+ }
+ }
+ }
+
+ for _, addedMember := range tmu.AddedMembers {
+ thread.Members = append(thread.Members, addedMember.ThreadMember)
+ if addedMember.Member != nil {
+ err = s.memberAdd(addedMember.Member)
+ if err != nil {
+ return err
+ }
+ }
+ if addedMember.Presence != nil {
+ err = s.presenceAdd(tmu.GuildID, addedMember.Presence)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ thread.MemberCount = tmu.MemberCount
+
+ return nil
+}
+
+// ThreadMemberUpdate sets or updates member data for the current user.
+func (s *State) ThreadMemberUpdate(mu *ThreadMemberUpdate) error {
+ thread, err := s.Channel(mu.ID)
+ if err != nil {
+ return err
+ }
+
+ thread.Member = mu.ThreadMember
+ return nil
+}
+
+// Channel gets a channel by ID, it will look in all guilds and private channels.
+func (s *State) Channel(channelID string) (*Channel, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+
+ if c, ok := s.channelMap[channelID]; ok {
+ return c, nil
+ }
+
+ return nil, ErrStateNotFound
+}
+
+// Emoji returns an emoji for a guild and emoji id.
+func (s *State) Emoji(guildID, emojiID string) (*Emoji, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return nil, err
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+
+ for _, e := range guild.Emojis {
+ if e.ID == emojiID {
+ return e, nil
+ }
+ }
+
+ return nil, ErrStateNotFound
+}
+
+// EmojiAdd adds an emoji to the current world state.
+func (s *State) EmojiAdd(guildID string, emoji *Emoji) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ for i, e := range guild.Emojis {
+ if e.ID == emoji.ID {
+ guild.Emojis[i] = emoji
+ return nil
+ }
+ }
+
+ guild.Emojis = append(guild.Emojis, emoji)
+ return nil
+}
+
+// EmojisAdd adds multiple emojis to the world state.
+func (s *State) EmojisAdd(guildID string, emojis []*Emoji) error {
+ for _, e := range emojis {
+ if err := s.EmojiAdd(guildID, e); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// MessageAdd adds a message to the current world state, or updates it if it exists.
+// If the channel cannot be found, the message is discarded.
+// Messages are kept in state up to s.MaxMessageCount per channel.
+func (s *State) MessageAdd(message *Message) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ c, err := s.Channel(message.ChannelID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ // If the message exists, merge in the new message contents.
+ for _, m := range c.Messages {
+ if m.ID == message.ID {
+ if message.Content != "" {
+ m.Content = message.Content
+ }
+ if message.EditedTimestamp != nil {
+ m.EditedTimestamp = message.EditedTimestamp
+ }
+ if message.Mentions != nil {
+ m.Mentions = message.Mentions
+ }
+ if message.Embeds != nil {
+ m.Embeds = message.Embeds
+ }
+ if message.Attachments != nil {
+ m.Attachments = message.Attachments
+ }
+ if !message.Timestamp.IsZero() {
+ m.Timestamp = message.Timestamp
+ }
+ if message.Author != nil {
+ m.Author = message.Author
+ }
+ if message.Components != nil {
+ m.Components = message.Components
+ }
+
+ return nil
+ }
+ }
+
+ c.Messages = append(c.Messages, message)
+
+ if len(c.Messages) > s.MaxMessageCount {
+ c.Messages = c.Messages[len(c.Messages)-s.MaxMessageCount:]
+ }
+
+ return nil
+}
+
+// MessageRemove removes a message from the world state.
+func (s *State) MessageRemove(message *Message) error {
+ if s == nil {
+ return ErrNilState
+ }
+
+ return s.messageRemoveByID(message.ChannelID, message.ID)
+}
+
+// messageRemoveByID removes a message by channelID and messageID from the world state.
+func (s *State) messageRemoveByID(channelID, messageID string) error {
+ c, err := s.Channel(channelID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ for i, m := range c.Messages {
+ if m.ID == messageID {
+ c.Messages = append(c.Messages[:i], c.Messages[i+1:]...)
+
+ return nil
+ }
+ }
+
+ return ErrStateNotFound
+}
+
+func (s *State) voiceStateUpdate(update *VoiceStateUpdate) error {
+ guild, err := s.Guild(update.GuildID)
+ if err != nil {
+ return err
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ // Handle Leaving Channel
+ if update.ChannelID == "" {
+ for i, state := range guild.VoiceStates {
+ if state.UserID == update.UserID {
+ guild.VoiceStates = append(guild.VoiceStates[:i], guild.VoiceStates[i+1:]...)
+ return nil
+ }
+ }
+ } else {
+ for i, state := range guild.VoiceStates {
+ if state.UserID == update.UserID {
+ guild.VoiceStates[i] = update.VoiceState
+ return nil
+ }
+ }
+
+ guild.VoiceStates = append(guild.VoiceStates, update.VoiceState)
+ }
+
+ return nil
+}
+
+// VoiceState gets a VoiceState by guild and user ID.
+func (s *State) VoiceState(guildID, userID string) (*VoiceState, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ guild, err := s.Guild(guildID)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, state := range guild.VoiceStates {
+ if state.UserID == userID {
+ return state, nil
+ }
+ }
+
+ return nil, ErrStateNotFound
+}
+
+// Message gets a message by channel and message ID.
+func (s *State) Message(channelID, messageID string) (*Message, error) {
+ if s == nil {
+ return nil, ErrNilState
+ }
+
+ c, err := s.Channel(channelID)
+ if err != nil {
+ return nil, err
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+
+ for _, m := range c.Messages {
+ if m.ID == messageID {
+ return m, nil
+ }
+ }
+
+ return nil, ErrStateNotFound
+}
+
+func (s *State) unlockedClearMaps() {
+ if s == nil {
+ return
+ }
+
+ clear(s.guildMap)
+ clear(s.channelMap)
+ clear(s.memberMap)
+}
+
+// OnReady takes a Ready event and updates all internal state.
+func (s *State) onReady(se *Session, r *Ready) (err error) {
+ if s == nil {
+ return ErrNilState
+ }
+
+ s.Lock()
+ defer s.Unlock()
+
+ // We must track at least the current user for Voice, even
+ // if state is disabled, store the bare essentials.
+ if !se.StateEnabled {
+ ready := Ready{
+ Version: r.Version,
+ SessionID: r.SessionID,
+ User: r.User,
+ Shard: r.Shard,
+ Application: r.Application,
+ }
+
+ s.unlockedClearMaps()
+ s.Ready = ready
+
+ return nil
+ }
+
+ s.unlockedClearMaps()
+ s.Ready = *r
+
+ findUser := func(userID string) *User {
+ if userID == r.User.ID {
+ return r.User
+ }
+ for _, user := range r.Users {
+ if user.ID == userID {
+ return user
+ }
+ }
+ return nil
+ }
+
+ for i, g := range s.Guilds {
+ s.guildMap[g.ID] = g
+ memberMap := s.createMemberMap(g)
+
+ if len(r.MergedMembers) == len(r.Guilds) {
+ for _, member := range r.MergedMembers[i] {
+ member.User = findUser(member.UserID)
+ member.GuildID = g.ID
+ if member.User != nil {
+ memberMap[member.User.ID] = member
+ }
+ }
+ }
+
+ for _, c := range g.Channels {
+ s.channelMap[c.ID] = c
+ }
+ for _, t := range g.Threads {
+ s.channelMap[t.ID] = t
+ }
+ }
+
+ for _, c := range s.PrivateChannels {
+ s.channelMap[c.ID] = c
+ }
+
+ return nil
+}
+
+// OnInterface handles all events related to states.
+func (s *State) OnInterface(se *Session, i interface{}) (err error) {
+ if s == nil {
+ return ErrNilState
+ }
+
+ r, ok := i.(*Ready)
+ if ok {
+ return s.onReady(se, r)
+ }
+
+ if !se.StateEnabled {
+ return nil
+ }
+
+ switch t := i.(type) {
+ case *GuildCreate:
+ err = s.GuildAdd(t.Guild)
+ case *GuildUpdate:
+ err = s.GuildAdd(t.Guild)
+ case *GuildDelete:
+ var old *Guild
+ old, err = s.Guild(t.ID)
+ if err == nil {
+ oldCopy := *old
+ t.BeforeDelete = &oldCopy
+ }
+
+ err = s.GuildRemove(t.Guild)
+ case *GuildMemberAdd:
+ var guild *Guild
+ // Updates the MemberCount of the guild.
+ guild, err = s.Guild(t.Member.GuildID)
+ if err != nil {
+ return err
+ }
+ guild.MemberCount++
+
+ // Caches member if tracking is enabled.
+ if s.TrackMembers {
+ err = s.MemberAdd(t.Member)
+ }
+ case *GuildMemberUpdate:
+ if s.TrackMembers {
+ var old *Member
+ old, err = s.Member(t.GuildID, t.User.ID)
+ if err == nil {
+ oldCopy := *old
+ t.BeforeUpdate = &oldCopy
+ }
+
+ err = s.MemberAdd(t.Member)
+ }
+ case *GuildMemberRemove:
+ var guild *Guild
+ // Updates the MemberCount of the guild.
+ guild, err = s.Guild(t.Member.GuildID)
+ if err != nil {
+ return err
+ }
+ guild.MemberCount--
+
+ // Removes member from the cache if tracking is enabled.
+ if s.TrackMembers {
+ old, getErr := s.Member(t.Member.GuildID, t.Member.User.ID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeDelete = &oldCopy
+ }
+
+ err = s.MemberRemove(t.Member)
+ }
+ case *GuildMembersChunk:
+ if s.TrackMembers {
+ for i := range t.Members {
+ t.Members[i].GuildID = t.GuildID
+ err = s.MemberAdd(t.Members[i])
+ }
+ }
+
+ if s.TrackPresences {
+ for _, p := range t.Presences {
+ err = s.PresenceAdd(t.GuildID, p)
+ }
+ }
+ case *GuildRoleCreate:
+ if s.TrackRoles {
+ err = s.RoleAdd(t.GuildID, t.Role)
+ }
+ case *GuildRoleUpdate:
+ if s.TrackRoles {
+ old, getErr := s.Role(t.GuildID, t.Role.ID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeUpdate = &oldCopy
+ }
+
+ err = s.RoleAdd(t.GuildID, t.Role)
+ }
+ case *GuildRoleDelete:
+ if s.TrackRoles {
+ old, getErr := s.Role(t.GuildID, t.RoleID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeDelete = &oldCopy
+ }
+
+ err = s.RoleRemove(t.GuildID, t.RoleID)
+ }
+ case *GuildEmojisUpdate:
+ if s.TrackEmojis {
+ var guild *Guild
+ guild, err = s.Guild(t.GuildID)
+ if err != nil {
+ return err
+ }
+ s.Lock()
+ defer s.Unlock()
+ guild.Emojis = t.Emojis
+ }
+ case *GuildStickersUpdate:
+ if s.TrackStickers {
+ var guild *Guild
+ guild, err = s.Guild(t.GuildID)
+ if err != nil {
+ return err
+ }
+ s.Lock()
+ defer s.Unlock()
+ guild.Stickers = t.Stickers
+ }
+ case *ChannelCreate:
+ if s.TrackChannels {
+ err = s.ChannelAdd(t.Channel)
+ }
+ case *ChannelUpdate:
+ if s.TrackChannels {
+ old, getErr := s.Channel(t.ID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeUpdate = &oldCopy
+ }
+ err = s.ChannelAdd(t.Channel)
+ }
+ case *ChannelDelete:
+ if s.TrackChannels {
+ old, getErr := s.Channel(t.ID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeDelete = &oldCopy
+ }
+ err = s.ChannelRemove(t.Channel)
+ }
+ case *ThreadCreate:
+ if s.TrackThreads {
+ err = s.ChannelAdd(t.Channel)
+ }
+ case *ThreadUpdate:
+ if s.TrackThreads {
+ old, getErr := s.Channel(t.ID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeUpdate = &oldCopy
+ }
+ err = s.ChannelAdd(t.Channel)
+ }
+ case *ThreadDelete:
+ if s.TrackThreads {
+ old, getErr := s.Channel(t.ID)
+ if getErr == nil {
+ oldCopy := *old
+ t.BeforeDelete = &oldCopy
+ }
+ err = s.ChannelRemove(t.Channel)
+ }
+ case *ThreadMemberUpdate:
+ if s.TrackThreads {
+ err = s.ThreadMemberUpdate(t)
+ }
+ case *ThreadMembersUpdate:
+ if s.TrackThreadMembers {
+ err = s.ThreadMembersUpdate(t)
+ }
+ case *ThreadListSync:
+ if s.TrackThreads {
+ err = s.ThreadListSync(t)
+ }
+ case *MessageCreate:
+ if s.MaxMessageCount != 0 {
+ err = s.MessageAdd(t.Message)
+ }
+ case *MessageUpdate:
+ if s.MaxMessageCount != 0 {
+ var old *Message
+ old, err = s.Message(t.ChannelID, t.ID)
+ if err == nil {
+ oldCopy := *old
+ t.BeforeUpdate = &oldCopy
+ }
+
+ err = s.MessageAdd(t.Message)
+ }
+ case *MessageDelete:
+ if s.MaxMessageCount != 0 {
+ var old *Message
+ old, err = s.Message(t.ChannelID, t.ID)
+ if err == nil {
+ oldCopy := *old
+ t.BeforeDelete = &oldCopy
+ }
+
+ err = s.MessageRemove(t.Message)
+ }
+ case *MessageDeleteBulk:
+ if s.MaxMessageCount != 0 {
+ for _, mID := range t.Messages {
+ s.messageRemoveByID(t.ChannelID, mID)
+ }
+ }
+ case *VoiceStateUpdate:
+ if s.TrackVoice {
+ var old *VoiceState
+ old, err = s.VoiceState(t.GuildID, t.UserID)
+ if err == nil {
+ oldCopy := *old
+ t.BeforeUpdate = &oldCopy
+ }
+
+ err = s.voiceStateUpdate(t)
+ }
+ case *PresenceUpdate:
+ if s.TrackPresences {
+ s.PresenceAdd(t.GuildID, &t.Presence)
+ }
+ if s.TrackMembers {
+ if t.Status == StatusOffline {
+ return
+ }
+
+ var m *Member
+ m, err = s.Member(t.GuildID, t.User.ID)
+
+ if err != nil {
+ // Member not found; this is a user coming online
+ m = &Member{
+ GuildID: t.GuildID,
+ User: t.User,
+ }
+ } else {
+ if t.User.Username != "" {
+ m.User.Username = t.User.Username
+ }
+ }
+
+ err = s.MemberAdd(m)
+ }
+ case *UserRequiredActionUpdate:
+ s.Lock()
+ s.RequiredAction = t.RequiredAction
+ s.Unlock()
+ }
+
+ return
+}
+
+// UserChannelPermissions returns the permission of a user in a channel.
+// userID : The ID of the user to calculate permissions for.
+// channelID : The ID of the channel to calculate permission for.
+func (s *State) UserChannelPermissions(userID, channelID string) (apermissions int64, err error) {
+ if s == nil {
+ return 0, ErrNilState
+ }
+
+ channel, err := s.Channel(channelID)
+ if err != nil {
+ err = fmt.Errorf("channel: %w", err)
+ return
+ }
+
+ guild, err := s.Guild(channel.GuildID)
+ if err != nil {
+ err = fmt.Errorf("guild: %w", err)
+ return
+ }
+
+ member, err := s.Member(guild.ID, userID)
+ if err != nil {
+ err = fmt.Errorf("member: %w", err)
+ return
+ }
+
+ return memberPermissions(guild, channel, userID, member.Roles), nil
+}
+
+// MessagePermissions returns the permissions of the author of the message
+// in the channel in which it was sent.
+func (s *State) MessagePermissions(message *Message) (apermissions int64, err error) {
+ if s == nil {
+ return 0, ErrNilState
+ }
+
+ if message.Author == nil || message.Member == nil {
+ return 0, ErrMessageIncompletePermissions
+ }
+
+ channel, err := s.Channel(message.ChannelID)
+ if err != nil {
+ return
+ }
+
+ guild, err := s.Guild(channel.GuildID)
+ if err != nil {
+ return
+ }
+
+ return memberPermissions(guild, channel, message.Author.ID, message.Member.Roles), nil
+}
+
+// UserColor returns the color of a user in a channel.
+// While colors are defined at a Guild level, determining for a channel is more useful in message handlers.
+// 0 is returned in cases of error, which is the color of @everyone.
+// userID : The ID of the user to calculate the color for.
+// channelID : The ID of the channel to calculate the color for.
+func (s *State) UserColor(userID, channelID string) int {
+ if s == nil {
+ return 0
+ }
+
+ channel, err := s.Channel(channelID)
+ if err != nil {
+ return 0
+ }
+
+ guild, err := s.Guild(channel.GuildID)
+ if err != nil {
+ return 0
+ }
+
+ member, err := s.Member(guild.ID, userID)
+ if err != nil {
+ return 0
+ }
+
+ return firstRoleColorColor(guild, member.Roles)
+}
+
+// MessageColor returns the color of the author's name as displayed
+// in the client associated with this message.
+func (s *State) MessageColor(message *Message) int {
+ if s == nil {
+ return 0
+ }
+
+ if message.Member == nil || message.Member.Roles == nil {
+ return 0
+ }
+
+ channel, err := s.Channel(message.ChannelID)
+ if err != nil {
+ return 0
+ }
+
+ guild, err := s.Guild(channel.GuildID)
+ if err != nil {
+ return 0
+ }
+
+ return firstRoleColorColor(guild, message.Member.Roles)
+}
+
+func firstRoleColorColor(guild *Guild, memberRoles []string) int {
+ roles := Roles(guild.Roles)
+ sort.Sort(roles)
+
+ for _, role := range roles {
+ for _, roleID := range memberRoles {
+ if role.ID == roleID {
+ if role.Color != 0 {
+ return role.Color
+ }
+ }
+ }
+ }
+
+ for _, role := range roles {
+ if role.ID == guild.ID {
+ return role.Color
+ }
+ }
+
+ return 0
+}
diff --git a/pkg/meowcord/structs.go b/pkg/meowcord/structs.go
new file mode 100644
index 0000000..6cf7d38
--- /dev/null
+++ b/pkg/meowcord/structs.go
@@ -0,0 +1,3469 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains all structures for the discordgo package. These
+// may be moved about later into separate files but I find it easier to have
+// them all located together.
+
+package meowcord
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "regexp"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/google/uuid"
+)
+
+// A Session represents a connection to the Discord API.
+type Session struct {
+ sync.RWMutex
+
+ // General configurable settings.
+
+ // Authentication token for this session
+ // TODO: Remove Below, Deprecated, Use Identify struct
+ Token string
+
+ MFA bool
+
+ // Debug for printing JSON request/responses
+ Debug bool // Deprecated, will be removed.
+ LogLevel int
+
+ // Should the session reconnect the websocket on errors.
+ ShouldReconnectOnError bool
+
+ // Should voice connections reconnect on a session reconnect.
+ ShouldReconnectVoiceOnSessionError bool
+
+ // Should the session retry requests when rate limited.
+ ShouldRetryOnRateLimit bool
+
+ // Identify is sent during initial handshake with the discord gateway.
+ // https://discord.com/developers/docs/topics/gateway#identify
+ Identify Identify
+
+ // TODO: Remove Below, Deprecated, Use Identify struct
+ // Should the session request compressed websocket data.
+ Compress bool
+
+ // Sharding
+ ShardID int
+ ShardCount int
+
+ // Should state tracking be enabled.
+ // State tracking is the best way for getting the users
+ // active guilds and the members of the guilds.
+ StateEnabled bool
+
+ // Whether or not to call event handlers synchronously.
+ // e.g. false = launch event handlers in their own goroutines.
+ SyncEvents bool
+
+ // Exposed but should not be modified by User.
+
+ // Whether the Data Websocket is ready
+ DataReady bool // NOTE: Maybe deprecated soon
+
+ // Max number of REST API retries
+ MaxRestRetries int
+
+ // Whether the Voice Websocket is ready
+ VoiceReady bool // NOTE: Deprecated.
+
+ // Whether the UDP Connection is ready
+ UDPReady bool // NOTE: Deprecated
+
+ // Stores a mapping of guild id's to VoiceConnections
+ VoiceConnections map[string]*VoiceConnection
+
+ // Managed state object, updated internally with events when
+ // StateEnabled is true.
+ State *State
+
+ // The http client used for REST requests
+ Client *http.Client
+
+ // GatewayHTTPClient is the [http.Client] used to perform the gateway
+ // WebSocket handshake. This MUST be an HTTP/1.1 client, as the hijack
+ // coder/websocket performs will NOT work over HTTP/2.
+ GatewayHTTPClient *http.Client
+ GatewayDialTimeout time.Duration
+
+ // The user agent used for REST APIs
+ UserAgent string
+
+ // Stores the last HeartbeatAck that was received (in UTC)
+ LastHeartbeatAck time.Time
+
+ // Stores the last Heartbeat sent (in UTC)
+ LastHeartbeatSent time.Time
+
+ // used to deal with rate limits
+ Ratelimiter *RateLimiter
+
+ // Event handlers
+ handlersMu sync.RWMutex
+ handlers map[string][]*eventHandlerInstance
+ onceHandlers map[string][]*eventHandlerInstance
+
+ EventHandler func(any)
+
+ // The websocket connection.
+ wsConn *websocket.Conn
+
+ // wsConnCtx is a long-lived context scoped to the current gateway
+ // connection.
+ wsConnCtx context.Context
+ wsConnCancel context.CancelFunc
+
+ zlibReader io.ReadCloser
+ zlibJSON *json.Decoder
+ zlibPipeReader *io.PipeReader
+ zlibPipeWriter *io.PipeWriter
+
+ // When nil, the session is not listening.
+ listening chan interface{}
+
+ // sequence tracks the current gateway api websocket sequence number
+ sequence *int64
+
+ // stores sessions current Discord Resume Gateway
+ resumeGatewayURL string
+
+ // stores sessions current Discord Gateway
+ gateway string
+
+ noClearGateway bool
+
+ // stores session ID of current Gateway connection
+ sessionID string
+
+ // used to make sure gateway websocket writes do not happen concurrently
+ wsMutex sync.Mutex
+
+ IsUser bool
+ launchSignature LaunchSignature
+ launchID uuid.UUID
+ HeartbeatSession HeartbeatSession
+ fetchHeaders map[string]string
+ downloadHeaders map[string]string
+ imageHeaders map[string]string
+
+ // synchronous hook to inspect HTTP responses from requests to Discord's
+ // REST API
+ RESTResponseHook func(req *http.Request, resp *http.Response, body []byte)
+
+ // BeforeReconnect, if set, is called during each internal reconnect
+ // iteration before attempting to connect to the gateway.
+ BeforeReconnect func(s *Session)
+
+ Logger func(msgL, caller int, format string, a ...interface{})
+}
+
+// ApplicationIntegrationType dictates where application can be installed and its available interaction contexts.
+type ApplicationIntegrationType uint
+
+const (
+ // ApplicationIntegrationGuildInstall indicates that app is installable to guilds.
+ ApplicationIntegrationGuildInstall ApplicationIntegrationType = 0
+ // ApplicationIntegrationUserInstall indicates that app is installable to users.
+ ApplicationIntegrationUserInstall ApplicationIntegrationType = 1
+)
+
+// ApplicationInstallParams represents application's installation parameters
+// for default in-app oauth2 authorization link.
+type ApplicationInstallParams struct {
+ Scopes []string `json:"scopes"`
+ Permissions int64 `json:"permissions,string"`
+}
+
+// ApplicationIntegrationTypeConfig represents application's configuration for a particular integration type.
+type ApplicationIntegrationTypeConfig struct {
+ OAuth2InstallParams *ApplicationInstallParams `json:"oauth2_install_params,omitempty"`
+}
+
+// Application stores values for a Discord Application
+type Application struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Icon string `json:"icon,omitempty"`
+ Description string `json:"description,omitempty"`
+ RPCOrigins []string `json:"rpc_origins,omitempty"`
+ BotPublic bool `json:"bot_public,omitempty"`
+ BotRequireCodeGrant bool `json:"bot_require_code_grant,omitempty"`
+ TermsOfServiceURL string `json:"terms_of_service_url"`
+ PrivacyProxyURL string `json:"privacy_policy_url"`
+ Owner *User `json:"owner"`
+ Summary string `json:"summary"`
+ VerifyKey string `json:"verify_key"`
+ Team *Team `json:"team"`
+ GuildID string `json:"guild_id"`
+ PrimarySKUID string `json:"primary_sku_id"`
+ Slug string `json:"slug"`
+ CoverImage string `json:"cover_image"`
+ Flags uint64 `json:"flags,omitempty"`
+
+ IntegrationTypesConfig map[ApplicationIntegrationType]*ApplicationIntegrationTypeConfig `json:"integration_types,omitempty"`
+
+ Bot *User `json:"bot,omitempty"`
+}
+
+// ApplicationRoleConnectionMetadataType represents the type of application role connection metadata.
+type ApplicationRoleConnectionMetadataType int
+
+// Application role connection metadata types.
+const (
+ ApplicationRoleConnectionMetadataIntegerLessThanOrEqual ApplicationRoleConnectionMetadataType = 1
+ ApplicationRoleConnectionMetadataIntegerGreaterThanOrEqual ApplicationRoleConnectionMetadataType = 2
+ ApplicationRoleConnectionMetadataIntegerEqual ApplicationRoleConnectionMetadataType = 3
+ ApplicationRoleConnectionMetadataIntegerNotEqual ApplicationRoleConnectionMetadataType = 4
+ ApplicationRoleConnectionMetadataDatetimeLessThanOrEqual ApplicationRoleConnectionMetadataType = 5
+ ApplicationRoleConnectionMetadataDatetimeGreaterThanOrEqual ApplicationRoleConnectionMetadataType = 6
+ ApplicationRoleConnectionMetadataBooleanEqual ApplicationRoleConnectionMetadataType = 7
+ ApplicationRoleConnectionMetadataBooleanNotEqual ApplicationRoleConnectionMetadataType = 8
+)
+
+// ApplicationRoleConnectionMetadata stores application role connection metadata.
+type ApplicationRoleConnectionMetadata struct {
+ Type ApplicationRoleConnectionMetadataType `json:"type"`
+ Key string `json:"key"`
+ Name string `json:"name"`
+ NameLocalizations map[Locale]string `json:"name_localizations"`
+ Description string `json:"description"`
+ DescriptionLocalizations map[Locale]string `json:"description_localizations"`
+}
+
+// ApplicationRoleConnection represents the role connection that an application has attached to a user.
+type ApplicationRoleConnection struct {
+ PlatformName string `json:"platform_name"`
+ PlatformUsername string `json:"platform_username"`
+ Metadata map[string]string `json:"metadata"`
+}
+
+// UserConnection is a Connection returned from the UserConnections endpoint
+type UserConnection struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Revoked bool `json:"revoked"`
+ Integrations []*Integration `json:"integrations"`
+}
+
+// Integration stores integration information
+type Integration struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Enabled bool `json:"enabled"`
+ Syncing bool `json:"syncing"`
+ RoleID string `json:"role_id"`
+ EnableEmoticons bool `json:"enable_emoticons"`
+ ExpireBehavior ExpireBehavior `json:"expire_behavior"`
+ ExpireGracePeriod int `json:"expire_grace_period"`
+ User *User `json:"user"`
+ Account IntegrationAccount `json:"account"`
+ SyncedAt time.Time `json:"synced_at"`
+}
+
+// ExpireBehavior of Integration
+// https://discord.com/developers/docs/resources/guild#integration-object-integration-expire-behaviors
+type ExpireBehavior int
+
+// Block of valid ExpireBehaviors
+const (
+ ExpireBehaviorRemoveRole ExpireBehavior = 0
+ ExpireBehaviorKick ExpireBehavior = 1
+)
+
+// IntegrationAccount is integration account information
+// sent by the UserConnections endpoint
+type IntegrationAccount struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// A VoiceRegion stores data for a specific voice region server.
+// https://discord.com/developers/docs/resources/voice#voice-region-object
+type VoiceRegion struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Optimal bool `json:"optimal"`
+ Deprecated bool `json:"deprecated"`
+ Custom bool `json:"custom"`
+ Hostname string `json:"sample_hostname"`
+ Port int `json:"sample_port"`
+}
+
+// A VoiceICE stores data for voice ICE servers.
+type VoiceICE struct {
+ TTL string `json:"ttl"`
+ Servers []*ICEServer `json:"servers"`
+}
+
+// A ICEServer stores data for a specific voice ICE server.
+type ICEServer struct {
+ URL string `json:"url"`
+ Username string `json:"username"`
+ Credential string `json:"credential"`
+}
+
+// InviteTargetType indicates the type of target of an invite
+// https://discord.com/developers/docs/resources/invite#invite-object-invite-target-types
+type InviteTargetType uint8
+
+// Invite target types
+const (
+ InviteTargetStream InviteTargetType = 1
+ InviteTargetEmbeddedApplication InviteTargetType = 2
+)
+
+// A Invite stores all data related to a specific Discord Guild or Channel invite.
+type Invite struct {
+ Guild *Guild `json:"guild"`
+ Channel *Channel `json:"channel"`
+ Inviter *User `json:"inviter"`
+ Code string `json:"code"`
+ CreatedAt time.Time `json:"created_at"`
+ MaxAge int `json:"max_age"`
+ Uses int `json:"uses"`
+ MaxUses int `json:"max_uses"`
+ Revoked bool `json:"revoked"`
+ Temporary bool `json:"temporary"`
+ Unique bool `json:"unique"`
+ TargetUser *User `json:"target_user"`
+ TargetType InviteTargetType `json:"target_type"`
+ TargetApplication *Application `json:"target_application"`
+
+ // will only be filled when using InviteWithCounts
+ ApproximatePresenceCount int `json:"approximate_presence_count"`
+ ApproximateMemberCount int `json:"approximate_member_count"`
+
+ ExpiresAt *time.Time `json:"expires_at"`
+}
+
+// ChannelType is the type of a Channel
+type ChannelType int
+
+// Block contains known ChannelType values
+const (
+ ChannelTypeGuildText ChannelType = 0
+ ChannelTypeDM ChannelType = 1
+ ChannelTypeGuildVoice ChannelType = 2
+ ChannelTypeGroupDM ChannelType = 3
+ ChannelTypeGuildCategory ChannelType = 4
+ ChannelTypeGuildNews ChannelType = 5
+ ChannelTypeGuildStore ChannelType = 6
+ ChannelTypeGuildNewsThread ChannelType = 10
+ ChannelTypeGuildPublicThread ChannelType = 11
+ ChannelTypeGuildPrivateThread ChannelType = 12
+ ChannelTypeGuildStageVoice ChannelType = 13
+ ChannelTypeGuildDirectory ChannelType = 14
+ ChannelTypeGuildForum ChannelType = 15
+ ChannelTypeGuildMedia ChannelType = 16
+)
+
+// ChannelFlags represent flags of a channel/thread.
+type ChannelFlags uint32
+
+// Block containing known ChannelFlags values.
+const (
+ // ChannelFlagPinned indicates whether the thread is pinned in the forum channel.
+ // NOTE: forum threads only.
+ ChannelFlagPinned ChannelFlags = 1 << 1
+ // ChannelFlagRequireTag indicates whether a tag is required to be specified when creating a thread.
+ // NOTE: forum channels only.
+ ChannelFlagRequireTag ChannelFlags = 1 << 4
+)
+
+// ForumSortOrderType represents sort order of a forum channel.
+type ForumSortOrderType int
+
+const (
+ // ForumSortOrderLatestActivity sorts posts by activity.
+ ForumSortOrderLatestActivity ForumSortOrderType = 0
+ // ForumSortOrderCreationDate sorts posts by creation time (from most recent to oldest).
+ ForumSortOrderCreationDate ForumSortOrderType = 1
+)
+
+// ForumLayout represents layout of a forum channel.
+type ForumLayout int
+
+const (
+ // ForumLayoutNotSet represents no default layout.
+ ForumLayoutNotSet ForumLayout = 0
+ // ForumLayoutListView displays forum posts as a list.
+ ForumLayoutListView ForumLayout = 1
+ // ForumLayoutGalleryView displays forum posts as a collection of tiles.
+ ForumLayoutGalleryView ForumLayout = 2
+)
+
+// A Channel holds all data related to an individual Discord channel.
+type Channel struct {
+ // The ID of the channel.
+ ID string `json:"id"`
+
+ // The ID of the guild to which the channel belongs, if it is in a guild.
+ // Else, this ID is empty (e.g. DM channels).
+ GuildID string `json:"guild_id"`
+
+ // The name of the channel.
+ Name string `json:"name"`
+
+ // The topic of the channel.
+ Topic string `json:"topic"`
+
+ // The type of the channel.
+ Type ChannelType `json:"type"`
+
+ // The ID of the last message sent in the channel. This is not
+ // guaranteed to be an ID of a valid message.
+ LastMessageID string `json:"last_message_id"`
+
+ // The timestamp of the last pinned message in the channel.
+ // nil if the channel has no pinned messages.
+ LastPinTimestamp *time.Time `json:"last_pin_timestamp"`
+
+ // An approximate count of messages in a thread, stops counting at 50
+ MessageCount int `json:"message_count"`
+ // An approximate count of users in a thread, stops counting at 50
+ MemberCount int `json:"member_count"`
+
+ // Whether the channel is marked as NSFW.
+ NSFW bool `json:"nsfw"`
+
+ // Icon of the group DM channel.
+ Icon string `json:"icon"`
+
+ // The position of the channel, used for sorting in client.
+ Position int `json:"position"`
+
+ // The bitrate of the channel, if it is a voice channel.
+ Bitrate int `json:"bitrate"`
+
+ // The recipients of the channel. This is only populated in DM channels.
+ Recipients []*User `json:"recipients"`
+
+ // Undocumented. Like Recipients, but just the user IDs. Seemingly not
+ // populated consistently; avoid.
+ RecipientIDs []string `json:"recipient_ids"`
+
+ // The messages in the channel. This is only present in state-cached channels,
+ // and State.MaxMessageCount must be non-zero.
+ Messages []*Message `json:"-"`
+
+ // A list of permission overwrites present for the channel.
+ PermissionOverwrites []*PermissionOverwrite `json:"permission_overwrites"`
+
+ // The user limit of the voice channel.
+ UserLimit int `json:"user_limit"`
+
+ // The ID of the parent channel, if the channel is under a category. For threads - id of the channel thread was created in.
+ ParentID string `json:"parent_id"`
+
+ // Amount of seconds a user has to wait before sending another message or creating another thread (0-21600)
+ // bots, as well as users with the permission manage_messages or manage_channel, are unaffected
+ RateLimitPerUser int `json:"rate_limit_per_user"`
+
+ // ID of the creator of the group DM or thread
+ OwnerID string `json:"owner_id"`
+
+ // ApplicationID of the DM creator Zeroed if guild channel or not a bot user
+ ApplicationID string `json:"application_id"`
+
+ // Thread-specific fields not needed by other channels
+ ThreadMetadata *ThreadMetadata `json:"thread_metadata,omitempty"`
+ // Thread member object for the current user, if they have joined the thread, only included on certain API endpoints
+ Member *ThreadMember `json:"thread_member"`
+
+ // All thread members. State channels only.
+ Members []*ThreadMember `json:"-"`
+
+ // Channel flags.
+ Flags ChannelFlags `json:"flags"`
+
+ // The set of tags that can be used in a forum channel.
+ AvailableTags []ForumTag `json:"available_tags"`
+
+ // The IDs of the set of tags that have been applied to a thread in a forum channel.
+ AppliedTags []string `json:"applied_tags"`
+
+ // Emoji to use as the default reaction to a forum post.
+ DefaultReactionEmoji ForumDefaultReaction `json:"default_reaction_emoji"`
+
+ // The initial RateLimitPerUser to set on newly created threads in a channel.
+ // This field is copied to the thread at creation time and does not live update.
+ DefaultThreadRateLimitPerUser int `json:"default_thread_rate_limit_per_user"`
+
+ // The default sort order type used to order posts in forum channels.
+ // Defaults to null, which indicates a preferred sort order hasn't been set by a channel admin.
+ DefaultSortOrder *ForumSortOrderType `json:"default_sort_order"`
+
+ // The default forum layout view used to display posts in forum channels.
+ // Defaults to ForumLayoutNotSet, which indicates a layout view has not been set by a channel admin.
+ DefaultForumLayout ForumLayout `json:"default_forum_layout"`
+
+ MemberIDsPreview []string `json:"member_ids_preview"`
+
+ // Whether or not this private channel is currently message request.
+ //
+ // This can become false (likely upon the user "accepting" the message
+ // request.)
+ IsMessageRequest bool `json:"is_message_request"`
+
+ // Conjecture: when the private channel was determined to be a message request.
+ // This can postdate when the first message was sent.
+ IsMessageRequestTimestamp *time.Time `json:"is_message_request_timestamp"`
+}
+
+// Mention returns a string which mentions the channel
+func (c *Channel) Mention() string {
+ return fmt.Sprintf("<#%s>", c.ID)
+}
+
+// IsThread is a helper function to determine if channel is a thread or not
+func (c *Channel) IsThread() bool {
+ return c.Type == ChannelTypeGuildPublicThread || c.Type == ChannelTypeGuildPrivateThread || c.Type == ChannelTypeGuildNewsThread
+}
+
+// A ChannelEdit holds Channel Field data for a channel edit.
+type ChannelEdit struct {
+ Name string `json:"name,omitempty"`
+ Topic string `json:"topic,omitempty"`
+ NSFW *bool `json:"nsfw,omitempty"`
+ Position *int `json:"position,omitempty"`
+ Bitrate int `json:"bitrate,omitempty"`
+ UserLimit int `json:"user_limit,omitempty"`
+ PermissionOverwrites []*PermissionOverwrite `json:"permission_overwrites,omitempty"`
+ ParentID string `json:"parent_id,omitempty"`
+ RateLimitPerUser *int `json:"rate_limit_per_user,omitempty"`
+ Flags *ChannelFlags `json:"flags,omitempty"`
+ DefaultThreadRateLimitPerUser *int `json:"default_thread_rate_limit_per_user,omitempty"`
+
+ // NOTE: threads only
+
+ Archived *bool `json:"archived,omitempty"`
+ AutoArchiveDuration int `json:"auto_archive_duration,omitempty"`
+ Locked *bool `json:"locked,omitempty"`
+ Invitable *bool `json:"invitable,omitempty"`
+
+ // NOTE: forum channels only
+
+ AvailableTags *[]ForumTag `json:"available_tags,omitempty"`
+ DefaultReactionEmoji *ForumDefaultReaction `json:"default_reaction_emoji,omitempty"`
+ DefaultSortOrder *ForumSortOrderType `json:"default_sort_order,omitempty"` // TODO: null
+ DefaultForumLayout *ForumLayout `json:"default_forum_layout,omitempty"`
+
+ // NOTE: forum threads only
+ AppliedTags *[]string `json:"applied_tags,omitempty"`
+}
+
+// A ChannelFollow holds data returned after following a news channel
+type ChannelFollow struct {
+ ChannelID string `json:"channel_id"`
+ WebhookID string `json:"webhook_id"`
+}
+
+// PermissionOverwriteType represents the type of resource on which
+// a permission overwrite acts.
+type PermissionOverwriteType int
+
+// The possible permission overwrite types.
+const (
+ PermissionOverwriteTypeRole PermissionOverwriteType = 0
+ PermissionOverwriteTypeMember PermissionOverwriteType = 1
+)
+
+// A PermissionOverwrite holds permission overwrite data for a Channel
+type PermissionOverwrite struct {
+ ID string `json:"id"`
+ Type PermissionOverwriteType `json:"type"`
+ Deny int64 `json:"deny,string"`
+ Allow int64 `json:"allow,string"`
+}
+
+// ThreadStart stores all parameters you can use with MessageThreadStartComplex or ThreadStartComplex
+type ThreadStart struct {
+ Name string `json:"name"`
+ AutoArchiveDuration int `json:"auto_archive_duration,omitempty"`
+ Type ChannelType `json:"type,omitempty"`
+ Invitable bool `json:"invitable,omitempty"`
+ RateLimitPerUser int `json:"rate_limit_per_user,omitempty"`
+
+ Location string `json:"location,omitempty"`
+
+ // NOTE: forum threads only
+ AppliedTags []string `json:"applied_tags,omitempty"`
+}
+
+// ThreadMetadata contains a number of thread-specific channel fields that are not needed by other channel types.
+type ThreadMetadata struct {
+ // Whether the thread is archived
+ Archived bool `json:"archived"`
+ // Duration in minutes to automatically archive the thread after recent activity, can be set to: 60, 1440, 4320, 10080
+ AutoArchiveDuration int `json:"auto_archive_duration"`
+ // Timestamp when the thread's archive status was last changed, used for calculating recent activity
+ ArchiveTimestamp time.Time `json:"archive_timestamp"`
+ // Whether the thread is locked; when a thread is locked, only users with MANAGE_THREADS can unarchive it
+ Locked bool `json:"locked"`
+ // Whether non-moderators can add other non-moderators to a thread; only available on private threads
+ Invitable bool `json:"invitable"`
+}
+
+// ThreadMember is used to indicate whether a user has joined a thread or not.
+// NOTE: ID and UserID are empty (omitted) on the member sent within each thread in the GUILD_CREATE event.
+type ThreadMember struct {
+ // The id of the thread
+ ID string `json:"id,omitempty"`
+ // The id of the user
+ UserID string `json:"user_id,omitempty"`
+ // The time the current user last joined the thread
+ JoinTimestamp time.Time `json:"join_timestamp"`
+ // Any user-thread settings, currently only used for notifications
+ Flags uint32 `json:"flags"`
+ // Additional information about the user.
+ // NOTE: only present if the withMember parameter is set to true
+ // when calling Session.ThreadMembers or Session.ThreadMember.
+ Member *Member `json:"member,omitempty"`
+}
+
+// ThreadsList represents a list of threads alongisde with thread member objects for the current user.
+type ThreadsList struct {
+ Threads []*Channel `json:"threads"`
+ Members []*ThreadMember `json:"members"`
+ HasMore bool `json:"has_more"`
+}
+
+// AddedThreadMember holds information about the user who was added to the thread
+type AddedThreadMember struct {
+ *ThreadMember
+ Member *Member `json:"member"`
+ Presence *Presence `json:"presence"`
+}
+
+// ForumDefaultReaction specifies emoji to use as the default reaction to a forum post.
+// NOTE: Exactly one of EmojiID and EmojiName must be set.
+type ForumDefaultReaction struct {
+ // The id of a guild's custom emoji.
+ EmojiID string `json:"emoji_id,omitempty"`
+ // The unicode character of the emoji.
+ EmojiName string `json:"emoji_name,omitempty"`
+}
+
+// ForumTag represents a tag that is able to be applied to a thread in a forum channel.
+type ForumTag struct {
+ ID string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Moderated bool `json:"moderated"`
+ EmojiID string `json:"emoji_id,omitempty"`
+ EmojiName string `json:"emoji_name,omitempty"`
+}
+
+// Emoji struct holds data related to Emoji's
+type Emoji struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Roles []string `json:"roles"`
+ User *User `json:"user"`
+ RequireColons bool `json:"require_colons"`
+ Managed bool `json:"managed"`
+ Animated bool `json:"animated"`
+ Available bool `json:"available"`
+}
+
+// EmojiRegex is the regex used to find and identify emojis in messages
+var (
+ EmojiRegex = regexp.MustCompile(`<(a|):[A-Za-z0-9_~]+:[0-9]{18,20}>`)
+)
+
+// MessageFormat returns a correctly formatted Emoji for use in Message content and embeds
+func (e *Emoji) MessageFormat() string {
+ if e.ID != "" && e.Name != "" {
+ if e.Animated {
+ return ""
+ }
+
+ return "<:" + e.APIName() + ">"
+ }
+
+ return e.APIName()
+}
+
+// APIName returns an correctly formatted API name for use in the MessageReactions endpoints.
+func (e *Emoji) APIName() string {
+ if e.ID != "" && e.Name != "" {
+ return e.Name + ":" + e.ID
+ }
+ if e.Name != "" {
+ return e.Name
+ }
+ return e.ID
+}
+
+// EmojiParams represents parameters needed to create or update an Emoji.
+type EmojiParams struct {
+ // Name of the emoji
+ Name string `json:"name,omitempty"`
+ // A base64 encoded emoji image, has to be smaller than 256KB.
+ // NOTE: can be only set on creation.
+ Image string `json:"image,omitempty"`
+ // Roles for which this emoji will be available.
+ // NOTE: can not be used with application emoji endpoints.
+ Roles []string `json:"roles,omitempty"`
+}
+
+// StickerFormat is the file format of the Sticker.
+type StickerFormat int
+
+// Defines all known Sticker types.
+const (
+ StickerFormatTypePNG StickerFormat = 1
+ StickerFormatTypeAPNG StickerFormat = 2
+ StickerFormatTypeLottie StickerFormat = 3
+ StickerFormatTypeGIF StickerFormat = 4
+)
+
+// StickerType is the type of sticker.
+type StickerType int
+
+// Defines Sticker types.
+const (
+ StickerTypeStandard StickerType = 1
+ StickerTypeGuild StickerType = 2
+)
+
+// Sticker represents a sticker object that can be sent in a Message.
+type Sticker struct {
+ ID string `json:"id"`
+ PackID string `json:"pack_id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Tags string `json:"tags"`
+ Type StickerType `json:"type"`
+ FormatType StickerFormat `json:"format_type"`
+ Available bool `json:"available"`
+ GuildID string `json:"guild_id"`
+ User *User `json:"user"`
+ SortValue int `json:"sort_value"`
+}
+
+func (sticker *Sticker) URL() string {
+ return EndpointStickerImage(sticker.ID, sticker.FormatType)
+}
+
+// StickerItem represents the smallest amount of data required to render a sticker. A partial sticker object.
+type StickerItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ FormatType StickerFormat `json:"format_type"`
+}
+
+func (sticker *StickerItem) URL() string {
+ return EndpointStickerImage(sticker.ID, sticker.FormatType)
+}
+
+// StickerPack represents a pack of standard stickers.
+type StickerPack struct {
+ ID string `json:"id"`
+ Stickers []*Sticker `json:"stickers"`
+ Name string `json:"name"`
+ SKUID string `json:"sku_id"`
+ CoverStickerID string `json:"cover_sticker_id"`
+ Description string `json:"description"`
+ BannerAssetID string `json:"banner_asset_id"`
+}
+
+// VerificationLevel type definition
+type VerificationLevel int
+
+// Constants for VerificationLevel levels from 0 to 4 inclusive
+const (
+ VerificationLevelNone VerificationLevel = 0
+ VerificationLevelLow VerificationLevel = 1
+ VerificationLevelMedium VerificationLevel = 2
+ VerificationLevelHigh VerificationLevel = 3
+ VerificationLevelVeryHigh VerificationLevel = 4
+)
+
+// ExplicitContentFilterLevel type definition
+type ExplicitContentFilterLevel int
+
+// Constants for ExplicitContentFilterLevel levels from 0 to 2 inclusive
+const (
+ ExplicitContentFilterDisabled ExplicitContentFilterLevel = 0
+ ExplicitContentFilterMembersWithoutRoles ExplicitContentFilterLevel = 1
+ ExplicitContentFilterAllMembers ExplicitContentFilterLevel = 2
+)
+
+// GuildNSFWLevel type definition
+type GuildNSFWLevel int
+
+// Constants for GuildNSFWLevel levels from 0 to 3 inclusive
+const (
+ GuildNSFWLevelDefault GuildNSFWLevel = 0
+ GuildNSFWLevelExplicit GuildNSFWLevel = 1
+ GuildNSFWLevelSafe GuildNSFWLevel = 2
+ GuildNSFWLevelAgeRestricted GuildNSFWLevel = 3
+)
+
+// MfaLevel type definition
+type MfaLevel int
+
+// Constants for MfaLevel levels from 0 to 1 inclusive
+const (
+ MfaLevelNone MfaLevel = 0
+ MfaLevelElevated MfaLevel = 1
+)
+
+// NotificationLevel denotes when to dispatch notifications for messages.
+type NotificationLevel int
+
+const (
+ NotificationLevelAllMessages NotificationLevel = 0
+ NotificationLevelOnlyMentions NotificationLevel = 1
+ // Only well-defined in UserGuildSettings.
+ NotificationLevelNoMessages NotificationLevel = 2
+ // Only well-defined in UserGuildSettings.
+ NotificationLevelInherit NotificationLevel = 3
+)
+
+// PremiumTier type definition
+type PremiumTier int
+
+// Constants for PremiumTier levels from 0 to 3 inclusive
+const (
+ PremiumTierNone PremiumTier = 0
+ PremiumTier1 PremiumTier = 1
+ PremiumTier2 PremiumTier = 2
+ PremiumTier3 PremiumTier = 3
+)
+
+type MinimalGuild struct {
+ ID string `json:"id"`
+ // Also has voice_states and embedded_activities
+}
+
+// A Guild holds all data related to a specific Discord Guild. Guilds are also
+// sometimes referred to as Servers in the Discord client.
+type Guild struct {
+ // The ID of the guild.
+ ID string `json:"id"`
+
+ // The name of the guild. (2–100 characters)
+ Name string `json:"name"`
+
+ // The hash of the guild's icon. Use Session.GuildIcon
+ // to retrieve the icon itself.
+ Icon string `json:"icon"`
+
+ // The voice region of the guild.
+ Region string `json:"region"`
+
+ // The ID of the AFK voice channel.
+ AfkChannelID string `json:"afk_channel_id"`
+
+ // The user ID of the owner of the guild.
+ OwnerID string `json:"owner_id"`
+
+ // If we are the owner of the guild
+ Owner bool `json:"owner"`
+
+ // The time at which the current user joined the guild.
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ JoinedAt time.Time `json:"joined_at"`
+
+ // The hash of the guild's discovery splash.
+ DiscoverySplash string `json:"discovery_splash"`
+
+ // The hash of the guild's splash.
+ Splash string `json:"splash"`
+
+ // The timeout, in seconds, before a user is considered AFK in voice.
+ AfkTimeout int `json:"afk_timeout"`
+
+ // The number of members in the guild.
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ MemberCount int `json:"member_count"`
+
+ // The verification level required for the guild.
+ VerificationLevel VerificationLevel `json:"verification_level"`
+
+ // Whether the guild is considered large. This is
+ // determined by a member threshold in the identify packet,
+ // and is currently hard-coded at 250 members in the library.
+ Large bool `json:"large"`
+
+ // The default message notification setting for the guild.
+ DefaultMessageNotifications MessageNotifications `json:"default_message_notifications"`
+
+ // A list of roles in the guild.
+ Roles []*Role `json:"roles"`
+
+ // A list of the custom emojis present in the guild.
+ Emojis []*Emoji `json:"emojis"`
+
+ // A list of the custom stickers present in the guild.
+ Stickers []*Sticker `json:"stickers"`
+
+ // A list of the members in the guild.
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ Members []*Member `json:"members"`
+
+ // A list of partial presence objects for members in the guild.
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ Presences []*Presence `json:"presences"`
+
+ // The maximum number of presences for the guild (the default value, currently 25000, is in effect when null is returned)
+ MaxPresences int `json:"max_presences"`
+
+ // The maximum number of members for the guild
+ MaxMembers int `json:"max_members"`
+
+ // A list of channels in the guild.
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ Channels []*Channel `json:"channels"`
+
+ // A list of all active threads in the guild that current user has permission to view
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events and thus is only present in state-cached guilds.
+ Threads []*Channel `json:"threads"`
+
+ // A list of voice states for the guild.
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ VoiceStates []*VoiceState `json:"voice_states"`
+
+ // Whether this guild is currently unavailable (most likely due to outage).
+ // This field is only present in GUILD_CREATE events and websocket
+ // update events, and thus is only present in state-cached guilds.
+ Unavailable bool `json:"unavailable"`
+
+ // The explicit content filter level
+ ExplicitContentFilter ExplicitContentFilterLevel `json:"explicit_content_filter"`
+
+ // The NSFW Level of the guild
+ NSFWLevel GuildNSFWLevel `json:"nsfw_level"`
+
+ // The list of enabled guild features
+ Features []GuildFeature `json:"features"`
+
+ // Required MFA level for the guild
+ MfaLevel MfaLevel `json:"mfa_level"`
+
+ // The application id of the guild if bot created.
+ ApplicationID string `json:"application_id"`
+
+ // Whether or not the Server Widget is enabled
+ WidgetEnabled bool `json:"widget_enabled"`
+
+ // The Channel ID for the Server Widget
+ WidgetChannelID string `json:"widget_channel_id"`
+
+ // The Channel ID to which system messages are sent (eg join and leave messages)
+ SystemChannelID string `json:"system_channel_id"`
+
+ // The System channel flags
+ SystemChannelFlags SystemChannelFlag `json:"system_channel_flags"`
+
+ // The ID of the rules channel ID, used for rules.
+ RulesChannelID string `json:"rules_channel_id"`
+
+ // the vanity url code for the guild
+ VanityURLCode string `json:"vanity_url_code"`
+
+ // the description for the guild
+ Description string `json:"description"`
+
+ // The hash of the guild's banner
+ Banner string `json:"banner"`
+
+ // The premium tier of the guild
+ PremiumTier PremiumTier `json:"premium_tier"`
+
+ // The total number of users currently boosting this server
+ PremiumSubscriptionCount int `json:"premium_subscription_count"`
+
+ // The preferred locale of a guild with the "PUBLIC" feature; used in server discovery and notices from Discord; defaults to "en-US"
+ PreferredLocale string `json:"preferred_locale"`
+
+ // The id of the channel where admins and moderators of guilds with the "PUBLIC" feature receive notices from Discord
+ PublicUpdatesChannelID string `json:"public_updates_channel_id"`
+
+ // The maximum amount of users in a video channel
+ MaxVideoChannelUsers int `json:"max_video_channel_users"`
+
+ // Approximate number of members in this guild, returned from the GET /guild/ endpoint when with_counts is true
+ ApproximateMemberCount int `json:"approximate_member_count"`
+
+ // Approximate number of non-offline members in this guild, returned from the GET /guild/ endpoint when with_counts is true
+ ApproximatePresenceCount int `json:"approximate_presence_count"`
+
+ // Permissions of our user
+ Permissions int64 `json:"permissions,string"`
+
+ // Stage instances in the guild
+ StageInstances []*StageInstance `json:"stage_instances"`
+
+ Properties *Guild `json:"properties,omitempty"`
+}
+
+// A GuildPreview holds data related to a specific public Discord Guild, even if the user is not in the guild.
+type GuildPreview struct {
+ // The ID of the guild.
+ ID string `json:"id"`
+
+ // The name of the guild. (2–100 characters)
+ Name string `json:"name"`
+
+ // The hash of the guild's icon. Use Session.GuildIcon
+ // to retrieve the icon itself.
+ Icon string `json:"icon"`
+
+ // The hash of the guild's splash.
+ Splash string `json:"splash"`
+
+ // The hash of the guild's discovery splash.
+ DiscoverySplash string `json:"discovery_splash"`
+
+ // A list of the custom emojis present in the guild.
+ Emojis []*Emoji `json:"emojis"`
+
+ // The list of enabled guild features
+ Features []string `json:"features"`
+
+ // Approximate number of members in this guild
+ // NOTE: this field is only filled when using GuildWithCounts
+ ApproximateMemberCount int `json:"approximate_member_count"`
+
+ // Approximate number of non-offline members in this guild
+ // NOTE: this field is only filled when using GuildWithCounts
+ ApproximatePresenceCount int `json:"approximate_presence_count"`
+
+ // the description for the guild
+ Description string `json:"description"`
+}
+
+// IconURL returns a URL to the guild's icon.
+//
+// size: The size of the desired icon image as a power of two
+// Image size can be any power of two between 16 and 4096.
+func (g *GuildPreview) IconURL(size string) string {
+ return iconURL(g.Icon, EndpointGuildIcon(g.ID, g.Icon), EndpointGuildIconAnimated(g.ID, g.Icon), size)
+}
+
+// GuildScheduledEvent is a representation of a scheduled event in a guild. Only for retrieval of the data.
+// https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event
+type GuildScheduledEvent struct {
+ // The ID of the scheduled event
+ ID string `json:"id"`
+ // The guild id which the scheduled event belongs to
+ GuildID string `json:"guild_id"`
+ // The channel id in which the scheduled event will be hosted, or null if scheduled entity type is EXTERNAL
+ ChannelID string `json:"channel_id"`
+ // The id of the user that created the scheduled event
+ CreatorID string `json:"creator_id"`
+ // The name of the scheduled event (1-100 characters)
+ Name string `json:"name"`
+ // The description of the scheduled event (1-1000 characters)
+ Description string `json:"description"`
+ // The time the scheduled event will start
+ ScheduledStartTime time.Time `json:"scheduled_start_time"`
+ // The time the scheduled event will end, required only when entity_type is EXTERNAL
+ ScheduledEndTime *time.Time `json:"scheduled_end_time"`
+ // The privacy level of the scheduled event
+ PrivacyLevel GuildScheduledEventPrivacyLevel `json:"privacy_level"`
+ // The status of the scheduled event
+ Status GuildScheduledEventStatus `json:"status"`
+ // Type of the entity where event would be hosted
+ // See field requirements
+ // https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-object-field-requirements-by-entity-type
+ EntityType GuildScheduledEventEntityType `json:"entity_type"`
+ // The id of an entity associated with a guild scheduled event
+ EntityID string `json:"entity_id"`
+ // Additional metadata for the guild scheduled event
+ EntityMetadata GuildScheduledEventEntityMetadata `json:"entity_metadata"`
+ // The user that created the scheduled event
+ Creator *User `json:"creator"`
+ // The number of users subscribed to the scheduled event
+ UserCount int `json:"user_count"`
+ // The cover image hash of the scheduled event
+ // see https://discord.com/developers/docs/reference#image-formatting for more
+ // information about image formatting
+ Image string `json:"image"`
+}
+
+// GuildScheduledEventParams are the parameters allowed for creating or updating a scheduled event
+// https://discord.com/developers/docs/resources/guild-scheduled-event#create-guild-scheduled-event
+type GuildScheduledEventParams struct {
+ // The channel id in which the scheduled event will be hosted, or null if scheduled entity type is EXTERNAL
+ ChannelID string `json:"channel_id,omitempty"`
+ // The name of the scheduled event (1-100 characters)
+ Name string `json:"name,omitempty"`
+ // The description of the scheduled event (1-1000 characters)
+ Description string `json:"description,omitempty"`
+ // The time the scheduled event will start
+ ScheduledStartTime *time.Time `json:"scheduled_start_time,omitempty"`
+ // The time the scheduled event will end, required only when entity_type is EXTERNAL
+ ScheduledEndTime *time.Time `json:"scheduled_end_time,omitempty"`
+ // The privacy level of the scheduled event
+ PrivacyLevel GuildScheduledEventPrivacyLevel `json:"privacy_level,omitempty"`
+ // The status of the scheduled event
+ Status GuildScheduledEventStatus `json:"status,omitempty"`
+ // Type of the entity where event would be hosted
+ // See field requirements
+ // https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-object-field-requirements-by-entity-type
+ EntityType GuildScheduledEventEntityType `json:"entity_type,omitempty"`
+ // Additional metadata for the guild scheduled event
+ EntityMetadata *GuildScheduledEventEntityMetadata `json:"entity_metadata,omitempty"`
+ // The cover image hash of the scheduled event
+ // see https://discord.com/developers/docs/reference#image-formatting for more
+ // information about image formatting
+ Image string `json:"image,omitempty"`
+}
+
+// MarshalJSON is a helper function to marshal GuildScheduledEventParams
+func (p GuildScheduledEventParams) MarshalJSON() ([]byte, error) {
+ type guildScheduledEventParams GuildScheduledEventParams
+
+ if p.EntityType == GuildScheduledEventEntityTypeExternal && p.ChannelID == "" {
+ return Marshal(struct {
+ guildScheduledEventParams
+ ChannelID json.RawMessage `json:"channel_id"`
+ }{
+ guildScheduledEventParams: guildScheduledEventParams(p),
+ ChannelID: json.RawMessage("null"),
+ })
+ }
+
+ return Marshal(guildScheduledEventParams(p))
+}
+
+// GuildScheduledEventEntityMetadata holds additional metadata for guild scheduled event.
+type GuildScheduledEventEntityMetadata struct {
+ // location of the event (1-100 characters)
+ // required for events with 'entity_type': EXTERNAL
+ Location string `json:"location"`
+}
+
+// GuildScheduledEventPrivacyLevel is the privacy level of a scheduled event.
+// https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-object-guild-scheduled-event-privacy-level
+type GuildScheduledEventPrivacyLevel int
+
+const (
+ // GuildScheduledEventPrivacyLevelGuildOnly makes the scheduled
+ // event is only accessible to guild members
+ GuildScheduledEventPrivacyLevelGuildOnly GuildScheduledEventPrivacyLevel = 2
+)
+
+// GuildScheduledEventStatus is the status of a scheduled event
+// Valid Guild Scheduled Event Status Transitions :
+// SCHEDULED --> ACTIVE --> COMPLETED
+// SCHEDULED --> CANCELED
+// https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-object-guild-scheduled-event-status
+type GuildScheduledEventStatus int
+
+const (
+ // GuildScheduledEventStatusScheduled represents the current event is in scheduled state
+ GuildScheduledEventStatusScheduled GuildScheduledEventStatus = 1
+ // GuildScheduledEventStatusActive represents the current event is in active state
+ GuildScheduledEventStatusActive GuildScheduledEventStatus = 2
+ // GuildScheduledEventStatusCompleted represents the current event is in completed state
+ GuildScheduledEventStatusCompleted GuildScheduledEventStatus = 3
+ // GuildScheduledEventStatusCanceled represents the current event is in canceled state
+ GuildScheduledEventStatusCanceled GuildScheduledEventStatus = 4
+)
+
+// GuildScheduledEventEntityType is the type of entity associated with a guild scheduled event.
+// https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-object-guild-scheduled-event-entity-types
+type GuildScheduledEventEntityType int
+
+const (
+ // GuildScheduledEventEntityTypeStageInstance represents a stage channel
+ GuildScheduledEventEntityTypeStageInstance GuildScheduledEventEntityType = 1
+ // GuildScheduledEventEntityTypeVoice represents a voice channel
+ GuildScheduledEventEntityTypeVoice GuildScheduledEventEntityType = 2
+ // GuildScheduledEventEntityTypeExternal represents an external event
+ GuildScheduledEventEntityTypeExternal GuildScheduledEventEntityType = 3
+)
+
+// GuildScheduledEventUser is a user subscribed to a scheduled event.
+// https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-user-object
+type GuildScheduledEventUser struct {
+ GuildScheduledEventID string `json:"guild_scheduled_event_id"`
+ User *User `json:"user"`
+ Member *Member `json:"member"`
+}
+
+// GuildOnboardingMode defines the criteria used to satisfy constraints that are required for enabling onboarding.
+// https://discord.com/developers/docs/resources/guild#guild-onboarding-object-onboarding-mode
+type GuildOnboardingMode int
+
+// Block containing known GuildOnboardingMode values.
+const (
+ // GuildOnboardingModeDefault counts default channels towards constraints.
+ GuildOnboardingModeDefault GuildOnboardingMode = 0
+ // GuildOnboardingModeAdvanced counts default channels and questions towards constraints.
+ GuildOnboardingModeAdvanced GuildOnboardingMode = 1
+)
+
+// GuildOnboarding represents the onboarding flow for a guild.
+// https://discord.com/developers/docs/resources/guild#guild-onboarding-object
+type GuildOnboarding struct {
+ // ID of the guild this onboarding flow is part of.
+ GuildID string `json:"guild_id,omitempty"`
+
+ // Prompts shown during onboarding and in the customize community (Channels & Roles) tab.
+ Prompts *[]GuildOnboardingPrompt `json:"prompts,omitempty"`
+
+ // Channel IDs that members get opted into automatically.
+ DefaultChannelIDs []string `json:"default_channel_ids,omitempty"`
+
+ // Whether onboarding is enabled in the guild.
+ Enabled *bool `json:"enabled,omitempty"`
+
+ // Mode of onboarding.
+ Mode *GuildOnboardingMode `json:"mode,omitempty"`
+}
+
+// GuildOnboardingPromptType is the type of an onboarding prompt.
+// https://discord.com/developers/docs/resources/guild#guild-onboarding-object-prompt-types
+type GuildOnboardingPromptType int
+
+// Block containing known GuildOnboardingPromptType values.
+const (
+ GuildOnboardingPromptTypeMultipleChoice GuildOnboardingPromptType = 0
+ GuildOnboardingPromptTypeDropdown GuildOnboardingPromptType = 1
+)
+
+// GuildOnboardingPrompt is a prompt shown during onboarding and in the customize community (Channels & Roles) tab.
+// https://discord.com/developers/docs/resources/guild#guild-onboarding-object-onboarding-prompt-structure
+type GuildOnboardingPrompt struct {
+ // ID of the prompt.
+ // NOTE: always requires to be a valid snowflake (e.g. "0"), see
+ // https://github.com/discord/discord-api-docs/issues/6320 for more information.
+ ID string `json:"id,omitempty"`
+
+ // Type of the prompt.
+ Type GuildOnboardingPromptType `json:"type"`
+
+ // Options available within the prompt.
+ Options []GuildOnboardingPromptOption `json:"options"`
+
+ // Title of the prompt.
+ Title string `json:"title"`
+
+ // Indicates whether users are limited to selecting one option for the prompt.
+ SingleSelect bool `json:"single_select"`
+
+ // Indicates whether the prompt is required before a user completes the onboarding flow.
+ Required bool `json:"required"`
+
+ // Indicates whether the prompt is present in the onboarding flow.
+ // If false, the prompt will only appear in the customize community (Channels & Roles) tab.
+ InOnboarding bool `json:"in_onboarding"`
+}
+
+// GuildOnboardingPromptOption is an option available within an onboarding prompt.
+// https://discord.com/developers/docs/resources/guild#guild-onboarding-object-prompt-option-structure
+type GuildOnboardingPromptOption struct {
+ // ID of the prompt option.
+ ID string `json:"id,omitempty"`
+
+ // IDs for channels a member is added to when the option is selected.
+ ChannelIDs []string `json:"channel_ids"`
+
+ // IDs for roles assigned to a member when the option is selected.
+ RoleIDs []string `json:"role_ids"`
+
+ // Emoji of the option.
+ // NOTE: when creating or updating a prompt option
+ // EmojiID, EmojiName and EmojiAnimated should be used instead.
+ Emoji *Emoji `json:"emoji,omitempty"`
+
+ // Title of the option.
+ Title string `json:"title"`
+
+ // Description of the option.
+ Description string `json:"description"`
+
+ // ID of the option's emoji.
+ // NOTE: only used when creating or updating a prompt option.
+ EmojiID string `json:"emoji_id,omitempty"`
+ // Name of the option's emoji.
+ // NOTE: only used when creating or updating a prompt option.
+ EmojiName string `json:"emoji_name,omitempty"`
+ // Whether the option's emoji is animated.
+ // NOTE: only used when creating or updating a prompt option.
+ EmojiAnimated *bool `json:"emoji_animated,omitempty"`
+}
+
+// A GuildTemplate represents a replicable template for guild creation
+type GuildTemplate struct {
+ // The unique code for the guild template
+ Code string `json:"code"`
+
+ // The name of the template
+ Name string `json:"name,omitempty"`
+
+ // The description for the template
+ Description *string `json:"description,omitempty"`
+
+ // The number of times this template has been used
+ UsageCount int `json:"usage_count"`
+
+ // The ID of the user who created the template
+ CreatorID string `json:"creator_id"`
+
+ // The user who created the template
+ Creator *User `json:"creator"`
+
+ // The timestamp of when the template was created
+ CreatedAt time.Time `json:"created_at"`
+
+ // The timestamp of when the template was last synced
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // The ID of the guild the template was based on
+ SourceGuildID string `json:"source_guild_id"`
+
+ // The guild 'snapshot' this template contains
+ SerializedSourceGuild *Guild `json:"serialized_source_guild"`
+
+ // Whether the template has unsynced changes
+ IsDirty bool `json:"is_dirty"`
+}
+
+// GuildTemplateParams stores the data needed to create or update a GuildTemplate.
+type GuildTemplateParams struct {
+ // The name of the template (1-100 characters)
+ Name string `json:"name,omitempty"`
+ // The description of the template (0-120 characters)
+ Description string `json:"description,omitempty"`
+}
+
+// MessageNotifications is the notification level for a guild
+// https://discord.com/developers/docs/resources/guild#guild-object-default-message-notification-level
+type MessageNotifications int
+
+// Block containing known MessageNotifications values
+const (
+ MessageNotificationsAllMessages MessageNotifications = 0
+ MessageNotificationsOnlyMentions MessageNotifications = 1
+)
+
+// SystemChannelFlag is the type of flags in the system channel (see SystemChannelFlag* consts)
+// https://discord.com/developers/docs/resources/guild#guild-object-system-channel-flags
+type SystemChannelFlag int
+
+// Block containing known SystemChannelFlag values
+const (
+ SystemChannelFlagsSuppressJoinNotifications SystemChannelFlag = 1 << 0
+ SystemChannelFlagsSuppressPremium SystemChannelFlag = 1 << 1
+ SystemChannelFlagsSuppressGuildReminderNotifications SystemChannelFlag = 1 << 2
+ SystemChannelFlagsSuppressJoinNotificationReplies SystemChannelFlag = 1 << 3
+)
+
+// IconURL returns a URL to the guild's icon.
+//
+// size: The size of the desired icon image as a power of two
+// Image size can be any power of two between 16 and 4096.
+func (g *Guild) IconURL(size string) string {
+ return iconURL(g.Icon, EndpointGuildIcon(g.ID, g.Icon), EndpointGuildIconAnimated(g.ID, g.Icon), size)
+}
+
+// BannerURL returns a URL to the guild's banner.
+//
+// size: The size of the desired banner image as a power of two
+// Image size can be any power of two between 16 and 4096.
+func (g *Guild) BannerURL(size string) string {
+ return bannerURL(g.Banner, EndpointGuildBanner(g.ID, g.Banner), EndpointGuildBannerAnimated(g.ID, g.Banner), size)
+}
+
+// A UserGuild holds a brief version of a Guild
+type UserGuild struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Icon string `json:"icon"`
+ Owner bool `json:"owner"`
+ Permissions int64 `json:"permissions,string"`
+ Features []GuildFeature `json:"features"`
+
+ // Approximate number of members in this guild.
+ // NOTE: this field is only filled when withCounts is true.
+ ApproximateMemberCount int `json:"approximate_member_count"`
+
+ // Approximate number of non-offline members in this guild.
+ // NOTE: this field is only filled when withCounts is true.
+ ApproximatePresenceCount int `json:"approximate_presence_count"`
+}
+
+// GuildFeature indicates the presence of a feature in a guild
+type GuildFeature string
+
+// Constants for GuildFeature
+const (
+ GuildFeatureAnimatedBanner GuildFeature = "ANIMATED_BANNER"
+ GuildFeatureAnimatedIcon GuildFeature = "ANIMATED_ICON"
+ GuildFeatureApplicationCommandPermissionV2 GuildFeature = "APPLICATION_COMMAND_PERMISSIONS_V2"
+ GuildFeatureAutoModeration GuildFeature = "AUTO_MODERATION"
+ GuildFeatureBanner GuildFeature = "BANNER"
+ GuildFeatureCommunity GuildFeature = "COMMUNITY"
+ GuildFeatureCreatorMonetizableProvisional GuildFeature = "CREATOR_MONETIZABLE_PROVISIONAL"
+ GuildFeatureCreatorStorePage GuildFeature = "CREATOR_STORE_PAGE"
+ GuildFeatureDeveloperSupportServer GuildFeature = "DEVELOPER_SUPPORT_SERVER"
+ GuildFeatureDiscoverable GuildFeature = "DISCOVERABLE"
+ GuildFeatureFeaturable GuildFeature = "FEATURABLE"
+ GuildFeatureInvitesDisabled GuildFeature = "INVITES_DISABLED"
+ GuildFeatureInviteSplash GuildFeature = "INVITE_SPLASH"
+ GuildFeatureMemberVerificationGateEnabled GuildFeature = "MEMBER_VERIFICATION_GATE_ENABLED"
+ GuildFeatureMoreSoundboard GuildFeature = "MORE_SOUNDBOARD"
+ GuildFeatureMoreStickers GuildFeature = "MORE_STICKERS"
+ GuildFeatureNews GuildFeature = "NEWS"
+ GuildFeaturePartnered GuildFeature = "PARTNERED"
+ GuildFeaturePreviewEnabled GuildFeature = "PREVIEW_ENABLED"
+ GuildFeatureRaidAlertsDisabled GuildFeature = "RAID_ALERTS_DISABLED"
+ GuildFeatureRoleIcons GuildFeature = "ROLE_ICONS"
+ GuildFeatureRoleSubscriptionsAvailableForPurchase GuildFeature = "ROLE_SUBSCRIPTIONS_AVAILABLE_FOR_PURCHASE"
+ GuildFeatureRoleSubscriptionsEnabled GuildFeature = "ROLE_SUBSCRIPTIONS_ENABLED"
+ GuildFeatureSoundboard GuildFeature = "SOUNDBOARD"
+ GuildFeatureTicketedEventsEnabled GuildFeature = "TICKETED_EVENTS_ENABLED"
+ GuildFeatureVanityURL GuildFeature = "VANITY_URL"
+ GuildFeatureVerified GuildFeature = "VERIFIED"
+ GuildFeatureVipRegions GuildFeature = "VIP_REGIONS"
+ GuildFeatureWelcomeScreenEnabled GuildFeature = "WELCOME_SCREEN_ENABLED"
+)
+
+// A GuildParams stores all the data needed to update discord guild settings
+type GuildParams struct {
+ Name string `json:"name,omitempty"`
+ Region string `json:"region,omitempty"`
+ VerificationLevel *VerificationLevel `json:"verification_level,omitempty"`
+ DefaultMessageNotifications int `json:"default_message_notifications,omitempty"` // TODO: Separate type?
+ ExplicitContentFilter int `json:"explicit_content_filter,omitempty"`
+ AfkChannelID string `json:"afk_channel_id,omitempty"`
+ AfkTimeout int `json:"afk_timeout,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ OwnerID string `json:"owner_id,omitempty"`
+ Splash string `json:"splash,omitempty"`
+ DiscoverySplash string `json:"discovery_splash,omitempty"`
+ Banner string `json:"banner,omitempty"`
+ SystemChannelID string `json:"system_channel_id,omitempty"`
+ SystemChannelFlags SystemChannelFlag `json:"system_channel_flags,omitempty"`
+ RulesChannelID string `json:"rules_channel_id,omitempty"`
+ PublicUpdatesChannelID string `json:"public_updates_channel_id,omitempty"`
+ PreferredLocale Locale `json:"preferred_locale,omitempty"`
+ Features []GuildFeature `json:"features,omitempty"`
+ Description string `json:"description,omitempty"`
+ PremiumProgressBarEnabled *bool `json:"premium_progress_bar_enabled,omitempty"`
+}
+
+// A Role stores information about Discord guild member roles.
+type Role struct {
+ // The ID of the role.
+ ID string `json:"id"`
+
+ // The name of the role.
+ Name string `json:"name"`
+
+ // Whether this role is managed by an integration, and
+ // thus cannot be manually added to, or taken from, members.
+ Managed bool `json:"managed"`
+
+ // Whether this role is mentionable.
+ Mentionable bool `json:"mentionable"`
+
+ // Whether this role is hoisted (shows up separately in member list).
+ Hoist bool `json:"hoist"`
+
+ // The hex color of this role.
+ Color int `json:"color"`
+
+ // The position of this role in the guild's role hierarchy.
+ Position int `json:"position"`
+
+ // The permissions of the role on the guild (doesn't include channel overrides).
+ // This is a combination of bit masks; the presence of a certain permission can
+ // be checked by performing a bitwise AND between this int and the permission.
+ Permissions int64 `json:"permissions,string"`
+
+ // The hash of the role icon. Use Role.IconURL to retrieve the icon's URL.
+ Icon string `json:"icon"`
+
+ // The emoji assigned to this role.
+ UnicodeEmoji string `json:"unicode_emoji"`
+
+ // The flags of the role, which describe its extra features.
+ // This is a combination of bit masks; the presence of a certain flag can
+ // be checked by performing a bitwise AND between this int and the flag.
+ Flags RoleFlags `json:"flags"`
+}
+
+// RoleFlags represent the flags of a Role.
+// https://discord.com/developers/docs/topics/permissions#role-object-role-flags
+type RoleFlags uint32
+
+// Block containing known RoleFlags values.
+const (
+ // RoleFlagInPrompt indicates whether the Role is selectable by members in an onboarding prompt.
+ RoleFlagInPrompt RoleFlags = 1 << 0
+)
+
+// Mention returns a string which mentions the role
+func (r *Role) Mention() string {
+ return fmt.Sprintf("<@&%s>", r.ID)
+}
+
+// IconURL returns the URL of the role's icon.
+//
+// size: The size of the desired role icon as a power of two
+// Image size can be any power of two between 16 and 4096.
+func (r *Role) IconURL(size string) string {
+ if r.Icon == "" {
+ return ""
+ }
+
+ URL := EndpointRoleIcon(r.ID, r.Icon)
+
+ if size != "" {
+ return URL + "?size=" + size
+ }
+ return URL
+}
+
+// RoleParams represents the parameters needed to create or update a Role
+type RoleParams struct {
+ // The role's name
+ Name string `json:"name,omitempty"`
+ // The color the role should have (as a decimal, not hex)
+ Color *int `json:"color,omitempty"`
+ // Whether to display the role's users separately
+ Hoist *bool `json:"hoist,omitempty"`
+ // The overall permissions number of the role
+ Permissions *int64 `json:"permissions,omitempty,string"`
+ // Whether this role is mentionable
+ Mentionable *bool `json:"mentionable,omitempty"`
+ // The role's unicode emoji.
+ // NOTE: can only be set if the guild has the ROLE_ICONS feature.
+ UnicodeEmoji *string `json:"unicode_emoji,omitempty"`
+ // The role's icon image encoded in base64.
+ // NOTE: can only be set if the guild has the ROLE_ICONS feature.
+ Icon *string `json:"icon,omitempty"`
+}
+
+// Roles are a collection of Role
+type Roles []*Role
+
+func (r Roles) Len() int {
+ return len(r)
+}
+
+func (r Roles) Less(i, j int) bool {
+ return r[i].Position > r[j].Position
+}
+
+func (r Roles) Swap(i, j int) {
+ r[i], r[j] = r[j], r[i]
+}
+
+// A VoiceState stores the voice states of Guilds
+type VoiceState struct {
+ GuildID string `json:"guild_id"`
+ ChannelID string `json:"channel_id"`
+ UserID string `json:"user_id"`
+ Member *Member `json:"member"`
+ SessionID string `json:"session_id"`
+ Deaf bool `json:"deaf"`
+ Mute bool `json:"mute"`
+ SelfDeaf bool `json:"self_deaf"`
+ SelfMute bool `json:"self_mute"`
+ SelfStream bool `json:"self_stream"`
+ SelfVideo bool `json:"self_video"`
+ Suppress bool `json:"suppress"`
+ RequestToSpeakTimestamp *time.Time `json:"request_to_speak_timestamp"`
+}
+
+// A Presence stores the online, offline, or idle and game status of Guild members.
+type Presence struct {
+ User *User `json:"user"`
+ Status Status `json:"status"`
+ Activities []*Activity `json:"activities"`
+ Since *int `json:"since"`
+ ClientStatus ClientStatus `json:"client_status"`
+}
+
+// A TimeStamps struct contains start and end times used in the rich presence "playing .." Game
+type TimeStamps struct {
+ EndTimestamp int64 `json:"end,omitempty"`
+ StartTimestamp int64 `json:"start,omitempty"`
+}
+
+// UnmarshalJSON unmarshals JSON into TimeStamps struct
+func (t *TimeStamps) UnmarshalJSON(b []byte) error {
+ temp := struct {
+ End float64 `json:"end,omitempty"`
+ Start float64 `json:"start,omitempty"`
+ }{}
+ err := Unmarshal(b, &temp)
+ if err != nil {
+ return err
+ }
+ t.EndTimestamp = int64(temp.End)
+ t.StartTimestamp = int64(temp.Start)
+ return nil
+}
+
+// An Assets struct contains assets and labels used in the rich presence "playing .." Game
+type Assets struct {
+ LargeImageID string `json:"large_image,omitempty"`
+ SmallImageID string `json:"small_image,omitempty"`
+ LargeText string `json:"large_text,omitempty"`
+ SmallText string `json:"small_text,omitempty"`
+}
+
+// MemberFlags represent flags of a guild member.
+// https://discord.com/developers/docs/resources/guild#guild-member-object-guild-member-flags
+type MemberFlags uint32
+
+// Block containing known MemberFlags values.
+const (
+ // MemberFlagDidRejoin indicates whether the Member has left and rejoined the guild.
+ MemberFlagDidRejoin MemberFlags = 1 << 0
+ // MemberFlagCompletedOnboarding indicates whether the Member has completed onboarding.
+ MemberFlagCompletedOnboarding MemberFlags = 1 << 1
+ // MemberFlagBypassesVerification indicates whether the Member is exempt from guild verification requirements.
+ MemberFlagBypassesVerification MemberFlags = 1 << 2
+ // MemberFlagStartedOnboarding indicates whether the Member has started onboarding.
+ MemberFlagStartedOnboarding MemberFlags = 1 << 3
+)
+
+// A Member stores user information for Guild members. A guild
+// member represents a certain user's presence in a guild.
+type Member struct {
+ // The guild ID on which the member exists.
+ GuildID string `json:"guild_id"`
+
+ // The user ID of the member, probably only in MergedMembers.
+ UserID string `json:"user_id"`
+
+ // The time at which the member joined the guild.
+ JoinedAt time.Time `json:"joined_at"`
+
+ // The nickname of the member, if they have one.
+ Nick string `json:"nick"`
+
+ // Whether the member is deafened at a guild level.
+ Deaf bool `json:"deaf"`
+
+ // Whether the member is muted at a guild level.
+ Mute bool `json:"mute"`
+
+ // The hash of the avatar for the guild member, if any.
+ Avatar string `json:"avatar"`
+
+ // The hash of the banner for the guild member, if any.
+ Banner string `json:"banner"`
+
+ // The underlying user on which the member is based.
+ User *User `json:"user"`
+
+ // A list of IDs of the roles which are possessed by the member.
+ Roles []string `json:"roles"`
+
+ // When the user used their Nitro boost on the server
+ PremiumSince *time.Time `json:"premium_since"`
+
+ // The flags of this member. This is a combination of bit masks; the presence of a certain
+ // flag can be checked by performing a bitwise AND between this int and the flag.
+ Flags MemberFlags `json:"flags"`
+
+ // Is true while the member hasn't accepted the membership screen.
+ Pending bool `json:"pending"`
+
+ // Total permissions of the member in the channel, including overrides, returned when in the interaction object.
+ Permissions int64 `json:"permissions,string"`
+
+ // The time at which the member's timeout will expire.
+ // Time in the past or nil if the user is not timed out.
+ CommunicationDisabledUntil *time.Time `json:"communication_disabled_until"`
+}
+
+// Mention creates a member mention
+func (m *Member) Mention() string {
+ return "<@!" + m.User.ID + ">"
+}
+
+// AvatarURL returns the URL of the member's avatar
+//
+// size: The size of the user's avatar as a power of two
+// if size is an empty string, no size parameter will
+// be added to the URL.
+func (m *Member) AvatarURL(size string) string {
+ if m.Avatar == "" {
+ return m.User.AvatarURL(size)
+ }
+ // The default/empty avatar case should be handled by the above condition
+ return avatarURL(m.Avatar, "", EndpointGuildMemberAvatar(m.GuildID, m.User.ID, m.Avatar),
+ EndpointGuildMemberAvatarAnimated(m.GuildID, m.User.ID, m.Avatar), size)
+
+}
+
+// BannerURL returns the URL of the member's banner image.
+//
+// size: The size of the desired banner image as a power of two
+// Image size can be any power of two between 16 and 4096.
+func (m *Member) BannerURL(size string) string {
+ if m.Banner == "" {
+ return m.User.BannerURL(size)
+ }
+ return bannerURL(
+ m.Banner,
+ EndpointGuildMemberBanner(m.GuildID, m.User.ID, m.Banner),
+ EndpointGuildMemberBannerAnimated(m.GuildID, m.User.ID, m.Banner),
+ size,
+ )
+}
+
+// DisplayName returns the member's guild nickname if they have one,
+// otherwise it returns their discord display name.
+func (m *Member) DisplayName() string {
+ if m.Nick != "" {
+ return m.Nick
+ }
+ return m.User.DisplayName()
+}
+
+// A Settings stores data for a specific users Discord client settings.
+type Settings struct {
+ RenderEmbeds bool `json:"render_embeds"`
+ InlineEmbedMedia bool `json:"inline_embed_media"`
+ InlineAttachmentMedia bool `json:"inline_attachment_media"`
+ EnableTTSCommand bool `json:"enable_tts_command"`
+ MessageDisplayCompact bool `json:"message_display_compact"`
+ ShowCurrentGame bool `json:"show_current_game"`
+ ConvertEmoticons bool `json:"convert_emoticons"`
+ Locale string `json:"locale"`
+ Theme string `json:"theme"`
+ GuildPositions []string `json:"guild_positions"`
+ RestrictedGuilds []string `json:"restricted_guilds"`
+ FriendSourceFlags *FriendSourceFlags `json:"friend_source_flags"`
+ Status Status `json:"status"`
+ DetectPlatformAccounts bool `json:"detect_platform_accounts"`
+ DeveloperMode bool `json:"developer_mode"`
+}
+
+// ClientStatus stores the online, offline, idle, or dnd status of each device of a Guild member.
+type ClientStatus struct {
+ Desktop Status `json:"desktop"`
+ Mobile Status `json:"mobile"`
+ Web Status `json:"web"`
+}
+
+// Status type definition
+type Status string
+
+// Constants for Status with the different current available status
+const (
+ StatusOnline Status = "online"
+ StatusIdle Status = "idle"
+ StatusDoNotDisturb Status = "dnd"
+ StatusInvisible Status = "invisible"
+ StatusOffline Status = "offline"
+)
+
+// FriendSourceFlags stores ... TODO :)
+type FriendSourceFlags struct {
+ All bool `json:"all"`
+ MutualGuilds bool `json:"mutual_guilds"`
+ MutualFriends bool `json:"mutual_friends"`
+}
+
+type RelationshipType int
+
+const (
+ RelationshipFriend RelationshipType = 1
+ RelationshipBlocked RelationshipType = 2
+ RelationshipIncomingFriendRequest RelationshipType = 3
+ RelationshipOutgoingFriendRequest RelationshipType = 4
+)
+
+// A Relationship between the logged in user and Relationship.User
+type Relationship struct {
+ Type RelationshipType `json:"type"`
+ ID string `json:"id"`
+ Nickname string `json:"nickname,omitempty"`
+}
+
+// A TooManyRequests struct holds information received from Discord
+// when receiving a HTTP 429 response.
+type TooManyRequests struct {
+ Bucket string `json:"bucket"`
+ Message string `json:"message"`
+ RetryAfter time.Duration `json:"retry_after"`
+}
+
+// UnmarshalJSON helps support translation of a milliseconds-based float
+// into a time.Duration on TooManyRequests.
+func (t *TooManyRequests) UnmarshalJSON(b []byte) error {
+ u := struct {
+ Bucket string `json:"bucket"`
+ Message string `json:"message"`
+ RetryAfter float64 `json:"retry_after"`
+ }{}
+ err := Unmarshal(b, &u)
+ if err != nil {
+ return err
+ }
+
+ t.Bucket = u.Bucket
+ t.Message = u.Message
+ whole, frac := math.Modf(u.RetryAfter)
+ t.RetryAfter = time.Duration(whole)*time.Second + time.Duration(frac*1000)*time.Millisecond
+ return nil
+}
+
+type ReadStateList struct {
+ Version int `json:"version"`
+ Partial bool `json:"partial"`
+ Entries []*ReadState `json:"entries"`
+}
+
+type StringOrInt string
+
+func (soi *StringOrInt) UnmarshalJSON(data []byte) error {
+ err := json.Unmarshal(data, (*string)(soi))
+ if err != nil {
+ var val int64
+ err = json.Unmarshal(data, &val)
+ if err != nil {
+ return err
+ }
+ *soi = (StringOrInt)(strconv.FormatInt(val, 10))
+ }
+ return nil
+}
+
+// A ReadState stores data on the read state of channels.
+type ReadState struct {
+ MentionCount int `json:"mention_count"`
+ LastMessageID StringOrInt `json:"last_message_id"`
+ ID string `json:"id"`
+}
+
+// An Ack is used to ack messages
+type Ack struct {
+ Token string `json:"token"`
+}
+
+// An PtrAck is used to ack messages
+type PtrAck struct {
+ Token *string `json:"token"`
+}
+
+// A GuildRole stores data for guild roles.
+type GuildRole struct {
+ Role *Role `json:"role"`
+ GuildID string `json:"guild_id"`
+}
+
+// A GuildBan stores data for a guild ban.
+type GuildBan struct {
+ Reason string `json:"reason"`
+ User *User `json:"user"`
+}
+
+// AutoModerationRule stores data for an auto moderation rule.
+type AutoModerationRule struct {
+ ID string `json:"id,omitempty"`
+ GuildID string `json:"guild_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ CreatorID string `json:"creator_id,omitempty"`
+ EventType AutoModerationRuleEventType `json:"event_type,omitempty"`
+ TriggerType AutoModerationRuleTriggerType `json:"trigger_type,omitempty"`
+ TriggerMetadata *AutoModerationTriggerMetadata `json:"trigger_metadata,omitempty"`
+ Actions []AutoModerationAction `json:"actions,omitempty"`
+ Enabled *bool `json:"enabled,omitempty"`
+ ExemptRoles *[]string `json:"exempt_roles,omitempty"`
+ ExemptChannels *[]string `json:"exempt_channels,omitempty"`
+}
+
+// AutoModerationRuleEventType indicates in what event context a rule should be checked.
+type AutoModerationRuleEventType int
+
+// Auto moderation rule event types.
+const (
+ // AutoModerationEventMessageSend is checked when a member sends or edits a message in the guild
+ AutoModerationEventMessageSend AutoModerationRuleEventType = 1
+)
+
+// AutoModerationRuleTriggerType represents the type of content which can trigger the rule.
+type AutoModerationRuleTriggerType int
+
+// Auto moderation rule trigger types.
+const (
+ AutoModerationEventTriggerKeyword AutoModerationRuleTriggerType = 1
+ AutoModerationEventTriggerHarmfulLink AutoModerationRuleTriggerType = 2
+ AutoModerationEventTriggerSpam AutoModerationRuleTriggerType = 3
+ AutoModerationEventTriggerKeywordPreset AutoModerationRuleTriggerType = 4
+)
+
+// AutoModerationKeywordPreset represents an internally pre-defined wordset.
+type AutoModerationKeywordPreset uint
+
+// Auto moderation keyword presets.
+const (
+ AutoModerationKeywordPresetProfanity AutoModerationKeywordPreset = 1
+ AutoModerationKeywordPresetSexualContent AutoModerationKeywordPreset = 2
+ AutoModerationKeywordPresetSlurs AutoModerationKeywordPreset = 3
+)
+
+// AutoModerationTriggerMetadata represents additional metadata used to determine whether rule should be triggered.
+type AutoModerationTriggerMetadata struct {
+ // Substrings which will be searched for in content.
+ // NOTE: should be only used with keyword trigger type.
+ KeywordFilter []string `json:"keyword_filter,omitempty"`
+ // Regular expression patterns which will be matched against content (maximum of 10).
+ // NOTE: should be only used with keyword trigger type.
+ RegexPatterns []string `json:"regex_patterns,omitempty"`
+
+ // Internally pre-defined wordsets which will be searched for in content.
+ // NOTE: should be only used with keyword preset trigger type.
+ Presets []AutoModerationKeywordPreset `json:"presets,omitempty"`
+
+ // Substrings which should not trigger the rule.
+ // NOTE: should be only used with keyword or keyword preset trigger type.
+ AllowList *[]string `json:"allow_list,omitempty"`
+
+ // Total number of unique role and user mentions allowed per message.
+ // NOTE: should be only used with mention spam trigger type.
+ MentionTotalLimit int `json:"mention_total_limit,omitempty"`
+}
+
+// AutoModerationActionType represents an action which will execute whenever a rule is triggered.
+type AutoModerationActionType int
+
+// Auto moderation actions types.
+const (
+ AutoModerationRuleActionBlockMessage AutoModerationActionType = 1
+ AutoModerationRuleActionSendAlertMessage AutoModerationActionType = 2
+ AutoModerationRuleActionTimeout AutoModerationActionType = 3
+)
+
+// AutoModerationActionMetadata represents additional metadata needed during execution for a specific action type.
+type AutoModerationActionMetadata struct {
+ // Channel to which user content should be logged.
+ // NOTE: should be only used with send alert message action type.
+ ChannelID string `json:"channel_id,omitempty"`
+
+ // Timeout duration in seconds (maximum of 2419200 - 4 weeks).
+ // NOTE: should be only used with timeout action type.
+ Duration int `json:"duration_seconds,omitempty"`
+
+ // Additional explanation that will be shown to members whenever their message is blocked (maximum of 150 characters).
+ // NOTE: should be only used with block message action type.
+ CustomMessage string `json:"custom_message,omitempty"`
+}
+
+// AutoModerationAction stores data for an auto moderation action.
+type AutoModerationAction struct {
+ Type AutoModerationActionType `json:"type"`
+ Metadata *AutoModerationActionMetadata `json:"metadata,omitempty"`
+}
+
+// A GuildEmbed stores data for a guild embed.
+type GuildEmbed struct {
+ Enabled *bool `json:"enabled,omitempty"`
+ ChannelID string `json:"channel_id,omitempty"`
+}
+
+// A GuildAuditLog stores data for a guild audit log.
+// https://discord.com/developers/docs/resources/audit-log#audit-log-object-audit-log-structure
+type GuildAuditLog struct {
+ Webhooks []*Webhook `json:"webhooks,omitempty"`
+ Users []*User `json:"users,omitempty"`
+ AuditLogEntries []*AuditLogEntry `json:"audit_log_entries"`
+ Integrations []*Integration `json:"integrations"`
+}
+
+// AuditLogEntry for a GuildAuditLog
+// https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-audit-log-entry-structure
+type AuditLogEntry struct {
+ TargetID string `json:"target_id"`
+ Changes []*AuditLogChange `json:"changes"`
+ UserID string `json:"user_id"`
+ ID string `json:"id"`
+ ActionType *AuditLogAction `json:"action_type"`
+ Options *AuditLogOptions `json:"options"`
+ Reason string `json:"reason"`
+}
+
+// AuditLogChange for an AuditLogEntry
+type AuditLogChange struct {
+ NewValue interface{} `json:"new_value"`
+ OldValue interface{} `json:"old_value"`
+ Key *AuditLogChangeKey `json:"key"`
+}
+
+// AuditLogChangeKey value for AuditLogChange
+// https://discord.com/developers/docs/resources/audit-log#audit-log-change-object-audit-log-change-key
+type AuditLogChangeKey string
+
+// Block of valid AuditLogChangeKey
+const (
+ // AuditLogChangeKeyAfkChannelID is sent when afk channel changed (snowflake) - guild
+ AuditLogChangeKeyAfkChannelID AuditLogChangeKey = "afk_channel_id"
+ // AuditLogChangeKeyAfkTimeout is sent when afk timeout duration changed (int) - guild
+ AuditLogChangeKeyAfkTimeout AuditLogChangeKey = "afk_timeout"
+ // AuditLogChangeKeyAllow is sent when a permission on a text or voice channel was allowed for a role (string) - role
+ AuditLogChangeKeyAllow AuditLogChangeKey = "allow"
+ // AudirChangeKeyApplicationID is sent when application id of the added or removed webhook or bot (snowflake) - channel
+ AuditLogChangeKeyApplicationID AuditLogChangeKey = "application_id"
+ // AuditLogChangeKeyArchived is sent when thread was archived/unarchived (bool) - thread
+ AuditLogChangeKeyArchived AuditLogChangeKey = "archived"
+ // AuditLogChangeKeyAsset is sent when asset is changed (string) - sticker
+ AuditLogChangeKeyAsset AuditLogChangeKey = "asset"
+ // AuditLogChangeKeyAutoArchiveDuration is sent when auto archive duration changed (int) - thread
+ AuditLogChangeKeyAutoArchiveDuration AuditLogChangeKey = "auto_archive_duration"
+ // AuditLogChangeKeyAvailable is sent when availability of sticker changed (bool) - sticker
+ AuditLogChangeKeyAvailable AuditLogChangeKey = "available"
+ // AuditLogChangeKeyAvatarHash is sent when user avatar changed (string) - user
+ AuditLogChangeKeyAvatarHash AuditLogChangeKey = "avatar_hash"
+ // AuditLogChangeKeyBannerHash is sent when guild banner changed (string) - guild
+ AuditLogChangeKeyBannerHash AuditLogChangeKey = "banner_hash"
+ // AuditLogChangeKeyBitrate is sent when voice channel bitrate changed (int) - channel
+ AuditLogChangeKeyBitrate AuditLogChangeKey = "bitrate"
+ // AuditLogChangeKeyChannelID is sent when channel for invite code or guild scheduled event changed (snowflake) - invite or guild scheduled event
+ AuditLogChangeKeyChannelID AuditLogChangeKey = "channel_id"
+ // AuditLogChangeKeyCode is sent when invite code changed (string) - invite
+ AuditLogChangeKeyCode AuditLogChangeKey = "code"
+ // AuditLogChangeKeyColor is sent when role color changed (int) - role
+ AuditLogChangeKeyColor AuditLogChangeKey = "color"
+ // AuditLogChangeKeyCommunicationDisabledUntil is sent when member timeout state changed (ISO8601 timestamp) - member
+ AuditLogChangeKeyCommunicationDisabledUntil AuditLogChangeKey = "communication_disabled_until"
+ // AuditLogChangeKeyDeaf is sent when user server deafened/undeafened (bool) - member
+ AuditLogChangeKeyDeaf AuditLogChangeKey = "deaf"
+ // AuditLogChangeKeyDefaultAutoArchiveDuration is sent when default auto archive duration for newly created threads changed (int) - channel
+ AuditLogChangeKeyDefaultAutoArchiveDuration AuditLogChangeKey = "default_auto_archive_duration"
+ // AuditLogChangeKeyDefaultMessageNotification is sent when default message notification level changed (int) - guild
+ AuditLogChangeKeyDefaultMessageNotification AuditLogChangeKey = "default_message_notifications"
+ // AuditLogChangeKeyDeny is sent when a permission on a text or voice channel was denied for a role (string) - role
+ AuditLogChangeKeyDeny AuditLogChangeKey = "deny"
+ // AuditLogChangeKeyDescription is sent when description changed (string) - guild, sticker, or guild scheduled event
+ AuditLogChangeKeyDescription AuditLogChangeKey = "description"
+ // AuditLogChangeKeyDiscoverySplashHash is sent when discovery splash changed (string) - guild
+ AuditLogChangeKeyDiscoverySplashHash AuditLogChangeKey = "discovery_splash_hash"
+ // AuditLogChangeKeyEnableEmoticons is sent when integration emoticons enabled/disabled (bool) - integration
+ AuditLogChangeKeyEnableEmoticons AuditLogChangeKey = "enable_emoticons"
+ // AuditLogChangeKeyEntityType is sent when entity type of guild scheduled event was changed (int) - guild scheduled event
+ AuditLogChangeKeyEntityType AuditLogChangeKey = "entity_type"
+ // AuditLogChangeKeyExpireBehavior is sent when integration expiring subscriber behavior changed (int) - integration
+ AuditLogChangeKeyExpireBehavior AuditLogChangeKey = "expire_behavior"
+ // AuditLogChangeKeyExpireGracePeriod is sent when integration expire grace period changed (int) - integration
+ AuditLogChangeKeyExpireGracePeriod AuditLogChangeKey = "expire_grace_period"
+ // AuditLogChangeKeyExplicitContentFilter is sent when change in whose messages are scanned and deleted for explicit content in the server is made (int) - guild
+ AuditLogChangeKeyExplicitContentFilter AuditLogChangeKey = "explicit_content_filter"
+ // AuditLogChangeKeyFormatType is sent when format type of sticker changed (int - sticker format type) - sticker
+ AuditLogChangeKeyFormatType AuditLogChangeKey = "format_type"
+ // AuditLogChangeKeyGuildID is sent when guild sticker is in changed (snowflake) - sticker
+ AuditLogChangeKeyGuildID AuditLogChangeKey = "guild_id"
+ // AuditLogChangeKeyHoist is sent when role is now displayed/no longer displayed separate from online users (bool) - role
+ AuditLogChangeKeyHoist AuditLogChangeKey = "hoist"
+ // AuditLogChangeKeyIconHash is sent when icon changed (string) - guild or role
+ AuditLogChangeKeyIconHash AuditLogChangeKey = "icon_hash"
+ // AuditLogChangeKeyID is sent when the id of the changed entity - sometimes used in conjunction with other keys (snowflake) - any
+ AuditLogChangeKeyID AuditLogChangeKey = "id"
+ // AuditLogChangeKeyInvitable is sent when private thread is now invitable/uninvitable (bool) - thread
+ AuditLogChangeKeyInvitable AuditLogChangeKey = "invitable"
+ // AuditLogChangeKeyInviterID is sent when person who created invite code changed (snowflake) - invite
+ AuditLogChangeKeyInviterID AuditLogChangeKey = "inviter_id"
+ // AuditLogChangeKeyLocation is sent when channel id for guild scheduled event changed (string) - guild scheduled event
+ AuditLogChangeKeyLocation AuditLogChangeKey = "location"
+ // AuditLogChangeKeyLocked is sent when thread was locked/unlocked (bool) - thread
+ AuditLogChangeKeyLocked AuditLogChangeKey = "locked"
+ // AuditLogChangeKeyMaxAge is sent when invite code expiration time changed (int) - invite
+ AuditLogChangeKeyMaxAge AuditLogChangeKey = "max_age"
+ // AuditLogChangeKeyMaxUses is sent when max number of times invite code can be used changed (int) - invite
+ AuditLogChangeKeyMaxUses AuditLogChangeKey = "max_uses"
+ // AuditLogChangeKeyMentionable is sent when role is now mentionable/unmentionable (bool) - role
+ AuditLogChangeKeyMentionable AuditLogChangeKey = "mentionable"
+ // AuditLogChangeKeyMfaLevel is sent when two-factor auth requirement changed (int - mfa level) - guild
+ AuditLogChangeKeyMfaLevel AuditLogChangeKey = "mfa_level"
+ // AuditLogChangeKeyMute is sent when user server muted/unmuted (bool) - member
+ AuditLogChangeKeyMute AuditLogChangeKey = "mute"
+ // AuditLogChangeKeyName is sent when name changed (string) - any
+ AuditLogChangeKeyName AuditLogChangeKey = "name"
+ // AuditLogChangeKeyNick is sent when user nickname changed (string) - member
+ AuditLogChangeKeyNick AuditLogChangeKey = "nick"
+ // AuditLogChangeKeyNSFW is sent when channel nsfw restriction changed (bool) - channel
+ AuditLogChangeKeyNSFW AuditLogChangeKey = "nsfw"
+ // AuditLogChangeKeyOwnerID is sent when owner changed (snowflake) - guild
+ AuditLogChangeKeyOwnerID AuditLogChangeKey = "owner_id"
+ // AuditLogChangeKeyPermissionOverwrite is sent when permissions on a channel changed (array of channel overwrite objects) - channel
+ AuditLogChangeKeyPermissionOverwrite AuditLogChangeKey = "permission_overwrites"
+ // AuditLogChangeKeyPermissions is sent when permissions for a role changed (string) - role
+ AuditLogChangeKeyPermissions AuditLogChangeKey = "permissions"
+ // AuditLogChangeKeyPosition is sent when text or voice channel position changed (int) - channel
+ AuditLogChangeKeyPosition AuditLogChangeKey = "position"
+ // AuditLogChangeKeyPreferredLocale is sent when preferred locale changed (string) - guild
+ AuditLogChangeKeyPreferredLocale AuditLogChangeKey = "preferred_locale"
+ // AuditLogChangeKeyPrivacylevel is sent when privacy level of the stage instance changed (integer - privacy level) - stage instance or guild scheduled event
+ AuditLogChangeKeyPrivacylevel AuditLogChangeKey = "privacy_level"
+ // AuditLogChangeKeyPruneDeleteDays is sent when number of days after which inactive and role-unassigned members are kicked changed (int) - guild
+ AuditLogChangeKeyPruneDeleteDays AuditLogChangeKey = "prune_delete_days"
+ // AuditLogChangeKeyPublicUpdatesChannelID is sent when id of the public updates channel changed (snowflake) - guild
+ AuditLogChangeKeyPublicUpdatesChannelID AuditLogChangeKey = "public_updates_channel_id"
+ // AuditLogChangeKeyRateLimitPerUser is sent when amount of seconds a user has to wait before sending another message changed (int) - channel
+ AuditLogChangeKeyRateLimitPerUser AuditLogChangeKey = "rate_limit_per_user"
+ // AuditLogChangeKeyRegion is sent when region changed (string) - guild
+ AuditLogChangeKeyRegion AuditLogChangeKey = "region"
+ // AuditLogChangeKeyRulesChannelID is sent when id of the rules channel changed (snowflake) - guild
+ AuditLogChangeKeyRulesChannelID AuditLogChangeKey = "rules_channel_id"
+ // AuditLogChangeKeySplashHash is sent when invite splash page artwork changed (string) - guild
+ AuditLogChangeKeySplashHash AuditLogChangeKey = "splash_hash"
+ // AuditLogChangeKeyStatus is sent when status of guild scheduled event was changed (int - guild scheduled event status) - guild scheduled event
+ AuditLogChangeKeyStatus AuditLogChangeKey = "status"
+ // AuditLogChangeKeySystemChannelID is sent when id of the system channel changed (snowflake) - guild
+ AuditLogChangeKeySystemChannelID AuditLogChangeKey = "system_channel_id"
+ // AuditLogChangeKeyTags is sent when related emoji of sticker changed (string) - sticker
+ AuditLogChangeKeyTags AuditLogChangeKey = "tags"
+ // AuditLogChangeKeyTemporary is sent when invite code is now temporary or never expires (bool) - invite
+ AuditLogChangeKeyTemporary AuditLogChangeKey = "temporary"
+ // TODO: remove when compatibility is not required
+ AuditLogChangeKeyTempoary = AuditLogChangeKeyTemporary
+ // AuditLogChangeKeyTopic is sent when text channel topic or stage instance topic changed (string) - channel or stage instance
+ AuditLogChangeKeyTopic AuditLogChangeKey = "topic"
+ // AuditLogChangeKeyType is sent when type of entity created (int or string) - any
+ AuditLogChangeKeyType AuditLogChangeKey = "type"
+ // AuditLogChangeKeyUnicodeEmoji is sent when role unicode emoji changed (string) - role
+ AuditLogChangeKeyUnicodeEmoji AuditLogChangeKey = "unicode_emoji"
+ // AuditLogChangeKeyUserLimit is sent when new user limit in a voice channel set (int) - voice channel
+ AuditLogChangeKeyUserLimit AuditLogChangeKey = "user_limit"
+ // AuditLogChangeKeyUses is sent when number of times invite code used changed (int) - invite
+ AuditLogChangeKeyUses AuditLogChangeKey = "uses"
+ // AuditLogChangeKeyVanityURLCode is sent when guild invite vanity url changed (string) - guild
+ AuditLogChangeKeyVanityURLCode AuditLogChangeKey = "vanity_url_code"
+ // AuditLogChangeKeyVerificationLevel is sent when required verification level changed (int - verification level) - guild
+ AuditLogChangeKeyVerificationLevel AuditLogChangeKey = "verification_level"
+ // AuditLogChangeKeyWidgetChannelID is sent when channel id of the server widget changed (snowflake) - guild
+ AuditLogChangeKeyWidgetChannelID AuditLogChangeKey = "widget_channel_id"
+ // AuditLogChangeKeyWidgetEnabled is sent when server widget enabled/disabled (bool) - guild
+ AuditLogChangeKeyWidgetEnabled AuditLogChangeKey = "widget_enabled"
+ // AuditLogChangeKeyRoleAdd is sent when new role added (array of partial role objects) - guild
+ AuditLogChangeKeyRoleAdd AuditLogChangeKey = "$add"
+ // AuditLogChangeKeyRoleRemove is sent when role removed (array of partial role objects) - guild
+ AuditLogChangeKeyRoleRemove AuditLogChangeKey = "$remove"
+)
+
+// AuditLogOptions optional data for the AuditLog
+// https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-optional-audit-entry-info
+type AuditLogOptions struct {
+ DeleteMemberDays string `json:"delete_member_days"`
+ MembersRemoved string `json:"members_removed"`
+ ChannelID string `json:"channel_id"`
+ MessageID string `json:"message_id"`
+ Count string `json:"count"`
+ ID string `json:"id"`
+ Type *AuditLogOptionsType `json:"type"`
+ RoleName string `json:"role_name"`
+ ApplicationID string `json:"application_id"`
+ AutoModerationRuleName string `json:"auto_moderation_rule_name"`
+ AutoModerationRuleTriggerType string `json:"auto_moderation_rule_trigger_type"`
+ IntegrationType string `json:"integration_type"`
+}
+
+// AuditLogOptionsType of the AuditLogOption
+// https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-optional-audit-entry-info
+type AuditLogOptionsType string
+
+// Valid Types for AuditLogOptionsType
+const (
+ AuditLogOptionsTypeRole AuditLogOptionsType = "0"
+ AuditLogOptionsTypeMember AuditLogOptionsType = "1"
+)
+
+// AuditLogAction is the Action of the AuditLog (see AuditLogAction* consts)
+// https://discord.com/developers/docs/resources/audit-log#audit-log-entry-object-audit-log-events
+type AuditLogAction int
+
+// Block contains Discord Audit Log Action Types
+const (
+ AuditLogActionGuildUpdate AuditLogAction = 1
+
+ AuditLogActionChannelCreate AuditLogAction = 10
+ AuditLogActionChannelUpdate AuditLogAction = 11
+ AuditLogActionChannelDelete AuditLogAction = 12
+ AuditLogActionChannelOverwriteCreate AuditLogAction = 13
+ AuditLogActionChannelOverwriteUpdate AuditLogAction = 14
+ AuditLogActionChannelOverwriteDelete AuditLogAction = 15
+
+ AuditLogActionMemberKick AuditLogAction = 20
+ AuditLogActionMemberPrune AuditLogAction = 21
+ AuditLogActionMemberBanAdd AuditLogAction = 22
+ AuditLogActionMemberBanRemove AuditLogAction = 23
+ AuditLogActionMemberUpdate AuditLogAction = 24
+ AuditLogActionMemberRoleUpdate AuditLogAction = 25
+ AuditLogActionMemberMove AuditLogAction = 26
+ AuditLogActionMemberDisconnect AuditLogAction = 27
+ AuditLogActionBotAdd AuditLogAction = 28
+
+ AuditLogActionRoleCreate AuditLogAction = 30
+ AuditLogActionRoleUpdate AuditLogAction = 31
+ AuditLogActionRoleDelete AuditLogAction = 32
+
+ AuditLogActionInviteCreate AuditLogAction = 40
+ AuditLogActionInviteUpdate AuditLogAction = 41
+ AuditLogActionInviteDelete AuditLogAction = 42
+
+ AuditLogActionWebhookCreate AuditLogAction = 50
+ AuditLogActionWebhookUpdate AuditLogAction = 51
+ AuditLogActionWebhookDelete AuditLogAction = 52
+
+ AuditLogActionEmojiCreate AuditLogAction = 60
+ AuditLogActionEmojiUpdate AuditLogAction = 61
+ AuditLogActionEmojiDelete AuditLogAction = 62
+
+ AuditLogActionMessageDelete AuditLogAction = 72
+ AuditLogActionMessageBulkDelete AuditLogAction = 73
+ AuditLogActionMessagePin AuditLogAction = 74
+ AuditLogActionMessageUnpin AuditLogAction = 75
+
+ AuditLogActionIntegrationCreate AuditLogAction = 80
+ AuditLogActionIntegrationUpdate AuditLogAction = 81
+ AuditLogActionIntegrationDelete AuditLogAction = 82
+ AuditLogActionStageInstanceCreate AuditLogAction = 83
+ AuditLogActionStageInstanceUpdate AuditLogAction = 84
+ AuditLogActionStageInstanceDelete AuditLogAction = 85
+
+ AuditLogActionStickerCreate AuditLogAction = 90
+ AuditLogActionStickerUpdate AuditLogAction = 91
+ AuditLogActionStickerDelete AuditLogAction = 92
+
+ AuditLogGuildScheduledEventCreate AuditLogAction = 100
+ AuditLogGuildScheduledEventUpdate AuditLogAction = 101
+ AuditLogGuildScheduledEventDelete AuditLogAction = 102
+
+ AuditLogActionThreadCreate AuditLogAction = 110
+ AuditLogActionThreadUpdate AuditLogAction = 111
+ AuditLogActionThreadDelete AuditLogAction = 112
+
+ AuditLogActionApplicationCommandPermissionUpdate AuditLogAction = 121
+
+ AuditLogActionAutoModerationRuleCreate AuditLogAction = 140
+ AuditLogActionAutoModerationRuleUpdate AuditLogAction = 141
+ AuditLogActionAutoModerationRuleDelete AuditLogAction = 142
+ AuditLogActionAutoModerationBlockMessage AuditLogAction = 143
+ AuditLogActionAutoModerationFlagToChannel AuditLogAction = 144
+ AuditLogActionAutoModerationUserCommunicationDisabled AuditLogAction = 145
+
+ AuditLogActionCreatorMonetizationRequestCreated AuditLogAction = 150
+ AuditLogActionCreatorMonetizationTermsAccepted AuditLogAction = 151
+
+ AuditLogActionOnboardingPromptCreate AuditLogAction = 163
+ AuditLogActionOnboardingPromptUpdate AuditLogAction = 164
+ AuditLogActionOnboardingPromptDelete AuditLogAction = 165
+ AuditLogActionOnboardingCreate AuditLogAction = 166
+ AuditLogActionOnboardingUpdate AuditLogAction = 167
+
+ AuditLogActionHomeSettingsCreate = 190
+ AuditLogActionHomeSettingsUpdate = 191
+)
+
+type MuteConfig struct {
+ // When the mute will expire.
+ EndTime *time.Time `json:"end_time"`
+ // The duration of the mute, in seconds. If this is -1, then the mute lasts
+ // forever.
+ SelectedTimeWindow *int `json:"selected_time_window"`
+}
+
+// A UserGuildSettingsChannelOverride stores data for a channel override for a users guild settings.
+type UserGuildSettingsChannelOverride struct {
+ ChannelID string `json:"channel_id"`
+ // Whether a guild category channel is collapsed.
+ Collapsed bool `json:"collapsed"`
+ MessageNotifications NotificationLevel `json:"message_notifications"`
+ MuteConfig *MuteConfig `json:"mute_config"`
+ Muted bool `json:"muted"`
+}
+
+type UserGuildSettingsList struct {
+ Version int `json:"version"`
+ Partial bool `json:"partial"`
+ Entries []*UserGuildSettings `json:"entries"`
+}
+
+// A UserGuildSettings stores data for a users guild settings.
+type UserGuildSettings struct {
+ ChannelOverrides []*UserGuildSettingsChannelOverride `json:"channel_overrides"`
+
+ // TODO: flags
+
+ // This is an empty string when the guild settings actually apply to the
+ // user's private channels (DMs).
+ GuildID string `json:"guild_id"`
+ HideMutedChannels bool `json:"hide_muted_channels"`
+ MessageNotifications NotificationLevel `json:"message_notifications"`
+ MobilePush bool `json:"mobile_push"`
+ MuteConfig *MuteConfig `json:"mute_config"`
+ MuteScheduledEvents bool `json:"mute_scheduled_events"`
+ Muted bool `json:"muted"`
+
+ // TODO: notify_highlights
+
+ SuppressEveryone bool `json:"suppress_everyone"`
+ SuppressRoles bool `json:"suppress_roles"`
+ Version int `json:"version"`
+}
+
+// A UserGuildSettingsChannelOverrideEdit stores partial data for editing ChannelOverrides.
+type UserGuildSettingsChannelOverrideEdit struct {
+ // Whether a guild category channel is collapsed.
+ Collapsed *bool `json:"collapsed,omitempty"`
+ MessageNotifications *NotificationLevel `json:"message_notifications,omitempty"`
+ MuteConfig *MuteConfig `json:"mute_config,omitempty"`
+ Muted *bool `json:"muted,omitempty"`
+}
+
+// A UserGuildSettingsEdit stores partial data for editing UserGuildSettings.
+//
+// Pointers and omitempty are used because sending "muted: null" in JSON
+// unmutes; the field must be omitted entirely.
+type UserGuildSettingsEdit struct {
+ ChannelOverrides map[string]*UserGuildSettingsChannelOverrideEdit `json:"channel_overrides,omitempty"`
+
+ // TODO: flags
+
+ HideMutedChannels *bool `json:"hide_muted_channels,omitempty"`
+ MessageNotifications *NotificationLevel `json:"message_notifications,omitempty"`
+ MobilePush *bool `json:"mobile_push,omitempty"`
+ MuteConfig *MuteConfig `json:"mute_config,omitempty"`
+ MuteScheduledEvents *bool `json:"mute_scheduled_events,omitempty"`
+ Muted *bool `json:"muted,omitempty"`
+
+ // TODO: notify_highlights
+
+ SuppressEveryone *bool `json:"suppress_everyone,omitempty"`
+ SuppressRoles *bool `json:"suppress_roles,omitempty"`
+}
+
+// GuildMemberParams stores data needed to update a member
+// https://discord.com/developers/docs/resources/guild#modify-guild-member
+type GuildMemberParams struct {
+ // Value to set user's nickname to.
+ Nick string `json:"nick,omitempty"`
+ // Array of role ids the member is assigned.
+ Roles *[]string `json:"roles,omitempty"`
+ // ID of channel to move user to (if they are connected to voice).
+ // Set to "" to remove user from a voice channel.
+ ChannelID *string `json:"channel_id,omitempty"`
+ // Whether the user is muted in voice channels.
+ Mute *bool `json:"mute,omitempty"`
+ // Whether the user is deafened in voice channels.
+ Deaf *bool `json:"deaf,omitempty"`
+ // When the user's timeout will expire and the user will be able
+ // to communicate in the guild again (up to 28 days in the future).
+ // Set to time.Time{} to remove timeout.
+ CommunicationDisabledUntil *time.Time `json:"communication_disabled_until,omitempty"`
+}
+
+// MarshalJSON is a helper function to marshal GuildMemberParams.
+func (p GuildMemberParams) MarshalJSON() (res []byte, err error) {
+ type guildMemberParams GuildMemberParams
+ v := struct {
+ guildMemberParams
+ ChannelID json.RawMessage `json:"channel_id,omitempty"`
+ CommunicationDisabledUntil json.RawMessage `json:"communication_disabled_until,omitempty"`
+ }{guildMemberParams: guildMemberParams(p)}
+
+ if p.ChannelID != nil {
+ if *p.ChannelID == "" {
+ v.ChannelID = json.RawMessage(`null`)
+ } else {
+ res, err = json.Marshal(p.ChannelID)
+ if err != nil {
+ return
+ }
+ v.ChannelID = res
+ }
+ }
+
+ if p.CommunicationDisabledUntil != nil {
+ if p.CommunicationDisabledUntil.IsZero() {
+ v.CommunicationDisabledUntil = json.RawMessage(`null`)
+ } else {
+ res, err = json.Marshal(p.CommunicationDisabledUntil)
+ if err != nil {
+ return
+ }
+ v.CommunicationDisabledUntil = res
+ }
+ }
+
+ return json.Marshal(v)
+}
+
+// GuildMemberAddParams stores data needed to add a user to a guild.
+// NOTE: All fields are optional, except AccessToken.
+type GuildMemberAddParams struct {
+ // Valid access_token for the user.
+ AccessToken string `json:"access_token"`
+ // Value to set users nickname to.
+ Nick string `json:"nick,omitempty"`
+ // A list of role ID's to set on the member.
+ Roles []string `json:"roles,omitempty"`
+ // Whether the user is muted.
+ Mute bool `json:"mute,omitempty"`
+ // Whether the user is deafened.
+ Deaf bool `json:"deaf,omitempty"`
+}
+
+type FormFieldError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+type FormFieldErrors struct {
+ Errors []FormFieldError `json:"_errors"`
+}
+
+// An APIErrorMessage is an api error message returned from discord
+type APIErrorMessage struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+
+ Errors map[string]FormFieldErrors `json:"errors"`
+}
+
+// MessageReaction stores the data for a message reaction.
+type MessageReaction struct {
+ UserID string `json:"user_id"`
+ MessageID string `json:"message_id"`
+ Emoji Emoji `json:"emoji"`
+ ChannelID string `json:"channel_id"`
+ GuildID string `json:"guild_id,omitempty"`
+}
+
+// GatewayBotResponse stores the data for the gateway/bot response
+type GatewayBotResponse struct {
+ URL string `json:"url"`
+ Shards int `json:"shards"`
+ SessionStartLimit SessionInformation `json:"session_start_limit"`
+}
+
+// SessionInformation provides the information for max concurrency sharding
+type SessionInformation struct {
+ Total int `json:"total,omitempty"`
+ Remaining int `json:"remaining,omitempty"`
+ ResetAfter int `json:"reset_after,omitempty"`
+ MaxConcurrency int `json:"max_concurrency,omitempty"`
+}
+
+// GatewayStatusUpdate is sent by the client to indicate a presence or status update
+// https://discord.com/developers/docs/topics/gateway#update-status-gateway-status-update-structure
+type GatewayStatusUpdate struct {
+ Since int `json:"since"`
+ Game Activity `json:"-"`
+ Status string `json:"status"`
+ AFK bool `json:"afk"`
+
+ Activities []Activity `json:"activities"`
+}
+
+// Activity defines the Activity sent with GatewayStatusUpdate
+// https://discord.com/developers/docs/topics/gateway#activity-object
+type Activity struct {
+ Name string `json:"name"`
+ Type ActivityType `json:"type"`
+ URL string `json:"url,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ ApplicationID string `json:"application_id,omitempty"`
+ State string `json:"state,omitempty"`
+ Details string `json:"details,omitempty"`
+ Timestamps TimeStamps `json:"timestamps,omitempty"`
+ Emoji Emoji `json:"emoji,omitempty"`
+ Party Party `json:"party,omitempty"`
+ Assets Assets `json:"assets,omitempty"`
+ Secrets Secrets `json:"secrets,omitempty"`
+ Instance bool `json:"instance,omitempty"`
+ Flags uint32 `json:"flags,omitempty"`
+}
+
+// UnmarshalJSON is a custom unmarshaljson to make CreatedAt a time.Time instead of an int
+func (activity *Activity) UnmarshalJSON(b []byte) error {
+ temp := struct {
+ Name string `json:"name"`
+ Type ActivityType `json:"type"`
+ URL string `json:"url,omitempty"`
+ CreatedAt int64 `json:"created_at"`
+ ApplicationID json.Number `json:"application_id,omitempty"`
+ State string `json:"state,omitempty"`
+ Details string `json:"details,omitempty"`
+ Timestamps TimeStamps `json:"timestamps,omitempty"`
+ Emoji Emoji `json:"emoji,omitempty"`
+ Party Party `json:"party,omitempty"`
+ Assets Assets `json:"assets,omitempty"`
+ Secrets Secrets `json:"secrets,omitempty"`
+ Instance bool `json:"instance,omitempty"`
+ Flags uint32 `json:"flags,omitempty"`
+ }{}
+ err := Unmarshal(b, &temp)
+ if err != nil {
+ return err
+ }
+ activity.ApplicationID = temp.ApplicationID.String()
+ activity.CreatedAt = time.Unix(0, temp.CreatedAt*1000000)
+ activity.Assets = temp.Assets
+ activity.Details = temp.Details
+ activity.Emoji = temp.Emoji
+ activity.Flags = temp.Flags
+ activity.Instance = temp.Instance
+ activity.Name = temp.Name
+ activity.Party = temp.Party
+ activity.Secrets = temp.Secrets
+ activity.State = temp.State
+ activity.Timestamps = temp.Timestamps
+ activity.Type = temp.Type
+ activity.URL = temp.URL
+ return nil
+}
+
+// Party defines the Party field in the Activity struct
+// https://discord.com/developers/docs/topics/gateway#activity-object
+type Party struct {
+ ID string `json:"id,omitempty"`
+ Size []int `json:"size,omitempty"`
+}
+
+// Secrets defines the Secrets field for the Activity struct
+// https://discord.com/developers/docs/topics/gateway#activity-object
+type Secrets struct {
+ Join string `json:"join,omitempty"`
+ Spectate string `json:"spectate,omitempty"`
+ Match string `json:"match,omitempty"`
+}
+
+// ActivityType is the type of Activity (see ActivityType* consts) in the Activity struct
+// https://discord.com/developers/docs/topics/gateway#activity-object-activity-types
+type ActivityType int
+
+// Valid ActivityType values
+const (
+ ActivityTypeGame ActivityType = 0
+ ActivityTypeStreaming ActivityType = 1
+ ActivityTypeListening ActivityType = 2
+ ActivityTypeWatching ActivityType = 3
+ ActivityTypeCustom ActivityType = 4
+ ActivityTypeCompeting ActivityType = 5
+)
+
+// Identify is sent during initial handshake with the discord gateway.
+// https://discord.com/developers/docs/topics/gateway#identify
+type Identify struct {
+ Token string `json:"token"`
+ Capabilities int `json:"capabilities,omitempty"`
+ Properties interface{} `json:"properties"`
+ Presence GatewayStatusUpdate `json:"presence,omitempty"`
+ Compress bool `json:"compress"`
+ ClientState *ClientState `json:"client_state,omitempty"`
+ LargeThreshold int `json:"large_threshold,omitempty"`
+ Shard *[2]int `json:"shard,omitempty"`
+ Intents Intent `json:"intents,omitempty"`
+}
+
+// IdentifyProperties contains the "properties" portion of an Identify packet
+// https://discord.com/developers/docs/topics/gateway#identify-identify-connection-properties
+type IdentifyProperties struct {
+ OS string `json:"$os"`
+ Browser string `json:"$browser"`
+ Device string `json:"$device"`
+ Referer string `json:"$referer"`
+ ReferringDomain string `json:"$referring_domain"`
+}
+
+// StageInstance holds information about a live stage.
+// https://discord.com/developers/docs/resources/stage-instance#stage-instance-resource
+type StageInstance struct {
+ // The id of this Stage instance
+ ID string `json:"id"`
+ // The guild id of the associated Stage channel
+ GuildID string `json:"guild_id"`
+ // The id of the associated Stage channel
+ ChannelID string `json:"channel_id"`
+ // The topic of the Stage instance (1-120 characters)
+ Topic string `json:"topic"`
+ // The privacy level of the Stage instance
+ // https://discord.com/developers/docs/resources/stage-instance#stage-instance-object-privacy-level
+ PrivacyLevel StageInstancePrivacyLevel `json:"privacy_level"`
+ // Whether or not Stage Discovery is disabled (deprecated)
+ DiscoverableDisabled bool `json:"discoverable_disabled"`
+ // The id of the scheduled event for this Stage instance
+ GuildScheduledEventID string `json:"guild_scheduled_event_id"`
+}
+
+// StageInstanceParams represents the parameters needed to create or edit a stage instance
+type StageInstanceParams struct {
+ // ChannelID represents the id of the Stage channel
+ ChannelID string `json:"channel_id,omitempty"`
+ // Topic of the Stage instance (1-120 characters)
+ Topic string `json:"topic,omitempty"`
+ // PrivacyLevel of the Stage instance (default GUILD_ONLY)
+ PrivacyLevel StageInstancePrivacyLevel `json:"privacy_level,omitempty"`
+ // SendStartNotification will notify @everyone that a Stage instance has started
+ SendStartNotification bool `json:"send_start_notification,omitempty"`
+}
+
+// StageInstancePrivacyLevel represents the privacy level of a Stage instance
+// https://discord.com/developers/docs/resources/stage-instance#stage-instance-object-privacy-level
+type StageInstancePrivacyLevel int
+
+const (
+ // StageInstancePrivacyLevelPublic The Stage instance is visible publicly. (deprecated)
+ StageInstancePrivacyLevelPublic StageInstancePrivacyLevel = 1
+ // StageInstancePrivacyLevelGuildOnly The Stage instance is visible to only guild members.
+ StageInstancePrivacyLevelGuildOnly StageInstancePrivacyLevel = 2
+)
+
+// PollLayoutType represents the layout of a poll.
+type PollLayoutType int
+
+// Valid PollLayoutType values.
+const (
+ PollLayoutTypeDefault PollLayoutType = 1
+)
+
+// PollMedia contains common data used by question and answers.
+type PollMedia struct {
+ Text string `json:"text,omitempty"`
+ Emoji *ComponentEmoji `json:"emoji,omitempty"` // TODO: rename the type
+}
+
+// PollAnswer represents a single answer in a poll.
+type PollAnswer struct {
+ // NOTE: should not be set on creation.
+ AnswerID int `json:"answer_id,omitempty"`
+ Media *PollMedia `json:"poll_media"`
+}
+
+// PollAnswerCount stores counted poll votes for a single answer.
+type PollAnswerCount struct {
+ ID int `json:"id"`
+ Count int `json:"count"`
+ MeVoted bool `json:"me_voted"`
+}
+
+// PollResults contains voting results on a poll.
+type PollResults struct {
+ Finalized bool `json:"is_finalized"`
+ AnswerCounts []*PollAnswerCount `json:"answer_counts"`
+}
+
+// Poll contains all poll related data.
+type Poll struct {
+ Question PollMedia `json:"question"`
+ Answers []PollAnswer `json:"answers"`
+ AllowMultiselect bool `json:"allow_multiselect"`
+ LayoutType PollLayoutType `json:"layout_type,omitempty"`
+
+ // NOTE: should be set only on creation, when fetching use Expiry.
+ Duration int `json:"duration,omitempty"`
+
+ // NOTE: available only when fetching.
+
+ Results *PollResults `json:"results,omitempty"`
+ // NOTE: as Discord documentation notes, this field might be null even when fetching.
+ Expiry *time.Time `json:"expiry,omitempty"`
+}
+
+// SKUType is the type of SKU (see SKUType* consts)
+// https://discord.com/developers/docs/monetization/skus
+type SKUType int
+
+// Valid SKUType values
+const (
+ SKUTypeDurable SKUType = 2
+ SKUTypeConsumable SKUType = 3
+ SKUTypeSubscription SKUType = 5
+ // SKUTypeSubscriptionGroup is a system-generated group for each subscription SKU.
+ SKUTypeSubscriptionGroup SKUType = 6
+)
+
+// SKUFlags is a bitfield of flags used to differentiate user and server subscriptions (see SKUFlag* consts)
+// https://discord.com/developers/docs/monetization/skus#sku-object-sku-flags
+type SKUFlags uint32
+
+const (
+ // SKUFlagAvailable indicates that the SKU is available for purchase.
+ SKUFlagAvailable SKUFlags = 1 << 2
+ // SKUFlagGuildSubscription indicates that the SKU is a guild subscription.
+ SKUFlagGuildSubscription SKUFlags = 1 << 7
+ // SKUFlagUserSubscription indicates that the SKU is a user subscription.
+ SKUFlagUserSubscription SKUFlags = 1 << 8
+)
+
+// SKU (stock-keeping units) represent premium offerings
+type SKU struct {
+ // The ID of the SKU
+ ID string `json:"id"`
+
+ // The Type of the SKU
+ Type SKUType `json:"type"`
+
+ // The ID of the parent application
+ ApplicationID string `json:"application_id"`
+
+ // Customer-facing name of the SKU.
+ Name string `json:"name"`
+
+ // System-generated URL slug based on the SKU's name.
+ Slug string `json:"slug"`
+
+ // SKUFlags combined as a bitfield. The presence of a certain flag can be checked
+ // by performing a bitwise AND operation between this int and the flag.
+ Flags SKUFlags `json:"flags"`
+}
+
+// Subscription represents a user making recurring payments for at least one SKU over an ongoing period.
+// https://discord.com/developers/docs/resources/subscription#subscription-object
+type Subscription struct {
+ // ID of the subscription
+ ID string `json:"id"`
+
+ // ID of the user who is subscribed
+ UserID string `json:"user_id"`
+
+ // List of SKUs subscribed to
+ SKUIDs []string `json:"sku_ids"`
+
+ // List of entitlements granted for this subscription
+ EntitlementIDs []string `json:"entitlement_ids"`
+
+ // List of SKUs that this user will be subscribed to at renewal
+ RenewalSKUIDs []string `json:"renewal_sku_ids,omitempty"`
+
+ // Start of the current subscription period
+ CurrentPeriodStart time.Time `json:"current_period_start"`
+
+ // End of the current subscription period
+ CurrentPeriodEnd time.Time `json:"current_period_end"`
+
+ // Current status of the subscription
+ Status SubscriptionStatus `json:"status"`
+
+ // When the subscription was canceled. Only present if the subscription has been canceled.
+ CanceledAt *time.Time `json:"canceled_at,omitempty"`
+
+ // ISO3166-1 alpha-2 country code of the payment source used to purchase the subscription. Missing unless queried with a private OAuth scope.
+ Country string `json:"country,omitempty"`
+}
+
+// SubscriptionStatus is the current status of a Subscription Object
+// https://discord.com/developers/docs/resources/subscription#subscription-statuses
+type SubscriptionStatus int
+
+// Valid SubscriptionStatus values
+const (
+ SubscriptionStatusActive = 0
+ SubscriptionStatusEnding = 1
+ SubscriptionStatusInactive = 2
+)
+
+// EntitlementType is the type of entitlement (see EntitlementType* consts)
+// https://discord.com/developers/docs/monetization/entitlements#entitlement-object-entitlement-types
+type EntitlementType int
+
+// Valid EntitlementType values
+const (
+ EntitlementTypePurchase = 1
+ EntitlementTypePremiumSubscription = 2
+ EntitlementTypeDeveloperGift = 3
+ EntitlementTypeTestModePurchase = 4
+ EntitlementTypeFreePurchase = 5
+ EntitlementTypeUserGift = 6
+ EntitlementTypePremiumPurchase = 7
+ EntitlementTypeApplicationSubscription = 8
+)
+
+// Entitlement represents that a user or guild has access to a premium offering
+// in your application.
+type Entitlement struct {
+ // The ID of the entitlement
+ ID string `json:"id"`
+
+ // The ID of the SKU
+ SKUID string `json:"sku_id"`
+
+ // The ID of the parent application
+ ApplicationID string `json:"application_id"`
+
+ // The ID of the user that is granted access to the entitlement's sku
+ // Only available for user subscriptions.
+ UserID string `json:"user_id,omitempty"`
+
+ // The type of the entitlement
+ Type EntitlementType `json:"type"`
+
+ // The entitlement was deleted
+ Deleted bool `json:"deleted"`
+
+ // The start date at which the entitlement is valid.
+ // Not present when using test entitlements.
+ StartsAt *time.Time `json:"starts_at,omitempty"`
+
+ // The date at which the entitlement is no longer valid.
+ // Not present when using test entitlements or when receiving an ENTITLEMENT_CREATE event.
+ EndsAt *time.Time `json:"ends_at,omitempty"`
+
+ // The ID of the guild that is granted access to the entitlement's sku.
+ // Only available for guild subscriptions.
+ GuildID string `json:"guild_id,omitempty"`
+
+ // Whether or not the entitlement has been consumed.
+ // Only available for consumable items.
+ Consumed *bool `json:"consumed,omitempty"`
+
+ // The SubscriptionID of the entitlement.
+ // Not present when using test entitlements.
+ SubscriptionID string `json:"subscription_id,omitempty"`
+}
+
+// EntitlementOwnerType is the type of entitlement (see EntitlementOwnerType* consts)
+type EntitlementOwnerType int
+
+// Valid EntitlementOwnerType values
+const (
+ EntitlementOwnerTypeGuildSubscription EntitlementOwnerType = 1
+ EntitlementOwnerTypeUserSubscription EntitlementOwnerType = 2
+)
+
+// EntitlementTest is used to test granting an entitlement to a user or guild
+type EntitlementTest struct {
+ // The ID of the SKU to grant the entitlement to
+ SKUID string `json:"sku_id"`
+
+ // The ID of the guild or user to grant the entitlement to
+ OwnerID string `json:"owner_id"`
+
+ // OwnerType is the type of which the entitlement should be created
+ OwnerType EntitlementOwnerType `json:"owner_type"`
+}
+
+// EntitlementFilterOptions are the options for filtering Entitlements
+type EntitlementFilterOptions struct {
+ // Optional user ID to look up for.
+ UserID string
+
+ // Optional array of SKU IDs to check for.
+ SkuIDs []string
+
+ // Optional timestamp to retrieve Entitlements before this time.
+ Before *time.Time
+
+ // Optional timestamp to retrieve Entitlements after this time.
+ After *time.Time
+
+ // Optional maximum number of entitlements to return (1-100, default 100).
+ Limit int
+
+ // Optional guild ID to look up for.
+ GuildID string
+
+ // Optional whether or not ended entitlements should be omitted.
+ ExcludeEnded bool
+}
+
+// MessagePin contains information about a pinned message, and the message itself
+type MessagePin struct {
+ // The time the message was pinned
+ PinnedAt time.Time `json:"pinned_at"`
+
+ // The message object which was pinned
+ Message *Message `json:"message"`
+}
+
+// ChannelMessagePinsList contains a list of pinned messages in a channel
+type ChannelMessagePinsList struct {
+ // The list of pinned messages
+ Items []*MessagePin `json:"items"`
+
+ // Whether there are more items available to fetch
+ HasMore bool `json:"has_more"`
+}
+
+// Constants for the different bit offsets of text channel permissions
+const (
+ // Deprecated: PermissionReadMessages has been replaced with PermissionViewChannel for text and voice channels
+ PermissionReadMessages = 1 << 10
+
+ // Allows for sending messages in a channel and creating threads in a forum (does not allow sending messages in threads).
+ PermissionSendMessages = 1 << 11
+
+ // Allows for sending of /tts messages.
+ PermissionSendTTSMessages = 1 << 12
+
+ // Allows for deletion of other users messages.
+ PermissionManageMessages = 1 << 13
+
+ // Links sent by users with this permission will be auto-embedded.
+ PermissionEmbedLinks = 1 << 14
+
+ // Allows for uploading images and files.
+ PermissionAttachFiles = 1 << 15
+
+ // Allows for reading of message history.
+ PermissionReadMessageHistory = 1 << 16
+
+ // Allows for using the @everyone tag to notify all users in a channel, and the @here tag to notify all online users in a channel.
+ PermissionMentionEveryone = 1 << 17
+
+ // Allows the usage of custom emojis from other servers.
+ PermissionUseExternalEmojis = 1 << 18
+
+ // Deprecated: PermissionUseSlashCommands has been replaced by PermissionUseApplicationCommands
+ PermissionUseSlashCommands = 1 << 31
+
+ // Allows members to use application commands, including slash commands and context menu commands.
+ PermissionUseApplicationCommands = 1 << 31
+
+ // Allows for deleting and archiving threads, and viewing all private threads.
+ PermissionManageThreads = 1 << 34
+
+ // Allows for creating public and announcement threads.
+ PermissionCreatePublicThreads = 1 << 35
+
+ // Allows for creating private threads.
+ PermissionCreatePrivateThreads = 1 << 36
+
+ // Allows the usage of custom stickers from other servers.
+ PermissionUseExternalStickers = 1 << 37
+
+ // Allows for sending messages in threads.
+ PermissionSendMessagesInThreads = 1 << 38
+
+ // Allows sending voice messages.
+ PermissionSendVoiceMessages = 1 << 46
+
+ // Allows sending polls.
+ PermissionSendPolls = 1 << 49
+
+ // Allows user-installed apps to send public responses. When disabled, users will still be allowed to use their apps but the responses will be ephemeral. This only applies to apps not also installed to the server.
+ PermissionUseExternalApps = 1 << 50
+)
+
+// Constants for the different bit offsets of voice permissions
+const (
+ // Allows for using priority speaker in a voice channel.
+ PermissionVoicePrioritySpeaker = 1 << 8
+
+ // Allows the user to go live.
+ PermissionVoiceStreamVideo = 1 << 9
+
+ // Allows for joining of a voice channel.
+ PermissionVoiceConnect = 1 << 20
+
+ // Allows for speaking in a voice channel.
+ PermissionVoiceSpeak = 1 << 21
+
+ // Allows for muting members in a voice channel.
+ PermissionVoiceMuteMembers = 1 << 22
+
+ // Allows for deafening of members in a voice channel.
+ PermissionVoiceDeafenMembers = 1 << 23
+
+ // Allows for moving of members between voice channels.
+ PermissionVoiceMoveMembers = 1 << 24
+
+ // Allows for using voice-activity-detection in a voice channel.
+ PermissionVoiceUseVAD = 1 << 25
+
+ // Allows for requesting to speak in stage channels.
+ PermissionVoiceRequestToSpeak = 1 << 32
+
+ // Deprecated: PermissionUseActivities has been replaced by PermissionUseEmbeddedActivities.
+ PermissionUseActivities = 1 << 39
+
+ // Allows for using Activities (applications with the EMBEDDED flag) in a voice channel.
+ PermissionUseEmbeddedActivities = 1 << 39
+
+ // Allows for using soundboard in a voice channel.
+ PermissionUseSoundboard = 1 << 42
+
+ // Allows the usage of custom soundboard sounds from other servers.
+ PermissionUseExternalSounds = 1 << 45
+)
+
+// Constants for general management.
+const (
+ // Allows for modification of own nickname.
+ PermissionChangeNickname = 1 << 26
+
+ // Allows for modification of other users nicknames.
+ PermissionManageNicknames = 1 << 27
+
+ // Allows management and editing of roles.
+ PermissionManageRoles = 1 << 28
+
+ // Allows management and editing of webhooks.
+ PermissionManageWebhooks = 1 << 29
+
+ // Deprecated: PermissionManageEmojis has been replaced by PermissionManageGuildExpressions.
+ PermissionManageEmojis = 1 << 30
+
+ // Allows for editing and deleting emojis, stickers, and soundboard sounds created by all users.
+ PermissionManageGuildExpressions = 1 << 30
+
+ // Allows for editing and deleting scheduled events created by all users.
+ PermissionManageEvents = 1 << 33
+
+ // Allows for viewing role subscription insights.
+ PermissionViewCreatorMonetizationAnalytics = 1 << 41
+
+ // Allows for creating emojis, stickers, and soundboard sounds, and editing and deleting those created by the current user.
+ PermissionCreateGuildExpressions = 1 << 43
+
+ // Allows for creating scheduled events, and editing and deleting those created by the current user.
+ PermissionCreateEvents = 1 << 44
+)
+
+// Constants for the different bit offsets of general permissions
+const (
+ // Allows creation of instant invites.
+ PermissionCreateInstantInvite = 1 << 0
+
+ // Allows kicking members.
+ PermissionKickMembers = 1 << 1
+
+ // Allows banning members.
+ PermissionBanMembers = 1 << 2
+
+ // Allows all permissions and bypasses channel permission overwrites.
+ PermissionAdministrator = 1 << 3
+
+ // Allows management and editing of channels.
+ PermissionManageChannels = 1 << 4
+
+ // Deprecated: PermissionManageServer has been replaced by PermissionManageGuild.
+ PermissionManageServer = 1 << 5
+
+ // Allows management and editing of the guild.
+ PermissionManageGuild = 1 << 5
+
+ // Allows for the addition of reactions to messages.
+ PermissionAddReactions = 1 << 6
+
+ // Allows for viewing of audit logs.
+ PermissionViewAuditLogs = 1 << 7
+
+ // Allows guild members to view a channel, which includes reading messages in text channels and joining voice channels.
+ PermissionViewChannel = 1 << 10
+
+ // Allows for viewing guild insights.
+ PermissionViewGuildInsights = 1 << 19
+
+ // Allows for timing out users to prevent them from sending or reacting to messages in chat and threads, and from speaking in voice and stage channels.
+ PermissionModerateMembers = 1 << 40
+
+ PermissionAllText = PermissionViewChannel |
+ PermissionSendMessages |
+ PermissionSendTTSMessages |
+ PermissionManageMessages |
+ PermissionEmbedLinks |
+ PermissionAttachFiles |
+ PermissionReadMessageHistory |
+ PermissionMentionEveryone
+ PermissionAllVoice = PermissionViewChannel |
+ PermissionVoiceConnect |
+ PermissionVoiceSpeak |
+ PermissionVoiceMuteMembers |
+ PermissionVoiceDeafenMembers |
+ PermissionVoiceMoveMembers |
+ PermissionVoiceUseVAD |
+ PermissionVoicePrioritySpeaker
+ PermissionAllChannel = PermissionAllText |
+ PermissionAllVoice |
+ PermissionCreateInstantInvite |
+ PermissionManageRoles |
+ PermissionManageChannels |
+ PermissionAddReactions |
+ PermissionViewAuditLogs
+ PermissionAll = PermissionAllChannel |
+ PermissionKickMembers |
+ PermissionBanMembers |
+ PermissionManageServer |
+ PermissionAdministrator |
+ PermissionManageWebhooks |
+ PermissionManageEmojis
+)
+
+// Block contains Discord JSON Error Response codes
+const (
+ ErrCodeGeneralError = 0
+
+ ErrCodeUnknownAccount = 10001
+ ErrCodeUnknownApplication = 10002
+ ErrCodeUnknownChannel = 10003
+ ErrCodeUnknownGuild = 10004
+ ErrCodeUnknownIntegration = 10005
+ ErrCodeUnknownInvite = 10006
+ ErrCodeUnknownMember = 10007
+ ErrCodeUnknownMessage = 10008
+ ErrCodeUnknownOverwrite = 10009
+ ErrCodeUnknownProvider = 10010
+ ErrCodeUnknownRole = 10011
+ ErrCodeUnknownToken = 10012
+ ErrCodeUnknownUser = 10013
+ ErrCodeUnknownEmoji = 10014
+ ErrCodeUnknownWebhook = 10015
+ ErrCodeUnknownWebhookService = 10016
+ ErrCodeUnknownSession = 10020
+ ErrCodeUnknownBan = 10026
+ ErrCodeUnknownSKU = 10027
+ ErrCodeUnknownStoreListing = 10028
+ ErrCodeUnknownEntitlement = 10029
+ ErrCodeUnknownBuild = 10030
+ ErrCodeUnknownLobby = 10031
+ ErrCodeUnknownBranch = 10032
+ ErrCodeUnknownStoreDirectoryLayout = 10033
+ ErrCodeUnknownRedistributable = 10036
+ ErrCodeUnknownGiftCode = 10038
+ ErrCodeUnknownStream = 10049
+ ErrCodeUnknownPremiumServerSubscribeCooldown = 10050
+ ErrCodeUnknownGuildTemplate = 10057
+ ErrCodeUnknownDiscoveryCategory = 10059
+ ErrCodeUnknownSticker = 10060
+ ErrCodeUnknownInteraction = 10062
+ ErrCodeUnknownApplicationCommand = 10063
+ ErrCodeUnknownVoiceState = 10065
+ ErrCodeUnknownApplicationCommandPermissions = 10066
+ ErrCodeUnknownStageInstance = 10067
+ ErrCodeUnknownGuildMemberVerificationForm = 10068
+ ErrCodeUnknownGuildWelcomeScreen = 10069
+ ErrCodeUnknownGuildScheduledEvent = 10070
+ ErrCodeUnknownGuildScheduledEventUser = 10071
+ ErrUnknownTag = 10087
+
+ ErrCodeBotsCannotUseEndpoint = 20001
+ ErrCodeOnlyBotsCanUseEndpoint = 20002
+ ErrCodeExplicitContentCannotBeSentToTheDesiredRecipients = 20009
+ ErrCodeYouAreNotAuthorizedToPerformThisActionOnThisApplication = 20012
+ ErrCodeThisActionCannotBePerformedDueToSlowmodeRateLimit = 20016
+ ErrCodeOnlyTheOwnerOfThisAccountCanPerformThisAction = 20018
+ ErrCodeMessageCannotBeEditedDueToAnnouncementRateLimits = 20022
+ ErrCodeChannelHasHitWriteRateLimit = 20028
+ ErrCodeTheWriteActionYouArePerformingOnTheServerHasHitTheWriteRateLimit = 20029
+ ErrCodeStageTopicContainsNotAllowedWordsForPublicStages = 20031
+ ErrCodeGuildPremiumSubscriptionLevelTooLow = 20035
+
+ ErrCodeMaximumGuildsReached = 30001
+ ErrCodeMaximumPinsReached = 30003
+ ErrCodeMaximumNumberOfRecipientsReached = 30004
+ ErrCodeMaximumGuildRolesReached = 30005
+ ErrCodeMaximumNumberOfWebhooksReached = 30007
+ ErrCodeMaximumNumberOfEmojisReached = 30008
+ ErrCodeTooManyReactions = 30010
+ ErrCodeMaximumNumberOfGuildChannelsReached = 30013
+ ErrCodeMaximumNumberOfAttachmentsInAMessageReached = 30015
+ ErrCodeMaximumNumberOfInvitesReached = 30016
+ ErrCodeMaximumNumberOfAnimatedEmojisReached = 30018
+ ErrCodeMaximumNumberOfServerMembersReached = 30019
+ ErrCodeMaximumNumberOfGuildDiscoverySubcategoriesReached = 30030
+ ErrCodeGuildAlreadyHasATemplate = 30031
+ ErrCodeMaximumNumberOfThreadParticipantsReached = 30033
+ ErrCodeMaximumNumberOfBansForNonGuildMembersHaveBeenExceeded = 30035
+ ErrCodeMaximumNumberOfBansFetchesHasBeenReached = 30037
+ ErrCodeMaximumNumberOfUncompletedGuildScheduledEventsReached = 30038
+ ErrCodeMaximumNumberOfStickersReached = 30039
+ ErrCodeMaximumNumberOfPruneRequestsHasBeenReached = 30040
+ ErrCodeMaximumNumberOfGuildWidgetSettingsUpdatesHasBeenReached = 30042
+ ErrCodeMaximumNumberOfEditsToMessagesOlderThanOneHourReached = 30046
+ ErrCodeMaximumNumberOfPinnedThreadsInForumChannelHasBeenReached = 30047
+ ErrCodeMaximumNumberOfTagsInForumChannelHasBeenReached = 30048
+
+ ErrCodeUnauthorized = 40001
+ ErrCodeActionRequiredVerifiedAccount = 40002
+ ErrCodeOpeningDirectMessagesTooFast = 40003
+ ErrCodeSendMessagesHasBeenTemporarilyDisabled = 40004
+ ErrCodeRequestEntityTooLarge = 40005
+ ErrCodeFeatureTemporarilyDisabledServerSide = 40006
+ ErrCodeUserIsBannedFromThisGuild = 40007
+ ErrCodeTargetIsNotConnectedToVoice = 40032
+ ErrCodeMessageAlreadyCrossposted = 40033
+ ErrCodeAnApplicationWithThatNameAlreadyExists = 40041
+ ErrCodeInteractionHasAlreadyBeenAcknowledged = 40060
+ ErrCodeTagNamesMustBeUnique = 40061
+
+ ErrCodeMissingAccess = 50001
+ ErrCodeInvalidAccountType = 50002
+ ErrCodeCannotExecuteActionOnDMChannel = 50003
+ ErrCodeEmbedDisabled = 50004
+ ErrCodeGuildWidgetDisabled = 50004
+ ErrCodeCannotEditFromAnotherUser = 50005
+ ErrCodeCannotSendEmptyMessage = 50006
+ ErrCodeCannotSendMessagesToThisUser = 50007
+ ErrCodeCannotSendMessagesInVoiceChannel = 50008
+ ErrCodeChannelVerificationLevelTooHigh = 50009
+ ErrCodeOAuth2ApplicationDoesNotHaveBot = 50010
+ ErrCodeOAuth2ApplicationLimitReached = 50011
+ ErrCodeInvalidOAuthState = 50012
+ ErrCodeMissingPermissions = 50013
+ ErrCodeInvalidAuthenticationToken = 50014
+ ErrCodeTooFewOrTooManyMessagesToDelete = 50016
+ ErrCodeCanOnlyPinMessageToOriginatingChannel = 50019
+ ErrCodeInviteCodeWasEitherInvalidOrTaken = 50020
+ ErrCodeCannotExecuteActionOnSystemMessage = 50021
+ ErrCodeCannotExecuteActionOnThisChannelType = 50024
+ ErrCodeInvalidOAuth2AccessTokenProvided = 50025
+ ErrCodeMissingRequiredOAuth2Scope = 50026
+ ErrCodeInvalidWebhookTokenProvided = 50027
+ ErrCodeInvalidRole = 50028
+ ErrCodeInvalidRecipients = 50033
+ ErrCodeMessageProvidedTooOldForBulkDelete = 50034
+ ErrCodeInvalidFormBody = 50035
+ ErrCodeInviteAcceptedToGuildApplicationsBotNotIn = 50036
+ ErrCodeInvalidAPIVersionProvided = 50041
+ ErrCodeFileUploadedExceedsTheMaximumSize = 50045
+ ErrCodeInvalidFileUploaded = 50046
+ ErrCodeInvalidGuild = 50055
+ ErrCodeInvalidMessageType = 50068
+ ErrCodeCannotDeleteAChannelRequiredForCommunityGuilds = 50074
+ ErrCodeInvalidStickerSent = 50081
+ ErrCodePerformedOperationOnArchivedThread = 50083
+ ErrCodeBeforeValueIsEarlierThanThreadCreationDate = 50085
+ ErrCodeCommunityServerChannelsMustBeTextChannels = 50086
+ ErrCodeThisServerIsNotAvailableInYourLocation = 50095
+ ErrCodeThisServerNeedsMonetizationEnabledInOrderToPerformThisAction = 50097
+ ErrCodeThisServerNeedsMoreBoostsToPerformThisAction = 50101
+ ErrCodeTheRequestBodyContainsInvalidJSON = 50109
+
+ ErrCodeNoUsersWithDiscordTagExist = 80004
+
+ ErrCodeReactionBlocked = 90001
+
+ ErrCodeAPIResourceIsCurrentlyOverloaded = 130000
+
+ ErrCodeTheStageIsAlreadyOpen = 150006
+
+ ErrCodeCannotReplyWithoutPermissionToReadMessageHistory = 160002
+ ErrCodeThreadAlreadyCreatedForThisMessage = 160004
+ ErrCodeThreadIsLocked = 160005
+ ErrCodeMaximumNumberOfActiveThreadsReached = 160006
+ ErrCodeMaximumNumberOfActiveAnnouncementThreadsReached = 160007
+
+ ErrCodeInvalidJSONForUploadedLottieFile = 170001
+ ErrCodeUploadedLottiesCannotContainRasterizedImages = 170002
+ ErrCodeStickerMaximumFramerateExceeded = 170003
+ ErrCodeStickerFrameCountExceedsMaximumOfOneThousandFrames = 170004
+ ErrCodeLottieAnimationMaximumDimensionsExceeded = 170005
+ ErrCodeStickerFrameRateOutOfRange = 170006
+ ErrCodeStickerAnimationDurationExceedsMaximumOfFiveSeconds = 170007
+
+ ErrCodeCannotUpdateAFinishedEvent = 180000
+ ErrCodeFailedToCreateStageNeededForStageEvent = 180002
+
+ ErrCodeCannotEnableOnboardingRequirementsAreNotMet = 350000
+ ErrCodeCannotUpdateOnboardingWhileBelowRequirements = 350001
+)
+
+// Intent is the type of a Gateway Intent
+// https://discord.com/developers/docs/topics/gateway#gateway-intents
+type Intent int
+
+// Constants for the different bit offsets of intents
+const (
+ IntentGuilds Intent = 1 << 0
+ IntentGuildMembers Intent = 1 << 1
+ IntentGuildModeration Intent = 1 << 2
+ IntentGuildEmojis Intent = 1 << 3
+ IntentGuildIntegrations Intent = 1 << 4
+ IntentGuildWebhooks Intent = 1 << 5
+ IntentGuildInvites Intent = 1 << 6
+ IntentGuildVoiceStates Intent = 1 << 7
+ IntentGuildPresences Intent = 1 << 8
+ IntentGuildMessages Intent = 1 << 9
+ IntentGuildMessageReactions Intent = 1 << 10
+ IntentGuildMessageTyping Intent = 1 << 11
+ IntentDirectMessages Intent = 1 << 12
+ IntentDirectMessageReactions Intent = 1 << 13
+ IntentDirectMessageTyping Intent = 1 << 14
+ IntentMessageContent Intent = 1 << 15
+ IntentGuildScheduledEvents Intent = 1 << 16
+ IntentAutoModerationConfiguration Intent = 1 << 20
+ IntentAutoModerationExecution Intent = 1 << 21
+ IntentGuildMessagePolls Intent = 1 << 24
+ IntentDirectMessagePolls Intent = 1 << 25
+
+ // TODO: remove when compatibility is not needed
+
+ IntentGuildBans Intent = IntentGuildModeration
+
+ IntentsGuilds Intent = 1 << 0
+ IntentsGuildMembers Intent = 1 << 1
+ IntentsGuildBans Intent = 1 << 2
+ IntentsGuildEmojis Intent = 1 << 3
+ IntentsGuildIntegrations Intent = 1 << 4
+ IntentsGuildWebhooks Intent = 1 << 5
+ IntentsGuildInvites Intent = 1 << 6
+ IntentsGuildVoiceStates Intent = 1 << 7
+ IntentsGuildPresences Intent = 1 << 8
+ IntentsGuildMessages Intent = 1 << 9
+ IntentsGuildMessageReactions Intent = 1 << 10
+ IntentsGuildMessageTyping Intent = 1 << 11
+ IntentsDirectMessages Intent = 1 << 12
+ IntentsDirectMessageReactions Intent = 1 << 13
+ IntentsDirectMessageTyping Intent = 1 << 14
+ IntentsMessageContent Intent = 1 << 15
+ IntentsGuildScheduledEvents Intent = 1 << 16
+
+ IntentsAllWithoutPrivileged = IntentGuilds |
+ IntentGuildBans |
+ IntentGuildEmojis |
+ IntentGuildIntegrations |
+ IntentGuildWebhooks |
+ IntentGuildInvites |
+ IntentGuildVoiceStates |
+ IntentGuildMessages |
+ IntentGuildMessageReactions |
+ IntentGuildMessageTyping |
+ IntentDirectMessages |
+ IntentDirectMessageReactions |
+ IntentDirectMessageTyping |
+ IntentGuildScheduledEvents |
+ IntentAutoModerationConfiguration |
+ IntentAutoModerationExecution
+
+ IntentsAll = IntentsAllWithoutPrivileged |
+ IntentGuildMembers |
+ IntentGuildPresences |
+ IntentMessageContent
+
+ IntentsNone Intent = 0
+)
+
+// MakeIntent used to help convert a gateway intent value for use in the Identify structure;
+// this was useful to help support the use of a pointer type when intents were optional.
+// This is now a no-op, and is not necessary to use.
+func MakeIntent(intents Intent) Intent {
+ return intents
+}
+
+// A RequiredAction is applied to a user account when an interactive security
+// or safety flow must be completed before the account may be further used.
+type RequiredAction string
+
+const (
+ RequireAgreements RequiredAction = "AGREEMENTS" // "Terms of Service and Policy Updates"
+ RequireCaptcha RequiredAction = "REQUIRE_CAPTCHA" // legacy
+ RequireVerifiedEmail RequiredAction = "REQUIRE_VERIFIED_EMAIL" // add a verified email
+ RequireVerifiedPhone RequiredAction = "REQUIRE_VERIFIED_PHONE" // add a verified phone number
+ RequireReverifiedEmail RequiredAction = "REQUIRE_REVERIFIED_EMAIL" // reaffirm ownership of existing email
+ RequireReverifiedPhone RequiredAction = "REQUIRE_REVERIFIED_PHONE" // reaffirm ownership of existing phone number
+ RequireVerifiedEmailOrVerifiedPhone RequiredAction = "REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE" // add a verified phone number or email
+ RequireReverifiedEmailOrVerifiedPhone RequiredAction = "REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE" // reaffirm ownership of existing email, or add a verified phone number
+ RequireVerifiedEmailOrReverifiedPhone RequiredAction = "REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE" // add a verified email, or reaffirm ownership of existing phone number
+ RequireReverifiedEmailOrReverifiedPhone RequiredAction = "REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE" // reaffirm ownership of existing email or phone number
+ RequireSafetyFlows RequiredAction = "REQUIRE_SAFETY_FLOWS" // server-driven safety flow UI
+)
+
+// AccountStanding quantifies a user account's standing with Discord.
+type AccountStanding int
+
+const (
+ StandingAllGood AccountStanding = 100 // "Your account is all good"
+ StandingLimited AccountStanding = 200 // "Your account is limited"
+ StandingVeryLimited AccountStanding = 300 // "Your account is very limited"
+ StandingAtRisk AccountStanding = 400 // "Your account is at risk"
+ StandingSuspended AccountStanding = 500 // "Your account is suspended"
+)
+
+// SafetyHub reflects a user account's standing on Discord as well as the
+// classifications (policy violations) affecting it.
+type SafetyHub struct {
+ AccountStanding struct {
+ State AccountStanding `json:"state"`
+ } `json:"account_standing"`
+ Classifications []Classification `json:"classifications"`
+ GuildClassifications []Classification `json:"guild_classifications,omitempty"`
+ IsAppealEligible bool `json:"is_appeal_eligible"`
+ IsDSAEligible bool `json:"is_dsa_eligible"`
+ // (more omitted)
+}
+
+// A Classification records a violation of Discord policy, attributed either to
+// the user directly or to a [Guild] they own or are a member of.
+//
+// Classifications are what collectively determine a user account's
+// [AccountStanding], and are contained in [SafetyHub].
+type Classification struct {
+ ID string `json:"id"`
+ Description string `json:"description"`
+ GuildMetadata *ClassificationGuildMetadata `json:"guild_metadata,omitempty"`
+ IsCOPPA bool `json:"is_coppa"`
+ IsSpam bool `json:"is_spam"`
+ AppealIngestionType *AppealIngestionType `json:"appeal_ingestion_type,omitempty"` // in-app appeal when nil
+ AppealStatus *struct {
+ Status AppealStatus `json:"status"`
+ } `json:"appeal_status,omitempty"`
+ MaxExpirationTime *time.Time `json:"max_expiration_time,omitempty"` // permanent when nil
+ // (more omitted)
+}
+
+type AppealStatus int
+
+const (
+ AppealReviewPending AppealStatus = 1
+ ClassificationUpheld AppealStatus = 2 // unused?
+ ClassificationInvalidated AppealStatus = 3 // unused?
+)
+
+type AppealIngestionType int
+
+const (
+ AppealWebForm AppealIngestionType = iota
+ AppealAgeVerify
+ AppealInApp
+)
+
+type ClassificationGuildMetadata struct {
+ // Name is the name of the [Guild] that was classified.
+ Name string `json:"name"`
+ MemberType ClassificationGuildMemberType `json:"member_type"`
+}
+
+// ClassificationGuildMemberType denotes a user account's relationship to a
+// [Guild] that received a [Classification].
+type ClassificationGuildMemberType int
+
+const (
+ ClassificationGuildOwner ClassificationGuildMemberType = 1
+ ClassificationGuildMember ClassificationGuildMemberType = 2
+)
diff --git a/pkg/meowcord/structs_test.go b/pkg/meowcord/structs_test.go
new file mode 100644
index 0000000..d5569ea
--- /dev/null
+++ b/pkg/meowcord/structs_test.go
@@ -0,0 +1,51 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "testing"
+)
+
+func TestMember_DisplayName(t *testing.T) {
+ user := &User{
+ GlobalName: "Global",
+ }
+ t.Run("no server nickname set", func(t *testing.T) {
+ m := &Member{
+ Nick: "",
+ User: user,
+ }
+ want := user.DisplayName()
+ if dn := m.DisplayName(); dn != want {
+ t.Errorf("Member.DisplayName() = %v, want %v", dn, want)
+ }
+ })
+ t.Run("server nickname set", func(t *testing.T) {
+ m := &Member{
+ Nick: "Server",
+ User: user,
+ }
+ if dn := m.DisplayName(); dn != m.Nick {
+ t.Errorf("Member.DisplayName() = %v, want %v", dn, m.Nick)
+ }
+ })
+}
diff --git a/pkg/meowcord/tools/cmd/eventhandlers/main.go b/pkg/meowcord/tools/cmd/eventhandlers/main.go
new file mode 100644
index 0000000..d54d884
--- /dev/null
+++ b/pkg/meowcord/tools/cmd/eventhandlers/main.go
@@ -0,0 +1,145 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package main
+
+import (
+ "bytes"
+ "go/format"
+ "go/parser"
+ "go/token"
+ "log"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "text/template"
+)
+
+var eventHandlerTmpl = template.Must(template.New("eventHandler").Funcs(template.FuncMap{
+ "constName": constName,
+ "isDiscordEvent": isDiscordEvent,
+ "privateName": privateName,
+}).Parse(`// Code generated by \"eventhandlers\"; DO NOT EDIT
+// See events.go
+
+package meowcord
+
+// Following are all the event types.
+// Event type values are used to match the events returned by Discord.
+// EventTypes surrounded by __ are synthetic and are internal to meowcord.
+const ({{range .}}
+ {{privateName .}}EventType = "{{constName .}}"{{end}}
+)
+{{range .}}
+// {{privateName .}}EventHandler is an event handler for {{.}} events.
+type {{privateName .}}EventHandler func(*Session, *{{.}})
+
+// Type returns the event type for {{.}} events.
+func (eh {{privateName .}}EventHandler) Type() string {
+ return {{privateName .}}EventType
+}
+{{if isDiscordEvent .}}
+// New returns a new instance of {{.}}.
+func (eh {{privateName .}}EventHandler) New() interface{} {
+ return &{{.}}{}
+}{{end}}
+// Handle is the handler for {{.}} events.
+func (eh {{privateName .}}EventHandler) Handle(s *Session, i interface{}) {
+ if t, ok := i.(*{{.}}); ok {
+ eh(s, t)
+ }
+}
+
+{{end}}
+func handlerForInterface(handler interface{}) EventHandler {
+ switch v := handler.(type) {
+ case func(*Session, interface{}):
+ return interfaceEventHandler(v){{range .}}
+ case func(*Session, *{{.}}):
+ return {{privateName .}}EventHandler(v){{end}}
+ }
+
+ return nil
+}
+
+func init() { {{range .}}{{if isDiscordEvent .}}
+ registerInterfaceProvider({{privateName .}}EventHandler(nil)){{end}}{{end}}
+}
+`))
+
+func main() {
+ var buf bytes.Buffer
+ dir := filepath.Dir(".")
+
+ fs := token.NewFileSet()
+ parsedFile, err := parser.ParseFile(fs, "events.go", nil, 0)
+ if err != nil {
+ log.Fatalf("warning: internal error: could not parse events.go: %s", err)
+ return
+ }
+
+ names := []string{}
+ for object := range parsedFile.Scope.Objects {
+ names = append(names, object)
+ }
+ sort.Strings(names)
+ eventHandlerTmpl.Execute(&buf, names)
+
+ src, err := format.Source(buf.Bytes())
+ if err != nil {
+ log.Println("warning: internal error: invalid Go generated:", err)
+ src = buf.Bytes()
+ }
+
+ err = os.WriteFile(filepath.Join(dir, strings.ToLower("eventhandlers.go")), src, 0644)
+ if err != nil {
+ log.Fatal(buf, "writing output: %s", err)
+ }
+}
+
+var constRegexp = regexp.MustCompile("([a-z])([A-Z])")
+
+func constCase(name string) string {
+ return strings.ToUpper(constRegexp.ReplaceAllString(name, "${1}_${2}"))
+}
+
+func isDiscordEvent(name string) bool {
+ switch {
+ case name == "Connect", name == "Disconnect", name == "InvalidAuth", name == "Event", name == "RateLimit", name == "Interface":
+ return false
+ default:
+ return true
+ }
+}
+
+func constName(name string) string {
+ if !isDiscordEvent(name) {
+ return "__" + constCase(name) + "__"
+ }
+
+ return constCase(name)
+}
+
+func privateName(name string) string {
+ return strings.ToLower(string(name[0])) + name[1:]
+}
diff --git a/pkg/meowcord/user.go b/pkg/meowcord/user.go
new file mode 100644
index 0000000..bffae61
--- /dev/null
+++ b/pkg/meowcord/user.go
@@ -0,0 +1,248 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "strconv"
+)
+
+// UserFlags is the flags of "user" (see UserFlags* consts)
+// https://discord.com/developers/docs/resources/user#user-object-user-flags
+type UserFlags uint64
+
+// Valid UserFlags values
+//
+// https://docs.discord.food/resources/user#user-flags
+const (
+ UserFlagDiscordEmployee UserFlags = 1 << iota
+ UserFlagDiscordPartner
+ UserFlagHypeSquadEvents
+ UserFlagBugHunterLevel1
+ UserFlagMFASMS // private
+ UserFlagPremiumPromoDismissed // private
+ UserFlagHouseBravery
+ UserFlagHouseBrilliance
+ UserFlagHouseBalance
+ UserFlagEarlySupporter
+ UserFlagTeamUser
+ UserFlagIsHubspotContact // private, harvested
+ UserFlagSystem // legacy
+ UserFlagHasUnreadUrgentMessages // private
+ UserFlagBugHunterLevel2
+ UserFlagUnderageDeleted // private, harvested
+ UserFlagVerifiedBot
+ UserFlagVerifiedBotDeveloper
+ UserFlagDiscordCertifiedModerator
+ UserFlagBotHTTPInteractions
+ UserFlagSpammer
+ UserFlagDisablePremium // private, legacy
+ UserFlagActiveBotDeveloper // legacy
+ UserFlagProvisionalAccount
+)
+
+// (9 bits, 24 through 32, are unknown)
+
+const (
+ UserFlagHighGlobalRatelimit UserFlags = 1 << (iota + 33) // private, harvested
+ UserFlagDeleted // private, harvested
+ UserFlagDisabledSuspiciousActivity // private, harvested
+ UserFlagSelfDeleted // private, harvested
+ UserFlagPremiumDiscriminator // private, harvested
+ UserFlagUsedDesktopClient // private, harvested
+ UserFlagUsedWebClient // private, harvested
+ UserFlagUsedMobileClient // private, harvested
+ UserFlagDisabled // private, harvested
+)
+
+// (bit 42 is unknown)
+
+const (
+ UserFlagHasSessionStarted UserFlags = 1 << (iota + 43)
+ UserFlagQuarantined // private
+)
+
+// (bits 45 and 46 are unknown)
+
+const (
+ UserFlagPremiumEligibleForUniqueUsername UserFlags = 1 << (iota + 47)
+)
+
+// (bits 48 and 49 are unknown)
+
+const (
+ UserFlagCollaborator UserFlags = 1 << (iota + 50)
+ UserFlagRestrictedCollaborator
+)
+
+// UserPremiumType is the type of premium (nitro) subscription a user has (see UserPremiumType* consts).
+// https://discord.com/developers/docs/resources/user#user-object-premium-types
+type UserPremiumType int
+
+// Valid UserPremiumType values.
+const (
+ UserPremiumTypeNone UserPremiumType = 0
+ UserPremiumTypeNitroClassic UserPremiumType = 1
+ UserPremiumTypeNitro UserPremiumType = 2
+ UserPremiumTypeNitroBasic UserPremiumType = 3
+)
+
+// UserPrimaryGuild represents a user's primary guild information.
+type UserPrimaryGuild struct {
+ // The ID of the user's primary guild.
+ IdentityGuildID string `json:"identity_guild_id"`
+
+ // Whether the user is displaying the primary guild's server tag.
+ IdentityEnabled bool `json:"identity_enabled"`
+
+ // The server tag of the user's primary guild. Limited to 4 characters
+ Tag string `json:"tag"`
+
+ // The server tag badge hash
+ // https://discord.com/developers/docs/reference#image-formatting
+ Badge string `json:"badge"`
+}
+
+// A User stores all data for an individual Discord user.
+type User struct {
+ // The ID of the user.
+ ID string `json:"id"`
+
+ // The email of the user. This is only present when
+ // the application possesses the email scope for the user.
+ Email string `json:"email"`
+
+ Phone string `json:"phone"`
+
+ // The user's username.
+ Username string `json:"username"`
+
+ // The hash of the user's avatar. Use Session.UserAvatar
+ // to retrieve the avatar itself.
+ Avatar string `json:"avatar"`
+
+ // The user's chosen language option.
+ Locale string `json:"locale"`
+
+ // The discriminator of the user (4 numbers after name).
+ Discriminator string `json:"discriminator"`
+
+ // The user's display name, if it is set.
+ // For bots, this is the application name.
+ GlobalName string `json:"global_name"`
+
+ // The token of the user. This is only present for
+ // the user represented by the current session.
+ Token string `json:"token"`
+
+ // Whether the user's email is verified.
+ Verified bool `json:"verified"`
+
+ // Whether the user has multi-factor authentication enabled.
+ MFAEnabled bool `json:"mfa_enabled"`
+
+ // The hash of the user's banner image.
+ Banner string `json:"banner"`
+
+ // User's banner color, encoded as an integer representation of hexadecimal color code
+ AccentColor int `json:"accent_color"`
+
+ // Whether the user is a bot.
+ Bot bool `json:"bot"`
+
+ // The public flags on a user's account.
+ // This is a combination of bit masks; the presence of a certain flag can
+ // be checked by performing a bitwise AND between this int and the flag.
+ PublicFlags UserFlags `json:"public_flags"`
+
+ // The type of Nitro subscription on a user's account.
+ // Only available when the request is authorized via a Bearer token.
+ PremiumType UserPremiumType `json:"premium_type"`
+
+ // Whether the user is an Official Discord System user (part of the urgent message system).
+ System bool `json:"system"`
+
+ // The flags on a user's account.
+ // Only available when the request is authorized via a Bearer token.
+ Flags UserFlags `json:"flags"`
+
+ // The user's primary guild.
+ PrimaryGuild UserPrimaryGuild `json:"primary_guild"`
+}
+
+// String returns a unique identifier of the form username#discriminator
+// or just username, if the discriminator is set to "0".
+func (u *User) String() string {
+ // If the user has been migrated from the legacy username system, their discriminator is "0".
+ // See https://support-dev.discord.com/hc/en-us/articles/13667755828631
+ if u.Discriminator == "0" {
+ return u.Username
+ }
+
+ return u.Username + "#" + u.Discriminator
+}
+
+// Mention return a string which mentions the user
+func (u *User) Mention() string {
+ return "<@" + u.ID + ">"
+}
+
+// AvatarURL returns a URL to the user's avatar.
+//
+// size: The size of the user's avatar as a power of two
+// if size is an empty string, no size parameter will
+// be added to the URL.
+func (u *User) AvatarURL(size string) string {
+ return avatarURL(
+ u.Avatar,
+ EndpointDefaultUserAvatar(u.DefaultAvatarIndex()),
+ EndpointUserAvatar(u.ID, u.Avatar),
+ EndpointUserAvatarAnimated(u.ID, u.Avatar),
+ size,
+ )
+}
+
+// BannerURL returns the URL of the users's banner image.
+//
+// size: The size of the desired banner image as a power of two
+// Image size can be any power of two between 16 and 4096.
+func (u *User) BannerURL(size string) string {
+ return bannerURL(u.Banner, EndpointUserBanner(u.ID, u.Banner), EndpointUserBannerAnimated(u.ID, u.Banner), size)
+}
+
+// DefaultAvatarIndex returns the index of the user's default avatar.
+func (u *User) DefaultAvatarIndex() int {
+ if u.Discriminator == "0" {
+ id, _ := strconv.ParseUint(u.ID, 10, 64)
+ return int((id >> 22) % 6)
+ }
+
+ id, _ := strconv.Atoi(u.Discriminator)
+ return id % 5
+}
+
+// DisplayName returns the user's global name if they have one, otherwise it returns their username.
+func (u *User) DisplayName() string {
+ if u.GlobalName != "" {
+ return u.GlobalName
+ }
+ return u.Username
+}
diff --git a/pkg/meowcord/user_test.go b/pkg/meowcord/user_test.go
new file mode 100644
index 0000000..77169f0
--- /dev/null
+++ b/pkg/meowcord/user_test.go
@@ -0,0 +1,79 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import "testing"
+
+func TestUser_String(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ u *User
+ want string
+ }{
+ {
+ name: "User with a discriminator",
+ u: &User{
+ Username: "bob",
+ Discriminator: "8192",
+ },
+ want: "bob#8192",
+ },
+ {
+ name: "User with discriminator set to 0",
+ u: &User{
+ Username: "aldiwildan",
+ Discriminator: "0",
+ },
+ want: "aldiwildan",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.u.String(); got != tc.want {
+ t.Errorf("User.String() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestUser_DisplayName(t *testing.T) {
+ t.Run("no global name set", func(t *testing.T) {
+ u := &User{
+ GlobalName: "",
+ Username: "username",
+ }
+ if dn := u.DisplayName(); dn != u.Username {
+ t.Errorf("User.DisplayName() = %v, want %v", dn, u.Username)
+ }
+ })
+ t.Run("global name set", func(t *testing.T) {
+ u := &User{
+ GlobalName: "global",
+ Username: "username",
+ }
+ if dn := u.DisplayName(); dn != u.GlobalName {
+ t.Errorf("User.DisplayName() = %v, want %v", dn, u.GlobalName)
+ }
+ })
+}
diff --git a/pkg/meowcord/util.go b/pkg/meowcord/util.go
new file mode 100644
index 0000000..b7b09fc
--- /dev/null
+++ b/pkg/meowcord/util.go
@@ -0,0 +1,153 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/textproto"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// SnowflakeTimestamp returns the creation time of a Snowflake ID relative to the creation of Discord.
+func SnowflakeTimestamp(ID string) (t time.Time, err error) {
+ i, err := strconv.ParseInt(ID, 10, 64)
+ if err != nil {
+ return
+ }
+ timestamp := (i >> 22) + 1420070400000
+ t = time.Unix(0, timestamp*1000000)
+ return
+}
+
+// MultipartBodyWithJSON returns the contentType and body for a discord request
+// data : The object to encode for payload_json in the multipart request
+// files : Files to include in the request
+func MultipartBodyWithJSON(data interface{}, files []*File) (requestContentType string, requestBody []byte, err error) {
+ body := &bytes.Buffer{}
+ bodywriter := multipart.NewWriter(body)
+
+ payload, err := Marshal(data)
+ if err != nil {
+ return
+ }
+
+ var p io.Writer
+
+ h := make(textproto.MIMEHeader)
+ h.Set("Content-Disposition", `form-data; name="payload_json"`)
+ h.Set("Content-Type", "application/json")
+
+ p, err = bodywriter.CreatePart(h)
+ if err != nil {
+ return
+ }
+
+ if _, err = p.Write(payload); err != nil {
+ return
+ }
+
+ for i, file := range files {
+ h := make(textproto.MIMEHeader)
+ h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="files[%d]"; filename="%s"`, i, quoteEscaper.Replace(file.Name)))
+ contentType := file.ContentType
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ h.Set("Content-Type", contentType)
+
+ p, err = bodywriter.CreatePart(h)
+ if err != nil {
+ return
+ }
+
+ if _, err = io.Copy(p, file.Reader); err != nil {
+ return
+ }
+ }
+
+ err = bodywriter.Close()
+ if err != nil {
+ return
+ }
+
+ return bodywriter.FormDataContentType(), body.Bytes(), nil
+}
+
+func withSize(URL, size string) string {
+ // FIXME This is gross. Ideally the endpoint functions themselves should be
+ // changed.
+
+ if size == "" {
+ return URL
+ }
+
+ sep := "?"
+ if strings.Contains(URL, "?") {
+ // The URL already has query parameters.
+ sep = "&"
+ }
+ return URL + sep + "size=" + size
+}
+
+func avatarURL(avatarHash, defaultAvatarURL, staticAvatarURL, animatedAvatarURL, size string) string {
+ var URL string
+ if avatarHash == "" {
+ URL = defaultAvatarURL
+ } else if strings.HasPrefix(avatarHash, "a_") {
+ URL = animatedAvatarURL
+ } else {
+ URL = staticAvatarURL
+ }
+
+ return withSize(URL, size)
+}
+
+func bannerURL(bannerHash, staticBannerURL, animatedBannerURL, size string) string {
+ var URL string
+ if bannerHash == "" {
+ return ""
+ } else if strings.HasPrefix(bannerHash, "a_") {
+ URL = animatedBannerURL
+ } else {
+ URL = staticBannerURL
+ }
+
+ return withSize(URL, size)
+}
+
+func iconURL(iconHash, staticIconURL, animatedIconURL, size string) string {
+ var URL string
+ if iconHash == "" {
+ return ""
+ } else if strings.HasPrefix(iconHash, "a_") {
+ URL = animatedIconURL
+ } else {
+ URL = staticIconURL
+ }
+
+ return withSize(URL, size)
+}
diff --git a/pkg/meowcord/util_test.go b/pkg/meowcord/util_test.go
new file mode 100644
index 0000000..434f26d
--- /dev/null
+++ b/pkg/meowcord/util_test.go
@@ -0,0 +1,42 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+import (
+ "testing"
+ "time"
+)
+
+func TestSnowflakeTimestamp(t *testing.T) {
+ // #discordgo channel ID :)
+ id := "155361364909621248"
+ parsedTimestamp, err := SnowflakeTimestamp(id)
+
+ if err != nil {
+ t.Errorf("returned error incorrect: got %v, want nil", err)
+ }
+
+ correctTimestamp := time.Date(2016, time.March, 4, 17, 10, 35, 869*1000000, time.UTC)
+ if !parsedTimestamp.Equal(correctTimestamp) {
+ t.Errorf("parsed time incorrect: got %v, want %v", parsedTimestamp, correctTimestamp)
+ }
+}
diff --git a/pkg/meowcord/voice.go b/pkg/meowcord/voice.go
new file mode 100644
index 0000000..b0fd796
--- /dev/null
+++ b/pkg/meowcord/voice.go
@@ -0,0 +1,1020 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains code related to Discord voice suppport
+
+package meowcord
+
+import (
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+)
+
+// ------------------------------------------------------------------------------------------------
+// Code related to both VoiceConnection Websocket and UDP connections.
+// ------------------------------------------------------------------------------------------------
+
+// A VoiceConnection struct holds all the data and functions related to a Discord Voice Connection.
+type VoiceConnection struct {
+ sync.RWMutex
+
+ Debug bool // If true, print extra logging -- DEPRECATED
+ LogLevel int
+ Ready bool // If true, voice is ready to send/receive audio
+ UserID string
+ GuildID string
+ ChannelID string
+ deaf bool
+ mute bool
+ speaking bool
+ reconnecting bool // If true, voice connection is trying to reconnect
+
+ OpusSend chan []byte // Chan for sending opus audio
+ OpusRecv chan *Packet // Chan for receiving opus audio
+
+ wsConn *websocket.Conn
+ wsMutex sync.Mutex
+ wsConnCtx context.Context
+ wsConnCancel context.CancelFunc
+ udpConn *net.UDPConn
+ session *Session
+
+ sessionID string
+ token string
+ endpoint string
+
+ // Used to send a close signal to goroutines
+ close chan struct{}
+
+ // Used to pass the sessionid from onVoiceStateUpdate
+ // sessionRecv chan string UNUSED ATM
+
+ aead cipher.AEAD
+ nonceCounter uint32
+
+ op4 voiceOP4
+ op2 voiceOP2
+
+ voiceSpeakingUpdateHandlers []VoiceSpeakingUpdateHandler
+}
+
+// VoiceSpeakingUpdateHandler type provides a function definition for the
+// VoiceSpeakingUpdate event
+type VoiceSpeakingUpdateHandler func(vc *VoiceConnection, vs *VoiceSpeakingUpdate)
+
+// Speaking sends a speaking notification to Discord over the voice websocket.
+// This must be sent as true prior to sending audio and should be set to false
+// once finished sending audio.
+// b : Send true if speaking, false if not.
+func (v *VoiceConnection) Speaking(b bool) (err error) {
+
+ v.log(LogDebug, "called (%t)", b)
+
+ type voiceSpeakingData struct {
+ Speaking bool `json:"speaking"`
+ Delay int `json:"delay"`
+ }
+
+ type voiceSpeakingOp struct {
+ Op int `json:"op"` // Always 5
+ Data voiceSpeakingData `json:"d"`
+ }
+
+ if v.wsConn == nil {
+ return fmt.Errorf("no VoiceConnection websocket")
+ }
+
+ data := voiceSpeakingOp{5, voiceSpeakingData{b, 0}}
+ v.wsMutex.Lock()
+ err = wsjson.Write(v.wsConnCtx, v.wsConn, data)
+ v.wsMutex.Unlock()
+
+ v.Lock()
+ defer v.Unlock()
+ if err != nil {
+ v.speaking = false
+ v.log(LogError, "Speaking() write json error, %s", err)
+ return
+ }
+
+ v.speaking = b
+
+ return
+}
+
+// ChangeChannel sends Discord a request to change channels within a Guild
+// !!! NOTE !!! This function may be removed in favour of just using ChannelVoiceJoin
+func (v *VoiceConnection) ChangeChannel(channelID string, mute, deaf bool) (err error) {
+
+ v.log(LogInformational, "called")
+
+ data := voiceChannelJoinOp{4, voiceChannelJoinData{&v.GuildID, &channelID, mute, deaf}}
+ // This writes to the gateway (session) websocket, not the voice one, so it
+ // uses the session's connection context.
+ v.session.wsMutex.Lock()
+ err = wsjson.Write(v.session.wsConnCtx, v.session.wsConn, data)
+ v.session.wsMutex.Unlock()
+ if err != nil {
+ return
+ }
+ v.ChannelID = channelID
+ v.deaf = deaf
+ v.mute = mute
+ v.speaking = false
+
+ return
+}
+
+// Disconnect disconnects from this voice channel and closes the websocket
+// and udp connections to Discord.
+func (v *VoiceConnection) Disconnect() (err error) {
+
+ // Send a OP4 with a nil channel to disconnect
+ v.Lock()
+ if v.sessionID != "" {
+ data := voiceChannelJoinOp{4, voiceChannelJoinData{&v.GuildID, nil, true, true}}
+ v.session.wsMutex.Lock()
+ err = wsjson.Write(v.session.wsConnCtx, v.session.wsConn, data)
+ v.session.wsMutex.Unlock()
+ v.sessionID = ""
+ }
+ v.Unlock()
+
+ // Close websocket and udp connections
+ v.Close()
+
+ v.log(LogInformational, "Deleting VoiceConnection %s", v.GuildID)
+
+ v.session.Lock()
+ delete(v.session.VoiceConnections, v.GuildID)
+ v.session.Unlock()
+
+ return
+}
+
+// Close closes the voice ws and udp connections
+func (v *VoiceConnection) Close() {
+
+ v.log(LogInformational, "called")
+
+ v.Lock()
+ defer v.Unlock()
+
+ v.Ready = false
+ v.speaking = false
+
+ if v.close != nil {
+ v.log(LogInformational, "closing v.close")
+ close(v.close)
+ v.close = nil
+ }
+
+ if v.udpConn != nil {
+ v.log(LogInformational, "closing udp")
+ err := v.udpConn.Close()
+ if err != nil {
+ v.log(LogError, "error closing udp connection, %s", err)
+ }
+ v.udpConn = nil
+ }
+
+ if v.wsConn != nil {
+ v.log(LogInformational, "closing websocket")
+
+ v.wsMutex.Lock()
+ err := v.wsConn.Close(websocket.StatusNormalClosure, "")
+ v.wsMutex.Unlock()
+ if err != nil {
+ v.log(LogError, "error closing websocket, %s", err)
+ }
+
+ if v.wsConnCancel != nil {
+ v.wsConnCancel()
+ }
+
+ v.wsConn = nil
+ v.wsConnCtx = nil
+ v.wsConnCancel = nil
+ }
+}
+
+// AddHandler adds a Handler for VoiceSpeakingUpdate events.
+func (v *VoiceConnection) AddHandler(h VoiceSpeakingUpdateHandler) {
+ v.Lock()
+ defer v.Unlock()
+
+ v.voiceSpeakingUpdateHandlers = append(v.voiceSpeakingUpdateHandlers, h)
+}
+
+// VoiceSpeakingUpdate is a struct for a VoiceSpeakingUpdate event.
+type VoiceSpeakingUpdate struct {
+ UserID string `json:"user_id"`
+ SSRC int `json:"ssrc"`
+ Speaking bool `json:"speaking"`
+}
+
+// ------------------------------------------------------------------------------------------------
+// Unexported Internal Functions Below.
+// ------------------------------------------------------------------------------------------------
+
+// A voiceOP4 stores the data for the voice operation 4 websocket event
+// which provides us with the NaCl SecretBox encryption key
+type voiceOP4 struct {
+ SecretKey [32]byte `json:"secret_key"`
+ Mode string `json:"mode"`
+}
+
+// A voiceOP2 stores the data for the voice operation 2 websocket event
+// which is sort of like the voice READY packet
+type voiceOP2 struct {
+ SSRC uint32 `json:"ssrc"`
+ Port int `json:"port"`
+ Modes []string `json:"modes"`
+ HeartbeatInterval time.Duration `json:"heartbeat_interval"`
+ IP string `json:"ip"`
+}
+
+// WaitUntilConnected waits for the Voice Connection to
+// become ready, if it does not become ready it returns an err
+func (v *VoiceConnection) waitUntilConnected() error {
+
+ v.log(LogInformational, "called")
+
+ i := 0
+ for {
+ v.RLock()
+ ready := v.Ready
+ v.RUnlock()
+ if ready {
+ return nil
+ }
+
+ if i > 10 {
+ return fmt.Errorf("timeout waiting for voice")
+ }
+
+ time.Sleep(1 * time.Second)
+ i++
+ }
+}
+
+// Open opens a voice connection. This should be called
+// after VoiceChannelJoin is used and the data VOICE websocket events
+// are captured.
+func (v *VoiceConnection) open() (err error) {
+
+ v.log(LogInformational, "called")
+
+ v.Lock()
+ defer v.Unlock()
+
+ // Don't open a websocket if one is already open
+ if v.wsConn != nil {
+ v.log(LogWarning, "refusing to overwrite non-nil websocket")
+ return
+ }
+
+ // TODO temp? loop to wait for the SessionID
+ i := 0
+ for {
+ if v.sessionID != "" {
+ break
+ }
+
+ if i > 20 { // only loop for up to 1 second total
+ return fmt.Errorf("did not receive voice Session ID in time")
+ }
+ // Release the lock, so sessionID can be populated upon receiving a VoiceStateUpdate event.
+ v.Unlock()
+ time.Sleep(50 * time.Millisecond)
+ i++
+ v.Lock()
+ }
+
+ // Connect to VoiceConnection Websocket
+ vg := "wss://" + strings.TrimSuffix(v.endpoint, ":80")
+ v.log(LogInformational, "connecting to voice endpoint %s", vg)
+
+ dialCtx := context.Background()
+ if v.session.GatewayDialTimeout > 0 {
+ var cancelDial context.CancelFunc
+ dialCtx, cancelDial = context.WithTimeout(dialCtx, v.session.GatewayDialTimeout)
+ defer cancelDial()
+ }
+ v.wsConn, _, err = websocket.Dial(dialCtx, vg, &websocket.DialOptions{
+ HTTPClient: v.session.GatewayHTTPClient,
+ })
+ if err != nil {
+ v.log(LogWarning, "error connecting to voice endpoint %s, %s", vg, err)
+ v.log(LogDebug, "voice struct: %#v\n", v)
+ return
+ }
+ v.wsConn.SetReadLimit(-1)
+ v.wsConnCtx, v.wsConnCancel = context.WithCancel(context.Background())
+
+ type voiceHandshakeData struct {
+ ServerID string `json:"server_id"`
+ UserID string `json:"user_id"`
+ SessionID string `json:"session_id"`
+ Token string `json:"token"`
+ }
+ type voiceHandshakeOp struct {
+ Op int `json:"op"` // Always 0
+ Data voiceHandshakeData `json:"d"`
+ }
+ data := voiceHandshakeOp{0, voiceHandshakeData{v.GuildID, v.UserID, v.sessionID, v.token}}
+
+ v.wsMutex.Lock()
+ err = wsjson.Write(v.wsConnCtx, v.wsConn, data)
+ v.wsMutex.Unlock()
+ if err != nil {
+ v.log(LogWarning, "error sending init packet, %s", err)
+ return
+ }
+
+ v.close = make(chan struct{})
+ go v.wsListen(v.wsConnCtx, v.wsConn, v.close)
+
+ // add loop/check for Ready bool here?
+ // then return false if not ready?
+ // but then wsListen will also err.
+
+ return
+}
+
+// wsListen listens on the voice websocket for messages and passes them
+// to the voice event handler. This is automatically called by the Open func
+func (v *VoiceConnection) wsListen(ctx context.Context, wsConn *websocket.Conn, close <-chan struct{}) {
+
+ v.log(LogInformational, "called")
+
+ for {
+ _, message, err := wsConn.Read(ctx)
+ if err != nil {
+ // 4014 indicates a manual disconnection by someone in the guild;
+ // we shouldn't reconnect.
+ if websocket.CloseStatus(err) == 4014 {
+ v.log(LogInformational, "received 4014 manual disconnection")
+
+ // Abandon the voice WS connection
+ v.Lock()
+ v.wsConn = nil
+ v.Unlock()
+
+ // Wait for VOICE_SERVER_UPDATE.
+ // When the bot is moved by the user to another voice channel,
+ // VOICE_SERVER_UPDATE is received after the code 4014.
+ for i := 0; i < 5; i++ { // TODO: temp, wait for VoiceServerUpdate.
+ <-time.After(1 * time.Second)
+
+ v.RLock()
+ reconnected := v.wsConn != nil
+ v.RUnlock()
+ if !reconnected {
+ continue
+ }
+ v.log(LogInformational, "successfully reconnected after 4014 manual disconnection")
+ return
+ }
+
+ // When VOICE_SERVER_UPDATE is not received, disconnect as usual.
+ v.log(LogInformational, "disconnect due to 4014 manual disconnection")
+
+ v.session.Lock()
+ delete(v.session.VoiceConnections, v.GuildID)
+ v.session.Unlock()
+
+ v.Close()
+
+ return
+ }
+
+ // Detect if we have been closed manually. If a Close() has already
+ // happened, the websocket we are listening on will be different to the
+ // current session.
+ v.RLock()
+ sameConnection := v.wsConn == wsConn
+ v.RUnlock()
+ if sameConnection {
+
+ v.log(LogError, "voice endpoint %s websocket closed unexpectedly, %s", v.endpoint, err)
+
+ // Start reconnect goroutine then exit.
+ go v.reconnect()
+ }
+ return
+ }
+
+ // Pass received message to voice event handler
+ select {
+ case <-close:
+ return
+ default:
+ go v.onEvent(message)
+ }
+ }
+}
+
+// wsEvent handles any voice websocket events. This is only called by the
+// wsListen() function.
+func (v *VoiceConnection) onEvent(message []byte) {
+
+ v.log(LogDebug, "received: %s", string(message))
+
+ var e Event
+ if err := json.Unmarshal(message, &e); err != nil {
+ v.log(LogError, "unmarshall error, %s", err)
+ return
+ }
+
+ switch e.Operation {
+
+ case 2: // READY
+
+ if err := json.Unmarshal(e.RawData, &v.op2); err != nil {
+ v.log(LogError, "OP2 unmarshall error, %s, %s", err, string(e.RawData))
+ return
+ }
+
+ // Start the voice websocket heartbeat to keep the connection alive
+ go v.wsHeartbeat(v.wsConnCtx, v.wsConn, v.close, v.op2.HeartbeatInterval)
+ // TODO monitor a chan/bool to verify this was successful
+
+ // Start the UDP connection
+ err := v.udpOpen()
+ if err != nil {
+ v.log(LogError, "error opening udp connection, %s", err)
+ return
+ }
+
+ // Start the opusSender.
+ // TODO: Should we allow 48000/960 values to be user defined?
+ // answer: no, 48k is required as per discord documentaiton and 960 is the most optimal frame size (based on testing)
+ if v.OpusSend == nil {
+ v.OpusSend = make(chan []byte, 2)
+ }
+ go v.opusSender(v.udpConn, v.close, v.OpusSend, 48000, 960)
+
+ // Start the opusReceiver
+ if !v.deaf {
+ if v.OpusRecv == nil {
+ v.OpusRecv = make(chan *Packet, 2)
+ }
+
+ go v.opusReceiver(v.udpConn, v.close, v.OpusRecv)
+ }
+
+ return
+
+ case 3: // HEARTBEAT response
+ // add code to use this to track latency?
+ // TODO: maybe actually implement this, seems cool
+ return
+
+ case 4: // udp encryption secret key
+ v.Lock()
+ defer v.Unlock()
+
+ v.op4 = voiceOP4{}
+ if err := json.Unmarshal(e.RawData, &v.op4); err != nil {
+ v.log(LogError, "OP4 unmarshall error, %s, %s", err, string(e.RawData))
+ return
+ }
+
+ // TODO: error handling? meh
+ block, _ := aes.NewCipher(v.op4.SecretKey[:])
+ v.aead, _ = cipher.NewGCM(block)
+
+ return
+
+ case 5:
+ if len(v.voiceSpeakingUpdateHandlers) == 0 {
+ return
+ }
+
+ voiceSpeakingUpdate := &VoiceSpeakingUpdate{}
+ if err := json.Unmarshal(e.RawData, voiceSpeakingUpdate); err != nil {
+ v.log(LogError, "OP5 unmarshall error, %s, %s", err, string(e.RawData))
+ return
+ }
+
+ for _, h := range v.voiceSpeakingUpdateHandlers {
+ h(v, voiceSpeakingUpdate)
+ }
+
+ default:
+ v.log(LogDebug, "unknown voice operation, %d, %s", e.Operation, string(e.RawData))
+ }
+}
+
+type voiceHeartbeatOp struct {
+ Op int `json:"op"` // Always 3
+ Data int `json:"d"`
+}
+
+// NOTE :: When a guild voice server changes how do we shut this down
+// properly, so a new connection can be setup without fuss?
+//
+// wsHeartbeat sends regular heartbeats to voice Discord so it knows the client
+// is still connected. If you do not send these heartbeats Discord will
+// disconnect the websocket connection after a few seconds.
+func (v *VoiceConnection) wsHeartbeat(ctx context.Context, wsConn *websocket.Conn, close <-chan struct{}, i time.Duration) {
+
+ if close == nil || wsConn == nil {
+ return
+ }
+
+ var err error
+ ticker := time.NewTicker(i * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ v.log(LogDebug, "sending heartbeat packet")
+ v.wsMutex.Lock()
+ err = wsjson.Write(ctx, wsConn, voiceHeartbeatOp{3, int(time.Now().Unix())})
+ v.wsMutex.Unlock()
+ if err != nil {
+ v.log(LogError, "error sending heartbeat to voice endpoint %s, %s", v.endpoint, err)
+ return
+ }
+
+ select {
+ case <-ticker.C:
+ // continue loop and send heartbeat
+ case <-close:
+ return
+ }
+ }
+}
+
+// ------------------------------------------------------------------------------------------------
+// Code related to the VoiceConnection UDP connection
+// ------------------------------------------------------------------------------------------------
+
+type voiceUDPData struct {
+ Address string `json:"address"` // Public IP of machine running this code
+ Port uint16 `json:"port"` // UDP Port of machine running this code
+ Mode string `json:"mode"` // always "xsalsa20_poly1305"
+}
+
+type voiceUDPD struct {
+ Protocol string `json:"protocol"` // Always "udp" ?
+ Data voiceUDPData `json:"data"`
+}
+
+type voiceUDPOp struct {
+ Op int `json:"op"` // Always 1
+ Data voiceUDPD `json:"d"`
+}
+
+// udpOpen opens a UDP connection to the voice server and completes the
+// initial required handshake. This connection is left open in the session
+// and can be used to send or receive audio. This should only be called
+// from voice.wsEvent OP2
+func (v *VoiceConnection) udpOpen() (err error) {
+
+ v.Lock()
+ defer v.Unlock()
+
+ if v.wsConn == nil {
+ return fmt.Errorf("nil voice websocket")
+ }
+
+ if v.udpConn != nil {
+ return fmt.Errorf("udp connection already open")
+ }
+
+ if v.close == nil {
+ return fmt.Errorf("nil close channel")
+ }
+
+ if v.endpoint == "" {
+ return fmt.Errorf("empty endpoint")
+ }
+
+ host := v.op2.IP + ":" + strconv.Itoa(v.op2.Port)
+ addr, err := net.ResolveUDPAddr("udp", host)
+ if err != nil {
+ v.log(LogWarning, "error resolving udp host %s, %s", host, err)
+ return
+ }
+
+ v.log(LogInformational, "connecting to udp addr %s", addr.String())
+ v.udpConn, err = net.DialUDP("udp", nil, addr)
+ if err != nil {
+ v.log(LogWarning, "error connecting to udp addr %s, %s", addr.String(), err)
+ return
+ }
+
+ // Create a 74 byte array to store the packet data
+ sb := make([]byte, 74)
+ binary.BigEndian.PutUint16(sb, 1) // Packet type (0x1 is request, 0x2 is response)
+ binary.BigEndian.PutUint16(sb[2:], 70) // Packet length (excluding type and length fields)
+ binary.BigEndian.PutUint32(sb[4:], v.op2.SSRC) // The SSRC code from the Op 2 VoiceConnection event
+
+ // And send that data over the UDP connection to Discord.
+ _, err = v.udpConn.Write(sb)
+ if err != nil {
+ v.log(LogWarning, "udp write error to %s, %s", addr.String(), err)
+ return
+ }
+
+ // Create a 74-byte array and listen for the initial handshake response
+ // from Discord. Once we get it parse the IP and PORT information out
+ // of the response. This should be our public IP and PORT as Discord
+ // saw us.
+ rb := make([]byte, 74)
+ rlen, _, err := v.udpConn.ReadFromUDP(rb)
+ if err != nil {
+ v.log(LogWarning, "udp read error, %s, %s", addr.String(), err)
+ return
+ }
+
+ if rlen < 74 {
+ v.log(LogWarning, "received udp packet too small")
+ return fmt.Errorf("received udp packet too small")
+ }
+
+ // Loop over position 8 through 71 to grab the IP address.
+ var ip string
+ for i := 8; i < len(rb)-2; i++ {
+ if rb[i] == 0 {
+ break
+ }
+ ip += string(rb[i])
+ }
+
+ // Grab port from position 72 and 73
+ port := binary.BigEndian.Uint16(rb[len(rb)-2:])
+
+ // Take the data from above and send it back to Discord to finalize
+ // the UDP connection handshake.
+
+ // AEAD AES256-GCM (RTP Size) aead_aes256_gcm_rtpsize 32-bit incremental integer value, appended to payload Available (Preferred)
+ data := voiceUDPOp{1, voiceUDPD{"udp", voiceUDPData{ip, port, "aead_aes256_gcm_rtpsize"}}}
+
+ v.wsMutex.Lock()
+ err = wsjson.Write(v.wsConnCtx, v.wsConn, data)
+ v.wsMutex.Unlock()
+ if err != nil {
+ v.log(LogWarning, "udp write error, %#v, %s", data, err)
+ return
+ }
+
+ // start udpKeepAlive
+ go v.udpKeepAlive(v.udpConn, v.close, 5*time.Second)
+ // TODO: find a way to check that it fired off okay
+
+ return
+}
+
+// udpKeepAlive sends a udp packet to keep the udp connection open
+// This is still a bit of a "proof of concept"
+func (v *VoiceConnection) udpKeepAlive(udpConn *net.UDPConn, close <-chan struct{}, i time.Duration) {
+
+ if udpConn == nil || close == nil {
+ return
+ }
+
+ var err error
+ var sequence uint64
+
+ packet := make([]byte, 8)
+
+ ticker := time.NewTicker(i)
+ defer ticker.Stop()
+ for {
+
+ binary.LittleEndian.PutUint64(packet, sequence)
+ sequence++
+
+ _, err = udpConn.Write(packet)
+ if err != nil {
+ v.log(LogError, "write error, %s", err)
+ return
+ }
+
+ select {
+ case <-ticker.C:
+ // continue loop and send keepalive
+ case <-close:
+ return
+ }
+ }
+}
+
+// opusSender will listen on the given channel and send any
+// pre-encoded opus audio to Discord. Supposedly.
+func (v *VoiceConnection) opusSender(udpConn *net.UDPConn, close <-chan struct{}, opus <-chan []byte, rate, size int) {
+
+ if udpConn == nil || close == nil {
+ return
+ }
+
+ // VoiceConnection is now ready to receive audio packets
+ // TODO: this needs reviewing as I think there must be a better way.
+ v.Lock()
+ v.Ready = true
+ v.Unlock()
+ defer func() {
+ v.Lock()
+ v.Ready = false
+ v.Unlock()
+ }()
+
+ var sequence uint16
+ var timestamp uint32
+ var recvbuf []byte
+ var ok bool
+ udpHeader := make([]byte, 12)
+ nonce := make([]byte, 12)
+
+ // build the parts that don't change in the udpHeader
+ udpHeader[0] = 0x80
+ udpHeader[1] = 0x78
+ binary.BigEndian.PutUint32(udpHeader[8:], v.op2.SSRC)
+
+ // start a send loop that loops until buf chan is closed
+ ticker := time.NewTicker(time.Millisecond * time.Duration(size/(rate/1000)))
+ defer ticker.Stop()
+ for {
+
+ // Get data from chan. If chan is closed, return.
+ select {
+ case <-close:
+ return
+ case recvbuf, ok = <-opus:
+ if !ok {
+ return
+ }
+ // else, continue loop
+ }
+
+ v.RLock()
+ speaking := v.speaking
+ v.RUnlock()
+ if !speaking {
+ err := v.Speaking(true)
+ if err != nil {
+ v.log(LogError, "error sending speaking packet, %s", err)
+ }
+ }
+
+ // Add sequence and timestamp to udpPacket
+ binary.BigEndian.PutUint16(udpHeader[2:], sequence)
+ binary.BigEndian.PutUint32(udpHeader[4:], timestamp)
+
+ // encrypt the opus data
+ // add incrementing nonce counter as per discord's requirements
+ binary.LittleEndian.PutUint32(nonce[:4], v.nonceCounter)
+ v.nonceCounter++
+
+ sendbuf := v.aead.Seal(nil, nonce, recvbuf, udpHeader)
+ sendbuf = append(sendbuf, nonce[:4]...) // 4 byte nonce to ciphertext appended
+ sendbuf = append(udpHeader, sendbuf...) // final
+
+ // block here until we're exactly at the right time :)
+ // Then send rtp audio packet to Discord over UDP
+ select {
+ case <-close:
+ return
+ case <-ticker.C:
+ // continue
+ }
+ _, err := udpConn.Write(sendbuf)
+
+ if err != nil {
+ v.log(LogError, "udp write error, %s", err)
+ v.log(LogDebug, "voice struct: %#v\n", v)
+ return
+ }
+
+ if (sequence) == 0xFFFF {
+ sequence = 0
+ } else {
+ sequence++
+ }
+
+ timestamp += uint32(size)
+ }
+}
+
+// A Packet contains the headers and content of a received voice packet.
+type Packet struct {
+ SSRC uint32
+ Sequence uint16
+ Timestamp uint32
+ Type []byte
+ Opus []byte
+ PCM []int16
+}
+
+// opusReceiver listens on the UDP socket for incoming packets
+// and sends them across the given channel
+// NOTE :: This function may change names later.
+func (v *VoiceConnection) opusReceiver(udpConn *net.UDPConn, close <-chan struct{}, c chan *Packet) {
+
+ if udpConn == nil || close == nil {
+ return
+ }
+
+ recvbuf := make([]byte, 2048)
+ var nonce [12]byte
+
+ for {
+ rlen, err := udpConn.Read(recvbuf)
+ if err != nil {
+ // Detect if we have been closed manually. If a Close() has already
+ // happened, the udp connection we are listening on will be different
+ // to the current session.
+ v.RLock()
+ sameConnection := v.udpConn == udpConn
+ v.RUnlock()
+ if sameConnection {
+
+ v.log(LogError, "udp read error, %s, %s", v.endpoint, err)
+ v.log(LogDebug, "voice struct: %#v\n", v)
+
+ go v.reconnect()
+ }
+ return
+ }
+
+ select {
+ case <-close:
+ return
+ default:
+ // continue loop
+ }
+
+ // For now, skip anything except RTP v2 packets (audio).
+ // RTP v2 => top two bits are 10 (0x80).
+ if rlen < 12 || (recvbuf[0]&0xC0) != 0x80 {
+ continue
+ }
+
+ // build a audio packet struct
+ p := Packet{}
+ p.Type = recvbuf[0:2]
+ p.Sequence = binary.BigEndian.Uint16(recvbuf[2:4])
+ p.Timestamp = binary.BigEndian.Uint32(recvbuf[4:8])
+ p.SSRC = binary.BigEndian.Uint32(recvbuf[8:12])
+
+ // RTP header parsing for *_rtpsize AEAD modes:
+ // - base RTP header is 12 bytes + 4 bytes per CSRC (CC).
+ // - if extension bit (X) is set, ONLY the 4-byte extension preamble is unencrypted/AAD;
+ // the extension payload is encrypted and must be stripped after decryption.
+ cc := int(recvbuf[0] & 0x0F)
+ hasExt := (recvbuf[0] & 0x10) != 0
+
+ baseHeaderLen := 12 + (4 * cc)
+ if rlen < baseHeaderLen {
+ continue
+ }
+
+ aadLen := baseHeaderLen
+ extPayloadBytes := 0
+ if hasExt {
+ if rlen < baseHeaderLen+4 {
+ continue
+ }
+ // Extension length is in 32-bit words at the end of the extension preamble.
+ extLenWords := int(binary.BigEndian.Uint16(recvbuf[baseHeaderLen+2 : baseHeaderLen+4]))
+ extPayloadBytes = extLenWords * 4
+ aadLen = baseHeaderLen + 4
+ }
+
+ if rlen < aadLen+4 {
+ continue
+ }
+
+ // decrypt opus data
+ payload := recvbuf[aadLen:rlen]
+ if len(payload) < 4 {
+ continue
+ }
+ nonceCounter := payload[len(payload)-4:]
+ cipherTextPayload := payload[:len(payload)-4]
+
+ binary.LittleEndian.PutUint32(nonce[:4], binary.LittleEndian.Uint32(nonceCounter))
+
+ if v.aead == nil {
+ continue
+ }
+ // AAD must cover the unencrypted header portion.
+ if plain, err := v.aead.Open(nil, nonce[:], cipherTextPayload, recvbuf[:aadLen]); err == nil {
+ // If header extensions are present, strip decrypted extension payload to get to Opus.
+ if extPayloadBytes > 0 {
+ if len(plain) < extPayloadBytes {
+ continue
+ }
+ plain = plain[extPayloadBytes:]
+ }
+ p.Opus = plain
+ } else {
+ continue
+ }
+
+ if c != nil {
+ select {
+ case c <- &p:
+ case <-close:
+ return
+ }
+ }
+ }
+}
+
+// Reconnect will close down a voice connection then immediately try to
+// reconnect to that session.
+// NOTE : This func is messy and a WIP while I find what works.
+// It will be cleaned up once a proven stable option is flushed out.
+// aka: this is ugly shit code, please don't judge too harshly.
+func (v *VoiceConnection) reconnect() {
+
+ v.log(LogInformational, "called")
+
+ v.Lock()
+ if v.reconnecting {
+ v.log(LogInformational, "already reconnecting to channel %s, exiting", v.ChannelID)
+ v.Unlock()
+ return
+ }
+ v.reconnecting = true
+ v.Unlock()
+
+ defer func() {
+ v.Lock()
+ v.reconnecting = false
+ v.Unlock()
+ }()
+
+ // Close any currently open connections
+ v.Close()
+
+ wait := time.Duration(1)
+ for {
+
+ <-time.After(wait * time.Second)
+ wait *= 2
+ if wait > 600 {
+ wait = 600
+ }
+
+ if !v.session.DataReady || v.session.wsConn == nil {
+ v.log(LogInformational, "cannot reconnect to channel %s with unready session", v.ChannelID)
+ continue
+ }
+
+ v.log(LogInformational, "trying to reconnect to channel %s", v.ChannelID)
+
+ _, err := v.session.ChannelVoiceJoin(v.GuildID, v.ChannelID, v.mute, v.deaf)
+ if err == nil {
+ v.log(LogInformational, "successfully reconnected to channel %s", v.ChannelID)
+ return
+ }
+
+ v.log(LogInformational, "error reconnecting to channel %s, %s", v.ChannelID, err)
+
+ // if the reconnect above didn't work lets just send a disconnect
+ // packet to reset things.
+ // Send a OP4 with a nil channel to disconnect
+ data := voiceChannelJoinOp{4, voiceChannelJoinData{&v.GuildID, nil, true, true}}
+ v.session.wsMutex.Lock()
+ err = wsjson.Write(v.session.wsConnCtx, v.session.wsConn, data)
+ v.session.wsMutex.Unlock()
+ if err != nil {
+ v.log(LogError, "error sending disconnect packet, %s", err)
+ }
+
+ }
+}
diff --git a/pkg/meowcord/webhook.go b/pkg/meowcord/webhook.go
new file mode 100644
index 0000000..4fceab9
--- /dev/null
+++ b/pkg/meowcord/webhook.go
@@ -0,0 +1,77 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package meowcord
+
+// Webhook stores the data for a webhook.
+type Webhook struct {
+ ID string `json:"id"`
+ Type WebhookType `json:"type"`
+ GuildID string `json:"guild_id"`
+ ChannelID string `json:"channel_id"`
+ User *User `json:"user"`
+ Name string `json:"name"`
+ Avatar string `json:"avatar"`
+ Token string `json:"token"`
+
+ // ApplicationID is the bot/OAuth2 application that created this webhook
+ ApplicationID string `json:"application_id,omitempty"`
+}
+
+// WebhookType is the type of Webhook (see WebhookType* consts) in the Webhook struct
+// https://discord.com/developers/docs/resources/webhook#webhook-object-webhook-types
+type WebhookType int
+
+// Valid WebhookType values
+const (
+ WebhookTypeIncoming WebhookType = 1
+ WebhookTypeChannelFollower WebhookType = 2
+)
+
+// WebhookParams is a struct for webhook params, used in the WebhookExecute command.
+type WebhookParams struct {
+ Content string `json:"content,omitempty"`
+ Username string `json:"username,omitempty"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+ TTS bool `json:"tts,omitempty"`
+ Files []*File `json:"-"`
+ Components []MessageComponent `json:"components"`
+ Embeds []*MessageEmbed `json:"embeds,omitempty"`
+ Attachments []*MessageAttachment `json:"attachments,omitempty"`
+ AllowedMentions *MessageAllowedMentions `json:"allowed_mentions,omitempty"`
+ // Only MessageFlagsSuppressEmbeds and MessageFlagsEphemeral can be set.
+ // MessageFlagsEphemeral can only be set when using Followup Message Create endpoint.
+ Flags MessageFlags `json:"flags,omitempty"`
+ // Name of the thread to create.
+ // NOTE: can only be set if the webhook channel is a forum.
+ ThreadName string `json:"thread_name,omitempty"`
+}
+
+// WebhookEdit stores data for editing of a webhook message.
+type WebhookEdit struct {
+ Content *string `json:"content,omitempty"`
+ Components *[]MessageComponent `json:"components,omitempty"`
+ Embeds *[]*MessageEmbed `json:"embeds,omitempty"`
+ Files []*File `json:"-"`
+ Attachments *[]*MessageAttachment `json:"attachments,omitempty"`
+ AllowedMentions *MessageAllowedMentions `json:"allowed_mentions,omitempty"`
+ Flags MessageFlags `json:"flags,omitempty"`
+}
diff --git a/pkg/meowcord/wsapi.go b/pkg/meowcord/wsapi.go
new file mode 100644
index 0000000..8cdbd47
--- /dev/null
+++ b/pkg/meowcord/wsapi.go
@@ -0,0 +1,1231 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright 2015-2016 Bruce Marriner . All rights reserved.
+// Copyright (C) 2026 The mautrix-discord contributors
+//
+// This file is derived from discordgo (https://github.com/bwmarrin/discordgo),
+// used under the BSD-3-Clause license; see README.md in this directory. This
+// file is distributed under the GNU AGPLv3 as follows:
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// This file contains low level functions for interacting with the Discord
+// data websocket interface.
+
+package meowcord
+
+import (
+ "compress/zlib"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync/atomic"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/google/uuid"
+)
+
+// ErrWSAlreadyOpen is thrown when you attempt to open
+// a websocket that already is open.
+var ErrWSAlreadyOpen = errors.New("web socket already opened")
+
+// ErrWSNotFound is thrown when you attempt to use a websocket
+// that doesn't exist
+var ErrWSNotFound = errors.New("no websocket connection exists")
+
+// ErrWSShardBounds is thrown when you try to use a shard ID that is
+// more than the total shard count
+var ErrWSShardBounds = errors.New("ShardID must be less than ShardCount")
+
+type resumePacket struct {
+ Op int `json:"op"`
+ Data struct {
+ Token string `json:"token"`
+ SessionID string `json:"session_id"`
+ Sequence int64 `json:"seq"`
+ } `json:"d"`
+}
+
+func (s *Session) closeZLib() {
+ if s.zlibReader != nil {
+ _ = s.zlibReader.Close()
+ }
+ if s.zlibReader != nil {
+ _ = s.zlibPipeReader.Close()
+ _ = s.zlibPipeWriter.Close()
+ }
+ s.zlibJSON = nil
+ s.zlibPipeReader = nil
+ s.zlibPipeWriter = nil
+ s.zlibReader = nil
+}
+
+// Open creates a websocket connection to Discord.
+// See: https://discord.com/developers/docs/topics/gateway#connecting
+func (s *Session) Open() error {
+ s.log(LogInformational, "called")
+
+ var err error
+
+ // Prevent Open or other major Session functions from
+ // being called while Open is still running.
+ s.Lock()
+ defer s.Unlock()
+
+ // If the websock is already open, bail out here.
+ if s.wsConn != nil {
+ return ErrWSAlreadyOpen
+ }
+
+ sequence := atomic.LoadInt64(s.sequence)
+
+ var gateway string
+ // Get the gateway to use for the Websocket connection
+ if sequence != 0 && s.sessionID != "" && s.resumeGatewayURL != "" {
+ s.log(LogDebug, "using resume gateway %s", s.resumeGatewayURL)
+ gateway = s.resumeGatewayURL
+ } else {
+ if s.gateway == "" {
+ s.gateway, err = s.Gateway()
+ if err != nil {
+ return err
+ }
+ }
+
+ gateway = s.gateway
+ }
+
+ s.zlibPipeReader, s.zlibPipeWriter = io.Pipe()
+
+ // Add the version and encoding to the URL
+ gateway += "?v=" + APIVersion + "&encoding=json&compress=zlib-stream"
+
+ // Connect to the Gateway
+ s.log(LogInformational, "connecting to gateway %s", gateway)
+ header := http.Header{}
+ if s.IsUser {
+ for k, v := range DroidWSHeaders {
+ header.Add(k, v)
+ }
+ } else {
+ header.Add("accept-encoding", "zlib")
+ }
+
+ dialCtx := context.Background()
+ if s.GatewayDialTimeout > 0 {
+ var cancelDial context.CancelFunc
+ dialCtx, cancelDial = context.WithTimeout(dialCtx, s.GatewayDialTimeout)
+ defer cancelDial()
+ }
+
+ s.wsConn, _, err = websocket.Dial(dialCtx, gateway, &websocket.DialOptions{
+ HTTPClient: s.GatewayHTTPClient,
+ HTTPHeader: header,
+ // Discord uses its own app-level compression (e.g. zlib-stream).
+ CompressionMode: websocket.CompressionDisabled,
+ })
+ if err != nil {
+ s.log(LogError, "error connecting to gateway %s, %s", s.gateway, err)
+ if !s.noClearGateway {
+ s.gateway = "" // clear cached gateway
+ }
+ s.wsConn = nil // Just to be safe.
+ s.closeZLib()
+ return err
+ }
+
+ // coder/websocket defaults to a 32 KiB read limit, which is far too small
+ // for Discord's READY messages, which can be massive... disable the limit
+ // entirely.
+ s.wsConn.SetReadLimit(-1)
+
+ s.wsConnCtx, s.wsConnCancel = context.WithCancel(context.Background())
+
+ defer func() {
+ // because of this, all code below must set err to the error
+ // when exiting with an error :) Maybe someone has a better
+ // way :)
+ if err != nil {
+ s.wsConnCancel()
+ s.wsConn.CloseNow()
+ s.wsConn = nil
+ s.wsConnCtx = nil
+ s.wsConnCancel = nil
+ s.closeZLib()
+ }
+ }()
+
+ // The first response from Discord should be an Op 10 (Hello) Packet.
+ // When processed by onEvent the heartbeat goroutine will be started.
+ mt, m, err := s.wsConn.Read(s.wsConnCtx)
+ if err != nil {
+ return err
+ }
+ e, err := s.onEvent(mt, m, true)
+ if err != nil {
+ return err
+ }
+ if e.Operation != 10 {
+ err = fmt.Errorf("expecting Op 10, got Op %d instead", e.Operation)
+ return err
+ }
+ s.log(LogInformational, "Op 10 Hello Packet received from Discord")
+ s.LastHeartbeatAck = time.Now().UTC()
+ var h helloOp
+ if err = json.Unmarshal(e.RawData, &h); err != nil {
+ err = fmt.Errorf("error unmarshalling helloOp, %s", err)
+ return err
+ }
+
+ // Now we send either an Op 2 Identity if this is a brand new
+ // connection or Op 6 Resume if we are resuming an existing connection.
+ if s.sessionID == "" && sequence == 0 {
+
+ // Send Op 2 Identity Packet
+ err = s.identify()
+ if err != nil {
+ err = fmt.Errorf("error sending identify packet to gateway, %s, %s", s.gateway, err)
+ return err
+ }
+
+ } else {
+
+ // Send Op 6 Resume Packet
+ p := resumePacket{}
+ p.Op = 6
+ p.Data.Token = s.Token
+ p.Data.SessionID = s.sessionID
+ p.Data.Sequence = sequence
+
+ s.log(LogInformational, "sending resume packet to gateway")
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, p)
+ s.wsMutex.Unlock()
+ if err != nil {
+ err = fmt.Errorf("error sending gateway resume packet, %s, %s", s.gateway, err)
+ return err
+ }
+
+ }
+
+ // A basic state is a hard requirement for Voice.
+ // We create it here so the below READY/RESUMED packet can populate
+ // the state :)
+ // XXX: Move to New() func?
+ if s.State == nil {
+ state := NewState()
+ state.TrackChannels = false
+ state.TrackEmojis = false
+ state.TrackMembers = false
+ state.TrackRoles = false
+ state.TrackVoice = false
+ s.State = state
+ }
+
+ // Now Discord should send us a READY or RESUMED packet.
+ mt, m, err = s.wsConn.Read(s.wsConnCtx)
+ if err != nil {
+ return err
+ }
+ e, err = s.onEvent(mt, m, true)
+ if err != nil {
+ return err
+ }
+ if s.IsUser && e.Type == "READY" {
+ s.wsMutex.Lock()
+ err := wsjson.Write(s.wsConnCtx, s.wsConn,
+ updateTimeSpentSessionOp{
+ Op: 41,
+ Data: updateTimeSpentSessionData{
+ InitializationTimestamp: s.HeartbeatSession.LastUsedTimestamp.UnixMilli(),
+ SessionID: s.HeartbeatSession.ID,
+ ClientLaunchID: s.launchID,
+ },
+ })
+ s.wsMutex.Unlock()
+ if err != nil {
+ s.log(LogError, "Failed to send UPDATE_TIME_SPENT_SESSION_ID, continuing: %v", err)
+ }
+ }
+ if e.Type != `READY` && e.Type != `RESUMED` {
+ // This is not fatal, but it does not follow their API documentation.
+ s.log(LogWarning, "Expected READY/RESUMED, instead got:\n%#v\n", e)
+ }
+ //s.log(LogInformational, "First Packet:\n%#v\n", e)
+
+ s.log(LogInformational, "We are now connected to Discord, emitting connect event")
+ s.handleEvent(connectEventType, &Connect{})
+
+ // A VoiceConnections map is a hard requirement for Voice.
+ // XXX: can this be moved to when opening a voice connection?
+ if s.VoiceConnections == nil {
+ s.log(LogInformational, "creating new VoiceConnections map")
+ s.VoiceConnections = make(map[string]*VoiceConnection)
+ }
+
+ // Create listening chan outside of listen, as it needs to happen inside the
+ // mutex lock and needs to exist before calling heartbeat and listen
+ // go rountines.
+ s.listening = make(chan interface{})
+
+ // Start sending heartbeats and reading messages from Discord. Capture a
+ // fresh context for each goroutine.
+ go s.heartbeat(s.wsConnCtx, s.wsConn, s.listening, h.HeartbeatInterval)
+ go s.listen(s.wsConnCtx, s.wsConn, s.listening)
+
+ s.log(LogInformational, "exiting")
+ return nil
+}
+
+// listen polls the websocket connection for events, it will stop when the
+// listening channel is closed, or an error occurs.
+func (s *Session) listen(ctx context.Context, wsConn *websocket.Conn, listening <-chan interface{}) {
+
+ s.log(LogInformational, "called")
+
+ for {
+
+ messageType, message, err := wsConn.Read(ctx)
+
+ if err != nil {
+
+ // Detect if we have been closed manually. If a Close() has already
+ // happened, the websocket we are listening on will be different to
+ // the current session.
+ s.RLock()
+ sameConnection := s.wsConn == wsConn
+ s.RUnlock()
+
+ if sameConnection {
+
+ s.log(LogWarning, "error reading from gateway %s websocket, %s", s.gateway, err)
+
+ closeCode := websocket.CloseStatus(err)
+
+ // There has been an error reading, close the websocket so that
+ // OnDisconnect event is emitted.
+ err := s.Close()
+ if err != nil {
+ s.log(LogWarning, "error closing session connection, %s", err)
+ }
+
+ switch closeCode {
+ case 4004:
+ s.log(LogInformational, "emit invalid auth event")
+ s.handleEvent(invalidAuthEventType, &InvalidAuth{})
+ return
+ }
+
+ s.log(LogInformational, "calling reconnect() now")
+ s.reconnect()
+ }
+
+ return
+ }
+
+ select {
+
+ case <-listening:
+ return
+
+ default:
+ s.onEvent(messageType, message, false)
+
+ }
+ }
+}
+
+type heartbeatOp struct {
+ Op int `json:"op"`
+ Data int64 `json:"d"`
+}
+
+func (s *Session) newHeartbeatOp(seq int64) interface{} {
+ if s.IsUser {
+ return newForegroundedQosHeartbeatOp(seq)
+ }
+ return heartbeatOp{Op: 1, Data: seq}
+}
+
+func newForegroundedQosHeartbeatOp(seq int64) qosHeartbeatOp {
+ return qosHeartbeatOp{
+ Op: 40,
+ Data: qosHeartbeatData{
+ Seq: seq,
+ Qos: qos{
+ Active: true,
+ Ver: 28,
+ Reasons: []string{"foregrounded"},
+ },
+ },
+ }
+}
+
+type qos struct {
+ Active bool `json:"active"`
+ Ver int `json:"ver"`
+ Reasons []string `json:"reasons"`
+}
+
+type qosHeartbeatData struct {
+ Seq int64 `json:"seq"`
+ Qos qos `json:"qos"`
+}
+
+type qosHeartbeatOp struct {
+ Op int `json:"op"`
+ Data qosHeartbeatData `json:"d"`
+}
+
+type updateTimeSpentSessionData struct {
+ InitializationTimestamp int64 `json:"initialization_timestamp"`
+ SessionID uuid.UUID `json:"session_id"`
+ ClientLaunchID uuid.UUID `json:"client_launch_id"`
+}
+
+type updateTimeSpentSessionOp struct {
+ Op int `json:"op"`
+ Data updateTimeSpentSessionData `json:"d"`
+}
+
+type helloOp struct {
+ HeartbeatInterval time.Duration `json:"heartbeat_interval"`
+}
+
+// FailedHeartbeatAcks is the Number of heartbeat intervals to wait until forcing a connection restart.
+const FailedHeartbeatAcks time.Duration = 5 * time.Millisecond
+
+// HeartbeatLatency returns the latency between heartbeat acknowledgement and heartbeat send.
+func (s *Session) HeartbeatLatency() time.Duration {
+
+ return s.LastHeartbeatAck.Sub(s.LastHeartbeatSent)
+
+}
+
+// heartbeat sends regular heartbeats to Discord so it knows the client
+// is still connected. If you do not send these heartbeats Discord will
+// disconnect the websocket connection after a few seconds.
+func (s *Session) heartbeat(ctx context.Context, wsConn *websocket.Conn, listening <-chan interface{}, heartbeatInterval time.Duration) {
+
+ s.log(LogInformational, "called")
+
+ if listening == nil || wsConn == nil {
+ return
+ }
+
+ var err error
+ ticker := time.NewTicker(heartbeatInterval * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ s.RLock()
+ last := s.LastHeartbeatAck
+ s.RUnlock()
+ sequence := atomic.LoadInt64(s.sequence)
+ s.log(LogDebug, "sending gateway websocket heartbeat seq %d", sequence)
+ s.wsMutex.Lock()
+ s.LastHeartbeatSent = time.Now().UTC()
+ err = wsjson.Write(ctx, wsConn, s.newHeartbeatOp(sequence))
+ s.wsMutex.Unlock()
+ if err != nil || time.Now().UTC().Sub(last) > (heartbeatInterval*FailedHeartbeatAcks) {
+ if err != nil {
+ s.log(LogError, "error sending heartbeat to gateway %s, %s", s.gateway, err)
+ } else {
+ s.log(LogError, "haven't gotten a heartbeat ACK in %v, triggering a reconnection", time.Now().UTC().Sub(last))
+ }
+ s.Close()
+ s.reconnect()
+ return
+ }
+ s.Lock()
+ s.DataReady = true
+ s.Unlock()
+
+ select {
+ case <-ticker.C:
+ // continue loop and send heartbeat
+ case <-listening:
+ return
+ }
+ }
+}
+
+// UpdateStatusData is provided to UpdateStatusComplex()
+type UpdateStatusData struct {
+ IdleSince *int `json:"since"`
+ Activities []*Activity `json:"activities"`
+ AFK bool `json:"afk"`
+ Status string `json:"status"`
+}
+
+type updateStatusOp struct {
+ Op int `json:"op"`
+ Data UpdateStatusData `json:"d"`
+}
+
+func newUpdateStatusData(idle int, activityType ActivityType, name, url string) *UpdateStatusData {
+ usd := &UpdateStatusData{
+ Status: "online",
+ }
+
+ if idle > 0 {
+ usd.IdleSince = &idle
+ }
+
+ if name != "" {
+ usd.Activities = []*Activity{{
+ Name: name,
+ Type: activityType,
+ URL: url,
+ }}
+ }
+
+ return usd
+}
+
+// UpdateGameStatus is used to update the user's status.
+// If idle>0 then set status to idle.
+// If name!="" then set game.
+// if otherwise, set status to active, and no activity.
+func (s *Session) UpdateGameStatus(idle int, name string) (err error) {
+ return s.UpdateStatusComplex(*newUpdateStatusData(idle, ActivityTypeGame, name, ""))
+}
+
+// UpdateWatchStatus is used to update the user's watch status.
+// If idle>0 then set status to idle.
+// If name!="" then set movie/stream.
+// if otherwise, set status to active, and no activity.
+func (s *Session) UpdateWatchStatus(idle int, name string) (err error) {
+ return s.UpdateStatusComplex(*newUpdateStatusData(idle, ActivityTypeWatching, name, ""))
+}
+
+// UpdateStreamingStatus is used to update the user's streaming status.
+// If idle>0 then set status to idle.
+// If name!="" then set game.
+// If name!="" and url!="" then set the status type to streaming with the URL set.
+// if otherwise, set status to active, and no game.
+func (s *Session) UpdateStreamingStatus(idle int, name string, url string) (err error) {
+ gameType := ActivityTypeGame
+ if url != "" {
+ gameType = ActivityTypeStreaming
+ }
+ return s.UpdateStatusComplex(*newUpdateStatusData(idle, gameType, name, url))
+}
+
+// UpdateListeningStatus is used to set the user to "Listening to..."
+// If name!="" then set to what user is listening to
+// Else, set user to active and no activity.
+func (s *Session) UpdateListeningStatus(name string) (err error) {
+ return s.UpdateStatusComplex(*newUpdateStatusData(0, ActivityTypeListening, name, ""))
+}
+
+// UpdateCustomStatus is used to update the user's custom status.
+// If state!="" then set the custom status.
+// Else, set user to active and remove the custom status.
+func (s *Session) UpdateCustomStatus(state string) (err error) {
+ data := UpdateStatusData{
+ Status: "online",
+ }
+
+ if state != "" {
+ // Discord requires a non-empty activity name, therefore we provide "Custom Status" as a placeholder.
+ data.Activities = []*Activity{{
+ Name: "Custom Status",
+ Type: ActivityTypeCustom,
+ State: state,
+ }}
+ }
+
+ return s.UpdateStatusComplex(data)
+}
+
+// UpdateStatusComplex allows for sending the raw status update data untouched by discordgo.
+func (s *Session) UpdateStatusComplex(usd UpdateStatusData) (err error) {
+ // The comment does say "untouched by discordgo", but we might need to lie a bit here.
+ // The Discord documentation lists `activities` as being nullable, but in practice this
+ // doesn't seem to be the case. I had filed an issue about this at
+ // https://github.com/discord/discord-api-docs/issues/2559, but as of writing this
+ // haven't had any movement on it, so at this point I'm assuming this is an error,
+ // and am fixing this bug accordingly. Because sending `null` for `activities` instantly
+ // disconnects us, I think that disallowing it from being sent in `UpdateStatusComplex`
+ // isn't that big of an issue.
+ if usd.Activities == nil {
+ usd.Activities = make([]*Activity, 0)
+ }
+
+ s.RLock()
+ defer s.RUnlock()
+ if s.wsConn == nil {
+ return ErrWSNotFound
+ }
+
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, updateStatusOp{3, usd})
+ s.wsMutex.Unlock()
+
+ return
+}
+
+type requestGuildMembersData struct {
+ // TODO: Deprecated. Use string instead of []string
+ GuildIDs []string `json:"guild_id"`
+ Query *string `json:"query,omitempty"`
+ UserIDs *[]string `json:"user_ids,omitempty"`
+ Limit int `json:"limit"`
+ Nonce string `json:"nonce,omitempty"`
+ Presences bool `json:"presences"`
+}
+
+type requestGuildMembersOp struct {
+ Op int `json:"op"`
+ Data requestGuildMembersData `json:"d"`
+}
+
+type markViewingData struct {
+ ChannelID string `json:"channel_id"`
+}
+
+type markViewingOp struct {
+ Op int `json:"op"`
+ Data markViewingData `json:"d"`
+}
+
+type GuildSubscribeData struct {
+ GuildID string `json:"guild_id"`
+ Channels map[string][][]int `json:"channels,omitempty"`
+ Typing bool `json:"typing,omitempty"`
+ Activities bool `json:"activities,omitempty"`
+ Threads bool `json:"threads,omitempty"`
+ Members []string `json:"members,omitempty"`
+ ThreadMemberLists []string `json:"thread_member_lists,omitempty"`
+}
+
+type guildSubscribeOp struct {
+ Op int `json:"op"`
+ Data GuildSubscribeData `json:"d"`
+}
+
+// RequestGuildMembers requests guild members from the gateway
+// The gateway responds with GuildMembersChunk events
+// guildID : Single Guild ID to request members of
+// query : String that username starts with, leave empty to return all members
+// limit : Max number of items to return, or 0 to request all members matched
+// nonce : Nonce to identify the Guild Members Chunk response
+// presences : Whether to request presences of guild members
+func (s *Session) RequestGuildMembers(guildID, query string, limit int, nonce string, presences bool) error {
+ return s.RequestGuildMembersBatch([]string{guildID}, query, limit, nonce, presences)
+}
+
+// RequestGuildMembersList requests guild members from the gateway
+// The gateway responds with GuildMembersChunk events
+// guildID : Single Guild ID to request members of
+// userIDs : IDs of users to fetch
+// limit : Max number of items to return, or 0 to request all members matched
+// nonce : Nonce to identify the Guild Members Chunk response
+// presences : Whether to request presences of guild members
+func (s *Session) RequestGuildMembersList(guildID string, userIDs []string, limit int, nonce string, presences bool) error {
+ return s.RequestGuildMembersBatchList([]string{guildID}, userIDs, limit, nonce, presences)
+}
+
+// RequestGuildMembersBatch requests guild members from the gateway
+// The gateway responds with GuildMembersChunk events
+// guildID : Slice of guild IDs to request members of
+// query : String that username starts with, leave empty to return all members
+// limit : Max number of items to return, or 0 to request all members matched
+// nonce : Nonce to identify the Guild Members Chunk response
+// presences : Whether to request presences of guild members
+//
+// NOTE: this function is deprecated, please use RequestGuildMembers instead
+func (s *Session) RequestGuildMembersBatch(guildIDs []string, query string, limit int, nonce string, presences bool) (err error) {
+ data := requestGuildMembersData{
+ GuildIDs: guildIDs,
+ Query: &query,
+ Limit: limit,
+ Nonce: nonce,
+ Presences: presences,
+ }
+ err = s.requestGuildMembers(data)
+ return
+}
+
+// RequestGuildMembersBatchList requests guild members from the gateway
+// The gateway responds with GuildMembersChunk events
+// guildID : Slice of guild IDs to request members of
+// userIDs : IDs of users to fetch
+// limit : Max number of items to return, or 0 to request all members matched
+// nonce : Nonce to identify the Guild Members Chunk response
+// presences : Whether to request presences of guild members
+//
+// NOTE: this function is deprecated, please use RequestGuildMembersList instead
+func (s *Session) RequestGuildMembersBatchList(guildIDs []string, userIDs []string, limit int, nonce string, presences bool) (err error) {
+ data := requestGuildMembersData{
+ GuildIDs: guildIDs,
+ UserIDs: &userIDs,
+ Limit: limit,
+ Nonce: nonce,
+ Presences: presences,
+ }
+ err = s.requestGuildMembers(data)
+ return
+}
+
+// GatewayWriteStruct allows for sending raw gateway structs over the gateway.
+func (s *Session) GatewayWriteStruct(data interface{}) (err error) {
+ s.RLock()
+ defer s.RUnlock()
+ if s.wsConn == nil {
+ return ErrWSNotFound
+ }
+
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, data)
+ s.wsMutex.Unlock()
+
+ return err
+}
+
+func (s *Session) requestGuildMembers(data requestGuildMembersData) (err error) {
+ s.log(LogInformational, "called")
+
+ s.RLock()
+ defer s.RUnlock()
+ if s.wsConn == nil {
+ return ErrWSNotFound
+ }
+
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, requestGuildMembersOp{8, data})
+ s.wsMutex.Unlock()
+
+ return
+}
+
+func (s *Session) MarkViewing(channelID string) (err error) {
+ if !s.IsUser {
+ s.log(LogWarning, "ignoring call")
+ return
+ }
+ s.log(LogInformational, "called")
+
+ s.RLock()
+ defer s.RUnlock()
+ if s.wsConn == nil {
+ return ErrWSNotFound
+ }
+
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, markViewingOp{13, markViewingData{channelID}})
+ s.wsMutex.Unlock()
+
+ return
+}
+
+func (s *Session) SubscribeGuild(dat GuildSubscribeData) (err error) {
+ if !s.IsUser {
+ s.log(LogWarning, "ignoring call")
+ return
+ }
+ s.log(LogInformational, "called")
+
+ s.RLock()
+ defer s.RUnlock()
+ if s.wsConn == nil {
+ return ErrWSNotFound
+ }
+
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, guildSubscribeOp{14, dat})
+ s.wsMutex.Unlock()
+
+ return
+}
+
+// onEvent is the "event handler" for all messages received on the
+// Discord Gateway API websocket connection.
+//
+// If you use the AddHandler() function to register a handler for a
+// specific event this function will pass the event along to that handler.
+//
+// If you use the AddHandler() function to register a handler for the
+// "OnEvent" event then all events will be passed to that handler.
+func (s *Session) onEvent(messageType websocket.MessageType, message []byte, isOnConnect bool) (*Event, error) {
+ var err error
+
+ // Decode the event into an Event struct.
+ var e *Event
+
+ // If this is a compressed message, uncompress it.
+ if messageType == websocket.MessageBinary {
+ go func() {
+ _, innerErr := s.zlibPipeWriter.Write(message)
+ if innerErr != nil {
+ s.log(LogError, "error writing websocket message to zlib pipe: %v", innerErr)
+ }
+ }()
+
+ if s.zlibReader == nil {
+ s.zlibReader, err = zlib.NewReader(s.zlibPipeReader)
+ if err != nil {
+ s.log(LogError, "error preparing zlib reader: %v", err)
+ s.zlibReader = nil
+ return nil, err
+ }
+ s.zlibJSON = json.NewDecoder(s.zlibReader)
+ }
+
+ if err = s.zlibJSON.Decode(&e); err != nil {
+ s.log(LogError, "error decoding websocket message, %s", err)
+ return e, err
+ }
+ } else {
+ if err = json.Unmarshal(message, &e); err != nil {
+ s.log(LogError, "error decoding websocket message, %s", err)
+ return e, err
+ }
+ }
+
+ s.log(LogDebug, "Op: %d, Seq: %d, Type: %s, Data: %s\n\n", e.Operation, e.Sequence, e.Type, string(e.RawData))
+
+ // Ping request.
+ // Must respond with a heartbeat packet within 5 seconds
+ if e.Operation == 1 {
+ s.log(LogInformational, "sending heartbeat in response to Op1")
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, s.newHeartbeatOp(atomic.LoadInt64(s.sequence)))
+ s.wsMutex.Unlock()
+ if err != nil {
+ s.log(LogError, "error sending heartbeat in response to Op1")
+ return e, err
+ }
+
+ return e, nil
+ }
+
+ // Reconnect
+ // Must immediately disconnect from gateway and reconnect to new gateway.
+ if e.Operation == 7 {
+ if isOnConnect {
+ s.log(LogInformational, "Got Op7 in connect handler, returning error")
+ return e, ErrImmediateDisconnect
+ } else {
+ s.log(LogInformational, "Closing and reconnecting in response to Op7")
+ s.CloseWithCode(websocket.StatusServiceRestart)
+ s.reconnect()
+ return e, nil
+ }
+ }
+
+ // Invalid Session
+ // Must respond with a Identify packet.
+ if e.Operation == 9 {
+ var resumable bool
+ if err := json.Unmarshal(e.RawData, &resumable); err != nil {
+ s.log(LogError, "error unmarshalling invalid session event, %s", err)
+ return e, err
+ }
+
+ if !resumable {
+ s.log(LogInformational, "Gateway session is not resumable, discarding its information")
+ s.resumeGatewayURL = ""
+ s.sessionID = ""
+ atomic.StoreInt64(s.sequence, 0)
+ }
+
+ if isOnConnect {
+ // The Session's lock is already held; calling CloseWithCode or
+ // reconnect at this point would deadlock. Rely on the caller
+ // (Open) to respond appropriately.
+ s.log(LogInformational, "Got Op9 in connect handler, returning error")
+ return e, ErrInvalidSessionOnConnect
+ }
+
+ s.log(LogInformational, "Closing and reconnecting in response to Op9")
+ s.CloseWithCode(websocket.StatusServiceRestart)
+ s.reconnect()
+ return e, nil
+ }
+
+ if e.Operation == 10 {
+ // Op10 is handled by Open()
+ return e, nil
+ }
+
+ if e.Operation == 11 {
+ s.Lock()
+ s.LastHeartbeatAck = time.Now().UTC()
+ s.Unlock()
+ s.log(LogDebug, "got heartbeat ACK")
+ return e, nil
+ }
+
+ // Do not try to Dispatch a non-Dispatch Message
+ if e.Operation != 0 {
+ // But we probably should be doing something with them.
+ // TEMP
+ s.log(LogWarning, "unknown Op: %d, Seq: %d, Type: %s, Data: %s, message: %s", e.Operation, e.Sequence, e.Type, string(e.RawData), string(message))
+ return e, nil
+ }
+
+ // Store the message sequence
+ atomic.StoreInt64(s.sequence, e.Sequence)
+
+ // Map event to registered event handlers and pass it along to any registered handlers.
+ if eh, ok := registeredInterfaceProviders[e.Type]; ok {
+ e.Struct = eh.New()
+
+ // Attempt to unmarshal our event.
+ if err = json.Unmarshal(e.RawData, e.Struct); err != nil {
+ s.log(LogError, "error unmarshalling %s event, %s", e.Type, err)
+ }
+
+ // Send event to any registered event handlers for it's type.
+ // Because the above doesn't cancel this, in case of an error
+ // the struct could be partially populated or at default values.
+ // However, most errors are due to a single field and I feel
+ // it's better to pass along what we received than nothing at all.
+ // TODO: Think about that decision :)
+ // Either way, READY events must fire, even with errors.
+ s.handleEvent(e.Type, e.Struct)
+ } else {
+ s.log(LogWarning, "unknown event: Op: %d, Seq: %d, Type: %s", e.Operation, e.Sequence, e.Type)
+ }
+
+ // For legacy reasons, we send the raw event also, this could be useful for handling unknown events.
+ s.handleEvent(eventEventType, e)
+
+ return e, nil
+}
+
+// ------------------------------------------------------------------------------------------------
+// Code related to voice connections that initiate over the data websocket
+// ------------------------------------------------------------------------------------------------
+
+type voiceChannelJoinData struct {
+ GuildID *string `json:"guild_id"`
+ ChannelID *string `json:"channel_id"`
+ SelfMute bool `json:"self_mute"`
+ SelfDeaf bool `json:"self_deaf"`
+}
+
+type voiceChannelJoinOp struct {
+ Op int `json:"op"`
+ Data voiceChannelJoinData `json:"d"`
+}
+
+// ChannelVoiceJoin joins the session user to a voice channel.
+//
+// gID : Guild ID of the channel to join.
+// cID : Channel ID of the channel to join.
+// mute : If true, you will be set to muted upon joining.
+// deaf : If true, you will be set to deafened upon joining.
+func (s *Session) ChannelVoiceJoin(gID, cID string, mute, deaf bool) (voice *VoiceConnection, err error) {
+
+ s.log(LogInformational, "called")
+
+ s.RLock()
+ voice = s.VoiceConnections[gID]
+ s.RUnlock()
+
+ if voice == nil {
+ voice = &VoiceConnection{}
+ s.Lock()
+ s.VoiceConnections[gID] = voice
+ s.Unlock()
+ }
+
+ voice.Lock()
+ voice.GuildID = gID
+ voice.ChannelID = cID
+ voice.deaf = deaf
+ voice.mute = mute
+ voice.session = s
+ voice.Unlock()
+
+ err = s.ChannelVoiceJoinManual(gID, cID, mute, deaf)
+ if err != nil {
+ return
+ }
+
+ // doesn't exactly work perfect yet.. TODO
+ err = voice.waitUntilConnected()
+ if err != nil {
+ s.log(LogWarning, "error waiting for voice to connect, %s", err)
+ voice.Close()
+ return
+ }
+
+ return
+}
+
+// ChannelVoiceJoinManual initiates a voice session to a voice channel, but does not complete it.
+//
+// This should only be used when the VoiceServerUpdate will be intercepted and used elsewhere.
+//
+// gID : Guild ID of the channel to join.
+// cID : Channel ID of the channel to join, leave empty to disconnect.
+// mute : If true, you will be set to muted upon joining.
+// deaf : If true, you will be set to deafened upon joining.
+func (s *Session) ChannelVoiceJoinManual(gID, cID string, mute, deaf bool) (err error) {
+
+ s.log(LogInformational, "called")
+
+ var channelID *string
+ if cID == "" {
+ channelID = nil
+ } else {
+ channelID = &cID
+ }
+
+ // Send the request to Discord that we want to join the voice channel
+ data := voiceChannelJoinOp{4, voiceChannelJoinData{&gID, channelID, mute, deaf}}
+ s.wsMutex.Lock()
+ err = wsjson.Write(s.wsConnCtx, s.wsConn, data)
+ s.wsMutex.Unlock()
+ return
+}
+
+// onVoiceStateUpdate handles Voice State Update events on the data websocket.
+func (s *Session) onVoiceStateUpdate(st *VoiceStateUpdate) {
+
+ // If we don't have a connection for the channel, don't bother
+ if st.ChannelID == "" {
+ return
+ }
+
+ // Check if we have a voice connection to update
+ s.RLock()
+ voice, exists := s.VoiceConnections[st.GuildID]
+ s.RUnlock()
+ if !exists {
+ return
+ }
+
+ // We only care about events that are about us.
+ if s.State.User.ID != st.UserID {
+ return
+ }
+
+ // Store the SessionID for later use.
+ voice.Lock()
+ voice.UserID = st.UserID
+ voice.sessionID = st.SessionID
+ voice.ChannelID = st.ChannelID
+ voice.Unlock()
+}
+
+// onVoiceServerUpdate handles the Voice Server Update data websocket event.
+//
+// This is also fired if the Guild's voice region changes while connected
+// to a voice channel. In that case, need to re-establish connection to
+// the new region endpoint.
+func (s *Session) onVoiceServerUpdate(st *VoiceServerUpdate) {
+
+ s.log(LogInformational, "called")
+
+ s.RLock()
+ voice, exists := s.VoiceConnections[st.GuildID]
+ s.RUnlock()
+
+ // If no VoiceConnection exists, just skip this
+ if !exists {
+ return
+ }
+
+ // If currently connected to voice ws/udp, then disconnect.
+ // Has no effect if not connected.
+ voice.Close()
+
+ // Store values for later use
+ voice.Lock()
+ voice.token = st.Token
+ voice.endpoint = st.Endpoint
+ voice.GuildID = st.GuildID
+ voice.Unlock()
+
+ // Open a connection to the voice server
+ err := voice.open()
+ if err != nil {
+ s.log(LogError, "onVoiceServerUpdate voice.open, %s", err)
+ }
+}
+
+type identifyOp struct {
+ Op int `json:"op"`
+ Data Identify `json:"d"`
+}
+
+// identify sends the identify packet to the gateway
+func (s *Session) identify() error {
+ s.log(LogDebug, "called")
+
+ // TODO: This is a temporary block of code to help
+ // maintain backwards compatibility
+ if !s.Compress {
+ s.Identify.Compress = false
+ }
+
+ // TODO: This is a temporary block of code to help
+ // maintain backwards compatibility
+ if s.Token != "" && s.Identify.Token == "" {
+ s.Identify.Token = s.Token
+ }
+
+ // TODO: Below block should be refactored so ShardID and ShardCount
+ // can be deprecated and their usage moved to the Session.Identify
+ // struct
+ if s.ShardCount > 1 {
+
+ if s.ShardID >= s.ShardCount {
+ return ErrWSShardBounds
+ }
+
+ s.Identify.Shard = &[2]int{s.ShardID, s.ShardCount}
+ }
+
+ // Send Identify packet to Discord
+ op := identifyOp{2, s.Identify}
+ dat, _ := json.Marshal(s.Identify)
+ s.log(LogDebug, "Identify Packet: %s", dat)
+ s.wsMutex.Lock()
+ err := wsjson.Write(s.wsConnCtx, s.wsConn, op)
+ s.wsMutex.Unlock()
+
+ return err
+}
+
+func (s *Session) reconnect() {
+
+ s.log(LogInformational, "called")
+
+ var err error
+
+ if s.ShouldReconnectOnError {
+
+ wait := time.Duration(1)
+
+ for {
+ if s.BeforeReconnect != nil {
+ s.BeforeReconnect(s)
+ }
+
+ s.log(LogInformational, "trying to reconnect to gateway")
+
+ err = s.Open()
+ if err == nil {
+ s.log(LogInformational, "successfully reconnected to gateway")
+
+ // I'm not sure if this is actually needed.
+ // if the gw reconnect works properly, voice should stay alive
+ // However, there seems to be cases where something "weird"
+ // happens. So we're doing this for now just to improve
+ // stability in those edge cases.
+ if s.ShouldReconnectVoiceOnSessionError {
+ s.RLock()
+ defer s.RUnlock()
+ for _, v := range s.VoiceConnections {
+
+ s.log(LogInformational, "reconnecting voice connection to guild %s", v.GuildID)
+ go v.reconnect()
+
+ // This is here just to prevent violently spamming the
+ // voice reconnects
+ time.Sleep(1 * time.Second)
+ }
+ }
+ return
+ }
+
+ // Certain race conditions can call reconnect() twice. If this happens, we
+ // just break out of the reconnect loop
+ if err == ErrWSAlreadyOpen {
+ s.log(LogInformational, "Websocket already exists, no need to reconnect")
+ return
+ }
+
+ s.log(LogError, "error reconnecting to gateway, %s", err)
+
+ if websocket.CloseStatus(err) == 4004 {
+ s.log(LogInformational, "emit invalid auth event")
+ s.handleEvent(invalidAuthEventType, &InvalidAuth{})
+ return
+ }
+
+ <-time.After(wait * time.Second)
+ wait *= 2
+ if wait > 600 {
+ wait = 600
+ }
+ }
+ }
+}
+
+// Close closes a websocket and stops all listening/heartbeat goroutines.
+// TODO: Add support for Voice WS/UDP
+func (s *Session) Close() error {
+ return s.CloseWithCode(websocket.StatusNormalClosure)
+}
+
+// CloseWithCode closes a websocket using the provided closeCode and stops all
+// listening/heartbeat goroutines.
+// TODO: Add support for Voice WS/UDP connections
+func (s *Session) CloseWithCode(closeCode websocket.StatusCode) (err error) {
+
+ s.log(LogInformational, "called")
+ s.Lock()
+
+ s.DataReady = false
+
+ if s.listening != nil {
+ s.log(LogInformational, "closing listening channel")
+ close(s.listening)
+ s.listening = nil
+ }
+
+ // TODO: Close all active Voice Connections too
+ // this should force stop any reconnecting voice channels too
+
+ if s.wsConn != nil {
+
+ s.log(LogInformational, "closing gateway websocket")
+ s.wsMutex.Lock()
+ // Close _before_ canceling the wsConnCtx, as cancelling triggers an
+ // asynchronous teardown of the underlying connection, which would race
+ // with our intentional Close.
+ err := s.wsConn.Close(closeCode, "")
+ s.wsMutex.Unlock()
+ if err != nil {
+ s.log(LogInformational, "error closing websocket, %s", err)
+ }
+
+ if s.wsConnCancel != nil {
+ s.wsConnCancel()
+ }
+
+ s.wsConn = nil
+ s.wsConnCtx = nil
+ s.wsConnCancel = nil
+ s.closeZLib()
+ }
+
+ s.Unlock()
+
+ s.log(LogInformational, "emit disconnect event")
+ s.handleEvent(disconnectEventType, &Disconnect{})
+
+ return
+}
diff --git a/pkg/msgconv/attachments.go b/pkg/msgconv/attachments.go
new file mode 100644
index 0000000..281b3fd
--- /dev/null
+++ b/pkg/msgconv/attachments.go
@@ -0,0 +1,135 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package msgconv
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/id"
+)
+
+type ReuploadedAttachment struct {
+ MXC id.ContentURIString
+ File *event.EncryptedFileInfo
+ Size int
+ MimeType string
+}
+
+func (mc *MessageConverter) ReuploadUnknownMedia(
+ ctx context.Context,
+ url string,
+ allowEncryption bool,
+) (*ReuploadedAttachment, error) {
+ return mc.ReuploadMedia(ctx, url, "", "", -1, allowEncryption)
+}
+
+func mib(size int64) float64 {
+ return float64(size) / 1024 / 1024
+}
+
+func (mc *MessageConverter) ReuploadMedia(
+ ctx context.Context,
+ downloadURL string,
+ mimeType string,
+ fileName string,
+ estimatedSize int,
+ allowEncryption bool,
+) (*ReuploadedAttachment, error) {
+ sess := ctx.Value(contextKeyDiscordClient).(*discordgo.Session)
+ httpClient := sess.Client
+ intent := ctx.Value(contextKeyIntent).(bridgev2.MatrixAPI)
+ var roomID id.RoomID
+ if allowEncryption {
+ roomID = ctx.Value(contextKeyPortal).(*bridgev2.Portal).MXID
+ }
+
+ req, err := http.NewRequest(http.MethodGet, downloadURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ if sess.IsUser {
+ for key, value := range discordgo.DroidDownloadHeaders {
+ req.Header.Set(key, value)
+ }
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode > 300 {
+ errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ logEvt := zerolog.Ctx(ctx).Error().
+ Str("media_url", downloadURL).
+ Int("status_code", resp.StatusCode)
+ if json.Valid(errBody) {
+ logEvt.RawJSON("error_json", errBody)
+ } else {
+ logEvt.Bytes("error_body", errBody)
+ }
+ logEvt.Msg("Media download failed")
+ return nil, fmt.Errorf("%w: unexpected status code %d", bridgev2.ErrMediaDownloadFailed, resp.StatusCode)
+ } else if resp.ContentLength > mc.MaxFileSize {
+ return nil, fmt.Errorf("%w (%.2f MiB > %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(resp.ContentLength), mib(mc.MaxFileSize))
+ }
+
+ requireFile := mimeType == ""
+ var size int64
+ mxc, file, err := intent.UploadMediaStream(ctx, roomID, int64(estimatedSize), requireFile, func(file io.Writer) (*bridgev2.FileStreamResult, error) {
+ var mbe *http.MaxBytesError
+ size, err = io.Copy(file, http.MaxBytesReader(nil, resp.Body, mc.MaxFileSize))
+ if err != nil {
+ if errors.As(err, &mbe) {
+ return nil, fmt.Errorf("%w (over %.2f MiB)", bridgev2.ErrMediaTooLarge, mib(mc.MaxFileSize))
+ }
+ return nil, err
+ }
+ if mimeType == "" {
+ mimeBuf := make([]byte, 512)
+ n, err := file.(*os.File).ReadAt(mimeBuf, 0)
+ if err != nil && !errors.Is(err, io.EOF) {
+ return nil, fmt.Errorf("couldn't read file for mime detection: %w", err)
+ }
+ mimeType = http.DetectContentType(mimeBuf[:n])
+ }
+ return &bridgev2.FileStreamResult{
+ FileName: fileName,
+ MimeType: mimeType,
+ }, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return &ReuploadedAttachment{
+ Size: int(size),
+ MXC: mxc,
+ File: file,
+ MimeType: mimeType,
+ }, nil
+}
diff --git a/pkg/msgconv/embed.go b/pkg/msgconv/embed.go
new file mode 100644
index 0000000..0bb6921
--- /dev/null
+++ b/pkg/msgconv/embed.go
@@ -0,0 +1,97 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package msgconv
+
+import (
+ "regexp"
+
+ "github.com/bwmarrin/discordgo"
+)
+
+type BridgeEmbedType int
+
+const (
+ EmbedUnknown BridgeEmbedType = iota
+ EmbedRich
+ EmbedLinkPreview
+ EmbedVideo
+)
+
+const discordLinkPattern = `https?://[^<\p{Zs}\x{feff}]*[^"'),.:;\]\p{Zs}\x{feff}]`
+
+// Discord links start with http:// or https://, contain at least two characters afterwards,
+// don't contain < or whitespace anywhere, and don't end with "'),.:;]
+//
+// Zero-width whitespace is mostly in the Format category and is allowed, except \uFEFF isn't for some reason
+var discordLinkRegex = regexp.MustCompile(discordLinkPattern)
+var discordLinkRegexFull = regexp.MustCompile("^" + discordLinkPattern + "$")
+
+func isActuallyLinkPreview(embed *discordgo.MessageEmbed) bool {
+ // Sending YouTube links creates a video embed, but we want to bridge it as a URL preview,
+ // so this is a hacky way to detect those.
+ return embed.Video != nil && embed.Video.ProxyURL == ""
+}
+
+// isPlainGifMessage returns whether a Discord message consists entirely of a
+// link to a GIF-like animated image. A single embed must also be present on the
+// message.
+//
+// This helps replicate Discord first-party client behavior, where the link is
+// hidden when these same conditions are fulfilled.
+func isPlainGifMessage(msg *discordgo.Message) bool {
+ if len(msg.Embeds) != 1 {
+ return false
+ }
+ embed := msg.Embeds[0]
+ isGifVideo := embed.Type == discordgo.EmbedTypeGifv && embed.Video != nil
+ isGifImage := embed.Type == discordgo.EmbedTypeImage && embed.Image == nil && embed.Thumbnail != nil && embed.Title == ""
+ contentIsOnlyURL := msg.Content == embed.URL || discordLinkRegexFull.MatchString(msg.Content)
+ return contentIsOnlyURL && (isGifVideo || isGifImage)
+}
+
+// getEmbedType determines how a Discord embed should be bridged to Matrix by
+// returning a BridgeEmbedType.
+func getEmbedType(msg *discordgo.Message, embed *discordgo.MessageEmbed) BridgeEmbedType {
+ switch embed.Type {
+ case discordgo.EmbedTypeLink, discordgo.EmbedTypeArticle:
+ return EmbedLinkPreview
+ case discordgo.EmbedTypeVideo:
+ if isActuallyLinkPreview(embed) {
+ return EmbedLinkPreview
+ }
+ return EmbedVideo
+ case discordgo.EmbedTypeGifv:
+ return EmbedVideo
+ case discordgo.EmbedTypeImage:
+ if msg != nil && isPlainGifMessage(msg) {
+ return EmbedVideo
+ } else if embed.Image == nil && embed.Thumbnail != nil {
+ return EmbedLinkPreview
+ }
+ return EmbedRich
+ case discordgo.EmbedTypeRich:
+ return EmbedRich
+ default:
+ return EmbedUnknown
+ }
+}
+
+var hackyReplyPattern = regexp.MustCompile(`^\*\*\[Replying to]\(https://discord.com/channels/(\d+)/(\d+)/(\d+)\)`)
+
+func isReplyEmbed(embed *discordgo.MessageEmbed) bool {
+ return hackyReplyPattern.MatchString(embed.Description)
+}
diff --git a/pkg/msgconv/formatter.go b/pkg/msgconv/formatter.go
new file mode 100644
index 0000000..88a859f
--- /dev/null
+++ b/pkg/msgconv/formatter.go
@@ -0,0 +1,132 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package msgconv
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/util"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/format"
+ "maunium.net/go/mautrix/format/mdext"
+)
+
+// escapeFixer is a hacky partial fix for the difference in escaping markdown, used with escapeReplacement
+//
+// Discord allows escaping with just one backslash, e.g. \__a__,
+// but standard markdown requires both to be escaped (\_\_a__)
+var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`)
+
+func escapeReplacement(s string) string {
+ return s[:2] + `\` + s[2:]
+}
+
+// indentableParagraphParser is the default paragraph parser with CanAcceptIndentedLine.
+// Used when disabling CodeBlockParser (as disabling it without a replacement will make indented blocks disappear).
+type indentableParagraphParser struct {
+ parser.BlockParser
+}
+
+var defaultIndentableParagraphParser = &indentableParagraphParser{BlockParser: parser.NewParagraphParser()}
+
+func (b *indentableParagraphParser) CanAcceptIndentedLine() bool {
+ return true
+}
+
+var removeFeaturesExceptLinks = []any{
+ parser.NewListParser(), parser.NewListItemParser(), parser.NewHTMLBlockParser(), parser.NewRawHTMLParser(),
+ parser.NewSetextHeadingParser(), parser.NewThematicBreakParser(),
+ parser.NewCodeBlockParser(),
+}
+var removeFeaturesAndLinks = append(removeFeaturesExceptLinks, parser.NewLinkParser())
+var fixIndentedParagraphs = goldmark.WithParserOptions(parser.WithBlockParsers(util.Prioritized(defaultIndentableParagraphParser, 500)))
+var discordExtensions = goldmark.WithExtensions(extension.Strikethrough, mdext.SimpleSpoiler, mdext.DiscordUnderline, ExtDiscordEveryone, ExtDiscordTag)
+
+var discordRenderer = goldmark.New(
+ goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesAndLinks...)),
+ fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
+)
+var discordRendererWithInlineLinks = goldmark.New(
+ goldmark.WithParser(mdext.ParserWithoutFeatures(removeFeaturesExceptLinks...)),
+ fixIndentedParagraphs, format.HTMLOptions, discordExtensions,
+)
+
+// renderDiscordMarkdownOnlyHTML converts Discord-flavored Markdown text to HTML.
+//
+// After conversion, if the text is surrounded by a single outermost paragraph
+// tag, it is unwrapped.
+func (mc *MessageConverter) renderDiscordMarkdownOnlyHTML(portal *bridgev2.Portal, source *bridgev2.UserLogin, text string, allowInlineLinks bool) string {
+ return format.UnwrapSingleParagraph(mc.renderDiscordMarkdownOnlyHTMLNoUnwrap(portal, source, text, allowInlineLinks))
+}
+
+// renderDiscordMarkdownOnlyHTMLNoUnwrap converts Discord-flavored Markdown text to HTML.
+func (mc *MessageConverter) renderDiscordMarkdownOnlyHTMLNoUnwrap(portal *bridgev2.Portal, source *bridgev2.UserLogin, text string, allowInlineLinks bool) string {
+ text = escapeFixer.ReplaceAllStringFunc(text, escapeReplacement)
+
+ var buf strings.Builder
+ ctx := parser.NewContext()
+ ctx.Set(parserContextPortal, portal)
+ ctx.Set(parserContextUserLogin, source)
+ renderer := discordRenderer
+ if allowInlineLinks {
+ renderer = discordRendererWithInlineLinks
+ }
+ err := renderer.Convert([]byte(text), &buf, parser.WithContext(ctx))
+ if err != nil {
+ panic(fmt.Errorf("markdown parser errored: %w", err))
+ }
+ return buf.String()
+}
+
+const formatterContextPortalKey = "fi.mau.discord.portal"
+const formatterContextAllowedMentionsKey = "fi.mau.discord.allowed_mentions"
+const formatterContextInputAllowedMentionsKey = "fi.mau.discord.input_allowed_mentions"
+const formatterContextInputAllowedLinkPreviewsKey = "fi.mau.discord.input_allowed_link_previews"
+
+var discordMarkdownEscaper = strings.NewReplacer(
+ `\`, `\\`,
+ `_`, `\_`,
+ `*`, `\*`,
+ `~`, `\~`,
+ "`", "\\`",
+ `|`, `\|`,
+ `<`, `\<`,
+ `#`, `\#`,
+)
+
+func escapeDiscordMarkdown(s string) string {
+ submatches := discordLinkRegex.FindAllStringIndex(s, -1)
+ if submatches == nil {
+ return discordMarkdownEscaper.Replace(s)
+ }
+ var builder strings.Builder
+ offset := 0
+ for _, match := range submatches {
+ start := match[0]
+ end := match[1]
+ builder.WriteString(discordMarkdownEscaper.Replace(s[offset:start]))
+ builder.WriteString(s[start:end])
+ offset = end
+ }
+ builder.WriteString(discordMarkdownEscaper.Replace(s[offset:]))
+ return builder.String()
+}
diff --git a/formatter_everyone.go b/pkg/msgconv/formatter_everyone.go
similarity index 98%
rename from formatter_everyone.go
rename to pkg/msgconv/formatter_everyone.go
index b1aed5a..6a2195f 100644
--- a/formatter_everyone.go
+++ b/pkg/msgconv/formatter_everyone.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2023 Tulir Asokan
+// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package main
+package msgconv
import (
"fmt"
diff --git a/formatter_tag.go b/pkg/msgconv/formatter_tag.go
similarity index 66%
rename from formatter_tag.go
rename to pkg/msgconv/formatter_tag.go
index fb7f741..559b73f 100644
--- a/formatter_tag.go
+++ b/pkg/msgconv/formatter_tag.go
@@ -1,5 +1,5 @@
// mautrix-discord - A Matrix-Discord puppeting bridge.
-// Copyright (C) 2022 Tulir Asokan
+// Copyright (C) 2026 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
@@ -14,30 +14,37 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-package main
+package msgconv
import (
+ "context"
"fmt"
+ "html"
"math"
"regexp"
"strconv"
"strings"
"time"
+ "github.com/rs/zerolog"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
+ "maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/id"
- "go.mau.fi/mautrix-discord/database"
+ "go.mau.fi/mautrix-discord/pkg/connector/discorddb"
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+ "go.mau.fi/mautrix-discord/pkg/router"
)
type astDiscordTag struct {
ast.BaseInline
- portal *Portal
+ source *bridgev2.UserLogin
+ portal *bridgev2.Portal
id int64
}
@@ -136,6 +143,15 @@ func (n *astDiscordCustomEmoji) String() string {
type discordTagParser struct{}
+type customEmojiMXCProvider interface {
+ GetCustomEmojiMXC(ctx context.Context, emojiID, name string, animated bool) (id.ContentURIString, error)
+}
+
+// (This interface is to avoid an import cycle.)
+type roleInfoProvider interface {
+ GetRoleByID(ctx context.Context, guildID, roleID string) (*discorddb.Role, error)
+}
+
// Regex to match everything in https://discord.com/developers/docs/reference#message-formatting
var discordTagRegex = regexp.MustCompile(`<(a?:\w+:|@[!&]?|#|t:)(\d+)(?::([tTdDfFR])|(\d+):(.+?))?>`)
var defaultDiscordTagParser = &discordTagParser{}
@@ -145,9 +161,11 @@ func (s *discordTagParser) Trigger() []byte {
}
var parserContextPortal = parser.NewContextKey()
+var parserContextUserLogin = parser.NewContextKey()
func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
- portal := pc.Get(parserContextPortal).(*Portal)
+ portal := pc.Get(parserContextPortal).(*bridgev2.Portal)
+ source := pc.Get(parserContextUserLogin).(*bridgev2.UserLogin)
//before := block.PrecendingCharacter()
line, _ := block.PeekLine()
match := discordTagRegex.FindSubmatch(line)
@@ -161,7 +179,7 @@ func (s *discordTagParser) Parse(parent ast.Node, block text.Reader, pc parser.C
if err != nil {
return nil
}
- tag := astDiscordTag{id: id, portal: portal}
+ tag := astDiscordTag{id: id, source: source, portal: portal}
tagName := string(match[1])
switch {
case tagName == "@":
@@ -261,50 +279,100 @@ func (r *discordTagHTMLRenderer) renderDiscordMention(w util.BufWriter, source [
if !entering {
return
}
+
+ log := zerolog.DefaultContextLogger.With().Str("action", "render discord mention").Logger()
+ ctx := log.WithContext(context.TODO())
+
switch node := n.(type) {
case *astDiscordUserMention:
var mxid id.UserID
var name string
- if puppet := node.portal.bridge.GetPuppetByID(strconv.FormatInt(node.id, 10)); puppet != nil {
- mxid = puppet.MXID
- name = puppet.Name
+ discordUserID := strconv.FormatInt(node.id, 10)
+ bridge := node.portal.Bridge
+
+ if ghost, _ := bridge.GetGhostByID(ctx, discordid.MakeUserID(discordUserID)); ghost != nil {
+ // TODO: Provide some kind of config option for this in the future.
+ // msgconv being in its own package means we can't just reach into
+ // the config. For now, avoid.
+ //
+ // if ghost.Name == "" {
+ // ghost.UpdateInfoIfNecessary(ctx, node.source, bridgev2.RemoteEventUnknown)
+ // }
+ mxid = ghost.Intent.GetMXID()
+ name = ghost.Name
}
- if user := node.portal.bridge.GetUserByID(strconv.FormatInt(node.id, 10)); user != nil {
- mxid = user.MXID
+ if discordUserID == discordid.ParseUserLoginID(node.source.ID) {
+ // Mentioning ourselves.
+ mxid = node.source.UserMXID
+ } else if ul := node.portal.Bridge.GetCachedUserLoginByID(discordid.MakeUserLoginID(discordUserID)); ul != nil {
+ // If the Discord user mentioned corresponds to someone else logged
+ // into the bridge, prefer their "real" MXID instead of the
+ // ghost's.
+ mxid = ul.UserMXID
+ }
+
+ if mxid != "" {
if name == "" {
- name = user.MXID.Localpart()
+ name = fmt.Sprintf("@%d", node.id)
}
+ _, _ = fmt.Fprintf(w, `%s`, mxid.URI().MatrixToURL(), html.EscapeString(name))
+ } else {
+ _, _ = fmt.Fprintf(w, "<@%d>", node.id)
}
- _, _ = fmt.Fprintf(w, `%s`, mxid.URI().MatrixToURL(), name)
return
case *astDiscordRoleMention:
- role := node.portal.bridge.DB.Role.GetByID(node.portal.GuildID, strconv.FormatInt(node.id, 10))
- if role != nil {
- _, _ = fmt.Fprintf(w, `@%s`, role.Color, role.Name)
- return
+ meta, _ := node.portal.Metadata.(*discordid.PortalMetadata)
+ if meta != nil && meta.GuildID != "" {
+ if provider, ok := node.portal.Bridge.Network.(roleInfoProvider); ok {
+ role, roleErr := provider.GetRoleByID(ctx, meta.GuildID, strconv.FormatInt(node.id, 10))
+ if roleErr != nil {
+ node.portal.Log.Warn().
+ Err(roleErr).
+ Str("guild_id", meta.GuildID).
+ Int64("role_id", node.id).
+ Msg("Failed to resolve role while rendering mention")
+ } else if role != nil {
+ _, _ = fmt.Fprintf(w, `@%s`, role.Color, html.EscapeString(role.Name))
+ return
+ }
+ }
}
case *astDiscordChannelMention:
- portal := node.portal.bridge.GetExistingPortalByID(database.PortalKey{
- ChannelID: strconv.FormatInt(node.id, 10),
- Receiver: "",
- })
- if portal != nil {
- if portal.MXID != "" {
- _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.bridge.AS.HomeserverDomain).MatrixToURL(), portal.Name)
- } else {
- _, _ = w.WriteString(portal.Name)
+ rtr, ok := node.source.Client.(router.Router)
+
+ if ok {
+ var r *router.Route
+ mentionedChannelID := strconv.FormatInt(node.id, 10)
+ r, err = rtr.Route(ctx, mentionedChannelID)
+
+ if err == nil && !r.Uncertain {
+ if portal, _ := node.portal.Bridge.GetExistingPortalByKey(ctx, r.PortalKey); portal != nil {
+ if portal.MXID != "" {
+ _, _ = fmt.Fprintf(w, `%s`, portal.MXID.URI(portal.Bridge.Matrix.ServerName()).MatrixToURL(), html.EscapeString(portal.Name))
+ } else {
+ _, _ = w.WriteString(portal.Name)
+ }
+ return
+ }
+ } else if err != nil {
+ node.portal.Log.Err(err).Msg("Failed to route mentioned channel")
}
- return
}
case *astDiscordCustomEmoji:
- reactionMXC := node.portal.getEmojiMXCByDiscordID(strconv.FormatInt(node.id, 10), node.name, node.animated)
- if !reactionMXC.IsEmpty() {
- attrs := "data-mx-emoticon"
- if node.animated {
- attrs += " data-mau-animated-emoji"
+ if resolver, ok := node.portal.Bridge.Network.(customEmojiMXCProvider); ok {
+ reactionMXC, resolveErr := resolver.GetCustomEmojiMXC(ctx, strconv.FormatInt(node.id, 10), node.name, node.animated)
+
+ if resolveErr != nil {
+ node.portal.Log.Warn().Err(resolveErr).Int64("emoji_id", node.id).Msg("Failed to resolve custom emoji while rendering message")
+ } else if reactionMXC != "" {
+ attrs := "data-mx-emoticon"
+ if node.animated {
+ attrs += " data-mau-animated-emoji"
+ }
+
+ _, _ = fmt.Fprintf(w, ``, string(reactionMXC), node.name, attrs)
+ return
}
- _, _ = fmt.Fprintf(w, ``, reactionMXC.String(), node.name, attrs)
- return
}
case *astDiscordTimestamp:
ts := time.Unix(node.timestamp, 0).UTC()
diff --git a/pkg/msgconv/from-discord.go b/pkg/msgconv/from-discord.go
new file mode 100644
index 0000000..9ae30c3
--- /dev/null
+++ b/pkg/msgconv/from-discord.go
@@ -0,0 +1,833 @@
+// mautrix-discord - A Matrix-Discord puppeting bridge.
+// Copyright (C) 2026 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package msgconv
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ "github.com/rs/zerolog"
+ "go.mau.fi/util/exmaps"
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/networkid"
+ "maunium.net/go/mautrix/event"
+ "maunium.net/go/mautrix/format"
+
+ "go.mau.fi/mautrix-discord/pkg/discordid"
+ "go.mau.fi/mautrix-discord/pkg/router"
+)
+
+type contextKey int
+
+const (
+ contextKeyPortal contextKey = iota
+ contextKeyIntent
+ contextKeyUserLogin
+ contextKeyDiscordClient
+)
+
+// videoEmbedPartIDPrefix is prepended to a video embed's URL to form its
+// [networkid.PartID]. Video embeds seemingly don't have a stable ID, so the
+// URL is used instead.
+const videoEmbedPartIDPrefix = "video_"
+
+// ToMatrix bridges a Discord message to Matrix.
+//
+// This method expects ghost information to be up-to-date.
+func (mc *MessageConverter) ToMatrix(
+ ctx context.Context,
+ portal *bridgev2.Portal,
+ intent bridgev2.MatrixAPI,
+ source *bridgev2.UserLogin,
+ session *discordgo.Session,
+ msg *discordgo.Message,
+ knownThreadRoot *networkid.MessageID,
+) *bridgev2.ConvertedMessage {
+ ctx = context.WithValue(ctx, contextKeyUserLogin, source)
+ ctx = context.WithValue(ctx, contextKeyIntent, intent)
+ ctx = context.WithValue(ctx, contextKeyPortal, portal)
+ ctx = context.WithValue(ctx, contextKeyDiscordClient, session)
+ predictedLength := len(msg.Attachments) + len(msg.StickerItems)
+ if msg.Content != "" {
+ predictedLength++
+ }
+ parts := make([]*bridgev2.ConvertedMessagePart, 0, predictedLength)
+ if textPart := mc.renderDiscordTextMessage(ctx, intent, portal, msg, source); textPart != nil {
+ parts = append(parts, textPart)
+ }
+
+ ctx = zerolog.Ctx(ctx).With().
+ Str("action", "convert discord message to matrix").
+ Str("message_id", msg.ID).
+ Logger().WithContext(ctx)
+ log := zerolog.Ctx(ctx)
+ handledIDs := make(exmaps.Set[string])
+
+ for _, att := range msg.Attachments {
+ if !handledIDs.Add(att.ID) {
+ continue
+ }
+
+ log := log.With().Str("attachment_id", att.ID).Logger()
+ mediaInfo := discordid.NewMediaInfoV1(source.ID, msg.ChannelID, msg.ID, att.ID)
+ if part := mc.renderDiscordAttachment(log.WithContext(ctx), att, &mediaInfo); part != nil {
+ part.ID = discordid.MakePartID(att.ID)
+ parts = append(parts, part)
+ }
+ }
+
+ for _, sticker := range msg.StickerItems {
+ if !handledIDs.Add(sticker.ID) {
+ continue
+ }
+
+ log := log.With().Str("sticker_id", sticker.ID).Logger()
+ if part := mc.renderDiscordSticker(log.WithContext(ctx), sticker); part != nil {
+ part.ID = discordid.MakePartID(sticker.ID)
+ parts = append(parts, part)
+ }
+ }
+
+ for i, embed := range msg.Embeds {
+ // Ignore non-video embeds, they're handled in convertDiscordTextMessage
+ if getEmbedType(msg, embed) != EmbedVideo {
+ continue
+ }
+ // Discord deduplicates embeds by URL. It makes things easier for us too.
+ if !handledIDs.Add(embed.URL) {
+ continue
+ }
+
+ log := log.With().
+ Str("computed_embed_type", "video").
+ Str("embed_type", string(embed.Type)).
+ Int("embed_index", i).
+ Logger()
+ part := mc.renderDiscordVideoEmbed(log.WithContext(ctx), embed)
+ if part != nil {
+ part.ID = discordid.MakePartID(videoEmbedPartIDPrefix + embed.URL)
+ parts = append(parts, part)
+ }
+ }
+
+ if len(parts) == 0 && msg.Thread != nil {
+ parts = append(parts, &bridgev2.ConvertedMessagePart{Type: event.EventMessage, Content: &event.MessageEventContent{
+ MsgType: event.MsgText,
+ Body: fmt.Sprintf("Created a thread: %s", msg.Thread.Name),
+ }})
+ }
+
+ // TODO(skip): Add extra metadata.
+ // for _, part := range parts {
+ // puppet.addWebhookMeta(part, msg)
+ // puppet.addMemberMeta(part, msg)
+ // }
+
+ var pmp *event.BeeperPerMessageProfile
+ if mc.PerMessageProfiles {
+ sender := discordid.MakeUserID(msg.Author.ID)
+ var profile event.BeeperPerMessageProfile
+ ghost, err := portal.Bridge.GetGhostByID(ctx, sender)
+ if err != nil {
+ log.Err(err).Msg("Failed to get ghost for per-message profile")
+ } else {
+ profile.ID = string(ghost.Intent.GetMXID())
+ profile.Displayname = ghost.Name
+ // Hacks on top of hacks: suppress mautrix's textual ": "
+ // body fallback, since the ghost already says who sent the
+ // message.
+ profile.HasFallback = true
+ if ghost.AvatarMXC != "" {
+ profile.AvatarURL = &ghost.AvatarMXC
+ }
+ }
+ pmp = &profile
+ }
+
+ if pmp != nil {
+ for _, part := range parts {
+ part.Content.BeeperPerMessageProfile = pmp
+ }
+ }
+
+ converted := &bridgev2.ConvertedMessage{Parts: parts}
+ if knownThreadRoot != nil {
+ threadRoot := *knownThreadRoot
+ converted.ThreadRoot = &threadRoot
+ }
+
+ // TODO This is sorta gross; it might be worth bundling these parameters
+ // into a struct.
+ mc.addReplyToConvertedMessage(
+ ctx,
+ converted,
+ source,
+ msg,
+ )
+
+ return converted
+}
+
+const forwardTemplateHTML = `