44 "context"
55 "errors"
66 "fmt"
7+ "io"
78 "net/url"
89 "slices"
910 "strconv"
@@ -15,6 +16,7 @@ import (
1516 "maunium.net/go/mautrix/bridgev2"
1617 "maunium.net/go/mautrix/bridgev2/database"
1718 "maunium.net/go/mautrix/bridgev2/networkid"
19+ "maunium.net/go/mautrix/bridgev2/simplevent"
1820 "maunium.net/go/mautrix/event"
1921
2022 "github.com/highesttt/matrix-line-messenger/pkg/line"
@@ -201,6 +203,201 @@ func lineSticonURL(productID, emojiID string) string {
201203 return fmt .Sprintf ("https://stickershop.line-scdn.net/sticonshop/v1/sticon/%s/android/%s.png" , productID , emojiID )
202204}
203205
206+ func reactionUploadMXC (uploadedMXC string , uploadedFile * event.EncryptedFileInfo ) (string , error ) {
207+ if uploadedFile != nil {
208+ return "" , errors .New ("reaction icon upload returned encrypted media" )
209+ }
210+ if uploadedMXC == "" {
211+ return "" , errors .New ("reaction icon upload returned an empty MXC URI" )
212+ }
213+ return uploadedMXC , nil
214+ }
215+
216+ func (lc * LineClient ) getPredefinedReactionMXC (ctx context.Context , prt int ) (string , error ) {
217+ if _ , ok := line .PredefinedReactionEmoji [prt ]; ! ok {
218+ return "" , fmt .Errorf ("unknown predefined reaction type %d" , prt )
219+ }
220+
221+ lc .cacheMu .Lock ()
222+ mxc := lc .reactionIconMXC [prt ]
223+ lc .cacheMu .Unlock ()
224+ if mxc != "" {
225+ return mxc , nil
226+ }
227+
228+ pngData , err := getReactionIconData (prt )
229+ if err != nil {
230+ return "" , fmt .Errorf ("get reaction icon data: %w" , err )
231+ }
232+ uploadedMXC , uploadedFile , err := lc .UserLogin .Bridge .Bot .UploadMedia (ctx , "" , pngData , "reaction.png" , "image/png" )
233+ if err != nil {
234+ return "" , fmt .Errorf ("upload reaction icon: %w" , err )
235+ }
236+ mxc , err = reactionUploadMXC (string (uploadedMXC ), uploadedFile )
237+ if err != nil {
238+ return "" , err
239+ }
240+
241+ lc .cacheMu .Lock ()
242+ if lc .reactionIconMXC == nil {
243+ lc .reactionIconMXC = make (map [int ]string )
244+ }
245+ if cached := lc .reactionIconMXC [prt ]; cached != "" {
246+ mxc = cached
247+ } else {
248+ lc .reactionIconMXC [prt ] = mxc
249+ }
250+ lc .cacheMu .Unlock ()
251+ return mxc , nil
252+ }
253+
254+ func (lc * LineClient ) getPaidReactionMXC (ctx context.Context , prt * line.PaidReactionType ) (string , error ) {
255+ if prt == nil || prt .ProductID == "" || prt .EmojiID == "" {
256+ return "" , errors .New ("paid reaction is missing product or emoji ID" )
257+ }
258+ iconURL := lineSticonURL (prt .ProductID , prt .EmojiID )
259+
260+ lc .cacheMu .Lock ()
261+ mxc := lc .paidReactionIconMXC [iconURL ]
262+ lc .cacheMu .Unlock ()
263+ if mxc != "" {
264+ return mxc , nil
265+ }
266+
267+ resp , err := lc .HTTPClient .Get (iconURL )
268+ if err != nil {
269+ return "" , fmt .Errorf ("download paid reaction icon: %w" , err )
270+ }
271+ defer resp .Body .Close ()
272+ if resp .StatusCode != 200 {
273+ return "" , fmt .Errorf ("download paid reaction icon: HTTP %d" , resp .StatusCode )
274+ }
275+ data , err := io .ReadAll (resp .Body )
276+ if err != nil {
277+ return "" , fmt .Errorf ("read paid reaction icon: %w" , err )
278+ }
279+ mimeType := resp .Header .Get ("Content-Type" )
280+ if mimeType == "" {
281+ mimeType = "image/png"
282+ }
283+ uploadedMXC , uploadedFile , err := lc .UserLogin .Bridge .Bot .UploadMedia (ctx , "" , data , "reaction.png" , mimeType )
284+ if err != nil {
285+ return "" , fmt .Errorf ("upload paid reaction icon: %w" , err )
286+ }
287+ mxc , err = reactionUploadMXC (string (uploadedMXC ), uploadedFile )
288+ if err != nil {
289+ return "" , fmt .Errorf ("paid %w" , err )
290+ }
291+
292+ lc .cacheMu .Lock ()
293+ if lc .paidReactionIconMXC == nil {
294+ lc .paidReactionIconMXC = make (map [string ]string )
295+ }
296+ if cached := lc .paidReactionIconMXC [iconURL ]; cached != "" {
297+ mxc = cached
298+ } else {
299+ lc .paidReactionIconMXC [iconURL ] = mxc
300+ }
301+ lc .cacheMu .Unlock ()
302+ return mxc , nil
303+ }
304+
305+ func (lc * LineClient ) convertMessageReactions (ctx context.Context , msg * line.Message ) ([]* bridgev2.BackfillReaction , bool ) {
306+ if msg == nil || msg .Reactions == nil {
307+ return nil , false
308+ }
309+
310+ converted := make ([]* bridgev2.BackfillReaction , 0 , len (msg .Reactions ))
311+ complete := true
312+ for _ , reaction := range msg .Reactions {
313+ if ! isUserMID (reaction .FromUserMID ) {
314+ complete = false
315+ lc .UserLogin .Bridge .Log .Warn ().
316+ Str ("msg_id" , msg .ID ).
317+ Str ("reaction_sender" , reaction .FromUserMID ).
318+ Msg ("Skipping historical reaction without a valid sender MID" )
319+ continue
320+ }
321+
322+ var (
323+ mxc string
324+ err error
325+ )
326+ switch {
327+ case reaction .ReactionType .PaidReactionType != nil :
328+ mxc , err = lc .getPaidReactionMXC (ctx , reaction .ReactionType .PaidReactionType )
329+ case reaction .ReactionType .PredefinedReactionType != 0 :
330+ mxc , err = lc .getPredefinedReactionMXC (ctx , reaction .ReactionType .PredefinedReactionType )
331+ default :
332+ err = errors .New ("reaction type is missing" )
333+ }
334+ if err != nil {
335+ complete = false
336+ lc .UserLogin .Bridge .Log .Warn ().
337+ Err (err ).
338+ Str ("msg_id" , msg .ID ).
339+ Str ("reaction_sender" , reaction .FromUserMID ).
340+ Msg ("Skipping unsupported historical reaction" )
341+ continue
342+ }
343+
344+ var timestamp time.Time
345+ if timestampMillis , err := reaction .AtMillis .Int64 (); err == nil && timestampMillis > 0 {
346+ timestamp = time .UnixMilli (timestampMillis )
347+ }
348+ converted = append (converted , & bridgev2.BackfillReaction {
349+ Timestamp : timestamp ,
350+ Sender : lc .eventSenderForMID (reaction .FromUserMID ),
351+ Emoji : mxc ,
352+ })
353+ }
354+ return converted , complete
355+ }
356+
357+ func (lc * LineClient ) queueMessageReactionSync (ctx context.Context , chatMID string , msg * line.Message ) bool {
358+ if msg == nil || ContentType (msg .ContentType ) == ContentSystem || msg .Reactions == nil {
359+ return false
360+ }
361+
362+ converted , complete := lc .convertMessageReactions (ctx , msg )
363+ if ! complete {
364+ // The embedded list is authoritative, but an upload/parse failure
365+ // means our converted view is incomplete. Do not accidentally redact
366+ // reactions that LINE still reports.
367+ return false
368+ }
369+ users := make (map [networkid.UserID ]* bridgev2.ReactionSyncUser , len (converted ))
370+ var timestamp time.Time
371+ for _ , reaction := range converted {
372+ if reaction .Timestamp .After (timestamp ) {
373+ timestamp = reaction .Timestamp
374+ }
375+ // LINE allows one reaction per user per message. Keeping the last
376+ // record also makes malformed duplicate entries deterministic.
377+ users [reaction .Sender .Sender ] = & bridgev2.ReactionSyncUser {
378+ Reactions : []* bridgev2.BackfillReaction {reaction },
379+ HasAllReactions : true ,
380+ }
381+ }
382+ if timestamp .IsZero () {
383+ timestamp = lc .parseMessageTimestamp (msg )
384+ }
385+
386+ result := lc .UserLogin .Bridge .QueueRemoteEvent (lc .UserLogin , & simplevent.ReactionSync {
387+ EventMeta : simplevent.EventMeta {
388+ Type : bridgev2 .RemoteEventReactionSync ,
389+ PortalKey : networkid.PortalKey {ID : makePortalID (chatMID ), Receiver : lc .UserLogin .ID },
390+ Timestamp : timestamp ,
391+ },
392+ TargetMessage : networkid .MessageID (msg .ID ),
393+ Reactions : & bridgev2.ReactionSyncData {
394+ Users : users ,
395+ HasAllUsers : true ,
396+ },
397+ })
398+ return result .Success && ! result .Ignored
399+ }
400+
204401func parseLineSticonURL (rawURL string ) (linePaidReactionRef , error ) {
205402 parsed , err := url .Parse (rawURL )
206403 if err != nil {
0 commit comments