diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go index 202569b2..5b4bd8b1 100644 --- a/pkg/connector/capabilities.go +++ b/pkg/connector/capabilities.go @@ -166,6 +166,7 @@ var whatsappCaps = &event.RoomFeatures{ event.StateRoomName.Type: {Level: event.CapLevelFullySupported}, event.StateRoomAvatar.Type: {Level: event.CapLevelFullySupported}, event.StateTopic.Type: {Level: event.CapLevelFullySupported}, + event.StatePinnedEvents.Type: {Level: event.CapLevelFullySupported}, event.StateBeeperDisappearingTimer.Type: {Level: event.CapLevelFullySupported}, }, MemberActions: event.MemberFeatureMap{ diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 55555665..e1c7c245 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -156,6 +156,7 @@ func (wa *WhatsAppClient) wrapDMInfo(ctx context.Context, jid types.JID) *bridge event.StateRoomName: 0, event.StateRoomAvatar: 0, event.StateTopic: 0, + event.StatePinnedEvents: 0, event.StateBeeperDisappearingTimer: 0, }, }, @@ -266,11 +267,12 @@ func (wa *WhatsAppClient) wrapGroupInfo(ctx context.Context, info *types.GroupIn Ban: ptr.Ptr(nobodyPL), // TODO allow invites if bridge config says to allow them, or maybe if relay mode is enabled? Events: map[event.Type]int{ - event.StateRoomName: metaChangePL, - event.StateRoomAvatar: metaChangePL, - event.StateTopic: metaChangePL, - event.EventReaction: defaultPL, - event.EventRedaction: defaultPL, + event.StateRoomName: metaChangePL, + event.StateRoomAvatar: metaChangePL, + event.StateTopic: metaChangePL, + event.StatePinnedEvents: defaultPL, + event.EventReaction: defaultPL, + event.EventRedaction: defaultPL, event.StateBeeperDisappearingTimer: metaChangePL, // TODO always allow poll responses diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index a330a98e..2f420155 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -8,6 +8,7 @@ import ( "fmt" "image" "image/jpeg" + "slices" "strings" "time" @@ -25,6 +26,7 @@ import ( "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "go.mau.fi/mautrix-whatsapp/pkg/msgconv" "go.mau.fi/mautrix-whatsapp/pkg/waid" @@ -46,6 +48,7 @@ var ( _ bridgev2.TagHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.MarkedUnreadHandlingNetworkAPI = (*WhatsAppClient)(nil) _ bridgev2.DeleteChatHandlingNetworkAPI = (*WhatsAppClient)(nil) + _ bridgev2.PinHandlingNetworkAPI = (*WhatsAppClient)(nil) ) func (wa *WhatsAppClient) HandleMatrixPollStart(ctx context.Context, msg *bridgev2.MatrixPollStart) (*bridgev2.MatrixMessageResponse, error) { @@ -603,6 +606,65 @@ func (wa *WhatsAppClient) HandleRoomTag(ctx context.Context, msg *bridgev2.Matri return wa.Client.SendAppState(ctx, appstate.BuildPin(chatJID, isFavorite)) } +func (wa *WhatsAppClient) HandleMatrixPinChange(ctx context.Context, msg *bridgev2.MatrixPinChange) (bool, error) { + log := zerolog.Ctx(ctx) + chatJID, err := waid.ParsePortalID(msg.Portal.ID) + if err != nil { + return false, err + } + var oldPinned []id.EventID + if msg.PrevContent != nil { + oldPinned = msg.PrevContent.Pinned + } + for _, evtID := range msg.Content.Pinned { + if !slices.Contains(oldPinned, evtID) { + if err := wa.sendPinInChat(ctx, chatJID, evtID, waE2E.PinInChatMessage_PIN_FOR_ALL); err != nil { + log.Err(err).Stringer("event_id", evtID).Msg("Failed to send pin to WhatsApp") + } + } + } + for _, evtID := range oldPinned { + if !slices.Contains(msg.Content.Pinned, evtID) { + if err := wa.sendPinInChat(ctx, chatJID, evtID, waE2E.PinInChatMessage_UNPIN_FOR_ALL); err != nil { + log.Err(err).Stringer("event_id", evtID).Msg("Failed to send unpin to WhatsApp") + } + } + } + return false, nil +} + +func (wa *WhatsAppClient) sendPinInChat(ctx context.Context, chatJID types.JID, evtID id.EventID, pinType waE2E.PinInChatMessage_Type) error { + msg, err := wa.Main.Bridge.DB.Message.GetPartByMXID(ctx, evtID) + if err != nil { + return fmt.Errorf("failed to get message by MXID: %w", err) + } + if msg == nil { + return fmt.Errorf("message %s not found in database", evtID) + } + parsed, err := waid.ParseMessageID(msg.ID) + if err != nil { + return fmt.Errorf("failed to parse message ID: %w", err) + } + fromMe := parsed.Sender.ToNonAD() == wa.JID.ToNonAD() || parsed.Sender.ToNonAD() == wa.GetStore().GetLID().ToNonAD() + var participant *string + if chatJID.Server == types.GroupServer { + participant = ptr.Ptr(parsed.Sender.String()) + } + _, err = wa.Client.SendMessage(ctx, chatJID, &waE2E.Message{ + PinInChatMessage: &waE2E.PinInChatMessage{ + Key: &waCommon.MessageKey{ + RemoteJID: ptr.Ptr(chatJID.String()), + FromMe: &fromMe, + ID: &parsed.ID, + Participant: participant, + }, + Type: pinType.Enum(), + SenderTimestampMS: proto.Int64(time.Now().UnixMilli()), + }, + }) + return err +} + func (wa *WhatsAppClient) getLastMessageInfo(ctx context.Context, chatJID types.JID, portalKey networkid.PortalKey) (time.Time, *waCommon.MessageKey, error) { msgs, err := wa.Main.Bridge.DB.Message.GetLastNInPortal(ctx, portalKey, 1) if err != nil { diff --git a/pkg/connector/handlewhatsapp.go b/pkg/connector/handlewhatsapp.go index eb42f439..5d04e59f 100644 --- a/pkg/connector/handlewhatsapp.go +++ b/pkg/connector/handlewhatsapp.go @@ -19,6 +19,7 @@ package connector import ( "context" "fmt" + "slices" "strconv" "strings" "time" @@ -36,7 +37,9 @@ import ( "maunium.net/go/mautrix/bridgev2/simplevent" "maunium.net/go/mautrix/bridgev2/status" "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + "go.mau.fi/mautrix-whatsapp/pkg/msgconv" "go.mau.fi/mautrix-whatsapp/pkg/waid" ) @@ -355,6 +358,9 @@ func (wa *WhatsAppClient) handleWAMessage(ctx context.Context, evt *events.Messa if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { return } + if parsedMessageType == "pin in chat" { + return wa.handleWAPinInChat(ctx, evt) + } if encReact := evt.Message.GetEncReactionMessage(); encReact != nil { decrypted, err := wa.Client.DecryptReaction(ctx, evt) if err != nil { @@ -816,6 +822,75 @@ func (wa *WhatsAppClient) handleWAPin(evt *events.Pin) bool { }) } +func (wa *WhatsAppClient) handleWAPinInChat(ctx context.Context, evt *events.Message) bool { + log := zerolog.Ctx(ctx) + pinMsg := evt.Message.GetPinInChatMessage() + if pinMsg == nil || pinMsg.GetKey() == nil { + log.Warn().Msg("Received pin in chat message with no key") + return true + } + isPin := pinMsg.GetType() != waE2E.PinInChatMessage_UNPIN_FOR_ALL + targetMsgID := msgconv.KeyToMessageID(ctx, wa.Client, evt.Info.Chat, evt.Info.Sender, pinMsg.GetKey()) + if targetMsgID == "" { + log.Warn().Msg("Failed to determine target message ID for pin in chat") + return true + } + portalKey := wa.makeWAPortalKey(evt.Info.Chat) + portal, err := wa.Main.Bridge.GetPortalByKey(ctx, portalKey) + if err != nil || portal == nil || portal.MXID == "" { + log.Warn().Err(err).Msg("Failed to get portal for pin in chat") + return true + } + targetMsg, err := wa.Main.Bridge.DB.Message.GetFirstPartByID(ctx, wa.UserLogin.ID, targetMsgID) + if err != nil { + log.Warn().Err(err).Msg("Failed to look up target message for pin in chat") + return true + } + if targetMsg == nil { + log.Debug().Str("target_message_id", string(targetMsgID)).Msg("Target message for pin not found in database, ignoring") + return true + } + mx, ok := wa.Main.Bridge.Matrix.(bridgev2.MatrixConnectorWithArbitraryRoomState) + if !ok { + log.Warn().Msg("Matrix connector does not support reading room state, can't update pinned events") + return true + } + var pinned []id.EventID + stateEvt, err := mx.GetStateEvent(ctx, portal.MXID, event.StatePinnedEvents, "") + if err != nil { + log.Debug().Err(err).Msg("Failed to get current pinned events state, assuming empty") + } else if stateEvt != nil { + content, ok := stateEvt.Content.Parsed.(*event.PinnedEventsEventContent) + if ok && content != nil { + pinned = content.Pinned + } + } + if isPin { + if slices.Contains(pinned, targetMsg.MXID) { + return true + } + pinned = append(pinned, targetMsg.MXID) + } else { + idx := slices.Index(pinned, targetMsg.MXID) + if idx < 0 { + return true + } + pinned = slices.Delete(pinned, idx, idx+1) + } + _, err = wa.Main.Bridge.Bot.SendState(ctx, portal.MXID, event.StatePinnedEvents, "", &event.Content{ + Parsed: &event.PinnedEventsEventContent{Pinned: pinned}, + }, evt.Info.Timestamp) + if err != nil { + log.Err(err).Msg("Failed to update pinned events state") + return false + } + log.Info(). + Bool("is_pin", isPin). + Stringer("target_event_id", targetMsg.MXID). + Msg("Updated pinned events in Matrix room") + return true +} + func (wa *WhatsAppClient) handleWAAppStateSyncComplete(ctx context.Context, evt *events.AppStateSyncComplete) { log := zerolog.Ctx(ctx).With(). Str("patch_name", string(evt.Name)). diff --git a/pkg/connector/wamsgtype.go b/pkg/connector/wamsgtype.go index 92e7f869..aacbd477 100644 --- a/pkg/connector/wamsgtype.go +++ b/pkg/connector/wamsgtype.go @@ -134,6 +134,8 @@ func getMessageType(waMsg *waE2E.Message) string { return "message history bundle" case waMsg.RequestPhoneNumberMessage != nil: return "request phone number" + case waMsg.PinInChatMessage != nil: + return "pin in chat" case waMsg.KeepInChatMessage != nil: return "keep in chat" case waMsg.StatusMentionMessage != nil: