From 7f91fa90f2a49a1a1174ab252599b4a925a26025 Mon Sep 17 00:00:00 2001 From: Dmitrii Balabanov Date: Mon, 23 Mar 2026 10:53:54 +0200 Subject: [PATCH] Preserve inbound reply IDs across channels --- pkg/channels/discord/discord.go | 3 ++ pkg/channels/discord/discord_test.go | 44 ++++++++++++++++++++++++++++ pkg/channels/matrix/matrix.go | 1 + pkg/channels/matrix/matrix_test.go | 44 ++++++++++++++++++++++++++++ pkg/channels/slack/slack.go | 6 ++++ pkg/channels/slack/slack_test.go | 30 +++++++++++++++++++ 6 files changed, 128 insertions(+) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index de7e7be8c..6e8926d83 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -478,6 +478,9 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } + if m.MessageReference != nil && m.MessageReference.MessageID != "" { + metadata["reply_to_message_id"] = m.MessageReference.MessageID + } c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) } diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f4..85a4dddd3 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,11 +1,15 @@ package discord import ( + "context" "net/http" "net/url" "testing" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) func TestApplyDiscordProxy_CustomProxy(t *testing.T) { @@ -89,3 +93,43 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestHandleMessage_PreservesReplyToMessageID(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, messageBus, nil), + ctx: context.Background(), + } + + session := &discordgo.Session{ + State: &discordgo.State{ + Ready: discordgo.Ready{ + User: &discordgo.User{ID: "bot"}, + }, + }, + } + + ch.handleMessage(session, &discordgo.MessageCreate{ + Message: &discordgo.Message{ + ID: "msg-2", + ChannelID: "chan-1", + Content: "hello", + Author: &discordgo.User{ + ID: "user-1", + Username: "alice", + }, + MessageReference: &discordgo.MessageReference{ + MessageID: "msg-1", + ChannelID: "chan-1", + }, + }, + }) + + inbound := <-messageBus.InboundChan() + if inbound.MessageID != "msg-2" { + t.Fatalf("expected MessageID msg-2, got %q", inbound.MessageID) + } + if inbound.ReplyToMessageID != "msg-1" { + t.Fatalf("expected ReplyToMessageID msg-1, got %q", inbound.ReplyToMessageID) + } +} diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index f6370fa20..a5e768dc5 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -743,6 +743,7 @@ func (c *MatrixChannel) handleMessageEvent(ctx context.Context, evt *event.Event "sender_raw": senderID, } if replyTo := msgEvt.GetRelatesTo().GetReplyTo(); replyTo != "" { + metadata["reply_to_message_id"] = replyTo.String() metadata["reply_to_msg_id"] = replyTo.String() } diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 7484c8d87..43b62eeb3 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,6 +14,8 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -386,3 +388,45 @@ func TestMessageContent(t *testing.T) { t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody) } } + +func TestHandleMessageEvent_PreservesReplyToMessageID(t *testing.T) { + messageBus := bus.NewMessageBus() + roomID := id.RoomID("!room:matrix.test") + ch := &MatrixChannel{ + BaseChannel: channels.NewBaseChannel("matrix", nil, messageBus, nil), + client: &mautrix.Client{UserID: id.UserID("@bot:matrix.test")}, + startTime: time.Unix(0, 0), + roomKindCache: newRoomKindCache(4, time.Minute), + } + ch.roomKindCache.set(roomID.String(), false, time.Now()) + + msgContent := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "hello", + RelatesTo: &event.RelatesTo{ + InReplyTo: &event.InReplyTo{EventID: id.EventID("$parent")}, + }, + } + + ch.handleMessageEvent(context.Background(), &event.Event{ + Sender: id.UserID("@alice:matrix.test"), + Type: event.EventMessage, + Timestamp: time.Now().UnixMilli(), + ID: id.EventID("$event"), + RoomID: roomID, + Content: event.Content{ + Parsed: msgContent, + }, + }) + + inbound := <-messageBus.InboundChan() + if inbound.MessageID != "$event" { + t.Fatalf("expected MessageID $event, got %q", inbound.MessageID) + } + if inbound.ReplyToMessageID != "$parent" { + t.Fatalf("expected ReplyToMessageID $parent, got %q", inbound.ReplyToMessageID) + } + if inbound.Metadata["reply_to_message_id"] != "$parent" { + t.Fatalf("expected metadata reply_to_message_id $parent, got %q", inbound.Metadata["reply_to_message_id"]) + } +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 5e2cecec0..00061ced4 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -375,6 +375,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "platform": "slack", "team_id": c.teamID, } + if threadTS != "" && threadTS != messageTS { + metadata["reply_to_message_id"] = threadTS + } logger.DebugCF("slack", "Received message", map[string]any{ "sender_id": senderID, @@ -447,6 +450,9 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { "is_mention": "true", "team_id": c.teamID, } + if threadTS != "" && threadTS != messageTS { + metadata["reply_to_message_id"] = threadTS + } c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender) } diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go index 23a7ee5c4..abeb27b3d 100644 --- a/pkg/channels/slack/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -1,9 +1,13 @@ package slack import ( + "context" "testing" + "github.com/slack-go/slack/slackevents" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) @@ -168,3 +172,29 @@ func TestSlackChannelIsAllowed(t *testing.T) { } }) } + +func TestHandleMessageEvent_PreservesReplyToMessageIDFromThreadTS(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &SlackChannel{ + BaseChannel: channels.NewBaseChannel("slack", nil, messageBus, nil), + ctx: context.Background(), + teamID: "T1", + } + + ch.handleMessageEvent(&slackevents.MessageEvent{ + Type: "message", + User: "U123", + Text: "hello", + ThreadTimeStamp: "1710000000.000100", + TimeStamp: "1710000000.000200", + Channel: "C123", + }) + + inbound := <-messageBus.InboundChan() + if inbound.MessageID != "1710000000.000200" { + t.Fatalf("expected MessageID to be message ts, got %q", inbound.MessageID) + } + if inbound.ReplyToMessageID != "1710000000.000100" { + t.Fatalf("expected ReplyToMessageID thread root ts, got %q", inbound.ReplyToMessageID) + } +}