Watch
1
0
Fork
You've already forked mautrix-discord
0
mirror of https://github.com/mautrix/discord.git synced 2026-11-10 08:09:18 -05:00

Compare commits

..
Author SHA1 Message Date
Tulir Asokan
0dd14c1eec Update pre-commit, actions and dependencies 2026-11-05 18:17:59 -05:00
Tulir Asokan
0979753fd4 Fix condition for reuploading stickers
Fixes #255
Closes #299
2026-11-05 18:13:39 -05:00
Trevor Nash-Keller
f78c391a46
Fix inverted check when creating parent category space (#267)
[skip ci]
2026-11-06 01:13:36 +02:00
Tulir Asokan
c62165a461 Bump version to v0.7.7 2026-08-16 15:19:50 +03:00
Tulir Asokan
748bac0716 Update Docker image to Alpine 3.24
[skip cd]
2026-08-08 12:44:01 +03:00
Tulir Asokan
8eea5d5026 Backport req http client
[skip cd]
2026-08-08 12:31:44 +03:00
Tulir Asokan
39440338b6 Update dependencies
[skip cd]
2026-08-08 12:18:19 +03:00
Tulir Asokan
da5e548b7f Bump lock-threads action version
[skip ci]
2026-07-04 12:54:37 +03:00
Tulir Asokan
d3d1c338f4 Don't check friend status when sending as bot
[skip cd]
2026-05-12 00:16:45 +03:00
Skip R.
bccdea7d24
Permit user DMs to bots even under ForbidDMingStrangers (#222)
[skip cd]
2026-03-23 18:12:10 -07:00
Skip R.
ceadbf3093
Add option to block user DMs to strangers using a synchronized relationship cache (#221)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2026-03-20 19:37:50 -07:00
Tulir Asokan
b69576cdc4 Don't panic if editing a message fails
[skip cd]
2026-03-11 22:49:04 +02:00
Tulir Asokan
19e26674e6 Bump version to v0.7.6 2026-02-16 15:49:46 +02:00
Tulir Asokan
daf6b9420c Add support for federation thumbnail endpoint 2026-02-15 21:48:10 +02:00
Tulir Asokan
fab784bfd8 Add new fields to uploads 2026-02-15 14:51:14 +02:00
Tulir Asokan
17c1938b4c Bump minimum Go version to 1.25 2026-02-15 14:48:14 +02:00
Tulir Asokan
ca9f032234 docker: fix working directory and update to Alpine 3.23 2026-02-12 16:29:35 +02:00
Tulir Asokan
5c4527f1b2 Disable restricted rooms by default 2026-01-21 19:09:42 +02:00
Skip R.
11b1ea5aa6
bump discordgo (#206) 2025-11-25 21:16:51 +02:00
Skip R.
d7292a0706
bump discordgo and add support for heartbeat sessions (#203) 2025-11-19 14:33:26 -08:00
Skip R.
9eaf213091
user: send errUserNotLoggedIn if we can't bridge event from logged-out user (#204) 2025-11-19 11:10:08 -08:00
Skip R.
c8c00a42bb
Bump discordgo (#200) 2025-10-30 08:06:21 -07:00
Skip R.
2182c0d38f
Only send CONNECTED bridge state on READY or RESUMED (#199) 2025-10-28 08:39:43 -07:00
202 changed files with 13471 additions and 36874 deletions

View file

@ -11,8 +11,5 @@ insert_final_newline = true
[*.{yaml,yml,sql}]
indent_style = space
[*.html]
indent_size = 2
[.gitlab-ci.yml]
indent_size = 2

View file

@ -2,9 +2,6 @@ name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
@ -26,12 +23,13 @@ jobs:
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3
- name: Install dependencies
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
go install github.com/beeper/pre-commit-go/bridgelint@latest
export PATH="$HOME/go/bin:$PATH"
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
- name: Install pre-commit
run: pip install pre-commit
- name: Lint
run: pre-commit run -a

View file

@ -17,7 +17,7 @@ jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
- uses: dessant/lock-threads@v6
id: lock
with:
issue-inactive-days: 90

2
.gitignore vendored
View file

@ -7,5 +7,3 @@
/mautrix-discord
/start
.env

View file

@ -1,3 +1,3 @@
include:
- project: 'mautrix/ci'
file: '/gov2-as-default.yml'
file: '/go.yml'

16
.idea/icon.svg generated
View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 128 128"
version="1.1"
id="svg4"
width="128"
height="128"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs8" />
<path
fill="#5865f2"
d="m 108.12978,23.89 a 105.15,105.15 0 0 0 -26.230005,-8.07 72.06,72.06 0 0 0 -3.36,6.83 97.68,97.68 0 0 0 -29.11,0 72.37,72.37 0 0 0 -3.36,-6.83 105.89,105.89 0 0 0 -26.25,8.09 C 3.2197751,48.47 -1.2802249,72.42 0.96977514,96.03 v 0 a 105.73,105.73 0 0 0 32.16999986,16.15 77.7,77.7 0 0 0 6.89,-11.11 68.42,68.42 0 0 1 -10.85,-5.18 c 0.91,-0.66 1.8,-1.34 2.66,-2 a 75.57,75.57 0 0 0 64.32,0 c 0.87,0.71 1.76,1.39 2.66,2 a 68.68,68.68 0 0 1 -10.87,5.19 77,77 0 0 0 6.89,11.1 105.25,105.25 0 0 0 32.190005,-16.14 v 0 c 2.64,-27.38 -4.51,-51.11 -18.9,-72.15 z M 42.879775,81.51 c -6.27,0 -11.45,-5.69 -11.45,-12.69 0,-7 5,-12.74 11.43,-12.74 6.43,0 11.57,5.74 11.46,12.74 -0.11,7 -5.05,12.69 -11.44,12.69 z m 42.24,0 c -6.28,0 -11.44,-5.69 -11.44,-12.69 0,-7 5,-12.74 11.44,-12.74 6.44,0 11.54,5.74 11.43,12.74 -0.11,7 -5.04,12.69 -11.43,12.69 z"
id="path2" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -12,18 +12,9 @@ repos:
rev: v1.0.0-rc.4
hooks:
- id: go-imports-repo
args:
- "-local"
- "go.mau.fi/mautrix-discord"
- "-w"
- id: go-vet-repo-mod
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.5.0
hooks:
- id: zerolog-ban-msgf
- id: zerolog-use-stringer
- id: bridgelint
language: system
verbose: true

View file

@ -1,3 +1,22 @@
# v0.7.7 (2026-08-16)
* Updated Docker image to Alpine 3.24.
* Added option to disable DMs to non-friends.
* Fixed panic if editing a message fails.
* Fixed websocket pings when using bot accounts on Discord.
# v0.7.6 (2026-02-16)
* Bumped minimum Go version to 1.25.
* Updated Docker image to Alpine 3.23.
* Added support for following tombstones.
* Added support for disabling link previews in messages sent to Discord using
[MSC4095].
* Added support for federation thumbnail endpoint when using direct media.
* Disabled using `restricted` join rules by default.
[MSC4095]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095
# v0.7.5 (2025-07-16)
* Fixed federation key response when using direct media.

View file

@ -14,7 +14,9 @@ ENV UID=1337 \
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
COPY --from=builder /usr/bin/mautrix-discord /usr/bin/mautrix-discord
COPY --from=builder /build/example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]

View file

@ -7,7 +7,9 @@ RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottiec
ARG EXECUTABLE=./mautrix-discord
COPY $EXECUTABLE /usr/bin/mautrix-discord
COPY ./example-config.yaml /opt/mautrix-discord/example-config.yaml
COPY ./docker-run.sh /docker-run.sh
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]

View file

@ -1,12 +0,0 @@
The mautrix-discord developers grant the following special exceptions:
* to Beeper the right to embed the program in the Beeper clients and servers,
and use and distribute the collective work without applying the license to
the whole.
* to Element the right to distribute compiled binaries of the program as a part
of the Element Server Suite and other server bundles without applying the
license.
All exceptions are only valid under the condition that any modifications to
the source code of mautrix-discord remain publicly available under the terms
of the GNU AGPL version 3 or later.

View file

@ -1,12 +1,4 @@
# mautrix-discord
> [!CAUTION]
> This branch houses a work-in-progress rewrite of the bridge to interface with
> [Megabridge/"bridgev2"][bridgev2]. This branch is **NOT** ready for general
> consumption, especially for self-hosting.
[bridgev2]: https://github.com/mautrix/go/tree/38278ef37d199d3a9deba04b825a094eea6c1d10/bridgev2/unorganized-docs
A Matrix-Discord puppeting bridge based on [discordgo](https://github.com/bwmarrin/discordgo).
## Documentation

View file

@ -1,24 +1,24 @@
# Features & roadmap
* Matrix → Discord
* [ ] Message content
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [ ] Custom emojis
* [ ] Message redactions
* [ ] Reactions
* [ ] Unicode emojis
* [x] Message redactions
* [x] Reactions
* [x] Unicode emojis
* [ ] Custom emojis (re-reacting with custom emojis sent from Discord already works)
* [ ] Executing Discord bot commands
* [ ] Basic arguments and subcommands
* [x] Basic arguments and subcommands
* [ ] Subcommand groups
* [ ] Mention arguments
* [ ] Attachment arguments
* [ ] Presence
* [ ] Typing notifications
* [ ] Own read status
* [x] Typing notifications
* [x] Own read status
* [ ] Power level
* [ ] Membership actions
* [ ] Invite
@ -31,37 +31,37 @@
* [ ] Initial room metadata
* Discord → Matrix
* [ ] Message content
* [ ] Plain text
* [ ] Formatted messages
* [ ] Media/files
* [ ] Replies
* [ ] Threads
* [ ] Auto-joining threads when opening
* [x] Plain text
* [x] Formatted messages
* [x] Media/files
* [x] Replies
* [x] Threads
* [x] Auto-joining threads when opening
* [ ] Backfilling threads after joining
* [ ] Custom emojis
* [ ] Embeds
* [x] Custom emojis
* [x] Embeds
* [ ] Interactive components
* [ ] Interactions (commands)
* [ ] @everyone/@here mentions into @room
* [ ] Message deletions
* [ ] Reactions
* [ ] Unicode emojis
* [ ] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
* [ ] Avatars
* [x] Interactions (commands)
* [x] @everyone/@here mentions into @room
* [x] Message deletions
* [x] Reactions
* [x] Unicode emojis
* [x] Custom emojis ([MSC4027](https://github.com/matrix-org/matrix-spec-proposals/pull/4027))
* [x] Avatars
* [ ] Presence
* [ ] Typing notifications (currently partial support: DMs work after you type in them)
* [ ] Own read status
* [x] Own read status
* [ ] Role permissions
* [ ] Membership actions
* [ ] Invite
* [ ] Join
* [ ] Leave
* [ ] Kick
* [ ] Channel/group DM metadata changes
* [ ] Title
* [ ] Avatar
* [ ] Description
* [ ] Initial channel/group DM metadata
* [x] Channel/group DM metadata changes
* [x] Title
* [x] Avatar
* [x] Description
* [x] Initial channel/group DM metadata
* [ ] User metadata changes
* [ ] Display name
* [ ] Avatar
@ -69,12 +69,11 @@
* [ ] Display name
* [ ] Avatar
* Misc
* [ ] Login methods
* [ ] QR scan from mobile
* [ ] Username/password
* [ ] Manually providing access token
* [ ] Automatic portal creation
* [ ] After login
* [ ] When receiving DM
* [x] Login methods
* [x] QR scan from mobile
* [x] Manually providing access token
* [x] Automatic portal creation
* [x] After login
* [x] When receiving DM
* [ ] Private chat creation by inviting Matrix puppet of Discord user to new room
* [ ] Option to use own Matrix account for messages sent from other Discord clients
* [x] Option to use own Matrix account for messages sent from other Discord clients

353
attachments.go Normal file
View file

@ -0,0 +1,353 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/bwmarrin/discordgo"
"github.com/gabriel-vasile/mimetype"
"go.mau.fi/util/exsync"
"go.mau.fi/util/ffmpeg"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func downloadDiscordAttachment(cli *http.Client, url string, maxSize int64) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for key, value := range discordgo.DroidDownloadHeaders {
req.Header.Set(key, value)
}
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
data, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status %d downloading %s: %s", resp.StatusCode, url, data)
}
if resp.Header.Get("Content-Length") != "" {
length, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse content length: %w", err)
} else if length > maxSize {
return nil, fmt.Errorf("attachment too large (%d > %d)", length, maxSize)
}
return io.ReadAll(resp.Body)
} else {
var mbe *http.MaxBytesError
data, err := io.ReadAll(http.MaxBytesReader(nil, resp.Body, maxSize))
if err != nil && errors.As(err, &mbe) {
return nil, fmt.Errorf("attachment too large (over %d)", maxSize)
}
return data, err
}
}
func uploadDiscordAttachment(cli *http.Client, url string, data []byte) error {
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
if err != nil {
return err
}
for key, value := range discordgo.DroidBaseHeaders {
req.Header.Set(key, value)
}
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Referer", "https://discord.com/")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "cross-site")
resp, err := cli.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 300 {
respData, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respData)
}
return nil
}
func downloadMatrixAttachment(intent *appservice.IntentAPI, content *event.MessageEventContent) ([]byte, error) {
var file *event.EncryptedFileInfo
rawMXC := content.URL
if content.File != nil {
file = content.File
rawMXC = file.URL
}
mxc, err := rawMXC.Parse()
if err != nil {
return nil, err
}
data, err := intent.DownloadBytes(mxc)
if err != nil {
return nil, err
}
if file != nil {
err = file.DecryptInPlace(data)
if err != nil {
return nil, err
}
}
return data, nil
}
func (br *DiscordBridge) uploadMatrixAttachment(intent *appservice.IntentAPI, data []byte, url string, encrypt bool, meta AttachmentMeta, semaWg *sync.WaitGroup) (*database.File, error) {
dbFile := br.DB.File.New()
dbFile.Timestamp = time.Now()
dbFile.URL = url
dbFile.ID = meta.AttachmentID
dbFile.EmojiName = meta.EmojiName
dbFile.Size = len(data)
dbFile.MimeType = mimetype.Detect(data).String()
if meta.MimeType == "" {
meta.MimeType = dbFile.MimeType
}
if strings.HasPrefix(meta.MimeType, "image/") {
cfg, _, _ := image.DecodeConfig(bytes.NewReader(data))
dbFile.Width = cfg.Width
dbFile.Height = cfg.Height
}
uploadMime := meta.MimeType
if encrypt {
dbFile.Encrypted = true
dbFile.DecryptionInfo = attachment.NewEncryptedFile()
dbFile.DecryptionInfo.EncryptInPlace(data)
uploadMime = "application/octet-stream"
}
req := mautrix.ReqUploadMedia{
ContentBytes: data,
ContentType: uploadMime,
}
if br.Config.Homeserver.AsyncMedia {
resp, err := intent.CreateMXC()
if err != nil {
return nil, err
}
dbFile.MXC = resp.ContentURI
req.MXC = resp.ContentURI
req.UnstableUploadURL = resp.UnstableUploadURL
semaWg.Add(1)
go func() {
defer semaWg.Done()
_, err = intent.UploadMedia(req)
if err != nil {
br.Log.Errorfln("Failed to upload %s: %v", req.MXC, err)
dbFile.Delete()
}
}()
} else {
uploaded, err := intent.UploadMedia(req)
if err != nil {
return nil, err
}
dbFile.MXC = uploaded.ContentURI
}
return dbFile, nil
}
type AttachmentMeta struct {
AttachmentID string
MimeType string
EmojiName string
CopyIfMissing bool
Converter func([]byte) ([]byte, string, error)
}
var NoMeta = AttachmentMeta{}
type attachmentKey struct {
URL string
Encrypt bool
}
func (br *DiscordBridge) convertLottie(data []byte) ([]byte, string, error) {
fps := br.Config.Bridge.AnimatedSticker.Args.FPS
width := br.Config.Bridge.AnimatedSticker.Args.Width
height := br.Config.Bridge.AnimatedSticker.Args.Height
target := br.Config.Bridge.AnimatedSticker.Target
var lottieTarget, outputMime string
switch target {
case "png":
lottieTarget = "png"
outputMime = "image/png"
fps = 1
case "gif":
lottieTarget = "gif"
outputMime = "image/gif"
case "webm":
lottieTarget = "pngs"
outputMime = "video/webm"
case "webp":
lottieTarget = "pngs"
outputMime = "image/webp"
case "disable":
return data, "application/json", nil
default:
return nil, "", fmt.Errorf("invalid animated sticker target %q in bridge config", br.Config.Bridge.AnimatedSticker.Target)
}
ctx := context.Background()
tempdir, err := os.MkdirTemp("", "mautrix_discord_lottie_")
if err != nil {
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() {
removErr := os.RemoveAll(tempdir)
if removErr != nil {
br.Log.Warnfln("Failed to delete lottie conversion temp dir: %v", removErr)
}
}()
lottieOutput := filepath.Join(tempdir, "out_")
if lottieTarget != "pngs" {
lottieOutput = filepath.Join(tempdir, "output."+lottieTarget)
}
cmd := exec.CommandContext(ctx, "lottieconverter", "-", lottieOutput, lottieTarget, fmt.Sprintf("%dx%d", width, height), strconv.Itoa(fps))
cmd.Stdin = bytes.NewReader(data)
err = cmd.Run()
if err != nil {
return nil, "", fmt.Errorf("failed to run lottieconverter: %w", err)
}
var path string
if lottieTarget == "pngs" {
var videoCodec string
outputExtension := "." + target
if target == "webm" {
videoCodec = "libvpx-vp9"
} else if target == "webp" {
videoCodec = "libwebp_anim"
} else {
panic(fmt.Errorf("impossible case: unknown target %q", target))
}
path, err = ffmpeg.ConvertPath(
ctx, lottieOutput+"*.png", outputExtension,
[]string{"-framerate", strconv.Itoa(fps), "-pattern_type", "glob"},
[]string{"-c:v", videoCodec, "-pix_fmt", "yuva420p", "-f", target},
false,
)
if err != nil {
return nil, "", fmt.Errorf("failed to run ffmpeg: %w", err)
}
} else {
path = lottieOutput
}
data, err = os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("failed to read converted file: %w", err)
}
return data, outputMime, nil
}
func (br *DiscordBridge) copyAttachmentToMatrix(intent *appservice.IntentAPI, url string, encrypt bool, meta AttachmentMeta) (returnDBFile *database.File, returnErr error) {
isCacheable := br.Config.Bridge.CacheMedia != "never" && (br.Config.Bridge.CacheMedia == "always" || !encrypt)
returnDBFile = br.DB.File.Get(url, encrypt)
if returnDBFile == nil {
transferKey := attachmentKey{url, encrypt}
once, _ := br.attachmentTransfers.GetOrSet(transferKey, &exsync.ReturnableOnce[*database.File]{})
returnDBFile, returnErr = once.Do(func() (onceDBFile *database.File, onceErr error) {
if isCacheable {
onceDBFile = br.DB.File.Get(url, encrypt)
if onceDBFile != nil {
return
}
}
const attachmentSizeVal = 1
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
onceErr = br.parallelAttachmentSemaphore.Acquire(ctx, attachmentSizeVal)
cancel()
if onceErr != nil {
br.ZLog.Warn().Err(onceErr).Msg("Failed to acquire semaphore")
onceErr = fmt.Errorf("reuploading timed out")
return
}
var semaWg sync.WaitGroup
semaWg.Add(1)
defer semaWg.Done()
go func() {
semaWg.Wait()
br.parallelAttachmentSemaphore.Release(attachmentSizeVal)
}()
var data []byte
data, onceErr = downloadDiscordAttachment(http.DefaultClient, url, br.MediaConfig.UploadSize)
if onceErr != nil {
return
}
if meta.Converter != nil {
data, meta.MimeType, onceErr = meta.Converter(data)
if onceErr != nil {
onceErr = fmt.Errorf("failed to convert attachment: %w", onceErr)
return
}
}
onceDBFile, onceErr = br.uploadMatrixAttachment(intent, data, url, encrypt, meta, &semaWg)
if onceErr != nil {
return
}
if isCacheable {
onceDBFile.Insert(nil)
}
br.attachmentTransfers.Delete(transferKey)
return
})
}
return
}
func (portal *Portal) getEmojiMXCByDiscordID(emojiID, name string, animated bool) id.ContentURI {
mxc := portal.bridge.DMA.EmojiMXC(emojiID, name, animated)
if !mxc.IsEmpty() {
return mxc
}
var url, mimeType string
if animated {
url = discordgo.EndpointEmojiAnimated(emojiID)
mimeType = "image/gif"
} else {
url = discordgo.EndpointEmoji(emojiID)
mimeType = "image/png"
}
dbFile, err := portal.bridge.copyAttachmentToMatrix(portal.MainIntent(), url, false, AttachmentMeta{
AttachmentID: emojiID,
MimeType: mimeType,
EmojiName: name,
})
if err != nil {
portal.log.Warn().Err(err).Str("emoji_id", emojiID).Msg("Failed to copy emoji to Matrix")
return id.ContentURI{}
}
return dbFile.MXC
}

383
backfill.go Normal file
View file

@ -0,0 +1,383 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"sort"
"github.com/bwmarrin/discordgo"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-discord/database"
)
func (portal *Portal) forwardBackfillInitial(source *User, thread *Thread) {
log := portal.log
defer func() {
log.Debug().Msg("Forward backfill finished, unlocking lock")
portal.forwardBackfillLock.Unlock()
}()
// This should only be called from CreateMatrixRoom which locks forwardBackfillLock before creating the room.
if portal.forwardBackfillLock.TryLock() {
panic("forwardBackfillInitial() called without locking forwardBackfillLock")
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Initial.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Initial.Thread
thread.initialBackfillAttempted = true
}
}
if limit == 0 {
return
}
with := log.With().
Str("action", "initial backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log = with.Logger()
portal.backfillLimited(log, source, limit, "", thread)
}
func (portal *Portal) ForwardBackfillMissed(source *User, serverLastMessageID string, thread *Thread) {
if portal.MXID == "" {
return
}
limit := portal.bridge.Config.Bridge.Backfill.Limits.Missed.Channel
if portal.GuildID == "" {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.DM
if thread != nil {
limit = portal.bridge.Config.Bridge.Backfill.Limits.Missed.Thread
}
}
if limit == 0 {
return
}
with := portal.log.With().
Str("action", "missed event backfill").
Str("room_id", portal.MXID.String()).
Int("limit", limit)
if thread != nil {
with = with.Str("thread_id", thread.ID)
}
log := with.Logger()
portal.forwardBackfillLock.Lock()
defer portal.forwardBackfillLock.Unlock()
var lastMessage *database.Message
if thread != nil {
lastMessage = portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
} else {
lastMessage = portal.bridge.DB.Message.GetLast(portal.Key)
}
if lastMessage == nil || serverLastMessageID == "" {
log.Debug().Msg("Not backfilling, no last message in database or no last message in metadata")
return
} else if !shouldBackfill(lastMessage.DiscordID, serverLastMessageID) {
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", serverLastMessageID).
Msg("Not backfilling, last message in database is newer than last message in metadata")
return
}
log.Debug().
Str("last_bridged_message", lastMessage.DiscordID).
Str("last_server_message", serverLastMessageID).
Msg("Backfilling missed messages")
if limit < 0 {
portal.backfillUnlimitedMissed(log, source, lastMessage.DiscordID, thread)
} else {
portal.backfillLimited(log, source, limit, lastMessage.DiscordID, thread)
}
}
const messageFetchChunkSize = 50
func (portal *Portal) collectBackfillMessages(log zerolog.Logger, source *User, limit int, until string, thread *Thread) ([]*discordgo.Message, bool, error) {
var messages []*discordgo.Message
var before string
var foundAll bool
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("before_id", before).Msg("Fetching messages for backfill")
newMessages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, before, "", "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
if err != nil {
return nil, false, err
}
if until != "" {
for i, msg := range newMessages {
if compareMessageIDs(msg.ID, until) <= 0 {
log.Debug().
Str("message_id", msg.ID).
Str("until_id", until).
Msg("Found message that was already bridged")
newMessages = newMessages[:i]
foundAll = true
break
}
}
}
messages = append(messages, newMessages...)
log.Debug().Int("count", len(newMessages)).Msg("Added messages to backfill collection")
if len(newMessages) < messageFetchChunkSize || len(messages) >= limit {
break
}
before = newMessages[len(newMessages)-1].ID
}
if len(messages) > limit {
foundAll = false
messages = messages[:limit]
}
return messages, foundAll, nil
}
func (portal *Portal) backfillLimited(log zerolog.Logger, source *User, limit int, after string, thread *Thread) {
messages, foundAll, err := portal.collectBackfillMessages(log, source, limit, after, thread)
if err != nil {
if source.handlePossible40002(err) {
panic(err)
}
log.Err(err).Msg("Error collecting messages to forward backfill")
return
}
log.Info().
Int("count", len(messages)).
Bool("found_all", foundAll).
Msg("Collected messages to backfill")
sort.Sort(MessageSlice(messages))
if !foundAll && after != "" {
_, err = portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: "Some messages may have been missed here while the bridge was offline.",
}, nil, 0)
if err != nil {
log.Warn().Err(err).Msg("Failed to send missed message warning")
} else {
log.Debug().Msg("Sent warning about possibly missed messages")
}
}
portal.sendBackfillBatch(log, source, messages, thread)
}
func (portal *Portal) backfillUnlimitedMissed(log zerolog.Logger, source *User, after string, thread *Thread) {
protoChannelID := portal.Key.ChannelID
if thread != nil {
protoChannelID = thread.ID
}
for {
log.Debug().Str("after_id", after).Msg("Fetching chunk of messages to backfill")
messages, err := source.Session.ChannelMessages(protoChannelID, messageFetchChunkSize, "", after, "", portal.RefererOptIfUser(source.Session, protoChannelID)...)
if err != nil {
log.Err(err).Msg("Error fetching chunk of messages to forward backfill")
return
}
log.Debug().Int("count", len(messages)).Msg("Fetched chunk of messages to backfill")
sort.Sort(MessageSlice(messages))
portal.sendBackfillBatch(log, source, messages, thread)
if len(messages) < messageFetchChunkSize {
// Assume that was all the missing messages
log.Debug().Msg("Chunk had less than 50 messages, stopping backfill")
return
}
after = messages[len(messages)-1].ID
}
}
func (portal *Portal) sendBackfillBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
if portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureBatchSending) {
log.Debug().Msg("Using hungryserv, sending messages with batch send endpoint")
portal.forwardBatchSend(log, source, messages, thread)
} else {
log.Debug().Msg("Not using hungryserv, sending messages one by one")
for _, msg := range messages {
portal.handleDiscordMessageCreate(source, msg, thread)
}
}
}
func (portal *Portal) forwardBatchSend(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) {
evts, metas, dbMessages := portal.convertMessageBatch(log, source, messages, thread)
if len(evts) == 0 {
log.Warn().Msg("Didn't get any events to backfill")
return
}
log.Info().Int("events", len(evts)).Msg("Converted messages to backfill")
resp, err := portal.MainIntent().BeeperBatchSend(portal.MXID, &mautrix.ReqBeeperBatchSend{
Forward: true,
Events: evts,
})
if err != nil {
log.Err(err).Msg("Error sending backfill batch")
return
}
for i, evtID := range resp.EventIDs {
dbMessages[i].MXID = evtID
if metas[i] != nil && metas[i].Flags == discordgo.MessageFlagsHasThread {
// TODO proper context
ctx := log.WithContext(context.Background())
portal.bridge.threadFound(ctx, source, &dbMessages[i], metas[i].ID, metas[i].Thread)
}
}
portal.bridge.DB.Message.MassInsert(portal.Key, dbMessages)
}
func (portal *Portal) convertMessageBatch(log zerolog.Logger, source *User, messages []*discordgo.Message, thread *Thread) ([]*event.Event, []*discordgo.Message, []database.Message) {
var discordThreadID string
var threadRootEvent, lastThreadEvent id.EventID
if thread != nil {
discordThreadID = thread.ID
threadRootEvent = thread.RootMXID
lastThreadEvent = threadRootEvent
lastInThread := portal.bridge.DB.Message.GetLastInThread(portal.Key, thread.ID)
if lastInThread != nil {
lastThreadEvent = lastInThread.MXID
}
}
evts := make([]*event.Event, 0, len(messages))
dbMessages := make([]database.Message, 0, len(messages))
metas := make([]*discordgo.Message, 0, len(messages))
ctx := context.Background()
for _, msg := range messages {
for _, mention := range msg.Mentions {
puppet := portal.bridge.GetPuppetByID(mention.ID)
puppet.UpdateInfo(nil, mention, nil)
}
puppet := portal.bridge.GetPuppetByID(msg.Author.ID)
puppet.UpdateInfo(source, msg.Author, msg)
intent := puppet.IntentFor(portal)
replyTo := portal.getReplyTarget(source, discordThreadID, msg.MessageReference, msg.Embeds, true)
mentions := portal.convertDiscordMentions(msg, false)
ts, _ := discordgo.SnowflakeTimestamp(msg.ID)
log := log.With().
Str("message_id", msg.ID).
Int("message_type", int(msg.Type)).
Str("author_id", msg.Author.ID).
Logger()
parts := portal.convertDiscordMessage(log.WithContext(ctx), puppet, intent, msg)
for i, part := range parts {
if (replyTo != nil || threadRootEvent != "") && part.Content.RelatesTo == nil {
part.Content.RelatesTo = &event.RelatesTo{}
}
if threadRootEvent != "" {
part.Content.RelatesTo.SetThread(threadRootEvent, lastThreadEvent)
}
if replyTo != nil {
part.Content.RelatesTo.SetReplyTo(replyTo.EventID)
// Only set reply for first event
replyTo = nil
}
part.Content.Mentions = mentions
// Only set mentions for first event, but keep empty object for rest
mentions = &event.Mentions{}
partName := part.AttachmentID
// Always use blank part name for first part so that replies and other things
// can reference it without knowing about attachments.
if i == 0 {
partName = ""
}
evt := &event.Event{
ID: portal.deterministicEventID(msg.ID, partName),
Type: part.Type,
Sender: intent.UserID,
Timestamp: ts.UnixMilli(),
Content: event.Content{
Parsed: part.Content,
Raw: part.Extra,
},
}
var err error
evt.Type, err = portal.encrypt(intent, &evt.Content, evt.Type)
if err != nil {
log.Err(err).Msg("Failed to encrypt event")
continue
}
intent.AddDoublePuppetValue(&evt.Content)
evts = append(evts, evt)
dbMessages = append(dbMessages, database.Message{
Channel: portal.Key,
DiscordID: msg.ID,
SenderID: msg.Author.ID,
Timestamp: ts,
AttachmentID: part.AttachmentID,
SenderMXID: intent.UserID,
})
if i == 0 {
metas = append(metas, msg)
} else {
metas = append(metas, nil)
}
lastThreadEvent = evt.ID
}
}
return evts, metas, dbMessages
}
func (portal *Portal) deterministicEventID(messageID, partName string) id.EventID {
data := fmt.Sprintf("%s/discord/%s/%s", portal.MXID, messageID, partName)
sum := sha256.Sum256([]byte(data))
return id.EventID(fmt.Sprintf("$%s:discord.com", base64.RawURLEncoding.EncodeToString(sum[:])))
}
// 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
}
type MessageSlice []*discordgo.Message
var _ sort.Interface = (MessageSlice)(nil)
func (a MessageSlice) Len() int {
return len(a)
}
func (a MessageSlice) Swap(i, j int) {
a[i], a[j] = a[j], a[i]
}
func (a MessageSlice) Less(i, j int) bool {
return compareMessageIDs(a[i].ID, a[j].ID) == -1
}

