Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and display-and-wait.
## Features

- **Echo Bridge Functionality**: Acts as a simple echo bridge for testing.
- **Per-Message Echo Controls**: Specific outbound messages can suppress or delay the remote echo.
- **Login Flows**: Supports multiple login flows such as password, cookies, local storage,
and display-and-wait.
- **Automation**: Configurable automation options for management rooms, login, portals,
Expand Down Expand Up @@ -48,6 +49,14 @@ automation:
timelimit: 0s # Duration for the initial startup infinite backfill, e.g. 10s, 1m, 1h
```

To test slow or missing remote echoes, send trigger phrases in the message body:

- `remote-echo none` keeps that send pending forever.
- `remote-echo fail` makes that send fail immediately with an error.
- `remote-echo delay 5s` delays the remote echo for the parsed Go duration.
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README says remote-echo delay 5s delays the echo for the "parsed Go duration", but the implementation only accepts a restricted subset via regex ([0-9]+ + ms|s|m|h) and will reject valid time.ParseDuration inputs like 1m30s, 250ms, 1.5s, etc. Either broaden the parser/regex to match time.ParseDuration semantics or adjust the documentation to reflect the supported format.

Suggested change
- `remote-echo delay 5s` delays the remote echo for the parsed Go duration.
- `remote-echo delay 5s` delays the remote echo for the given duration, specified as an integer followed by a unit (`ms`, `s`, `m`, or `h`), for example `5s`, `250ms`, `2m`, or `1h`.

Copilot uses AI. Check for mistakes.

Other messages keep the existing immediate-success behavior.

## Running

To run DummyBridge using Docker, execute the following command:
Expand Down
127 changes: 120 additions & 7 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"time"

Expand All @@ -14,12 +16,15 @@ import (
"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/bridgev2/status"
"maunium.net/go/mautrix/event"
)

type DummyClient struct {
wg sync.WaitGroup
wg sync.WaitGroup
ctx context.Context
stop context.CancelFunc

UserLogin *bridgev2.UserLogin
Connector *DummyConnector
Expand All @@ -31,6 +36,8 @@ var _ bridgev2.BackfillingNetworkAPI = (*DummyClient)(nil)
var _ bridgev2.DeleteChatHandlingNetworkAPI = (*DummyClient)(nil)
var _ bridgev2.MessageRequestAcceptingNetworkAPI = (*DummyClient)(nil)

var delayedRemoteEchoPattern = regexp.MustCompile(`(?i)^remote-echo\s+delay\s+([0-9]+(?:ms|s|m|h))$`)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the file around line 39 to see the pattern definition
cat -n pkg/connector/client.go | sed -n '30,50p'

Repository: beeper/dummybridge

Length of output: 1088


🏁 Script executed:

# Read lines 224-242 mentioned in "Also applies to"
cat -n pkg/connector/client.go | sed -n '220,250p'

Repository: beeper/dummybridge

Length of output: 1180


🏁 Script executed:

# Search for getRemoteEchoBehavior function to understand the parsing logic
rg -n "getRemoteEchoBehavior" pkg/connector/client.go -A 25

Repository: beeper/dummybridge

Length of output: 1907


🏁 Script executed:

# Check for README to verify what documentation says about the format
fd -name "README*" -type f

Repository: beeper/dummybridge

Length of output: 292


🏁 Script executed:

# Search for README files
find . -type f -iname "README*" | head -20

Repository: beeper/dummybridge

Length of output: 73


🏁 Script executed:

# Check if there's documentation about remote-echo format
rg -i "remote-echo" -A 3 -B 3 README* 2>/dev/null | head -50

Repository: beeper/dummybridge

Length of output: 406


🏁 Script executed:

# Verify what time.ParseDuration accepts by checking Go standard library documentation reference
# Let's also test the regex claim directly
python3 << 'EOF'
import re

pattern = re.compile(r'(?i)^remote-echo\s+delay\s+([0-9]+(?:ms|s|m|h))$')

test_cases = [
    "remote-echo delay 100ms",
    "remote-echo delay 5s",
    "remote-echo delay 1m",
    "remote-echo delay 2h",
    "remote-echo delay 1m30s",  # Should fail - two units
    "remote-echo delay 250us",    # Should fail - us not in list
    "remote-echo delay 100",      # Should fail - no unit
]

print("Testing regex pattern:")
for test in test_cases:
    match = pattern.search(test)
    print(f"  '{test}': {'MATCH' if match else 'NO MATCH'}")
    if match:
        print(f"    -> captured: '{match.group(1)}'")
EOF

Repository: beeper/dummybridge

Length of output: 418


remote-echo delay regex only accepts single-unit Go durations, not compound or nanosecond-precision formats.

The regex pattern rejects valid Go durations like 1m30s or 250us, silently falling back to immediate-success behavior. This contradicts the README claim of "parsed Go duration" support. Either tighten the README wording to document the supported subset, or change the regex to (?i)^remote-echo\s+delay\s+(.+)$ and let time.ParseDuration (line 238) handle validation.

Suggested change
-var delayedRemoteEchoPattern = regexp.MustCompile(`(?i)^remote-echo\s+delay\s+([0-9]+(?:ms|s|m|h))$`)
+var delayedRemoteEchoPattern = regexp.MustCompile(`(?i)^remote-echo\s+delay\s+(.+)$`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/connector/client.go` at line 39, The regex delayedRemoteEchoPattern
currently only matches single-unit durations and should be broadened so
time.ParseDuration handles full Go duration syntax: update the pattern declared
in delayedRemoteEchoPattern to accept the entire duration token (e.g., use a
capture that matches any non-empty sequence instead of the current single-unit
pattern) and keep using time.ParseDuration where the capture is parsed (the code
path around the time.ParseDuration call that handles remote-echo delay). Ensure
the variable name delayedRemoteEchoPattern and the existing capture group used
by the downstream parsing logic are preserved so only validation shifts to
time.ParseDuration.


