Skip to content

Commit a46eb84

Browse files
MaxMFCCopilot
andcommitted
fix(matrix): advance the transaction ID between events within a notification
Both advance sites gated on transactionIDString == "", so the token path never advanced its id: every event in a notification reused the one generated per sendServer() call. A homeserver recognizes the repeat as a retransmission, so everything after the first event in a room is dropped silently, with a 200 each time. With attachments that costs the message body itself, since the text is sent last. Advance the string path by generating the next uuid, and route sendMessage() through advanceMessageTransaction() rather than repeating the guard inline. Fixes #88 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bb46f97 commit a46eb84

3 files changed

Lines changed: 111 additions & 4 deletions

File tree

internal/notify/matrix.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,25 @@ func (m *MatrixTarget) uploadFetch(attachment Attachment, params url.Values) (bo
409409

410410
// advanceMessageTransaction moves to the next transaction id, which upstream
411411
// does after every event so a retry is not mistaken for a retransmission.
412+
//
413+
// A notification can produce several events in one room -- a file per
414+
// attachment, then the text -- and an id that does not move between them is
415+
// what the homeserver uses to recognize a retransmission, so everything after
416+
// the first event is dropped. The spec asks for an id unique across requests
417+
// sharing an access token, so this advances per event rather than per room.
412418
func (m *MatrixTarget) advanceMessageTransaction() {
413-
if m.version == matrixVersionV3 && m.accessToken != "" &&
414-
m.accessToken != m.password && m.transactionIDString == "" {
419+
if m.version != matrixVersionV3 || m.accessToken == "" {
420+
return
421+
}
422+
423+
// The token path carries its id as a uuid string rather than a counter,
424+
// so it advances by generating the next one.
425+
if m.transactionIDString != "" {
426+
m.transactionIDString = newUUIDv4()
427+
return
428+
}
429+
430+
if m.accessToken != m.password {
415431
m.transactionID++
416432
}
417433
}
@@ -727,8 +743,8 @@ func (m *MatrixTarget) sendMessage(roomID, body, title string, notifyType Notify
727743

728744
path := m.messagePath(roomID)
729745
ok, _, _ := m.fetch(path, payload, nil, m.messageMethod(), "")
730-
if ok && m.version == matrixVersionV3 && m.accessToken != "" && m.accessToken != m.password && m.transactionIDString == "" {
731-
m.transactionID++
746+
if ok {
747+
m.advanceMessageTransaction()
732748
}
733749
_ = notifyType
734750
return ok

internal/notify/matrix_e2ee.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,10 @@ func (m *MatrixTarget) transactionValue() string {
641641
// advanceTransaction moves the counter on, keeping it in storage so a later
642642
// process does not reuse an id the server has already seen.
643643
func (m *MatrixTarget) advanceTransaction() {
644+
// The token path carries its id as a uuid string; there is no counter to
645+
// persist, so it advances by generating the next one.
644646
if m.transactionIDString != "" {
647+
m.transactionIDString = newUUIDv4()
645648
return
646649
}
647650

internal/notify/matrix_transaction_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,91 @@ func TestMatrixAccessTokenTransactionIDIsUnique(t *testing.T) {
5050
t.Fatalf("transaction id is the deterministic placeholder %q rather than a generated value", first)
5151
}
5252
}
53+
54+
// sendTransactionIDs returns the transaction id of every m.room.message send
55+
// issued by one notification, in order.
56+
func sendTransactionIDs(t *testing.T, specs []notify.RequestSpec) []string {
57+
t.Helper()
58+
59+
const marker = "/send/m.room.message/"
60+
ids := []string{}
61+
for _, spec := range specs {
62+
if idx := strings.Index(spec.URL, marker); idx >= 0 {
63+
ids = append(ids, spec.URL[idx+len(marker):])
64+
}
65+
}
66+
return ids
67+
}
68+
69+
// assertUniqueTransactionIDs fails when a notification reuses an id, naming
70+
// the collision rather than only reporting that one happened.
71+
func assertUniqueTransactionIDs(t *testing.T, ids []string, want int) {
72+
t.Helper()
73+
74+
if len(ids) != want {
75+
t.Fatalf("expected %d m.room.message sends, got %d", want, len(ids))
76+
}
77+
78+
seen := map[string]int{}
79+
for i, id := range ids {
80+
if first, ok := seen[id]; ok {
81+
t.Fatalf("sends %d and %d share transaction id %q; a homeserver is "+
82+
"entitled to treat the later one as a retransmission and drop it",
83+
first, i, id)
84+
}
85+
seen[id] = i
86+
}
87+
}
88+
89+
// TestMatrixMultiRoomTransactionIDsAreUnique covers a notification addressed
90+
// to more than one room.
91+
//
92+
// Synapse tolerates a repeat here, because it keys idempotency on the request
93+
// path and that carries the room id. The spec is the stricter of the two --
94+
// it asks for an id unique across requests sharing an access token -- so this
95+
// pins the behavior a homeserver keying on the token alone would need.
96+
func TestMatrixMultiRoomTransactionIDsAreUnique(t *testing.T) {
97+
t.Setenv("APPRISE_FIXED_TIME", "")
98+
notify.ConfigureStorage("", 8, nil)
99+
t.Cleanup(func() { notify.ConfigureStorage("", 8, nil) })
100+
101+
specs := testutil.CaptureGoRequests(t, func() error {
102+
return notify.SendTargetURL(
103+
"matrixs://tokenabc123@matrix.example.com/%23room1:example.com/%23room2:example.com?e2ee=no",
104+
"body", "title", "", notify.NotifyInfo)
105+
})
106+
107+
assertUniqueTransactionIDs(t, sendTransactionIDs(t, specs), 2)
108+
}
109+
110+
// TestMatrixAttachmentTransactionIDsAreUnique covers the several events a
111+
// single room receives when files are attached: one per file, then the text.
112+
//
113+
// This is the case that fails against a real homeserver: same room, so same
114+
// request path, so a repeated id is a retransmission. It costs the message
115+
// body itself, since the text is sent last and is what gets discarded.
116+
func TestMatrixAttachmentTransactionIDsAreUnique(t *testing.T) {
117+
t.Setenv("APPRISE_FIXED_TIME", "")
118+
notify.ConfigureStorage("", 8, nil)
119+
t.Cleanup(func() { notify.ConfigureStorage("", 8, nil) })
120+
121+
specs := testutil.CaptureGoRequests(t, func() error {
122+
target, err := notify.ParseURL("matrixs://tokenabc123@matrix.example.com/%23room1:example.com?e2ee=no")
123+
if err != nil {
124+
return err
125+
}
126+
sender, err := notify.NewTarget(target)
127+
if err != nil {
128+
return err
129+
}
130+
131+
return notify.DispatchSend(sender, "body", "title", notify.NotifyInfo,
132+
[]notify.Attachment{
133+
{Name: "one.txt", MIMEType: "text/plain", Data: []byte("first")},
134+
{Name: "two.txt", MIMEType: "text/plain", Data: []byte("second")},
135+
})
136+
})
137+
138+
// Two attachment events plus the text message.
139+
assertUniqueTransactionIDs(t, sendTransactionIDs(t, specs), 3)
140+
}

0 commit comments

Comments
 (0)