From cfe89a4c51cf954bc3dc3eecb8b8d2d7cc96371c Mon Sep 17 00:00:00 2001 From: SHOUHE001 Date: Sat, 7 Nov 2026 01:40:20 +0900 Subject: [PATCH] connector: sync own read receipts from other LINE clients Handle SEND_CHAT_CHECKED (operation 40) so that a chat read in the LINE mobile app is also marked as read on Matrix. Co-Authored-By: Claude Opus 5.5 --- pkg/connector/consts.go | 1 + pkg/connector/read_receipt_test.go | 74 ++++++++++++++++++++++++++++++ pkg/connector/sync.go | 32 +++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 pkg/connector/read_receipt_test.go diff --git a/pkg/connector/consts.go b/pkg/connector/consts.go index 1dee41ed..0662133e 100644 --- a/pkg/connector/consts.go +++ b/pkg/connector/consts.go @@ -6,6 +6,7 @@ type OperationType int const ( OpSendMessage OperationType = 25 OpReceiveMessage OperationType = 26 + OpSendChatChecked OperationType = 40 OpSendChatRemoved OperationType = 41 OpContactUpdate OperationType = 49 OpReadReceipt OperationType = 55 diff --git a/pkg/connector/read_receipt_test.go b/pkg/connector/read_receipt_test.go new file mode 100644 index 00000000..065f6e3b --- /dev/null +++ b/pkg/connector/read_receipt_test.go @@ -0,0 +1,74 @@ +package connector + +import ( + "encoding/json" + "testing" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + + "github.com/beeper/line/pkg/line" +) + +func newOwnReadReceiptTestClient() *LineClient { + return &LineClient{ + Mid: "Uown", + UserLogin: &bridgev2.UserLogin{ + UserLogin: &database.UserLogin{ID: networkid.UserLoginID("Uown")}, + }, + } +} + +func TestMakeOwnReadReceiptEvent(t *testing.T) { + lc := newOwnReadReceiptTestClient() + + receipt, ok := lc.makeOwnReadReceiptEvent(line.Operation{ + Type: int(OpSendChatChecked), + Param1: "Cgroup", + Param2: "12345", + CreatedTime: json.Number("1700000000000"), + }) + if !ok { + t.Fatal("receipt was rejected") + } + + if receipt.GetType() != bridgev2.RemoteEventReadReceipt { + t.Fatalf("type = %v", receipt.GetType()) + } + if want := (networkid.PortalKey{ID: "Cgroup", Receiver: "Uown"}); receipt.GetPortalKey() != want { + t.Fatalf("portal key = %+v, want %+v", receipt.GetPortalKey(), want) + } + if sender := receipt.GetSender(); !sender.IsFromMe || sender.Sender != "Uown" { + t.Fatalf("sender = %+v, want own login", sender) + } + if receipt.GetLastReceiptTarget() != "12345" { + t.Fatalf("last target = %q", receipt.GetLastReceiptTarget()) + } + if !receipt.GetReadUpTo().IsZero() { + t.Fatalf("read up to = %v, want zero so an unknown target is not guessed", receipt.GetReadUpTo()) + } + if want := time.UnixMilli(1700000000000); !receipt.GetTimestamp().Equal(want) { + t.Fatalf("timestamp = %v, want %v", receipt.GetTimestamp(), want) + } +} + +func TestMakeOwnReadReceiptEventRejectsMalformedOperations(t *testing.T) { + lc := newOwnReadReceiptTestClient() + + for _, tc := range []struct { + name string + op line.Operation + }{ + {name: "missing chat", op: line.Operation{Type: int(OpSendChatChecked), Param2: "12345"}}, + {name: "missing message", op: line.Operation{Type: int(OpSendChatChecked), Param1: "Upeer"}}, + {name: "another operation", op: line.Operation{Type: int(OpReadReceipt), Param1: "Upeer", Param2: "12345"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if receipt, ok := lc.makeOwnReadReceiptEvent(tc.op); ok || receipt != nil { + t.Fatalf("got receipt %+v, ok %v", receipt, ok) + } + }) + } +} diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index 1b5662bf..f0b49bff 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -1997,6 +1997,16 @@ func (lc *LineClient) handleOperation(ctx context.Context, op line.Operation) { lc.syncSingleChat(context.Background(), op) }() + case OpSendChatChecked: + receipt, ok := lc.makeOwnReadReceiptEvent(op) + if !ok { + lc.UserLogin.Bridge.Log.Warn(). + Int("op_type", op.Type). + Msg("Ignoring malformed own read receipt") + return + } + lc.UserLogin.Bridge.QueueRemoteEvent(lc.UserLogin, receipt) + case OpReadReceipt: portalID := makePortalID(op.Param1) senderID := makeUserID(op.Param2) @@ -2387,6 +2397,28 @@ func (lc *LineClient) handleMemberLeft(chatMid, leaverMid string) { lc.handleMemberLeaveWithSender(chatMid, leaverMid, lc.eventSenderForMID(leaverMid)) } +// makeOwnReadReceiptEvent converts SEND_CHAT_CHECKED, where param1 is the chat +// MID and param2 is the last message this account read. +func (lc *LineClient) makeOwnReadReceiptEvent(op line.Operation) (*simplevent.Receipt, bool) { + if OperationType(op.Type) != OpSendChatChecked || op.Param1 == "" || op.Param2 == "" { + return nil, false + } + + ts := time.Now() + if timestamp, err := op.CreatedTime.Int64(); err == nil && timestamp > 0 { + ts = time.UnixMilli(timestamp) + } + return &simplevent.Receipt{ + EventMeta: simplevent.EventMeta{ + Type: bridgev2.RemoteEventReadReceipt, + PortalKey: networkid.PortalKey{ID: makePortalID(op.Param1), Receiver: lc.UserLogin.ID}, + Timestamp: ts, + Sender: bridgev2.EventSender{IsFromMe: true, Sender: networkid.UserID(lc.UserLogin.ID)}, + }, + LastTarget: networkid.MessageID(op.Param2), + }, true +} + func (lc *LineClient) makeNotifiedMemberRemovalEvent(op line.Operation) (*simplevent.ChatInfoChange, bool) { if OperationType(op.Type) != OpNotifiedDeleteOtherFromChat || !isChatMID(op.Param1) ||