View file

@ -1,4 +1,2 @@
#!/bin/sh
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
GO_LDFLAGS="-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
go build -ldflags="-s -w $GO_LDFLAGS" ./cmd/mautrix-discord "$@"
go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"

View file

@ -1,579 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"maps"
"net/http"
"os"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
"github.com/google/uuid"
"github.com/rs/zerolog"
"golang.org/x/term"
"go.mau.fi/util/exhttp"
"go.mau.fi/mautrix-discord/pkg/discordauth"
"go.mau.fi/mautrix-discord/pkg/discordtransport"
)
const fallbackClientBuildNumber = 497254
var mainJSRegex = regexp.MustCompile(`src="(/assets/web\.[a-f0-9]{12,32}\.js)"`)
var buildNumberRegex = regexp.MustCompile(`(?:buildNumber|build_number):\s?['"]?(\d{6,})['"]?`)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var buildNumberFlag int
var apiBase string
var verbose bool
flag.IntVar(&buildNumberFlag, "build-number", 0, "Discord client build number (default: auto-detect from discord.com)")
flag.StringVar(&apiBase, "api-base", "https://discord.com/api/v9", "Discord API base URL")
flag.BoolVar(&verbose, "verbose", false, "Lower the log level to debug")
flag.Parse()
log := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).
Level(zerolog.InfoLevel).
With().
Timestamp().
Logger()
if verbose {
log = log.Level(zerolog.DebugLevel)
}
ctx, stop := signal.NotifyContext(log.WithContext(context.Background()), os.Interrupt, syscall.SIGTERM)
defer stop()
client, err := discordtransport.CompileTransport(exhttp.SensibleClientSettings, discordtransport.TransportOptions{})
if err != nil {
return fmt.Errorf("failed to create auth http client: %w", err)
}
captchaServer := newCaptchaServer(log.With().Str("component", "authtester captcha").Logger())
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := captchaServer.Close(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "Failed to gracefully terminate CAPTCHA server: %v\n", err)
}
}()
prompter := newPrompter(os.Stdin, os.Stdout, captchaServer)
buildNumber := buildNumberFlag
if buildNumber == 0 {
fmt.Fprintln(os.Stdout, "Detecting an appropriate Discord client build number...")
buildNumber, err = fetchClientBuildNumber(ctx, client)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to detect build number automatically: %v\n", err)
fmt.Fprintf(os.Stderr, "Falling back to build number %d\n", fallbackClientBuildNumber)
buildNumber = fallbackClientBuildNumber
}
}
fmt.Fprintf(os.Stdout, "Using client build number %d\n", buildNumber)
personality, err := newDefaultPersonality(buildNumber)
if err != nil {
return fmt.Errorf("failed to create auth personality: %w", err)
}
machine := discordauth.NewAuthMachine(ctx, client, personality)
machine.APIBase = apiBase
fmt.Fprintln(os.Stdout, "Preparing Discord auth...")
if err = machine.Prepare(ctx); err != nil {
return fmt.Errorf("failed to prepare auth machine: %w", err)
}
fmt.Fprintln(os.Stdout, "Logging in...")
resp, err := prompter.driveAuthMachine(ctx, machine)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
fmt.Fprintln(os.Stdout, "Login succeeded.")
fmt.Fprintf(os.Stdout, "User ID: %s\n", resp.UserID)
fmt.Fprintf(os.Stdout, "Token length: %d\n", len(resp.Token.UnwrapSensitive()))
if resp.UserSettings.Locale != "" {
fmt.Fprintf(os.Stdout, "User locale: %s\n", resp.UserSettings.Locale)
}
if resp.UserSettings.Theme != "" {
fmt.Fprintf(os.Stdout, "User theme: %s\n", resp.UserSettings.Theme)
}
return nil
}
func newDefaultPersonality(buildNumber int) (*discordauth.Personality, error) {
launchSignature, err := discordgo.NewVanillaSignature()
if err != nil {
return nil, fmt.Errorf("failed to generate launch signature: %w", err)
}
extraHeaders := maps.Clone(discordgo.DroidFetchHeaders)
delete(extraHeaders, "User-Agent")
return &discordauth.Personality{
UserAgent: discordgo.DroidBrowserUserAgent,
Locale: "en-US",
TimeZone: defaultTimeZone(),
DebugOptions: discordauth.DefaultDebugOptions,
SuperProperties: discordauth.SuperProperties{
OS: "Windows",
Browser: "Chrome",
SystemLocale: "en-US",
HasClientMods: false,
BrowserUserAgent: discordgo.DroidBrowserUserAgent,
BrowserVersion: discordgo.DroidBrowserVersion,
OSVersion: "10",
ReleaseChannel: "stable",
ClientBuildNumber: buildNumber,
ClientLaunchID: uuid.NewString(),
LaunchSignature: launchSignature,
ClientAppState: "focused",
},
ExtraHeaders: extraHeaders,
}, nil
}
func defaultTimeZone() string {
timeZone := time.Now().Location().String()
if timeZone == "" || timeZone == "Local" {
return "UTC"
}
return timeZone
}
func fetchClientBuildNumber(ctx context.Context, client *http.Client) (int, error) {
mainPageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/channels/@me", nil)
if err != nil {
return 0, fmt.Errorf("failed to create main page request: %w", err)
}
addHeaders(mainPageReq.Header, discordgo.DroidBaseHeaders)
mainPageReq.Header.Set("Sec-Fetch-Dest", "document")
mainPageReq.Header.Set("Sec-Fetch-Mode", "navigate")
mainPageReq.Header.Set("Sec-Fetch-Site", "none")
mainPageReq.Header.Set("Sec-Fetch-User", "?1")
mainPageReq.Header.Set("Upgrade-Insecure-Requests", "1")
mainPageReq.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")
mainPageData, err := doRequest(ctx, client, mainPageReq)
if err != nil {
return 0, fmt.Errorf("failed to fetch main page: %w", err)
}
mainJSMatch := mainJSRegex.FindSubmatch(mainPageData)
if mainJSMatch == nil {
return 0, fmt.Errorf("failed to find main JS URL in Discord main page")
}
jsReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com"+string(mainJSMatch[1]), nil)
if err != nil {
return 0, fmt.Errorf("failed to create JS request: %w", err)
}
addHeaders(jsReq.Header, discordgo.DroidBaseHeaders)
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", "*/*")
jsData, err := doRequest(ctx, client, jsReq)
if err != nil {
return 0, fmt.Errorf("failed to fetch main JS: %w", err)
}
buildNumberMatch := buildNumberRegex.FindSubmatch(jsData)
if buildNumberMatch == nil {
return 0, fmt.Errorf("failed to find build number in Discord JS bundle")
}
buildNumber, err := strconv.Atoi(string(buildNumberMatch[1]))
if err != nil {
return 0, fmt.Errorf("failed to parse build number %q: %w", buildNumberMatch[1], err)
}
return buildNumber, nil
}
func doRequest(ctx context.Context, client *http.Client, req *http.Request) ([]byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read %s %s response body: %w", req.Method, req.URL, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %s for %s %s", resp.Status, req.Method, req.URL)
}
if err := ctx.Err(); err != nil {
return nil, err
}
return body, nil
}
func addHeaders(header http.Header, values map[string]string) {
for key, value := range values {
header.Set(key, value)
}
}
type prompter struct {
in *bufio.Reader
inFile *os.File
out io.Writer
captchaServer *captchaServer
mfaChallenge *discordauth.LoginMFARequired
}
type mfaMethodOption struct {
Type discordauth.AuthenticatorType
Label string
CodePrompt string
}
func newPrompter(in io.Reader, out io.Writer, captchaServer *captchaServer) *prompter {
file, _ := in.(*os.File)
return &prompter{
in: bufio.NewReader(in),
inFile: file,
out: out,
captchaServer: captchaServer,
}
}
func (p *prompter) promptRequired(label string) (string, error) {
value, err := p.prompt(label)
if err != nil {
return "", err
}
if value == "" {
return "", fmt.Errorf("%s is required", strings.ToLower(label))
}
return value, nil
}
func (p *prompter) prompt(label string) (string, error) {
fmt.Fprintf(p.out, "%s: ", label)
line, err := p.in.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", err
}
if errors.Is(err, io.EOF) && len(line) == 0 {
return "", io.EOF
}
return strings.TrimRight(line, "\r\n"), nil
}
func (p *prompter) promptSecretRequired(label string) (string, error) {
value, err := p.promptSecret(label)
if err != nil {
return "", err
}
if value == "" {
return "", fmt.Errorf("%s is required", strings.ToLower(label))
}
return value, nil
}
func (p *prompter) promptSecret(label string) (string, error) {
if p.inFile == nil || !term.IsTerminal(int(p.inFile.Fd())) {
return p.prompt(label)
}
fmt.Fprintf(p.out, "%s: ", label)
line, err := term.ReadPassword(int(p.inFile.Fd()))
fmt.Fprintln(p.out)
if err != nil {
return "", err
}
return strings.TrimRight(string(line), "\r\n"), nil
}
func (p *prompter) promptMFAChoice(options []mfaMethodOption) (mfaMethodOption, error) {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Available MFA methods:")
for i, option := range options {
fmt.Fprintf(p.out, " %d. %s\n", i+1, option.Label)
}
for {
choice, err := p.promptRequired("Choose MFA method")
if err != nil {
return mfaMethodOption{}, err
}
index, err := strconv.Atoi(choice)
if err == nil && index >= 1 && index <= len(options) {
return options[index-1], nil
}
fmt.Fprintf(p.out, "Invalid choice %q. Enter a number from 1 to %d.\n", choice, len(options))
}
}
func (p *prompter) driveAuthMachine(ctx context.Context, machine *discordauth.AuthMachine) (*discordauth.LoginCompleted, error) {
var answer *discordauth.Answer
for {
prompt, done, err := machine.Advance(ctx, answer)
if err != nil {
return nil, err
}
if done != nil {
return done, nil
}
if prompt == nil {
return nil, fmt.Errorf("auth machine did not advance")
}
answer, err = p.answerPrompt(ctx, prompt)
if err != nil {
return nil, err
}
}
}
func (p *prompter) answerPrompt(ctx context.Context, prompt *discordauth.Prompt) (*discordauth.Answer, error) {
switch {
case prompt.CredsPrompt != nil:
return p.answerCreds(prompt.CredsPrompt)
case prompt.Captcha != nil:
solution, err := p.SolveCaptcha(ctx, prompt.Captcha)
if err != nil {
return nil, err
}
return &discordauth.Answer{Solution: solution}, nil
case prompt.EmailVerifyPrompt != nil:
if err := p.WaitForEmailVerification(ctx); err != nil {
return nil, err
}
return &discordauth.Answer{}, nil
case prompt.MFAChallengePrompt != nil:
return p.answerMFAMethod(prompt.MFAChallengePrompt)
case prompt.MFACodePrompt != nil:
return p.answerMFACode(prompt.MFACodePrompt)
default:
return nil, fmt.Errorf("auth machine returned an empty prompt")
}
}
func (p *prompter) answerCreds(prompt *discordauth.CredsPrompt) (*discordauth.Answer, error) {
if prompt.Reason != "" {
fmt.Fprintln(p.out)
fmt.Fprintf(p.out, "Discord rejected those credentials: %s\n", prompt.Reason)
}
login, err := p.promptRequired("Email or phone")
if err != nil {
return nil, fmt.Errorf("failed to read login: %w", err)
}
password, err := p.promptSecretRequired("Password")
if err != nil {
return nil, fmt.Errorf("failed to read password: %w", err)
}
return &discordauth.Answer{
Creds: discordauth.NewCreds(login, password),
}, nil
}
func supportedMFAMethods(challenge *discordauth.LoginMFARequired) []mfaMethodOption {
options := make([]mfaMethodOption, 0, 3)
if challenge.TOTPEnabled {
options = append(options, mfaMethodOption{
Type: discordauth.AuthenticatorTOTP,
Label: "TOTP authenticator",
CodePrompt: "TOTP code",
})
}
if challenge.SMSEnabled {
options = append(options, mfaMethodOption{
Type: discordauth.AuthenticatorSMS,
Label: "SMS code",
CodePrompt: "SMS code",
})
}
if challenge.BackupCodesAccepted {
options = append(options, mfaMethodOption{
Type: discordauth.AuthenticatorBackup,
Label: "Backup code",
CodePrompt: "Backup code",
})
}
return options
}
func newMFAContinue(challenge *discordauth.LoginMFARequired, authType discordauth.AuthenticatorType, code string) *discordauth.MFAContinue {
return &discordauth.MFAContinue{
Type: authType,
MFAContinuation: discordauth.MFAContinuation{
MFAState: challenge.MFAState,
Code: code,
},
}
}
func (p *prompter) answerMFAMethod(prompt *discordauth.MFAChallengePrompt) (*discordauth.Answer, error) {
challenge := prompt.LoginMFARequired
if challenge == nil {
return nil, fmt.Errorf("auth machine returned an MFA prompt without a challenge")
}
if prompt.Reason != "" {
fmt.Fprintln(p.out)
fmt.Fprintf(p.out, "Discord rejected that MFA response: %s\n", prompt.Reason)
}
options := supportedMFAMethods(challenge)
if len(options) == 0 {
if challenge.WebAuthnCredential != nil {
panic("authtester does not support WebAuthn MFA")
}
return nil, fmt.Errorf("discord did not offer a supported MFA method")
}
selected := options[0]
if len(options) == 1 {
fmt.Fprintln(p.out)
fmt.Fprintf(p.out, "Using MFA method: %s\n", selected.Label)
} else {
var err error
selected, err = p.promptMFAChoice(options)
if err != nil {
return nil, err
}
}
if selected.Type == discordauth.AuthenticatorSMS {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Requesting an MFA SMS code...")
}
p.mfaChallenge = challenge
return &discordauth.Answer{
PickedMFAType: &selected.Type,
}, nil
}
func (p *prompter) answerMFACode(prompt *discordauth.MFACodePrompt) (*discordauth.Answer, error) {
if p.mfaChallenge == nil {
return nil, fmt.Errorf("auth machine requested an MFA code before an MFA challenge")
}
selected := mfaMethodOption{
Type: prompt.Type,
}
switch prompt.Type {
case discordauth.AuthenticatorTOTP:
selected.CodePrompt = "TOTP code"
case discordauth.AuthenticatorSMS:
fmt.Fprintln(p.out, "Discord sent an MFA SMS code.")
selected.CodePrompt = "SMS code"
case discordauth.AuthenticatorBackup:
selected.CodePrompt = "Backup code"
default:
return nil, fmt.Errorf("unsupported MFA code prompt type %q", prompt.Type)
}
code, err := p.promptSecretRequired(selected.CodePrompt)
if err != nil {
return nil, err
}
if selected.Type == discordauth.AuthenticatorBackup {
code = strings.ReplaceAll(code, "-", "")
}
return &discordauth.Answer{
MFAContinue: newMFAContinue(p.mfaChallenge, selected.Type, code),
}, nil
}
func (p *prompter) SolveCaptcha(ctx context.Context, captcha *discordauth.Captcha) (*discordauth.CaptchaSolution, error) {
captchaData, err := json.MarshalIndent(captcha, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to encode captcha challenge: %w", err)
}
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Received CAPTCHA challenge:")
fmt.Fprintln(p.out, string(captchaData))
if p.captchaServer != nil && supportsBrowserCaptcha(captcha) {
pageURL, waitForSolution, err := p.captchaServer.startChallenge(captcha)
if err != nil {
fmt.Fprintf(p.out, "Failed to start local CAPTCHA page: %v\n", err)
fmt.Fprintln(p.out, "Falling back to manual token entry.")
} else {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Open this page in your browser and solve the CAPTCHA:")
fmt.Fprintf(p.out, " %s\n", pageURL)
fmt.Fprintln(p.out, "If the page reports an error or you cancel it, authtester will fall back to manual token entry.")
solution, err := waitForSolution(ctx)
switch {
case err == nil:
return &discordauth.CaptchaSolution{Solution: solution}, nil
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
return nil, err
case errors.Is(err, errCaptchaBrowserCanceled):
fmt.Fprintln(p.out, "Local CAPTCHA page was canceled.")
fmt.Fprintln(p.out, "Falling back to manual token entry.")
default:
fmt.Fprintf(p.out, "Local CAPTCHA page failed: %v\n", err)
fmt.Fprintln(p.out, "Falling back to manual token entry.")
}
}
} else {
fmt.Fprintln(p.out, "Local browser flow only supports hCaptcha challenges with a sitekey.")
}
solution, err := p.promptRequired("CAPTCHA solution")
if err != nil {
return nil, err
}
return &discordauth.CaptchaSolution{Solution: solution}, nil
}
func (p *prompter) WaitForEmailVerification(ctx context.Context) error {
fmt.Fprintln(p.out)
fmt.Fprintln(p.out, "Discord detected a login from a new location and emailed a")
fmt.Fprintln(p.out, "verification link. Open the email, click the link to authorize")
fmt.Fprintln(p.out, "this login, then continue.")
// The return value is irrelevant; we just block until the user confirms.
_, err := p.prompt("Press enter once you've verified")
return err
}

View file

@ -1,460 +0,0 @@
package main
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.mau.fi/mautrix-discord/pkg/discordauth"
)
var errCaptchaBrowserCanceled = errors.New("captcha browser flow canceled")
//go:embed main_captcha.html
var captchaPageHTML string
type captchaServer struct {
mu sync.Mutex
log zerolog.Logger
handler http.Handler
server *http.Server
ln net.Listener
baseURL string
active *activeCaptcha
}
type activeCaptcha struct {
challenge browserCaptchaChallenge
resultCh chan captchaBrowserResult
}
type browserCaptchaChallenge struct {
ID string `json:"id"`
Service discordauth.CaptchaService `json:"service"`
SiteKey string `json:"site_key"`
RqData string `json:"rqdata,omitempty"`
Invisible bool `json:"invisible"`
}
type captchaBrowserResult struct {
token string
err error
}
type captchaSolveRequest struct {
ID string `json:"id"`
Token string `json:"token"`
}
type captchaCancelRequest struct {
ID string `json:"id"`
}
type captchaErrorRequest struct {
ID string `json:"id"`
Error string `json:"error"`
}
type captchaErrorResponse struct {
Error string `json:"error"`
}
func newCaptchaServer(log zerolog.Logger) *captchaServer {
cs := &captchaServer{log: log}
mux := http.NewServeMux()
mux.HandleFunc("/", cs.handlePage)
mux.HandleFunc("/api/challenge", cs.handleChallenge)
mux.HandleFunc("/api/solve", cs.handleSolve)
mux.HandleFunc("/api/cancel", cs.handleCancel)
mux.HandleFunc("/api/error", cs.handleError)
cs.handler = mux
return cs
}
func supportsBrowserCaptcha(captcha *discordauth.Captcha) bool {
return captcha != nil &&
captcha.Service == discordauth.CaptchaServiceHCaptcha &&
captcha.SiteKey != nil &&
strings.TrimSpace(*captcha.SiteKey) != ""
}
func (cs *captchaServer) startChallenge(captcha *discordauth.Captcha) (string, func(context.Context) (string, error), error) {
if !supportsBrowserCaptcha(captcha) {
return "", nil, fmt.Errorf("browser flow only supports hcaptcha challenges with a sitekey")
}
if err := cs.ensureStarted(); err != nil {
return "", nil, err
}
challenge := &activeCaptcha{
challenge: browserCaptchaChallenge{
ID: uuid.NewString(),
Service: captcha.Service,
SiteKey: strings.TrimSpace(*captcha.SiteKey),
Invisible: captcha.Invisible,
},
resultCh: make(chan captchaBrowserResult, 1),
}
if captcha.RqData != nil {
challenge.challenge.RqData = *captcha.RqData
}
cs.mu.Lock()
if cs.active != nil {
cs.log.Warn().
Str("replaced_challenge_id", cs.active.challenge.ID).
Msg("Replacing active CAPTCHA challenge before it was resolved")
}
cs.active = challenge
pageURL := cs.baseURL
cs.mu.Unlock()
cs.log.Info().
Str("challenge_id", challenge.challenge.ID).
Str("captcha_service", string(challenge.challenge.Service)).
Bool("captcha_invisible", challenge.challenge.Invisible).
Bool("captcha_has_rqdata", challenge.challenge.RqData != "").
Str("page_url", pageURL).
Msg("Started local CAPTCHA challenge")
wait := func(ctx context.Context) (string, error) {
defer cs.clearActiveChallenge(challenge.challenge.ID)
select {
case result := <-challenge.resultCh:
if result.err != nil {
cs.log.Warn().
Str("challenge_id", challenge.challenge.ID).
Err(result.err).
Msg("Local CAPTCHA challenge completed with error")
return "", result.err
}
if result.token == "" {
return "", fmt.Errorf("browser page returned an empty CAPTCHA token")
}
cs.log.Info().
Str("challenge_id", challenge.challenge.ID).
Int("token_length", len(result.token)).
Msg("Local CAPTCHA challenge returned a token")
return result.token, nil
case <-ctx.Done():
cs.log.Warn().
Str("challenge_id", challenge.challenge.ID).
Err(ctx.Err()).
Msg("Stopped waiting for local CAPTCHA challenge")
return "", ctx.Err()
}
}
return pageURL, wait, nil
}
func (cs *captchaServer) ensureStarted() error {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.server != nil {
return nil
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("failed to listen on 127.0.0.1: %w", err)
}
addr := ln.Addr().(*net.TCPAddr)
server := &http.Server{
Handler: cs.handler,
ReadHeaderTimeout: 5 * time.Second,
}
cs.ln = ln
cs.server = server
cs.baseURL = fmt.Sprintf("http://localhost:%d/", addr.Port)
cs.log.Info().
Str("listen_addr", ln.Addr().String()).
Str("page_url", cs.baseURL).
Msg("Started local CAPTCHA server")
go func() {
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
cs.log.Error().Err(err).Msg("Local CAPTCHA server stopped unexpectedly")
cs.failActiveChallenge(fmt.Errorf("captcha server stopped unexpectedly: %w", err))
}
}()
return nil
}
func (cs *captchaServer) Close(ctx context.Context) error {
cs.mu.Lock()
server := cs.server
cs.server = nil
cs.ln = nil
cs.baseURL = ""
cs.active = nil
cs.mu.Unlock()
if server == nil {
return nil
}
cs.log.Info().Msg("Shutting down local CAPTCHA server")
return server.Shutdown(ctx)
}
func (cs *captchaServer) clearActiveChallenge(id string) {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.active != nil && cs.active.challenge.ID == id {
cs.active = nil
}
}
func (cs *captchaServer) failActiveChallenge(err error) {
cs.log.Error().Err(err).Msg("Failing active CAPTCHA challenge")
cs.mu.Lock()
active := cs.active
cs.active = nil
cs.mu.Unlock()
if active == nil {
return
}
select {
case active.resultCh <- captchaBrowserResult{err: err}:
default:
}
}
func (cs *captchaServer) resolveActiveChallenge(id string, result captchaBrowserResult) error {
cs.mu.Lock()
active := cs.active
if active == nil {
cs.mu.Unlock()
cs.log.Warn().
Str("challenge_id", id).
Msg("Attempted to resolve CAPTCHA challenge, but none is active")
return fmt.Errorf("no active captcha challenge")
}
if active.challenge.ID != id {
cs.mu.Unlock()
cs.log.Warn().
Str("challenge_id", id).
Str("active_challenge_id", active.challenge.ID).
Msg("Attempted to resolve a stale CAPTCHA challenge")
return fmt.Errorf("captcha challenge is no longer current")
}
cs.active = nil
cs.mu.Unlock()
select {
case active.resultCh <- result:
return nil
default:
cs.log.Warn().
Str("challenge_id", id).
Msg("CAPTCHA challenge was already resolved")
return fmt.Errorf("captcha challenge already resolved")
}
}
func (cs *captchaServer) currentChallenge() *browserCaptchaChallenge {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.active == nil {
return nil
}
challenge := cs.active.challenge
return &challenge
}
func (cs *captchaServer) handlePage(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodGet {
log.Warn().Msg("Rejected CAPTCHA page request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodGet)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(captchaPageHTML))
log.Info().Msg("Served CAPTCHA page")
}
func (cs *captchaServer) handleChallenge(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodGet {
log.Warn().Msg("Rejected CAPTCHA challenge request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodGet)
return
}
challenge := cs.currentChallenge()
if challenge == nil {
log.Warn().Msg("Requested CAPTCHA challenge, but none is active")
writeCaptchaJSON(w, http.StatusNotFound, captchaErrorResponse{Error: "no active captcha challenge"})
return
}
log.Info().
Str("challenge_id", challenge.ID).
Str("captcha_service", string(challenge.Service)).
Bool("captcha_invisible", challenge.Invisible).
Bool("captcha_has_rqdata", challenge.RqData != "").
Msg("Served active CAPTCHA challenge")
writeCaptchaJSON(w, http.StatusOK, challenge)
}
func (cs *captchaServer) handleSolve(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodPost {
log.Warn().Msg("Rejected CAPTCHA solve request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodPost)
return
}
var req captchaSolveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Rejected CAPTCHA solve request with invalid JSON body")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
return
}
if strings.TrimSpace(req.ID) == "" {
log.Warn().Msg("Rejected CAPTCHA solve request without challenge id")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
log.Warn().
Str("challenge_id", req.ID).
Msg("Rejected CAPTCHA solve request with empty token")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing captcha token"})
return
}
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{token: req.Token}); err != nil {
log.Warn().
Str("challenge_id", req.ID).
Err(err).
Msg("Rejected CAPTCHA solve request")
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
return
}
log.Info().
Str("challenge_id", req.ID).
Int("token_length", len(req.Token)).
Msg("Accepted CAPTCHA token from browser page")
w.WriteHeader(http.StatusNoContent)
}
func (cs *captchaServer) handleCancel(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodPost {
log.Warn().Msg("Rejected CAPTCHA cancel request with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodPost)
return
}
var req captchaCancelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Rejected CAPTCHA cancel request with invalid JSON body")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
return
}
if strings.TrimSpace(req.ID) == "" {
log.Warn().Msg("Rejected CAPTCHA cancel request without challenge id")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
return
}
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{err: errCaptchaBrowserCanceled}); err != nil {
log.Warn().
Str("challenge_id", req.ID).
Err(err).
Msg("Rejected CAPTCHA cancel request")
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
return
}
log.Info().
Str("challenge_id", req.ID).
Msg("Browser page canceled CAPTCHA flow")
w.WriteHeader(http.StatusNoContent)
}
func (cs *captchaServer) handleError(w http.ResponseWriter, r *http.Request) {
log := cs.requestLogger(r)
if r.Method != http.MethodPost {
log.Warn().Msg("Rejected CAPTCHA error report with unsupported method")
writeCaptchaMethodNotAllowed(w, http.MethodPost)
return
}
var req captchaErrorRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Warn().Err(err).Msg("Rejected CAPTCHA error report with invalid JSON body")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "invalid JSON body"})
return
}
if strings.TrimSpace(req.ID) == "" {
log.Warn().Msg("Rejected CAPTCHA error report without challenge id")
writeCaptchaJSON(w, http.StatusBadRequest, captchaErrorResponse{Error: "missing challenge id"})
return
}
message := strings.TrimSpace(req.Error)
if message == "" {
message = "browser page reported an unknown error"
}
if err := cs.resolveActiveChallenge(req.ID, captchaBrowserResult{err: fmt.Errorf("%s", message)}); err != nil {
log.Warn().
Str("challenge_id", req.ID).
Err(err).
Msg("Rejected CAPTCHA browser error report")
writeCaptchaJSON(w, http.StatusConflict, captchaErrorResponse{Error: err.Error()})
return
}
log.Warn().
Str("challenge_id", req.ID).
Str("browser_error", message).
Msg("Browser page reported CAPTCHA error")
w.WriteHeader(http.StatusNoContent)
}
func (cs *captchaServer) requestLogger(r *http.Request) zerolog.Logger {
return cs.log.With().
Str("http_method", r.Method).
Str("http_path", r.URL.Path).
Str("remote_addr", r.RemoteAddr).
Logger()
}
func writeCaptchaJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeCaptchaMethodNotAllowed(w http.ResponseWriter, allowed string) {
w.Header().Set("Allow", allowed)
writeCaptchaJSON(w, http.StatusMethodNotAllowed, captchaErrorResponse{Error: "method not allowed"})
}

View file

@ -1,225 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mautrix-discord hCaptcha</title>
<style>
:root {
color-scheme: light dark;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
#status {
margin: 1rem 0;
padding: 1rem;
background-color: light-dark(#ddd, #333);
&.error {
background-color: hsla(0 62.7% 52.9% / 0.2);
color: red;
font-weight: bold;
}
}
#captcha {
min-height: 90px;
}
button {
display: block;
margin-top: 1rem;
font: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
</head>
<body>
<main>
<h1>mautrix-discord hCaptcha</h1>
<main>
<div id="status">Loading challenge state...</div>
<div id="captcha"></div>
<button id="cancel" type="button">
Cancel & Use Terminal Fallback
</button>
</main>
</main>
<script>
let challenge = null;
let widgetID = null;
const statusEl = document.getElementById("status");
const cancelButton = document.getElementById("cancel");
function setStatus(message, isError) {
statusEl.textContent = message;
statusEl.classList.toggle("error", Boolean(isError));
}
async function parseError(response) {
let message = "Request failed";
try {
const body = await response.json();
if (body && body.error) {
message = body.error;
}
} catch (err) {}
return message;
}
async function postJSON(path, payload) {
const response = await fetch(path, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await parseError(response));
}
}
async function reportError(message) {
setStatus(message, true);
if (!challenge) {
return;
}
try {
await postJSON("/api/error", {id: challenge.id, error: message});
} catch (err) {}
}
async function cancelChallenge() {
if (!challenge) {
setStatus("No active challenge to cancel.", true);
return;
}
cancelButton.disabled = true;
setStatus(
"Canceling browser flow and switching back to terminal fallback...",
);
try {
await postJSON("/api/cancel", {id: challenge.id});
setStatus("Canceled! Check your terminal for manual entry.");
} catch (err) {
setStatus("Failed to cancel: " + err.message, true);
cancelButton.disabled = false;
}
}
async function submitToken(token) {
setStatus("Submitting CAPTCHA token...");
cancelButton.disabled = true;
await postJSON("/api/solve", {id: challenge.id, token: token});
setStatus("CAPTCHA token submitted! Check your terminal.");
}
function loadHCaptchaScript() {
const script = document.createElement("script");
script.src =
"https://js.hcaptcha.com/1/api.js?render=explicit&onload=onHCaptchaLoad&recaptchacompat=off&host=discord.com";
script.async = true;
script.defer = true;
script.onerror = function () {
reportError("Failed to load hCaptcha API script.");
};
document.head.appendChild(script);
}
function applyRqData() {
if (!challenge || !challenge.rqdata) {
return;
}
hcaptcha.setData(widgetID, {rqdata: challenge.rqdata});
}
function renderHCaptcha() {
widgetID = hcaptcha.render("captcha", {
sitekey: challenge.site_key,
size: challenge.invisible ? "invisible" : "normal",
callback: function (token) {
submitToken(token).catch(function (err) {
reportError("Failed to submit token: " + err.message);
});
},
"open-callback": function () {
setStatus("CAPTCHA challenge opened. Complete it in this page.");
},
"close-callback": function () {
setStatus(
"CAPTCHA challenge was closed. Re-open it here or cancel to use terminal fallback.",
);
},
"expired-callback": function () {
reportError("hCaptcha token expired before submission.");
},
"chalexpired-callback": function () {
reportError("hCaptcha challenge expired.");
},
"error-callback": function (message) {
reportError("hCaptcha error: " + message);
},
});
applyRqData();
if (challenge.invisible) {
setStatus("Launching invisible challenge...");
hcaptcha.execute(widgetID);
} else {
setStatus(
"Solve the CAPTCHA below. The token will submit automatically.",
);
}
}
async function loadChallenge() {
const response = await fetch("/api/challenge", {cache: "no-store"});
if (!response.ok) {
throw new Error(await parseError(response));
}
challenge = await response.json();
if (challenge.service !== "hcaptcha") {
throw new Error(
"Only hCaptcha challenges are supported by this test page.",
);
}
if (!challenge.site_key) {
throw new Error("Challenge is missing captcha_sitekey.");
}
}
async function init() {
cancelButton.addEventListener("click", function () {
cancelChallenge().catch(function (err) {
setStatus("Failed to cancel: " + err.message, true);
});
});
try {
await loadChallenge();
setStatus("Challenge state loaded. Loading hCaptcha...");
loadHCaptchaScript();
} catch (err) {
reportError(err.message);
}
}
window.onHCaptchaLoad = function () {
try {
renderHCaptcha();
} catch (err) {
reportError("Failed to initialize hCaptcha: " + err.message);
}
};
init();
</script>
</body>
</html>

View file

@ -1,37 +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 <https://www.gnu.org/licenses/>.
package main
import (
_ "embed"
)
const legacyMigrateRenameTables = `
ALTER TABLE portal RENAME TO portal_old;
ALTER TABLE puppet RENAME TO puppet_old;
ALTER TABLE "user" RENAME TO user_old;
ALTER TABLE message RENAME TO message_old;
ALTER TABLE reaction RENAME TO reaction_old;
ALTER TABLE user_portal RENAME TO user_portal_old;
ALTER TABLE guild RENAME TO guild_old;
ALTER TABLE role RENAME TO role_old;
ALTER TABLE thread RENAME TO thread_old;
ALTER TABLE discord_file RENAME TO discord_file_old;
`
//go:embed legacymigrate.sql
var legacyMigrateCopyData string

Some files were not shown because too many files have changed in this diff Show more