var dummyRoomCaps = &event.RoomFeatures{
ID: "com.beeper.dummy.capabilities",

Expand Down Expand Up @@ -67,6 +74,8 @@ var dummyRoomCaps = &event.RoomFeatures{
}

func (dc *DummyClient) Connect(ctx context.Context) {
dc.ctx, dc.stop = context.WithCancel(ctx)

state := status.BridgeState{
UserID: dc.UserLogin.UserMXID,
RemoteName: dc.UserLogin.RemoteName,
Expand All @@ -81,7 +90,7 @@ func (dc *DummyClient) Connect(ctx context.Context) {
log.Info().Int("portals", dc.Connector.Config.Automation.Portals.Count).Msg("Generating portals after login")
for range dc.Connector.Config.Automation.Portals.Count {
if _, err := generatePortal(
ctx,
dc.ctx,
dc.Connector.br,
dc.UserLogin,
dc.Connector.Config.Automation.Portals.Members,
Expand All @@ -95,6 +104,9 @@ func (dc *DummyClient) Connect(ctx context.Context) {
}

func (dc *DummyClient) Disconnect() {
if dc.stop != nil {
dc.stop()
}
dc.wg.Wait()
}

Expand Down Expand Up @@ -157,16 +169,35 @@ func (dc *DummyClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma
_ = msg.Portal.Save(ctx)
}

messageID := randomMessageID()
if msg.Event != nil && msg.Event.Unsigned.TransactionID != "" {
messageID = networkid.MessageID(msg.Event.Unsigned.TransactionID)
}

timestamp := time.Now()
if msg.Event != nil && msg.Event.Timestamp != 0 {
timestamp = time.UnixMilli(msg.Event.Timestamp)
}

behavior := getRemoteEchoBehavior(msg.Content)
if behavior.fail {
return nil, errors.New("dummy remote echo failure")
}
if behavior.pending {
transactionID := getTransactionID(msg)
dbMessage := &database.Message{
ID: randomMessageID(),
SenderID: networkid.UserID(dc.UserLogin.ID),
Timestamp: timestamp,
}
msg.AddPendingToSave(dbMessage, transactionID, nil)
dc.queueRemoteEcho(msg, transactionID, timestamp, behavior.delay)
return &bridgev2.MatrixMessageResponse{
DB: dbMessage,
Pending: true,
}, nil
Comment on lines +177 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n pkg/connector/client.go | head -200

Repository: beeper/dummybridge

Length of output: 7293


🏁 Script executed:

# Find the getRemoteEchoBehavior and queueRemoteEcho function definitions
rg -n "func.*getRemoteEchoBehavior|func.*queueRemoteEcho" pkg/connector/client.go -A 20

Repository: beeper/dummybridge

Length of output: 1288


🏁 Script executed:

# Check lines 177-190 and 236-239 specifically
sed -n '170,200p' pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 915


🏁 Script executed:

# Also check around line 236
sed -n '230,250p' pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 520


🏁 Script executed:

# Search for any code that might resolve this pending state
rg -n "AddPendingToSave|Pending" pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 141


🏁 Script executed:

# Check if there's any context about what happens with unresolved pending transactions
rg -n "pending" pkg/connector/client.go -B 2 -A 2

Repository: beeper/dummybridge

Length of output: 661


🏁 Script executed:

# Search the broader codebase for any cleanup of pending states
rg -n "CancelPending|ResolvePending|pending.*resolve|pending.*cancel" --type go

Repository: beeper/dummybridge

Length of output: 44


Skip pending path for no-echo messages.

When remote-echo none is sent, getRemoteEchoBehavior() returns {pending: true, delay: 0}. This enters the pending block at line 178, calls msg.AddPendingToSave(), then passes delay=0 to queueRemoteEcho(). Since queueRemoteEcho() immediately returns when delay <= 0, the pending transaction is never resolved. The Pending/AddPendingToSave flow is meant for cases where a real remote event arrives later; no-echo messages have no follow-up.

Skip the pending block for zero-delay and no-portal cases by adding those conditions to the if statement:

Suggested change
-	if behavior.pending {
+	if behavior.pending && behavior.delay > 0 && msg.Portal != nil {
 		transactionID := getTransactionID(msg)
 		dbMessage := &database.Message{
 			ID:        networkid.MessageID(transactionID),
 			SenderID:  networkid.UserID(dc.UserLogin.ID),
 			Timestamp: timestamp,
 		}
 		msg.AddPendingToSave(dbMessage, transactionID, nil)
 		dc.queueRemoteEcho(msg, transactionID, timestamp, behavior.delay)
 		return &bridgev2.MatrixMessageResponse{
 			DB:      dbMessage,
 			Pending: true,
 		}, nil
 	}

Also applies to: 236-239

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/connector/client.go` around lines 177 - 190, The pending-path is
incorrectly taken for "remote-echo none" because getRemoteEchoBehavior can
return pending=true with delay=0; update the conditional around the pending
handling in the connector to skip creating a pending DB message and calling
msg.AddPendingToSave when behavior.delay <= 0 (and when there's no portal flag
if applicable): change the if that checks behavior.pending (the block that calls
getTransactionID, constructs database.Message, calls msg.AddPendingToSave and
dc.queueRemoteEcho) to require behavior.pending && behavior.delay > 0 (and also
ensure any portal-specific flag is considered), so queueRemoteEcho is only
invoked for real delayed remote-echo cases and the zero-delay/no-echo path does
not create unresolved pending transactions.

}

messageID := randomMessageID()
if msg.Event != nil && msg.Event.Unsigned.TransactionID != "" {
messageID = networkid.MessageID(msg.Event.Unsigned.TransactionID)
}

return &bridgev2.MatrixMessageResponse{
DB: &database.Message{
ID: messageID,
Expand All @@ -177,6 +208,88 @@ func (dc *DummyClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Ma
}, nil
}

func getTransactionID(msg *bridgev2.MatrixMessage) networkid.TransactionID {
if msg.Event != nil && msg.Event.Unsigned.TransactionID != "" {
return networkid.TransactionID(msg.Event.Unsigned.TransactionID)
}
return networkid.TransactionID(randomMessageID())
}

type remoteEchoBehavior struct {
pending bool
delay time.Duration
fail bool
}

func getRemoteEchoBehavior(content *event.MessageEventContent) remoteEchoBehavior {
if content == nil {
return remoteEchoBehavior{}
}
body := strings.TrimSpace(content.Body)
if strings.EqualFold(body, "remote-echo none") {
return remoteEchoBehavior{pending: true}
} else if strings.EqualFold(body, "remote-echo fail") {
return remoteEchoBehavior{fail: true}
}
matches := delayedRemoteEchoPattern.FindStringSubmatch(body)
if len(matches) != 2 {
return remoteEchoBehavior{}
}
delay, err := time.ParseDuration(matches[1])
if err != nil {
return remoteEchoBehavior{}
}
return remoteEchoBehavior{pending: true, delay: delay}
}

func (dc *DummyClient) queueRemoteEcho(msg *bridgev2.MatrixMessage, transactionID networkid.TransactionID, timestamp time.Time, delay time.Duration) {
if delay <= 0 || msg.Portal == nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Complete zero-delay echoes instead of leaving them pending

When a message body is remote-echo delay 0s, HandleMatrixMessage marks it as pending and then queueRemoteEcho immediately returns because of delay <= 0, so no remote event is ever queued and the send stays pending forever. This differs from the documented “delay by duration” behavior, where a zero duration should complete immediately rather than behave like remote-echo none.

Useful? React with 👍 / 👎.

return
}
Comment on lines +245 to +248
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remote-echo delay 0s currently results in behavior.pending=true (parsed duration is 0), but queueRemoteEcho returns early for delay <= 0, so the message stays pending forever. Consider either treating a zero delay as an immediate remote echo (still queue with no timer) or rejecting/ignoring zero delays in getRemoteEchoBehavior so the send isn't marked pending.

Copilot uses AI. Check for mistakes.

dc.wg.Add(1)
go func() {
defer dc.wg.Done()

timer := time.NewTimer(delay)
defer timer.Stop()

select {
case <-dc.ctx.Done():
return
Comment on lines +257 to +259
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queueRemoteEcho uses dc.ctx.Done() in the goroutine, but dc.ctx is only initialized in Connect. If HandleMatrixMessage (or this helper) can run before Connect, this will panic due to a nil context.Context. Consider defaulting dc.ctx to the passed-in ctx (or context.Background()) when dc.ctx is nil.

Copilot uses AI. Check for mistakes.
case <-timer.C:
}

dc.UserLogin.QueueRemoteEvent(&simplevent.PreConvertedMessage{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
PortalKey: msg.Portal.PortalKey,
Sender: bridgev2.EventSender{
IsFromMe: true,
SenderLogin: dc.UserLogin.ID,
Sender: networkid.UserID(dc.UserLogin.ID),
},
Timestamp: timestamp,
StreamOrder: time.Now().UnixNano(),
},
Data: &bridgev2.ConvertedMessage{Parts: []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: cloneMessageContent(msg.Content),
}}},
ID: randomMessageID(),
TransactionID: transactionID,
})
}()
}

func cloneMessageContent(content *event.MessageEventContent) *event.MessageEventContent {
if content == nil {
return nil
}
cloned := *content
return &cloned
Comment on lines +245 to +290
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Snapshot the echoed payload before starting the timer goroutine.

This goroutine closes over msg and only clones msg.Content after the delay expires. Any mutation to the request object after HandleMatrixMessage returns will change what gets echoed. Capture the portal key and cloned content up front, then use those immutable values inside the goroutine.

Suggested change
 func (dc *DummyClient) queueRemoteEcho(msg *bridgev2.MatrixMessage, transactionID networkid.TransactionID, timestamp time.Time, delay time.Duration) {
 	if delay <= 0 || msg.Portal == nil {
 		return
 	}
+	portalKey := msg.Portal.PortalKey
+	content := cloneMessageContent(msg.Content)
 
 	dc.wg.Add(1)
 	go func() {
 		defer dc.wg.Done()
@@
 		dc.UserLogin.QueueRemoteEvent(&simplevent.PreConvertedMessage{
 			EventMeta: simplevent.EventMeta{
 				Type:      bridgev2.RemoteEventMessage,
-				PortalKey: msg.Portal.PortalKey,
+				PortalKey: portalKey,
 				Sender: bridgev2.EventSender{
 					IsFromMe:    true,
 					SenderLogin: dc.UserLogin.ID,
 					Sender:      networkid.UserID(dc.UserLogin.ID),
 				},
@@
 			},
 			Data: &bridgev2.ConvertedMessage{Parts: []*bridgev2.ConvertedMessagePart{{
 				Type:    event.EventMessage,
-				Content: cloneMessageContent(msg.Content),
+				Content: content,
 			}}},
 			ID:            randomMessageID(),
 			TransactionID: transactionID,
 		})
 	}()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (dc *DummyClient) queueRemoteEcho(msg *bridgev2.MatrixMessage, transactionID networkid.TransactionID, timestamp time.Time, delay time.Duration) {
if delay <= 0 || msg.Portal == nil {
return
}
dc.wg.Add(1)
go func() {
defer dc.wg.Done()
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-dc.ctx.Done():
return
case <-timer.C:
}
dc.UserLogin.QueueRemoteEvent(&simplevent.PreConvertedMessage{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
PortalKey: msg.Portal.PortalKey,
Sender: bridgev2.EventSender{
IsFromMe: true,
SenderLogin: dc.UserLogin.ID,
Sender: networkid.UserID(dc.UserLogin.ID),
},
Timestamp: timestamp,
StreamOrder: time.Now().UnixNano(),
},
Data: &bridgev2.ConvertedMessage{Parts: []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: cloneMessageContent(msg.Content),
}}},
ID: randomMessageID(),
TransactionID: transactionID,
})
}()
}
func cloneMessageContent(content *event.MessageEventContent) *event.MessageEventContent {
if content == nil {
return nil
}
cloned := *content
return &cloned
func (dc *DummyClient) queueRemoteEcho(msg *bridgev2.MatrixMessage, transactionID networkid.TransactionID, timestamp time.Time, delay time.Duration) {
if delay <= 0 || msg.Portal == nil {
return
}
portalKey := msg.Portal.PortalKey
content := cloneMessageContent(msg.Content)
dc.wg.Add(1)
go func() {
defer dc.wg.Done()
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-dc.ctx.Done():
return
case <-timer.C:
}
dc.UserLogin.QueueRemoteEvent(&simplevent.PreConvertedMessage{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventMessage,
PortalKey: portalKey,
Sender: bridgev2.EventSender{
IsFromMe: true,
SenderLogin: dc.UserLogin.ID,
Sender: networkid.UserID(dc.UserLogin.ID),
},
Timestamp: timestamp,
StreamOrder: time.Now().UnixNano(),
},
Data: &bridgev2.ConvertedMessage{Parts: []*bridgev2.ConvertedMessagePart{{
Type: event.EventMessage,
Content: content,
}}},
ID: randomMessageID(),
TransactionID: transactionID,
})
}()
}
func cloneMessageContent(content *event.MessageEventContent) *event.MessageEventContent {
if content == nil {
return nil
}
cloned := *content
return &cloned
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/connector/client.go` around lines 236 - 281, The goroutine closes over
msg and clones msg.Content only after the delay, so mutations to msg between
scheduling and execution can change the echoed payload; fix by snapshotting
needed values before starting the goroutine in queueRemoteEcho: read and store
portalKey := msg.Portal.PortalKey and clonedContent :=
cloneMessageContent(msg.Content) (and any other fields used from msg) before
calling dc.wg.Add(1)/go func(), then use portalKey and clonedContent inside the
goroutine instead of referencing msg.Portal or msg.Content.

Comment on lines +285 to +290
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file and examine the context around cloneMessageContent
fd pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 44


🏁 Script executed:

# Search for the MessageEventContent definition in the event package
fd -e go | xargs grep -l "type MessageEventContent struct"

Repository: beeper/dummybridge

Length of output: 46


🏁 Script executed:

# Look at the cloneMessageContent function and its context
rg -A 20 "func cloneMessageContent" pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 922


🏁 Script executed:

# Find all usages of cloneMessageContent to understand how it's used
rg "cloneMessageContent" --type go

Repository: beeper/dummybridge

Length of output: 246


🏁 Script executed:

# Find where MessageEventContent is defined - likely in mautrix or similar dependency
fd -e go | xargs grep -l "type MessageEventContent struct"

Repository: beeper/dummybridge

Length of output: 46


🏁 Script executed:

# Get the context around the usage of cloneMessageContent
rg -B 10 -A 3 "Content: cloneMessageContent" pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 524


🏁 Script executed:

# Search for delayed echo references to understand the context
rg -i "delayed.*echo|echo.*delayed" --type go pkg/connector/

Repository: beeper/dummybridge

Length of output: 273


🏁 Script executed:

# Look for the full context of delayed remote echo handling
rg -B 5 -A 20 "delayedRemoteEchoPattern" pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 2182


🏁 Script executed:

# Check the imports in client.go to understand where event comes from
head -50 pkg/connector/client.go | grep -E "import|event"

Repository: beeper/dummybridge

Length of output: 540


🏁 Script executed:

# Search for MessageEventContent definition anywhere in go files (might be imported from mautrix)
rg "type MessageEventContent" --type go

Repository: beeper/dummybridge

Length of output: 44


🌐 Web query:

MessageEventContent mautrix go struct fields

💡 Result:

In maunium.net/go/mautrix/event, MessageEventContent is defined with these exported fields (and JSON keys): [1]

  • MsgType MessageType json:"msgtype,omitempty"
  • Body string json:"body"
  • Format Format json:"format,omitempty"
  • FormattedBody string json:"formatted_body,omitempty"
  • GeoURI string json:"geo_uri,omitempty"
  • URL id.ContentURIString json:"url,omitempty"
  • Info *FileInfo json:"info,omitempty"
  • File *EncryptedFileInfo json:"file,omitempty"
  • FileName string json:"filename,omitempty"
  • Mentions *Mentions json:"m.mentions,omitempty"
  • NewContent *MessageEventContent json:"m.new_content,omitempty"
  • RelatesTo *RelatesTo json:"m.relates_to,omitempty"
  • To id.UserID json:"to,omitempty"
  • FromDevice id.DeviceID json:"from_device,omitempty"
  • Methods []VerificationMethod json:"methods,omitempty"
  • MessageSendRetry *BeeperRetryMetadata json:"com.beeper.message_send_retry,omitempty"
  • BeeperGalleryImages []*MessageEventContent json:"com.beeper.gallery.images,omitempty"
  • BeeperGalleryCaption string json:"com.beeper.gallery.caption,omitempty"
  • BeeperGalleryCaptionHTML string json:"com.beeper.gallery.caption_html,omitempty"
  • BeeperPerMessageProfile *BeeperPerMessageProfile json:"com.beeper.per_message_profile,omitempty"
  • BeeperActionMessage *BeeperActionMessage json:"com.beeper.action_message,omitempty"
  • BeeperLinkPreviews []*BeeperLinkPreview json:"com.beeper.linkpreviews,omitempty"
  • BeeperDisappearingTimer *BeeperDisappearingTimer json:"com.beeper.disappearing_timer,omitempty"
  • MSC1767Audio *MSC1767Audio json:"org.matrix.msc1767.audio,omitempty"
  • MSC3245Voice *MSC3245Voice json:"org.matrix.msc3245.voice,omitempty"
  • MSC4391BotCommand *MSC4391BotCommandInput json:"org.matrix.msc4391.command,omitempty"

It also “contains filtered or unexported fields” (so there are additional private fields not shown in GoDocs). [1]

Source(s):
[1] (pkg.go.dev)

Citations:


🏁 Script executed:

# Get more context around queueRemoteEcho to see how the cloned content is used
rg -A 30 "func (dc \*DummyClient) queueRemoteEcho" pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 44


🏁 Script executed:

# Get the full queueRemoteEcho function
rg -A 40 "func \(dc \*DummyClient\) queueRemoteEcho" pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 1182


🏁 Script executed:

# Look at the full file around line 285 to understand the complete context
sed -n '270,320p' pkg/connector/client.go

Repository: beeper/dummybridge

Length of output: 1736


cloneMessageContent performs only a shallow copy, leaving nested reference fields shared with the original.

event.MessageEventContent contains pointer fields (Info, File, Mentions, NewContent, RelatesTo, MessageSendRetry, BeeperPerMessageProfile, BeeperActionMessage, BeeperLinkPreviews) and slice fields (Methods, BeeperGalleryImages, BeeperLinkPreviews) that are not deep-copied. When the delayed remote echo is queued, if the original msg.Content is modified after this function returns but before the delayed goroutine processes it, those mutations are visible through the cloned copy, breaking the state isolation that the delayed path requires. A deep copy is needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/connector/client.go` around lines 285 - 290, cloneMessageContent
currently does a shallow copy of *event.MessageEventContent which leaves pointer
and slice fields shared and can lead to races when the original msg.Content is
mutated before a delayed goroutine runs; update cloneMessageContent to perform a
deep copy: allocate a new event.MessageEventContent, copy primitive fields, and
for each pointer field (Info, File, Mentions, NewContent, RelatesTo,
MessageSendRetry, BeeperPerMessageProfile, BeeperActionMessage,
BeeperLinkPreviews) allocate and copy the pointed-to structs, and for slice
fields (Methods, BeeperGalleryImages, BeeperLinkPreviews, Mentions if a slice)
create new slices and copy each element (deep-cloning elements as needed) so the
returned copy is fully independent of the original.

}

func (dc *DummyClient) HandleMatrixDeleteChat(ctx context.Context, msg *bridgev2.MatrixDeleteChat) error {
// bridgev2 will delete the portal + Matrix room after this returns nil.
// For dummybridge, there's no separate remote-side deletion to do.
Expand Down
39 changes: 39 additions & 0 deletions pkg/connector/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package connector

import (
"testing"
"time"

"maunium.net/go/mautrix/event"
)

func TestGetRemoteEchoBehavior(t *testing.T) {
tests := []struct {
name string
body string
pending bool
delay time.Duration
fail bool
}{
{name: "normal message", body: "hello", pending: false},
{name: "no echo trigger", body: "remote-echo none", pending: true},
{name: "fail trigger", body: "remote-echo fail", fail: true},
{name: "delay trigger", body: "remote-echo delay 5s", pending: true, delay: 5 * time.Second},
{name: "case insensitive", body: "REMOTE-ECHO DELAY 2m", pending: true, delay: 2 * time.Minute},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := getRemoteEchoBehavior(&event.MessageEventContent{Body: tc.body})
if got.pending != tc.pending {
t.Fatalf("pending = %v, want %v", got.pending, tc.pending)
}
if got.delay != tc.delay {
t.Fatalf("delay = %s, want %s", got.delay, tc.delay)
}
if got.fail != tc.fail {
t.Fatalf("fail = %v, want %v", got.fail, tc.fail)
}
})
}
}