diff --git a/attachment.go b/attachment.go deleted file mode 100644 index dc35593..0000000 --- a/attachment.go +++ /dev/null @@ -1,109 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Beeper, Max Sandholm -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package main - -import ( - "bytes" - "image" - "strings" - - "maunium.net/go/mautrix/crypto/attachment" - - "maunium.net/go/mautrix" - "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -func (portal *Portal) downloadMatrixAttachment(content *event.MessageEventContent) ([]byte, error) { - var file *event.EncryptedFileInfo - rawMXC := content.URL - - if content.File != nil { - file = content.File - rawMXC = file.URL - } - - mxc, err := rawMXC.Parse() - if err != nil { - return nil, err - } - - data, err := portal.MainIntent().DownloadBytes(mxc) - if err != nil { - return nil, err - } - - if file != nil { - err = file.DecryptInPlace(data) - if err != nil { - return nil, err - } - } - - return data, nil -} - -func (portal *Portal) uploadMedia(intent *appservice.IntentAPI, data []byte, content *event.MessageEventContent) error { - uploadMimeType, file := portal.encryptFileInPlace(data, content.Info.MimeType) - - req := mautrix.ReqUploadMedia{ - ContentBytes: data, - ContentType: uploadMimeType, - } - var mxc id.ContentURI - if portal.bridge.Config.Homeserver.AsyncMedia { - uploaded, err := intent.UploadAsync(req) - if err != nil { - return err - } - mxc = uploaded.ContentURI - } else { - uploaded, err := intent.UploadMedia(req) - if err != nil { - return err - } - mxc = uploaded.ContentURI - } - - if file != nil { - file.URL = mxc.CUString() - content.File = file - } else { - content.URL = mxc.CUString() - } - - content.Info.Size = len(data) - if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") { - cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) - content.Info.Width, content.Info.Height = cfg.Width, cfg.Height - } - return nil -} - -func (portal *Portal) encryptFileInPlace(data []byte, mimeType string) (string, *event.EncryptedFileInfo) { - if !portal.Encrypted { - return mimeType, nil - } - - file := &event.EncryptedFileInfo{ - EncryptedFile: *attachment.NewEncryptedFile(), - URL: "", - } - file.EncryptInPlace(data) - return "application/octet-stream", file -} diff --git a/auth/auth.go b/auth/auth.go index 344a8a7..b8ebeb4 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -151,7 +151,7 @@ func signin(log log.Logger, userID, teamID, password string) (string, error) { return data.Token, nil } -func LoginPassword(l log.Logger, email, team, password string) (*Info, error) { +func LoginPassword(email, team, password string) (*Info, error) { log := log.Sub("auth") teamID, err := findTeam(log, team) diff --git a/avatar.go b/avatar.go index e5e510d..4439c11 100644 --- a/avatar.go +++ b/avatar.go @@ -17,6 +17,7 @@ package main import ( + "context" "fmt" "io" "net/http" @@ -25,7 +26,7 @@ import ( "maunium.net/go/mautrix/id" ) -func uploadPlainFile(intent *appservice.IntentAPI, url string) (id.ContentURI, error) { +func uploadPlainFile(ctx context.Context, intent *appservice.IntentAPI, url string) (id.ContentURI, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return id.ContentURI{}, fmt.Errorf("failed to prepare request: %w", err) @@ -43,7 +44,7 @@ func uploadPlainFile(intent *appservice.IntentAPI, url string) (id.ContentURI, e } mime := http.DetectContentType(data) - resp, err := intent.UploadBytes(data, mime) + resp, err := intent.UploadBytes(ctx, data, mime) if err != nil { return id.ContentURI{}, fmt.Errorf("failed to upload avatar to Matrix: %w", err) } diff --git a/backfillqueue.go b/backfillqueue.go.dis similarity index 81% rename from backfillqueue.go rename to backfillqueue.go.dis index 7ca4a3d..43b6746 100644 --- a/backfillqueue.go +++ b/backfillqueue.go.dis @@ -19,8 +19,9 @@ package main import ( "time" - "go.mau.fi/mautrix-slack/database" log "maunium.net/go/maulogger/v2" + + "go.mau.fi/mautrix-slack/database" ) type BackfillQueue struct { @@ -53,17 +54,17 @@ func (bq *BackfillQueue) GetNextBackfill(reCheckChannel chan bool) *database.Bac } } -func (bridge *SlackBridge) HandleBackfillRequestsLoop() { +func (br *SlackBridge) HandleBackfillRequestsLoop() { reCheckChannel := make(chan bool) - bridge.BackfillQueue.reCheckChannels = append(bridge.BackfillQueue.reCheckChannels, reCheckChannel) + br.BackfillQueue.reCheckChannels = append(br.BackfillQueue.reCheckChannels, reCheckChannel) for { - state := bridge.BackfillQueue.GetNextBackfill(reCheckChannel) - bridge.Log.Infofln("Handling backfill for portal %s", state.Portal) + state := br.BackfillQueue.GetNextBackfill(reCheckChannel) + br.Log.Infofln("Handling backfill for portal %s", state.Portal) - portal := bridge.GetPortalByID(*state.Portal) + portal := br.GetPortalByID(*state.Portal) - bridge.backfillInChunks(state, portal) + br.backfillInChunks(state, portal) //req.MarkDone() } } diff --git a/bridgeinfo.go b/bridgeinfo.go deleted file mode 100644 index 3217556..0000000 --- a/bridgeinfo.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "reflect" - - "maunium.net/go/mautrix/event" -) - -type CustomBridgeInfoContent struct { - event.BridgeEventContent - RoomType string `json:"com.beeper.room_type,omitempty"` -} - -func init() { - event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) - event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) -} diff --git a/commands.go b/commands.go index d864b3d..fb9d133 100644 --- a/commands.go +++ b/commands.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -22,6 +22,7 @@ import ( "strings" "maunium.net/go/mautrix/bridge/commands" + "maunium.net/go/mautrix/bridge/status" ) type WrappedCommandEvent struct { @@ -60,26 +61,28 @@ var cmdPing = &commands.FullHandler{ Name: "ping", Help: commands.HelpMeta{ Section: commands.HelpSectionAuth, - Description: "Check which teams you're currently signed into", + Description: "Check which workspaces you're currently signed into", }, } func fnPing(ce *WrappedCommandEvent) { - if len(ce.User.Teams) == 0 { - ce.Reply("You are not signed in to any Slack teams.") - return - } var text strings.Builder - text.WriteString("You are signed in to the following Slack teams:\n") - for _, team := range ce.User.Teams { - teamInfo := ce.Bridge.DB.TeamInfo.GetBySlackTeam(team.Key.TeamID) - text.WriteString(fmt.Sprintf("%s - %s - %s.slack.com", teamInfo.TeamID, teamInfo.TeamName, teamInfo.TeamDomain)) - if team.RTM == nil { - text.WriteString(" (Error: not connected to Slack)") + text.WriteString("You're signed into the following Slack workspaces:\n") + ce.Bridge.userAndTeamLock.Lock() + isEmpty := len(ce.User.teams) == 0 + for _, ut := range ce.User.teams { + _, _ = fmt.Fprintf(&text, "* `%s`: %s / %s.slack.com", ut.Team.ID, ut.Team.Name, ut.Team.Domain) + if ut.RTM == nil { + text.WriteString(" (not connected)") } - text.WriteRune('\n') + text.WriteByte('\n') + } + ce.Bridge.userAndTeamLock.Unlock() + if isEmpty { + ce.Reply("You aren't signed into any Slack workspaces") + } else { + ce.Reply(text.String()) } - ce.Reply(text.String()) } var cmdLoginPassword = &commands.FullHandler{ @@ -98,13 +101,8 @@ func fnLoginPassword(ce *WrappedCommandEvent) { return } - if ce.User.IsLoggedInTeam(ce.Args[0], ce.Args[1]) { - ce.Reply("%s is already logged in to team %s", ce.Args[0], ce.Args[1]) - return - } - user := ce.Bridge.GetUserByMXID(ce.User.MXID) - err := user.LoginTeam(ce.Args[0], ce.Args[1], ce.Args[2]) + err := user.LoginTeam(ce.Ctx, ce.Args[0], ce.Args[1], ce.Args[2]) if err != nil { ce.Reply("Failed to log in as %s for team %s: %v", ce.Args[0], ce.Args[1], err) return @@ -133,7 +131,7 @@ func fnLoginToken(ce *WrappedCommandEvent) { cookieToken, _ := url.PathUnescape(ce.Args[1]) user := ce.Bridge.GetUserByMXID(ce.User.MXID) - info, err := user.TokenLogin(ce.Args[0], cookieToken) + info, err := user.TokenLogin(ce.Ctx, ce.Args[0], cookieToken) if err != nil { ce.Reply("Failed to log in with token: %v", err) } else { @@ -153,20 +151,23 @@ var cmdLogout = &commands.FullHandler{ } func fnLogout(ce *WrappedCommandEvent) { - if len(ce.Args) != 2 { - ce.Reply("**Usage**: $cmdprefix logout ") - + if len(ce.Args) != 1 { + ce.Reply("**Usage**: $cmdprefix logout ") return } - domain := strings.TrimSuffix(ce.Args[1], ".slack.com") - userTeam := ce.User.bridge.DB.UserTeam.GetBySlackDomain(ce.User.MXID, ce.Args[0], domain) - - err := ce.User.LogoutUserTeam(userTeam) - if err != nil { - ce.Reply("Error logging out: %v", err) - } else { - ce.Reply("Logged out successfully.") + teamID := strings.ToUpper(ce.Args[1]) + if teamID[0] != 'T' { + ce.Reply("That doesn't look like a workspace ID") + return } + ut := ce.User.GetTeam(teamID) + if ut == nil || ut.Token == "" { + ce.Reply("You're not logged into that team") + return + } + + ut.Logout(ce.Ctx, status.BridgeState{StateEvent: status.StateLoggedOut}) + ce.Reply("Logged out %s in %s / %s.slack.com", ut.Email, ut.TeamID, ut.Team.Name, ut.Team.Domain) } var cmdSyncTeams = &commands.FullHandler{ @@ -180,21 +181,21 @@ var cmdSyncTeams = &commands.FullHandler{ } func fnSyncTeams(ce *WrappedCommandEvent) { - for _, team := range ce.User.Teams { - ce.User.UpdateTeam(team, true) - } - ce.Reply("Done syncing teams.") + //for _, team := range ce.User.Teams { + // ce.User.UpdateTeam(team, true) + //} + //ce.Reply("Done syncing teams.") } var cmdDeletePortal = &commands.FullHandler{ Func: wrapCommand(fnDeletePortal), Name: "delete-portal", RequiresPortal: true, + RequiresAdmin: true, // TODO allow deleting without bridge admin if it's the only user } func fnDeletePortal(ce *WrappedCommandEvent) { - ce.Portal.delete() - - ce.Bridge.cleanupRoom(ce.Portal.MainIntent(), ce.Portal.MXID, false, ce.Log) - ce.Log.Infofln("Deleted portal") + ce.Portal.Delete(ce.Ctx) + ce.Portal.Cleanup(ce.Ctx) + ce.ZLog.Info().Msg("Deleted portal") } diff --git a/config/bridge.go b/config/bridge.go index 0fed970..7daa261 100644 --- a/config/bridge.go +++ b/config/bridge.go @@ -21,7 +21,6 @@ import ( "fmt" "strings" "text/template" - "time" "github.com/slack-go/slack" @@ -56,19 +55,23 @@ func (mb *MaxMessages) GetMaxMessagesFor(t database.ChannelType) int { } type BridgeConfig struct { - UsernameTemplate string `yaml:"username_template"` - DisplaynameTemplate string `yaml:"displayname_template"` - BotDisplaynameTemplate string `yaml:"bot_displayname_template"` - ChannelNameTemplate string `yaml:"channel_name_template"` - PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` + UsernameTemplate string `yaml:"username_template"` + DisplaynameTemplate string `yaml:"displayname_template"` + ChannelNameTemplate string `yaml:"channel_name_template"` + TeamNameTemplate string `yaml:"team_name_template"` + PrivateChatPortalMeta string `yaml:"private_chat_portal_meta"` CommandPrefix string `yaml:"command_prefix"` - DeliveryReceipts bool `yaml:"delivery_receipts"` - ResendBridgeInfo bool `yaml:"resend_bridge_info"` - MessageStatusEvents bool `yaml:"message_status_events"` - MessageErrorNotices bool `yaml:"message_error_notices"` - CustomEmojiReactions bool `yaml:"custom_emoji_reactions"` + DeliveryReceipts bool `yaml:"delivery_receipts"` + ResendBridgeInfo bool `yaml:"resend_bridge_info"` + MessageStatusEvents bool `yaml:"message_status_events"` + MessageErrorNotices bool `yaml:"message_error_notices"` + CustomEmojiReactions bool `yaml:"custom_emoji_reactions"` + KickOnLogout bool `yaml:"kick_on_logout"` + WorkspaceAvatarInRooms bool `yaml:"workspace_avatar_in_rooms"` + ParticipantSyncCount int `yaml:"participant_sync_count"` + ParticipantSyncOnlyOnCreate bool `yaml:"participant_sync_only_on_create"` ManagementRoomText bridgeconfig.ManagementRoomTexts `yaml:"management_room_text"` @@ -80,17 +83,7 @@ type BridgeConfig struct { DefaultBridgeReceipts bool `yaml:"default_bridge_receipts"` DefaultBridgePresence bool `yaml:"default_bridge_presence"` - DoublePuppetServerMap map[string]string `yaml:"double_puppet_server_map"` - DoublePuppetAllowDiscovery bool `yaml:"double_puppet_allow_discovery"` - LoginSharedSecretMap map[string]string `yaml:"login_shared_secret_map"` - - MessageHandlingTimeout struct { - ErrorAfterStr string `yaml:"error_after"` - DeadlineStr string `yaml:"deadline"` - - ErrorAfter time.Duration `yaml:"-"` - Deadline time.Duration `yaml:"-"` - } `yaml:"message_handling_timeout"` + DoublePuppetConfig bridgeconfig.DoublePuppetConfig `yaml:",inline"` Encryption bridgeconfig.EncryptionConfig `yaml:"encryption"` @@ -117,6 +110,7 @@ type BridgeConfig struct { displaynameTemplate *template.Template `yaml:"-"` botDisplaynameTemplate *template.Template `yaml:"-"` channelNameTemplate *template.Template `yaml:"-"` + teamNameTemplate *template.Template `yaml:"-"` } type umBridgeConfig BridgeConfig @@ -139,12 +133,12 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { return err } - bc.botDisplaynameTemplate, err = template.New("bot_displayname").Parse(bc.BotDisplaynameTemplate) + bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate) if err != nil { return err } - bc.channelNameTemplate, err = template.New("channel_name").Parse(bc.ChannelNameTemplate) + bc.teamNameTemplate, err = template.New("team_name").Parse(bc.TeamNameTemplate) if err != nil { return err } @@ -154,50 +148,59 @@ func (bc *BridgeConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { var _ bridgeconfig.BridgeConfig = (*BridgeConfig)(nil) -func (bc BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { +func (bc *BridgeConfig) GetEncryptionConfig() bridgeconfig.EncryptionConfig { return bc.Encryption } -func (bc BridgeConfig) GetCommandPrefix() string { +func (bc *BridgeConfig) GetCommandPrefix() string { return bc.CommandPrefix } -func (bc BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { +func (bc *BridgeConfig) GetManagementRoomTexts() bridgeconfig.ManagementRoomTexts { return bc.ManagementRoomText } -func (bc BridgeConfig) FormatUsername(userid string) string { +func (bc *BridgeConfig) GetDoublePuppetConfig() bridgeconfig.DoublePuppetConfig { + return bc.DoublePuppetConfig +} + +func (bc *BridgeConfig) FormatUsername(userID string) string { var buffer strings.Builder - _ = bc.usernameTemplate.Execute(&buffer, userid) + _ = bc.usernameTemplate.Execute(&buffer, userID) return buffer.String() } -func (bc BridgeConfig) FormatDisplayname(user *slack.User) string { +func (bc *BridgeConfig) FormatDisplayname(user *slack.User) string { var buffer strings.Builder - _ = bc.displaynameTemplate.Execute(&buffer, user.Profile) + _ = bc.displaynameTemplate.Execute(&buffer, user) return buffer.String() } -func (bc BridgeConfig) FormatBotDisplayname(bot *slack.Bot) string { +func (bc *BridgeConfig) FormatBotDisplayname(bot *slack.Bot) string { + return bc.FormatDisplayname(&slack.User{ + ID: bot.ID, + Name: bot.Name, + IsBot: true, + }) +} + +type ChannelNameParams struct { + *slack.Channel + Type database.ChannelType + TeamName string + TeamDomain string +} + +func (bc *BridgeConfig) FormatChannelName(params ChannelNameParams) string { var buffer strings.Builder - _ = bc.botDisplaynameTemplate.Execute(&buffer, bot) + _ = bc.channelNameTemplate.Execute(&buffer, params) return buffer.String() } -type ChannelNameParams struct { - Name string - Type database.ChannelType - TeamName string -} - -func (bc BridgeConfig) FormatChannelName(params ChannelNameParams) string { - if params.Type == database.ChannelTypeDM || params.Type == database.ChannelTypeGroupDM { - return "" - } else { - var buffer strings.Builder - _ = bc.channelNameTemplate.Execute(&buffer, params) - return buffer.String() - } +func (bc *BridgeConfig) FormatTeamName(params *slack.TeamInfo) string { + var buffer strings.Builder + _ = bc.teamNameTemplate.Execute(&buffer, params) + return buffer.String() } func (bc *BridgeConfig) GetResendBridgeInfo() bool { diff --git a/config/config.go b/config/config.go index 0bd1906..0cdea2d 100644 --- a/config/config.go +++ b/config/config.go @@ -29,7 +29,7 @@ type Config struct { func (config *Config) CanAutoDoublePuppet(userID id.UserID) bool { _, homeserver, _ := userID.Parse() - _, hasSecret := config.Bridge.LoginSharedSecretMap[homeserver] + _, hasSecret := config.Bridge.DoublePuppetConfig.SharedSecretMap[homeserver] return hasSecret } diff --git a/config/upgrade.go b/config/upgrade.go index 8a952b4..051d9c6 100644 --- a/config/upgrade.go +++ b/config/upgrade.go @@ -17,9 +17,9 @@ package config import ( + up "go.mau.fi/util/configupgrade" + "go.mau.fi/util/random" "maunium.net/go/mautrix/bridge/bridgeconfig" - "maunium.net/go/mautrix/util" - up "maunium.net/go/mautrix/util/configupgrade" ) func DoUpgrade(helper *up.Helper) { @@ -27,13 +27,17 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Str, "bridge", "username_template") helper.Copy(up.Str, "bridge", "displayname_template") - helper.Copy(up.Str, "bridge", "bot_displayname_template") helper.Copy(up.Str, "bridge", "channel_name_template") + helper.Copy(up.Str, "bridge", "team_name_template") helper.Copy(up.Int, "bridge", "portal_message_buffer") helper.Copy(up.Bool, "bridge", "delivery_receipts") helper.Copy(up.Bool, "bridge", "message_status_events") helper.Copy(up.Bool, "bridge", "message_error_notices") helper.Copy(up.Bool, "bridge", "custom_emoji_reactions") + helper.Copy(up.Bool, "bridge", "kick_on_logout") + helper.Copy(up.Bool, "bridge", "workspace_avatar_in_rooms") + helper.Copy(up.Int, "bridge", "participant_sync_count") + helper.Copy(up.Bool, "bridge", "participant_sync_only_on_create") helper.Copy(up.Bool, "bridge", "sync_with_custom_puppets") helper.Copy(up.Bool, "bridge", "sync_direct_chat_list") helper.Copy(up.Bool, "bridge", "federate_rooms") @@ -85,7 +89,7 @@ func DoUpgrade(helper *up.Helper) { helper.Copy(up.Str, "bridge", "provisioning", "prefix") if secret, ok := helper.Get(up.Str, "bridge", "provisioning", "shared_secret"); !ok || secret == "generate" { - sharedSecret := util.RandomString(64) + sharedSecret := random.String(64) helper.Set(up.Str, sharedSecret, "bridge", "provisioning", "shared_secret") } else { helper.Copy(up.Str, "bridge", "provisioning", "shared_secret") diff --git a/custompuppet.go b/custompuppet.go index 6785dd8..ebb4061 100644 --- a/custompuppet.go +++ b/custompuppet.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,335 +17,68 @@ package main import ( - "crypto/hmac" - "crypto/sha512" - "encoding/hex" + "context" "errors" "fmt" - "time" - "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" - "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/id" ) -var ( - ErrNoCustomMXID = errors.New("no custom mxid set") - ErrMismatchingMXID = errors.New("whoami result does not match custom mxid") -) - -// ///////////////////////////////////////////////////////////////////////////// -// additional bridge api -// ///////////////////////////////////////////////////////////////////////////// -func (br *SlackBridge) newDoublePuppetClient(mxid id.UserID, accessToken string) (*mautrix.Client, error) { - _, homeserver, err := mxid.Parse() - if err != nil { - return nil, err - } - - homeserverURL, found := br.Config.Bridge.DoublePuppetServerMap[homeserver] - if !found { - if homeserver == br.AS.HomeserverDomain { - homeserverURL = "" - } else if br.Config.Bridge.DoublePuppetAllowDiscovery { - resp, err := mautrix.DiscoverClientAPI(homeserver) - if err != nil { - return nil, fmt.Errorf("failed to find homeserver URL for %s: %v", homeserver, err) - } - - homeserverURL = resp.Homeserver.BaseURL - br.Log.Debugfln("Discovered URL %s for %s to enable double puppeting for %s", homeserverURL, homeserver, mxid) - } else { - return nil, fmt.Errorf("double puppeting from %s is not allowed", homeserver) - } - } - - return br.AS.NewExternalMautrixClient(mxid, accessToken, homeserverURL) -} +var _ bridge.DoublePuppet = (*User)(nil) -// ///////////////////////////////////////////////////////////////////////////// -// mautrix.Syncer implementation -// ///////////////////////////////////////////////////////////////////////////// -func (puppet *Puppet) GetFilterJSON(_ id.UserID) *mautrix.Filter { - everything := []event.Type{{Type: "*"}} - return &mautrix.Filter{ - Presence: mautrix.FilterPart{ - Senders: []id.UserID{puppet.CustomMXID}, - Types: []event.Type{event.EphemeralEventPresence}, - }, - AccountData: mautrix.FilterPart{NotTypes: everything}, - Room: mautrix.RoomFilter{ - Ephemeral: mautrix.FilterPart{Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}}, - IncludeLeave: false, - AccountData: mautrix.FilterPart{NotTypes: everything}, - State: mautrix.FilterPart{NotTypes: everything}, - Timeline: mautrix.FilterPart{NotTypes: everything}, - }, +func (user *User) SwitchCustomMXID(accessToken string, mxid id.UserID) error { + if mxid != user.MXID { + return errors.New("mismatching mxid") } -} - -func (puppet *Puppet) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) { - puppet.log.Warnln("Sync error:", err) - if errors.Is(err, mautrix.MUnknownToken) { - if !puppet.tryRelogin(err, "syncing") { - return 0, err - } - - puppet.customIntent.AccessToken = puppet.AccessToken - - return 0, nil - } - - return 10 * time.Second, nil -} - -func (puppet *Puppet) ProcessResponse(resp *mautrix.RespSync, _ string) error { - if !puppet.customUser.IsLoggedIn() { - puppet.log.Debugln("Skipping sync processing: custom user not connected to slack") - - return nil + user.DoublePuppetIntent = nil + user.AccessToken = accessToken + err := user.StartCustomMXID(false) + if err != nil { + return err } - - // for roomID, events := range resp.Rooms.Join { - // for _, evt := range events.Ephemeral.Events { - // evt.RoomID = roomID - // err := evt.Content.ParseRaw(evt.Type) - // if err != nil { - // continue - // } - - // switch evt.Type { - // case event.EphemeralEventReceipt: - // if puppet.EnableReceipts { - // go puppet.bridge.MatrixHandler.HandleReceipt(evt) - // } - // case event.EphemeralEventTyping: - // go puppet.bridge.MatrixHandler.HandleTyping(evt) - // } - // } - // } - - // if puppet.EnablePresence { - // for _, evt := range resp.Presence.Events { - // if evt.Sender != puppet.CustomMXID { - // continue - // } - - // err := evt.Content.ParseRaw(evt.Type) - // if err != nil { - // continue - // } - - // go puppet.bridge.MatrixHandler.HandlePresence(evt) - // } - // } - - return nil -} - -// ///////////////////////////////////////////////////////////////////////////// -// mautrix.Storer implementation -// ///////////////////////////////////////////////////////////////////////////// -func (puppet *Puppet) SaveFilterID(_ id.UserID, _ string) { -} - -func (puppet *Puppet) SaveNextBatch(_ id.UserID, nbt string) { - puppet.NextBatch = nbt - puppet.Update() -} - -func (puppet *Puppet) SaveRoom(_ *mautrix.Room) { -} - -func (puppet *Puppet) LoadFilterID(_ id.UserID) string { - return "" -} - -func (puppet *Puppet) LoadNextBatch(_ id.UserID) string { - return puppet.NextBatch -} - -func (puppet *Puppet) LoadRoom(_ id.RoomID) *mautrix.Room { return nil } -// ///////////////////////////////////////////////////////////////////////////// -// additional puppet api -// ///////////////////////////////////////////////////////////////////////////// -func (puppet *Puppet) clearCustomMXID() { - puppet.CustomMXID = "" - puppet.AccessToken = "" - puppet.customIntent = nil - puppet.customUser = nil -} - -func (puppet *Puppet) newCustomIntent() (*appservice.IntentAPI, error) { - if puppet.CustomMXID == "" { - return nil, ErrNoCustomMXID - } - - client, err := puppet.bridge.newDoublePuppetClient(puppet.CustomMXID, puppet.AccessToken) +func (user *User) ClearCustomMXID() { + user.DoublePuppetIntent = nil + user.AccessToken = "" + err := user.Update(context.TODO()) if err != nil { - return nil, err + user.zlog.Warn().Err(err).Msg("Failed to clear access token from database") } - - client.Syncer = puppet - client.Store = puppet - - ia := puppet.bridge.AS.NewIntentAPI("custom") - ia.Client = client - ia.Localpart, _, _ = puppet.CustomMXID.Parse() - ia.UserID = puppet.CustomMXID - ia.IsCustomPuppet = true - - return ia, nil } -func (puppet *Puppet) StartCustomMXID(reloginOnFail bool) error { - if puppet.CustomMXID == "" { - puppet.clearCustomMXID() - - return nil - } - - intent, err := puppet.newCustomIntent() +func (user *User) StartCustomMXID(reloginOnFail bool) error { + newIntent, newAccessToken, err := user.bridge.DoublePuppet.Setup(context.TODO(), user.MXID, user.AccessToken, reloginOnFail) if err != nil { - puppet.clearCustomMXID() - + user.ClearCustomMXID() return err } - - resp, err := intent.Whoami() - if err != nil { - if !reloginOnFail || (errors.Is(err, mautrix.MUnknownToken) && !puppet.tryRelogin(err, "initializing double puppeting")) { - puppet.clearCustomMXID() - - return err + if user.AccessToken != newAccessToken { + user.AccessToken = newAccessToken + err = user.Update(context.TODO()) + if err != nil { + return fmt.Errorf("failed to save access token: %w", err) } - - intent.AccessToken = puppet.AccessToken - } else if resp.UserID != puppet.CustomMXID { - puppet.clearCustomMXID() - - return ErrMismatchingMXID } - - puppet.customIntent = intent - puppet.customUser = puppet.bridge.GetUserByMXID(puppet.CustomMXID) - puppet.startSyncing() - + user.DoublePuppetIntent = newIntent return nil } -func (puppet *Puppet) tryRelogin(cause error, action string) bool { - if !puppet.bridge.Config.CanAutoDoublePuppet(puppet.CustomMXID) { - return false - } - - puppet.log.Debugfln("Trying to relogin after '%v' while %s", cause, action) - - accessToken, err := puppet.loginWithSharedSecret(puppet.CustomMXID, puppet.TeamID) - if err != nil { - puppet.log.Errorfln("Failed to relogin after '%v' while %s: %v", cause, action, err) - - return false - } - - puppet.log.Infofln("Successfully relogined after '%v' while %s", cause, action) - puppet.AccessToken = accessToken - - return true +func (user *User) CustomIntent() *appservice.IntentAPI { + return user.DoublePuppetIntent } -func (puppet *Puppet) startSyncing() { - if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets { +func (user *User) tryAutomaticDoublePuppeting() { + if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) || user.DoublePuppetIntent != nil { return } - - go func() { - puppet.log.Debugln("Starting syncing...") - puppet.customIntent.SyncPresence = "offline" - - err := puppet.customIntent.Sync() - if err != nil { - puppet.log.Errorln("Fatal error syncing:", err) - } - }() -} - -func (puppet *Puppet) stopSyncing() { - if !puppet.bridge.Config.Bridge.SyncWithCustomPuppets { - return - } - - puppet.customIntent.StopSync() -} - -func (puppet *Puppet) loginWithSharedSecret(mxid id.UserID, teamID string) (string, error) { - _, homeserver, _ := mxid.Parse() - - puppet.log.Debugfln("Logging into %s with shared secret", mxid) - - loginSecret := puppet.bridge.Config.Bridge.LoginSharedSecretMap[homeserver] - - client, err := puppet.bridge.newDoublePuppetClient(mxid, "") + err := user.StartCustomMXID(true) if err != nil { - return "", fmt.Errorf("failed to create mautrix client to log in: %v", err) - } - - req := mautrix.ReqLogin{ - Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: string(mxid)}, - DeviceID: id.DeviceID(fmt.Sprintf("Slack Bridge %s", teamID)), - InitialDeviceDisplayName: "Slack Bridge", - } - if loginSecret == "appservice" { - client.AccessToken = puppet.bridge.AS.Registration.AppToken - req.Type = mautrix.AuthTypeAppservice + user.zlog.Warn().Err(err).Msg("Failed to login with shared secret for double puppeting") } else { - mac := hmac.New(sha512.New, []byte(loginSecret)) - mac.Write([]byte(mxid)) - req.Password = hex.EncodeToString(mac.Sum(nil)) - req.Type = mautrix.AuthTypePassword - } - resp, err := client.Login(&req) - if err != nil { - return "", err - } - - return resp.AccessToken, nil -} - -func (puppet *Puppet) SwitchCustomMXID(accessToken string, mxid id.UserID) error { - prevCustomMXID := puppet.CustomMXID - if puppet.customIntent != nil { - puppet.stopSyncing() - } - - puppet.CustomMXID = mxid - puppet.AccessToken = accessToken - - err := puppet.StartCustomMXID(false) - if err != nil { - return err - } - - if prevCustomMXID != "" { - delete(puppet.bridge.puppetsByCustomMXID, prevCustomMXID) - } - - if puppet.CustomMXID != "" { - puppet.bridge.puppetsByCustomMXID[puppet.CustomMXID] = puppet + user.zlog.Info().Msg("Successfully automatically enabled double puppet") } - - puppet.EnablePresence = puppet.bridge.Config.Bridge.DefaultBridgePresence - puppet.EnableReceipts = puppet.bridge.Config.Bridge.DefaultBridgeReceipts - - puppet.bridge.AS.StateStore.MarkRegistered(puppet.CustomMXID) - - puppet.Update() - - // TODO leave rooms with default puppet - - return nil } diff --git a/database/attachment.go b/database/attachment.go deleted file mode 100644 index 6d1112c..0000000 --- a/database/attachment.go +++ /dev/null @@ -1,94 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - "database/sql" - "errors" - - log "maunium.net/go/maulogger/v2" - - "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" -) - -type Attachment struct { - db *Database - log log.Logger - - Channel PortalKey - - SlackMessageID string - SlackFileID string - MatrixEventID id.EventID - SlackThreadID string -} - -func (a *Attachment) Scan(row dbutil.Scannable) *Attachment { - err := row.Scan( - &a.Channel.TeamID, &a.Channel.ChannelID, - &a.SlackMessageID, &a.SlackFileID, - &a.MatrixEventID, &a.SlackThreadID) - - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - a.log.Errorln("Database scan failed:", err) - } - - return nil - } - - return a -} - -func (a *Attachment) Insert(txn dbutil.Transaction) { - query := "INSERT INTO attachment" + - " (team_id, channel_id, slack_message_id, slack_file_id, " + - " matrix_event_id, slack_thread_id) VALUES ($1, $2, $3, $4, $5, $6);" - - args := []interface{}{a.Channel.TeamID, a.Channel.ChannelID, - a.SlackMessageID, a.SlackFileID, - a.MatrixEventID, a.SlackThreadID, - } - - var err error - if txn != nil { - _, err = txn.Exec(query, args...) - } else { - _, err = a.db.Exec(query, args...) - } - - if err != nil { - a.log.Warnfln("Failed to insert attachment for %s@%s: %v", a.Channel, a.SlackMessageID, err) - } -} - -func (a *Attachment) Delete() { - query := "DELETE FROM attachment WHERE" + - " team_id=$1 AND channel_id=$2 AND slack_file_id=$3 AND" + - " matrix_event_id=$4" - - _, err := a.db.Exec( - query, - a.Channel.TeamID, a.Channel.ChannelID, - a.SlackFileID, a.MatrixEventID, - ) - - if err != nil { - a.log.Warnfln("Failed to delete attachment for %s@%s: %v", a.Channel, a.SlackFileID, err) - } -} diff --git a/database/attachmentquery.go b/database/attachmentquery.go deleted file mode 100644 index a7bd4e9..0000000 --- a/database/attachmentquery.go +++ /dev/null @@ -1,101 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type AttachmentQuery struct { - db *Database - log log.Logger -} - -const ( - attachmentSelect = "SELECT team_id, channel_id, " + - " slack_message_id, slack_file_id, matrix_event_id, slack_thread_id" + - " FROM attachment" -) - -func (aq *AttachmentQuery) New() *Attachment { - return &Attachment{ - db: aq.db, - log: aq.log, - } -} - -func (aq *AttachmentQuery) GetAllBySlackMessageID(key PortalKey, slackMessageID string) []*Attachment { - query := attachmentSelect + " WHERE team_id=$1 AND channel_id=$2" + - " AND slack_message_id=$3" - - return aq.getAll(query, key.TeamID, key.ChannelID, slackMessageID) -} - -func (aq *AttachmentQuery) getAll(query string, args ...interface{}) []*Attachment { - rows, err := aq.db.Query(query, args...) - if err != nil { - aq.log.Debugfln("getAll failed: %v", err) - - return nil - } - - if rows == nil { - return nil - } - - attachments := []*Attachment{} - for rows.Next() { - attachments = append(attachments, aq.New().Scan(rows)) - } - - return attachments -} - -func (aq *AttachmentQuery) GetBySlackFileID(key PortalKey, slackMessageID, slackFileID string) *Attachment { - query := attachmentSelect + " WHERE team_id=$1 AND channel_id=$2" + - " AND slack_message_id=$3 AND slack_file_id=$4" - - return aq.get(query, key.TeamID, key.ChannelID, slackMessageID, slackFileID) -} - -func (aq *AttachmentQuery) GetByMatrixID(key PortalKey, matrixEventID id.EventID) *Attachment { - query := attachmentSelect + " WHERE team_id=$1 AND channel_id=$2 AND matrix_event_id=$3" - - return aq.get(query, key.TeamID, key.ChannelID, matrixEventID) -} - -func (aq *AttachmentQuery) get(query string, args ...interface{}) *Attachment { - row := aq.db.QueryRow(query, args...) - if row == nil { - return nil - } - - return aq.New().Scan(row) -} - -func (aq *AttachmentQuery) GetLast(key PortalKey) *Attachment { - query := attachmentSelect + " WHERE team_id=$1 AND channel_id=$2 ORDER BY slack_message_id DESC LIMIT 1" - - row := aq.db.QueryRow(query, key.TeamID, key.ChannelID) - if row == nil { - aq.log.Debugfln("failed to find last attachment for portal` %s", key) - return nil - } - - return aq.New().Scan(row) -} diff --git a/database/backfill.go b/database/backfill.go index b3849c8..69863a9 100644 --- a/database/backfill.go +++ b/database/backfill.go @@ -1,4 +1,4 @@ -// mautrix-whatsapp - A Matrix-WhatsApp puppeting bridge. +// mautrix-slack - A Matrix-Slack puppeting bridge. // Copyright (C) 2021, 2022 Tulir Asokan, Sumner Evans, Max Sandholm // // This program is free software: you can redistribute it and/or modify @@ -17,71 +17,35 @@ package database import ( - "database/sql" - "errors" + "context" - log "maunium.net/go/maulogger/v2" - - "maunium.net/go/mautrix/util/dbutil" + "go.mau.fi/util/dbutil" ) type BackfillQuery struct { - db *Database - log log.Logger + *dbutil.QueryHelper[*BackfillState] } -func (bq *BackfillQuery) NewBackfillState(portalKey *PortalKey) *BackfillState { - return &BackfillState{ - db: bq.db, - log: bq.log, - Portal: portalKey, - } +func newBackfill(qh *dbutil.QueryHelper[*BackfillState]) *BackfillState { + return &BackfillState{qh: qh} } const ( getBackfillState = ` SELECT team_id, channel_id, dispatched, backfill_complete, message_count, immediate_complete FROM backfill_state - WHERE team_id=$1 - AND channel_id=$2 + WHERE team_id=$1 AND channel_id=$2 ` - getNextUnfinishedBackfillState = ` SELECT team_id, channel_id, dispatched, backfill_complete, message_count, immediate_complete FROM backfill_state WHERE dispatched IS FALSE AND backfill_complete IS FALSE - AND immediate_complete=$1 - ORDER BY message_count ASC + ORDER BY CASE WHEN immediate_complete THEN 0 ELSE 1 END, message_count LIMIT 1 ` -) - -type BackfillState struct { - db *Database - log log.Logger - - // Fields - Portal *PortalKey - Dispatched bool - BackfillComplete bool - MessageCount int - ImmediateComplete bool -} - -func (b *BackfillState) Scan(row dbutil.Scannable) *BackfillState { - err := row.Scan(&b.Portal.TeamID, &b.Portal.ChannelID, &b.Dispatched, &b.BackfillComplete, &b.MessageCount, &b.ImmediateComplete) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - b.log.Errorln("Database scan failed:", err) - } - return nil - } - return b -} - -func (b *BackfillState) Upsert() { - _, err := b.db.Exec(` + undispatchAllBackfillsQuery = `UPDATE backfill_state SET dispatched=false` + upsertBackfillStateQuery = ` INSERT INTO backfill_state (team_id, channel_id, dispatched, backfill_complete, message_count, immediate_complete) VALUES ($1, $2, $3, $4, $5, $6) @@ -90,59 +54,49 @@ func (b *BackfillState) Upsert() { dispatched=EXCLUDED.dispatched, backfill_complete=EXCLUDED.backfill_complete, message_count=EXCLUDED.message_count, - immediate_complete=EXCLUDED.immediate_complete`, - b.Portal.TeamID, b.Portal.ChannelID, b.Dispatched, b.BackfillComplete, b.MessageCount, b.ImmediateComplete) - if err != nil { - b.log.Warnfln("Failed to insert backfill state for %s: %v", b.Portal, err) - } + immediate_complete=EXCLUDED.immediate_complete + ` +) + +// UndispatchAll undispatches backfills so they can be retried in case the bridge crashed/was stopped during backfill +// Sent messages are tracked in the message and portal tables so this shouldn't lead to duplicate backfills +func (bq *BackfillQuery) UndispatchAll(ctx context.Context) error { + return bq.Exec(ctx, undispatchAllBackfillsQuery) } -func (b *BackfillState) SetDispatched(d bool) { - b.Dispatched = d - b.Upsert() +func (bq *BackfillQuery) GetBackfillState(ctx context.Context, portalKey PortalKey) (*BackfillState, error) { + return bq.QueryOne(ctx, getBackfillState, portalKey.TeamID, portalKey.ChannelID) } -// Undispatch backfills so they can be retried in case the bridge crashed/was stopped during backfill -// Sent messages are tracked in the message and portal tables so this shouldn't lead to duplicate backfills -func (b *BackfillQuery) UndispatchAll() { - _, err := b.db.Exec(`UPDATE backfill_state SET dispatched=FALSE`) - if err != nil { - b.log.Warnfln("Failed to undispatch all currently dispatched backfills") - } +func (bq *BackfillQuery) GetNextUnfinishedBackfillState(ctx context.Context) (*BackfillState, error) { + return bq.QueryOne(ctx, getNextUnfinishedBackfillState) +} + +type BackfillState struct { + qh *dbutil.QueryHelper[*BackfillState] + + Portal PortalKey + Dispatched bool + BackfillComplete bool + MessageCount int + ImmediateComplete bool +} + +func (b *BackfillState) Scan(row dbutil.Scannable) (*BackfillState, error) { + return dbutil.ValueOrErr(b, row.Scan( + &b.Portal.TeamID, &b.Portal.ChannelID, &b.Dispatched, &b.BackfillComplete, &b.MessageCount, &b.ImmediateComplete, + )) +} + +func (b *BackfillState) sqlVariables() []any { + return []any{b.Portal.TeamID, b.Portal.ChannelID, b.Dispatched, b.BackfillComplete, b.MessageCount, b.ImmediateComplete} } -func (bq *BackfillQuery) GetBackfillState(portalKey *PortalKey) (backfillState *BackfillState) { - rows, err := bq.db.Query(getBackfillState, portalKey.TeamID, portalKey.ChannelID) - if err != nil || rows == nil { - bq.log.Error(err) - return - } - defer rows.Close() - if rows.Next() { - backfillState = bq.NewBackfillState(portalKey).Scan(rows) - } - return +func (b *BackfillState) Upsert(ctx context.Context) error { + return b.qh.Exec(ctx, upsertBackfillStateQuery, b.sqlVariables()...) } -func (bq *BackfillQuery) GetNextUnfinishedBackfillState() (backfillState *BackfillState) { - rows, err := bq.db.Query(getNextUnfinishedBackfillState, false) - if err != nil || rows == nil { - bq.log.Error(err) - return - } - defer rows.Close() - if rows.Next() { - backfillState = bq.NewBackfillState(&PortalKey{}).Scan(rows) - } else { - rows, err := bq.db.Query(getNextUnfinishedBackfillState, true) - if err != nil || rows == nil { - bq.log.Error(err) - return - } - defer rows.Close() - if rows.Next() { - backfillState = bq.NewBackfillState(&PortalKey{}).Scan(rows) - } - } - return +func (b *BackfillState) SetDispatched(ctx context.Context, d bool) error { + b.Dispatched = d + return b.Upsert(ctx) } diff --git a/database/database.go b/database/database.go index 94d5596..8341394 100644 --- a/database/database.go +++ b/database/database.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,16 +17,11 @@ package database import ( - "database/sql" - _ "embed" - _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" - - "maunium.net/go/mautrix/util/dbutil" + "go.mau.fi/util/dbutil" "go.mau.fi/mautrix-slack/database/upgrades" - "maunium.net/go/maulogger/v2" ) type Database struct { @@ -38,73 +33,24 @@ type Database struct { Puppet *PuppetQuery Message *MessageQuery Reaction *ReactionQuery - Attachment *AttachmentQuery - TeamInfo *TeamInfoQuery + TeamPortal *TeamPortalQuery Backfill *BackfillQuery Emoji *EmojiQuery } -func New(baseDB *dbutil.Database, log maulogger.Logger) *Database { - db := &Database{Database: baseDB} - +func New(db *dbutil.Database) *Database { db.UpgradeTable = upgrades.Table - db.User = &UserQuery{ - db: db, - log: log.Sub("User"), - } - db.UserTeam = &UserTeamQuery{ - db: db, - log: log.Sub("UserTeam"), - } - db.Portal = &PortalQuery{ - db: db, - log: log.Sub("Portal"), - } - db.Puppet = &PuppetQuery{ - db: db, - log: log.Sub("Puppet"), - } - db.Message = &MessageQuery{ - db: db, - log: log.Sub("Message"), - } - db.Reaction = &ReactionQuery{ - db: db, - log: log.Sub("Reaction"), - } - db.Attachment = &AttachmentQuery{ - db: db, - log: log.Sub("Attachment"), - } - db.TeamInfo = &TeamInfoQuery{ - db: db, - log: log.Sub("TeamInfo"), - } - db.Backfill = &BackfillQuery{ - db: db, - log: log.Sub("Backfill"), - } - db.Emoji = &EmojiQuery{ - db: db, - log: log.Sub("Emoji"), - } - - return db -} - -func strPtr(val string) *string { - if val == "" { - return nil - } - return &val -} - -func sqlNullString(val string) (ret sql.NullString) { - if val == "" { - return ret - } else { - ret.String = val - ret.Valid = true - return ret + return &Database{ + Database: db, + + User: &UserQuery{dbutil.MakeQueryHelper(db, newUser)}, + UserTeam: &UserTeamQuery{dbutil.MakeQueryHelper(db, newUserTeam)}, + Portal: &PortalQuery{dbutil.MakeQueryHelper(db, newPortal)}, + Puppet: &PuppetQuery{dbutil.MakeQueryHelper(db, newPuppet)}, + Message: &MessageQuery{dbutil.MakeQueryHelper(db, newMessage)}, + Reaction: &ReactionQuery{dbutil.MakeQueryHelper(db, newReaction)}, + TeamPortal: &TeamPortalQuery{dbutil.MakeQueryHelper(db, newTeamPortal)}, + Backfill: &BackfillQuery{dbutil.MakeQueryHelper(db, newBackfill)}, + Emoji: &EmojiQuery{dbutil.MakeQueryHelper(db, newEmoji)}, } } diff --git a/database/emoji.go b/database/emoji.go index 14e23cb..92c6147 100644 --- a/database/emoji.go +++ b/database/emoji.go @@ -1,109 +1,137 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + package database import ( + "context" "database/sql" + "fmt" + "strings" - log "maunium.net/go/maulogger/v2" - + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" ) type EmojiQuery struct { - db *Database - log log.Logger + *dbutil.QueryHelper[*Emoji] } -func (eq *EmojiQuery) New() *Emoji { - return &Emoji{ - db: eq.db, - log: eq.log, - } +func newEmoji(qh *dbutil.QueryHelper[*Emoji]) *Emoji { + return &Emoji{qh: qh} } -func (eq *EmojiQuery) GetEmojiCount(slackTeam string) (count int, err error) { - row := eq.db.QueryRow(`SELECT COUNT(*) FROM emoji WHERE slack_team=$1`, slackTeam) - err = row.Scan(&count) +const ( + getEmojiBySlackIDQuery = ` + SELECT team_id, emoji_id, value, alias, image_mxc FROM emoji WHERE team_id=$1 AND emoji_id=$2 + ` + getEmojiByMXCQuery = ` + SELECT team_id, emoji_id, value, alias, image_mxc FROM emoji WHERE image_mxc=$1 ORDER BY alias NULLS FIRST + ` + getEmojiCountInTeamQuery = ` + SELECT COUNT(*) FROM emoji WHERE team_id=$1 + ` + upsertEmojiQuery = ` + INSERT INTO emoji (team_id, emoji_id, value, alias, image_mxc) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (team_id, emoji_id) DO UPDATE + SET value = excluded.value, alias = excluded.alias, image_mxc = excluded.image_mxc + ` + renameEmojiQuery = `UPDATE emoji SET emoji_id=$3 WHERE team_id=$1 AND emoji_id=$2` + deleteEmojiQueryPostgres = `DELETE FROM emoji WHERE team_id=$1 AND emoji_id=ANY($2)` + deleteEmojiQuerySQLite = `DELETE FROM emoji WHERE team_id=? AND emoji_id IN (?)` + pruneEmojiQueryPostgres = `DELETE FROM emoji WHERE team_id=$1 AND emoji_id<>ANY($2)` + pruneEmojiQuerySQLite = `DELETE FROM emoji WHERE team_id=? AND emoji_id NOT IN (?)` +) + +func (eq *EmojiQuery) GetEmojiCount(ctx context.Context, teamID string) (count int, err error) { + err = eq.GetDB().QueryRow(ctx, getEmojiCountInTeamQuery, teamID).Scan(&count) return } -func (eq *EmojiQuery) GetBySlackID(slackID string, slackTeam string) *Emoji { - query := `SELECT slack_id, slack_team, alias, image_url FROM emoji WHERE slack_id=$1 AND slack_team=$2` - - row := eq.db.QueryRow(query, slackID, slackTeam) - if row == nil { - return nil - } +func (eq *EmojiQuery) GetBySlackID(ctx context.Context, teamID, emojiID string) (*Emoji, error) { + return eq.QueryOne(ctx, getEmojiBySlackIDQuery, teamID, emojiID) +} - return eq.New().Scan(row) +func (eq *EmojiQuery) GetByMXC(ctx context.Context, mxc id.ContentURI) (*Emoji, error) { + return eq.QueryOne(ctx, getEmojiByMXCQuery, mxc) } -func (eq *EmojiQuery) GetByMXC(mxc id.ContentURI) *Emoji { - query := `SELECT slack_id, slack_team, alias, image_url FROM emoji WHERE image_url=$1 ORDER BY alias NULLS FIRST` +func buildSQLiteEmojiDeleteQuery(baseQuery string, teamID string, emojiIDs ...string) (string, []any) { + args := make([]any, 1+len(emojiIDs)) + args[0] = teamID + for i, emojiID := range emojiIDs { + args[i+1] = emojiID + } + placeholderRepeat := strings.Repeat("?,", len(emojiIDs)) + inClause := fmt.Sprintf("IN (%s)", placeholderRepeat[:len(placeholderRepeat)-1]) + query := strings.Replace(baseQuery, "IN (?)", inClause, 1) + return query, args +} - row := eq.db.QueryRow(query, mxc.String()) - if row == nil { - return nil +func (eq *EmojiQuery) DeleteMany(ctx context.Context, teamID string, emojiIDs ...string) error { + switch eq.GetDB().Dialect { + case dbutil.Postgres: + return eq.Exec(ctx, deleteEmojiQueryPostgres, teamID, emojiIDs) + default: + query, args := buildSQLiteEmojiDeleteQuery(deleteEmojiQuerySQLite, teamID, emojiIDs...) + return eq.Exec(ctx, query, args...) } +} - return eq.New().Scan(row) +func (eq *EmojiQuery) Prune(ctx context.Context, teamID string, emojiIDs ...string) error { + switch eq.GetDB().Dialect { + case dbutil.Postgres: + return eq.Exec(ctx, pruneEmojiQueryPostgres, teamID, emojiIDs) + default: + query, args := buildSQLiteEmojiDeleteQuery(pruneEmojiQuerySQLite, teamID, emojiIDs...) + return eq.Exec(ctx, query, args...) + } } type Emoji struct { - db *Database - log log.Logger + qh *dbutil.QueryHelper[*Emoji] - SlackID string - SlackTeam string - Alias string - ImageURL id.ContentURI + TeamID string + EmojiID string + Value string + Alias string + ImageMXC id.ContentURI } -func (e *Emoji) Scan(row dbutil.Scannable) *Emoji { +func (e *Emoji) Scan(row dbutil.Scannable) (*Emoji, error) { var alias sql.NullString var imageURL sql.NullString - err := row.Scan(&e.SlackID, &e.SlackTeam, &alias, &imageURL) + err := row.Scan(&e.TeamID, &e.EmojiID, &e.Value, &alias, &imageURL) if err != nil { - if err != sql.ErrNoRows { - e.log.Errorln("Database scan failed:", err) - } - - return nil + return nil, err } - - e.ImageURL, _ = id.ParseContentURI(imageURL.String) + e.ImageMXC, _ = id.ParseContentURI(imageURL.String) e.Alias = alias.String - - return e + return e, nil } -func (e *Emoji) Upsert(txn dbutil.Transaction) { - query := "INSERT INTO emoji" + - " (slack_id, slack_team, alias, image_url) VALUES ($1, $2, $3, $4)" + - " ON CONFLICT (slack_id, slack_team) DO UPDATE" + - " SET alias = excluded.alias, image_url = excluded.image_url" - - args := []interface{}{e.SlackID, e.SlackTeam, strPtr(e.Alias), strPtr(e.ImageURL.String())} - - var err error - if txn != nil { - _, err = txn.Exec(query, args...) - } else { - _, err = e.db.Exec(query, args...) - } - - if err != nil { - e.log.Warnfln("Failed to insert emoji %s %s: %v", e.SlackID, e.SlackTeam, err) - } +func (e *Emoji) sqlVariables() []any { + return []any{e.TeamID, e.EmojiID, e.Value, dbutil.StrPtr(e.Alias), dbutil.StrPtr(e.ImageMXC.String())} } -func (e *Emoji) Delete() { - query := "DELETE FROM emoji" + - " WHERE slack_id=$1 AND slack_team=$2" - - _, err := e.db.Exec(query, e.SlackID, e.SlackTeam) +func (e *Emoji) Upsert(ctx context.Context) error { + return e.qh.Exec(ctx, upsertEmojiQuery, e.sqlVariables()...) +} - if err != nil { - e.log.Warnfln("Failed to delete emoji %s %s: %v", e.SlackID, e.SlackTeam, err) - } +func (e *Emoji) Rename(ctx context.Context, newID string) error { + return e.qh.Exec(ctx, renameEmojiQuery, e.TeamID, e.EmojiID, newID) } diff --git a/database/message.go b/database/message.go index 4e4138a..842a72b 100644 --- a/database/message.go +++ b/database/message.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,73 +17,192 @@ package database import ( - "database/sql" - "errors" - - log "maunium.net/go/maulogger/v2" - + "context" + "database/sql/driver" + "fmt" + "math" + "strconv" + "strings" + "time" + + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" ) -type Message struct { - db *Database - log log.Logger +type MessageQuery struct { + *dbutil.QueryHelper[*Message] +} - Channel PortalKey +func newMessage(qh *dbutil.QueryHelper[*Message]) *Message { + return &Message{qh: qh} +} - SlackID string - MatrixID id.EventID +const ( + getMessageBaseQuery = ` + SELECT team_id, channel_id, message_id, part_id, thread_id, author_id, mxid FROM message + ` + getMessageBySlackIDQuery = getMessageBaseQuery + ` + WHERE team_id=$1 AND channel_id=$2 AND message_id=$3 + ` + getFirstMessagePartBySlackIDQuery = getMessageBySlackIDQuery + ` ORDER BY part_id ASC LIMIT 1` + getLastMessagePartBySlackIDQuery = getMessageBySlackIDQuery + ` ORDER BY part_id DESC LIMIT 1` + getMessageByMXIDQuery = getMessageBaseQuery + ` + WHERE mxid=$1 + ` + getFirstMessageInChannelQuery = getMessageBaseQuery + ` + WHERE team_id=$1 AND channel_id=$2 + ORDER BY message_id ASC LIMIT 1 + ` + getLastMessageInChannelQuery = getMessageBaseQuery + ` + WHERE team_id=$1 AND channel_id=$2 + ORDER BY message_id DESC LIMIT 1 + ` + getFirstMessageInThreadQuery = getMessageBaseQuery + ` + WHERE team_id=$1 AND channel_id=$2 AND thread_id=$3 + ORDER BY message_id ASC LIMIT 1 + ` + getLastMessageInThreadQuery = getMessageBaseQuery + ` + WHERE team_id=$1 AND channel_id=$2 AND thread_id=$3 + ORDER BY message_id DESC LIMIT 1 + ` + insertMessageQuery = ` + INSERT INTO message (team_id, channel_id, message_id, part_id, thread_id, author_id, mxid) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ` + deleteMessageQuery = ` + DELETE FROM message WHERE team_id=$1 AND channel_id=$2 AND message_id=$3 AND part_id=$4 + ` +) - SlackThreadID string +func (mq *MessageQuery) GetBySlackID(ctx context.Context, key PortalKey, slackID string) ([]*Message, error) { + return mq.QueryMany(ctx, getMessageBySlackIDQuery, key, slackID) +} - AuthorID string +func (mq *MessageQuery) GetFirstPartBySlackID(ctx context.Context, key PortalKey, slackID string) (*Message, error) { + return mq.QueryOne(ctx, getFirstMessagePartBySlackIDQuery, key, slackID) } -func (m *Message) Scan(row dbutil.Scannable) *Message { - var threadID sql.NullString +func (mq *MessageQuery) GetLastPartBySlackID(ctx context.Context, key PortalKey, slackID string) (*Message, error) { + return mq.QueryOne(ctx, getLastMessagePartBySlackIDQuery, key, slackID) +} - err := row.Scan(&m.Channel.TeamID, &m.Channel.ChannelID, &m.SlackID, &m.MatrixID, &m.AuthorID, &threadID) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - m.log.Errorln("Database scan failed:", err) - } +func (mq *MessageQuery) GetByMXID(ctx context.Context, eventID id.EventID) (*Message, error) { + return mq.QueryOne(ctx, getMessageByMXIDQuery, eventID) +} - return nil - } +func (mq *MessageQuery) GetFirstInChannel(ctx context.Context, key PortalKey) (*Message, error) { + return mq.QueryOne(ctx, getFirstMessageInChannelQuery, key) +} + +func (mq *MessageQuery) GetLastInChannel(ctx context.Context, key PortalKey) (*Message, error) { + return mq.QueryOne(ctx, getLastMessageInChannelQuery, key) +} - m.SlackThreadID = threadID.String +func (mq *MessageQuery) GetFirstInThread(ctx context.Context, key PortalKey, threadID string) (*Message, error) { + return mq.QueryOne(ctx, getFirstMessageInThreadQuery, key, threadID) +} - return m +func (mq *MessageQuery) GetLastInThread(ctx context.Context, key PortalKey, threadID string) (*Message, error) { + return mq.QueryOne(ctx, getLastMessageInThreadQuery, key, threadID) } -func (m *Message) Insert(txn dbutil.Transaction) { - query := "INSERT INTO message" + - " (team_id, channel_id, slack_message_id, matrix_message_id," + - " author_id, slack_thread_id) VALUES ($1, $2, $3, $4, $5, $6)" +type PartType string - args := []interface{}{m.Channel.TeamID, - m.Channel.ChannelID, m.SlackID, m.MatrixID, m.AuthorID, strPtr(m.SlackThreadID)} +const ( + PartTypeFile PartType = "file" +) +type PartID struct { + Type PartType + Index int + ID string +} + +func (pid PartID) Value() (driver.Value, error) { + return pid.String(), nil +} + +func (pid *PartID) Scan(i any) error { + strVal, ok := i.(string) + if !ok { + return fmt.Errorf("invalid type %T for PartID.Scan", i) + } + parts := strings.Split(strVal, ":") + if len(parts) != 3 { + return fmt.Errorf("invalid PartID format: %q", strVal) + } + pid.Type = PartType(parts[0]) + switch pid.Type { + case PartTypeFile: + default: + return fmt.Errorf("invalid PartID type: %q", pid.Type) + } var err error - if txn != nil { - _, err = txn.Exec(query, args...) - } else { - _, err = m.db.Exec(query, args...) + pid.Index, err = strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("invalid PartID index: %w", err) } + pid.ID = parts[2] + return nil +} - if err != nil { - m.log.Warnfln("Failed to insert %s@%s: %v", m.Channel, m.SlackID, err) +func (pid *PartID) String() string { + if pid == nil || pid.Type == "" { + return "" } + return fmt.Sprintf("%s:%d:%s", pid.Type, pid.Index, pid.ID) } -func (m *Message) Delete() { - query := "DELETE FROM message" + - " WHERE team_id=$1 AND channel_id=$2 AND slack_message_id=$3 AND matrix_message_id=$4" +type Message struct { + qh *dbutil.QueryHelper[*Message] - _, err := m.db.Exec(query, m.Channel.TeamID, m.Channel.ChannelID, m.SlackID, m.MatrixID) + PortalKey + MessageID string + Part PartID + ThreadID string + AuthorID string + + MXID id.EventID +} + +func (m *Message) Scan(row dbutil.Scannable) (*Message, error) { + return dbutil.ValueOrErr(m, row.Scan( + &m.TeamID, + &m.ChannelID, + &m.MessageID, + &m.Part, + &m.ThreadID, + &m.AuthorID, + &m.MXID, + )) +} + +func (m *Message) SlackTS() (time.Time, error) { + floatID, err := strconv.ParseFloat(m.MessageID, 64) if err != nil { - m.log.Warnfln("Failed to delete %s@%s: %v", m.Channel, m.SlackID, err) + return time.Time{}, fmt.Errorf("failed to parse message ID: %w", err) } + sec, dec := math.Modf(floatID) + return time.Unix(int64(sec), int64(dec*float64(time.Second))), nil +} + +func (m *Message) SlackURLPath() string { + path := fmt.Sprintf("/archives/%[1]s/p%[2]s", m.ChannelID, strings.ReplaceAll(m.MessageID, ".", "")) + if m.ThreadID != "" { + path = fmt.Sprintf("%s?thread_id=%s&cid=%s", path, m.ThreadID, m.ChannelID) + } + return path +} + +func (m *Message) sqlVariables() []any { + return []any{m.TeamID, m.ChannelID, m.MessageID, m.Part, dbutil.StrPtr(m.ThreadID), m.AuthorID, m.MXID} +} + +func (m *Message) Insert(ctx context.Context) error { + return m.qh.Exec(ctx, insertMessageQuery, m.sqlVariables()...) +} + +func (m *Message) Delete(ctx context.Context) error { + return m.qh.Exec(ctx, deleteMessageQuery, m.TeamID, m.ChannelID, m.MessageID, m.Part) } diff --git a/database/messagequery.go b/database/messagequery.go deleted file mode 100644 index cf1842f..0000000 --- a/database/messagequery.go +++ /dev/null @@ -1,119 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type MessageQuery struct { - db *Database - log log.Logger -} - -const ( - messageSelect = "SELECT team_id, channel_id, slack_message_id," + - " matrix_message_id, author_id, slack_thread_id FROM message" -) - -func (mq *MessageQuery) New() *Message { - return &Message{ - db: mq.db, - log: mq.log, - } -} - -func (mq *MessageQuery) GetAll(key PortalKey) []*Message { - query := messageSelect + " WHERE team_id=$1 AND channel_id=$2" - - rows, err := mq.db.Query(query, key.TeamID, key.ChannelID) - if err != nil || rows == nil { - return nil - } - - messages := []*Message{} - for rows.Next() { - messages = append(messages, mq.New().Scan(rows)) - } - - return messages -} - -func (mq *MessageQuery) GetBySlackID(key PortalKey, slackID string) *Message { - query := messageSelect + " WHERE team_id=$1" + - " AND channel_id=$2 AND slack_message_id=$3" - - row := mq.db.QueryRow(query, key.TeamID, key.ChannelID, slackID) - if row == nil { - mq.log.Debugfln("failed to find existing message for slack_id` %s", slackID) - return nil - } - - return mq.New().Scan(row) -} - -func (mq *MessageQuery) GetByMatrixID(key PortalKey, matrixID id.EventID) *Message { - query := messageSelect + " WHERE team_id=$1 AND channel_id=$2 AND matrix_message_id=$3" - - row := mq.db.QueryRow(query, key.TeamID, key.ChannelID, matrixID) - if row == nil { - return nil - } - - return mq.New().Scan(row) -} - -func (mq *MessageQuery) GetLastInThread(key PortalKey, slackThreadId string) *Message { - query := messageSelect + " WHERE team_id=$1 AND channel_id=$2 AND slack_thread_id=$3 ORDER BY slack_message_id DESC LIMIT 1" - - row := mq.db.QueryRow(query, key.TeamID, key.ChannelID, slackThreadId) - if row == nil { - return mq.GetBySlackID(key, slackThreadId) - } - - message := mq.New().Scan(row) - if message == nil { - return mq.GetBySlackID(key, slackThreadId) - } - - return message -} - -func (mq *MessageQuery) GetFirst(key PortalKey) *Message { - query := messageSelect + " WHERE team_id=$1 AND channel_id=$2 ORDER BY slack_message_id ASC LIMIT 1" - - row := mq.db.QueryRow(query, key.TeamID, key.ChannelID) - if row == nil { - mq.log.Debugfln("failed to find existing message for portal` %s", key) - return nil - } - - return mq.New().Scan(row) -} - -func (mq *MessageQuery) GetLast(key PortalKey) *Message { - query := messageSelect + " WHERE team_id=$1 AND channel_id=$2 ORDER BY slack_message_id DESC LIMIT 1" - - row := mq.db.QueryRow(query, key.TeamID, key.ChannelID) - if row == nil { - mq.log.Debugfln("failed to find existing message for portal` %s", key) - return nil - } - - return mq.New().Scan(row) -} diff --git a/database/portal.go b/database/portal.go index e06c019..54cba16 100644 --- a/database/portal.go +++ b/database/portal.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,14 +17,88 @@ package database import ( + "context" "database/sql" - log "maunium.net/go/maulogger/v2" - + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" ) +type PortalQuery struct { + *dbutil.QueryHelper[*Portal] +} + +func newPortal(qh *dbutil.QueryHelper[*Portal]) *Portal { + return &Portal{qh: qh} +} + +const ( + getAllPortalsQuery = ` + SELECT team_id, channel_id, receiver, mxid, type, dm_user_id, + plain_name, name, name_set, topic, topic_set, avatar, avatar_mxc, avatar_set, + encrypted, in_space, first_slack_id + FROM portal + ` + getPortalByIDQuery = getAllPortalsQuery + `WHERE team_id=$1 AND channel_id=$2` + getPortalByMXIDQuery = getAllPortalsQuery + `WHERE mxid=$1` + getDMPortalsWithUserQuery = getAllPortalsQuery + `WHERE team_id=$1 AND dm_user_id=$2 AND type=$3` + getUserTeamPortalSubquery = ` + SELECT 1 FROM user_team_portal + WHERE user_team_portal.user_mxid=$1 + AND user_team_portal.user_id=$2 + AND user_team_portal.team_id=$3 + AND user_team_portal.channel_id=portal.channel_id + ` + getAllPortalsForUserQuery = getAllPortalsQuery + "WHERE EXISTS(" + getUserTeamPortalSubquery + ")" + insertPortalQuery = ` + INSERT INTO portal ( + team_id, channel_id, receiver, mxid, type, dm_user_id, + plain_name, name, name_set, topic, topic_set, avatar, avatar_mxc, avatar_set, + encrypted, in_space, first_slack_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ` + updatePortalQuery = ` + UPDATE portal SET + receiver=$3, mxid=$4, type=$5, dm_user_id=$6, + plain_name=$7, name=$8, name_set=$9, topic=$10, topic_set=$11, + avatar=$12, avatar_mxc=$13, avatar_set=$14, + encrypted=$15, in_space=$16, first_slack_id=$17 + WHERE team_id=$1 AND channel_id=$2 + ` + deletePortalQuery = ` + DELETE FROM portal WHERE team_id=$1 AND channel_id=$2 + ` + insertUserTeamPortalQuery = ` + INSERT INTO user_team_portal (team_id, user_id, channel_id, user_mxid) + VALUES ($1, $2, $3, $4) + ON CONFLICT DO NOTHING + ` + deleteUserTeamPortalQuery = ` + DELETE FROM user_team_portal WHERE team_id=$1 AND user_id=$2 AND channel_id=$3 + ` +) + +func (pq *PortalQuery) GetAll(ctx context.Context) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllPortalsQuery) +} + +func (pq *PortalQuery) GetByID(ctx context.Context, key PortalKey) (*Portal, error) { + return pq.QueryOne(ctx, getPortalByIDQuery, key.TeamID, key.ChannelID) +} + +func (pq *PortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*Portal, error) { + return pq.QueryOne(ctx, getPortalByMXIDQuery, mxid) +} + +func (pq *PortalQuery) GetAllForUserTeam(ctx context.Context, utk UserTeamMXIDKey) ([]*Portal, error) { + return pq.QueryMany(ctx, getAllPortalsForUserQuery, utk.UserMXID, utk.UserID, utk.TeamID) +} + +func (pq *PortalQuery) FindPrivateChatsWith(ctx context.Context, utk UserTeamKey) ([]*Portal, error) { + return pq.QueryMany(ctx, getDMPortalsWithUserQuery, utk.TeamID, utk.UserID, ChannelTypeDM) +} + type ChannelType int64 const ( @@ -48,11 +122,11 @@ func (ct ChannelType) String() string { } type Portal struct { - db *Database - log log.Logger + qh *dbutil.QueryHelper[*Portal] - Key PortalKey - MXID id.RoomID + PortalKey + Receiver string + MXID id.RoomID Type ChannelType DMUserID string @@ -62,118 +136,71 @@ type Portal struct { NameSet bool Topic string TopicSet bool - Encrypted bool Avatar string - AvatarURL id.ContentURI + AvatarMXC id.ContentURI AvatarSet bool + Encrypted bool + InSpace bool - FirstEventID id.EventID - NextBatchID id.BatchID FirstSlackID string - - InSpace bool } -func (p *Portal) Scan(row dbutil.Scannable) *Portal { - var mxid, dmUserID, avatarURL, firstEventID, nextBatchID, firstSlackID sql.NullString - - err := row.Scan(&p.Key.TeamID, &p.Key.ChannelID, &mxid, - &p.Type, &dmUserID, &p.PlainName, &p.Name, &p.NameSet, &p.Topic, - &p.TopicSet, &p.Avatar, &avatarURL, &p.AvatarSet, &firstEventID, - &p.Encrypted, &nextBatchID, &firstSlackID, &p.InSpace) - +func (p *Portal) Scan(row dbutil.Scannable) (*Portal, error) { + var mxid, dmUserID, avatarMXC, firstSlackID sql.NullString + err := row.Scan( + &p.TeamID, + &p.ChannelID, + &p.Receiver, + &mxid, + &p.Type, + &dmUserID, + &p.PlainName, + &p.Name, + &p.NameSet, + &p.Topic, + &p.TopicSet, + &p.Avatar, + &avatarMXC, + &p.AvatarSet, + &p.Encrypted, + &p.InSpace, + &firstSlackID, + ) if err != nil { - if err != sql.ErrNoRows { - p.log.Errorln("Database scan failed:", err) - } - - return nil + return nil, err } - p.MXID = id.RoomID(mxid.String) p.DMUserID = dmUserID.String - p.AvatarURL, _ = id.ParseContentURI(avatarURL.String) - p.FirstEventID = id.EventID(firstEventID.String) - p.NextBatchID = id.BatchID(nextBatchID.String) + p.AvatarMXC, _ = id.ParseContentURI(avatarMXC.String) p.FirstSlackID = firstSlackID.String - - return p + return p, nil } -func (p *Portal) mxidPtr() *id.RoomID { - if p.MXID != "" { - return &p.MXID +func (p *Portal) sqlVariables() []any { + return []any{ + p.TeamID, p.ChannelID, p.Receiver, dbutil.StrPtr(p.MXID), p.Type, dbutil.StrPtr(p.DMUserID), + p.PlainName, p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, dbutil.StrPtr(p.AvatarMXC.String()), p.AvatarSet, + p.Encrypted, p.InSpace, p.FirstSlackID, } - return nil } -func (p *Portal) Insert() { - query := "INSERT INTO portal" + - " (team_id, channel_id, mxid, type, dm_user_id, plain_name," + - " name, name_set, topic, topic_set, avatar, avatar_url, avatar_set," + - " first_event_id, encrypted, next_batch_id, first_slack_id, in_space)" + - " VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)" - - _, err := p.db.Exec(query, p.Key.TeamID, p.Key.ChannelID, - p.mxidPtr(), p.Type, p.DMUserID, p.PlainName, p.Name, p.NameSet, - p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), p.AvatarSet, - p.FirstEventID.String(), p.Encrypted, p.NextBatchID.String(), p.FirstSlackID, p.InSpace) - - if err != nil { - p.log.Warnfln("Failed to insert %s: %v", p.Key, err) - } +func (p *Portal) Insert(ctx context.Context) error { + return p.qh.Exec(ctx, insertPortalQuery, p.sqlVariables()...) } -func (p *Portal) Update(txn dbutil.Transaction) { - query := "UPDATE portal SET" + - " mxid=$1, type=$2, dm_user_id=$3, plain_name=$4, name=$5, name_set=$6," + - " topic=$7, topic_set=$8, avatar=$9, avatar_url=$10, avatar_set=$11," + - " first_event_id=$12, encrypted=$13, next_batch_id=$14, first_slack_id=$15, in_space=$16" + - " WHERE team_id=$17 AND channel_id=$18" - - args := []interface{}{p.mxidPtr(), p.Type, p.DMUserID, p.PlainName, - p.Name, p.NameSet, p.Topic, p.TopicSet, p.Avatar, p.AvatarURL.String(), - p.AvatarSet, p.FirstEventID.String(), p.Encrypted, p.NextBatchID.String(), p.FirstSlackID, - p.InSpace, p.Key.TeamID, p.Key.ChannelID} - - var err error - if txn != nil { - _, err = txn.Exec(query, args...) - } else { - _, err = p.db.Exec(query, args...) - } - - if err != nil { - p.log.Warnfln("Failed to update %s: %v", p.Key, err) - } +func (p *Portal) Update(ctx context.Context) error { + return p.qh.Exec(ctx, updatePortalQuery, p.sqlVariables()...) } -func (p *Portal) Delete() { - query := "DELETE FROM portal WHERE team_id=$1 AND channel_id=$2" - _, err := p.db.Exec(query, p.Key.TeamID, p.Key.ChannelID) - if err != nil { - p.log.Warnfln("Failed to delete %s: %v", p.Key, err) - } +func (p *Portal) Delete(ctx context.Context) error { + return p.qh.Exec(ctx, deletePortalQuery, p.TeamID, p.ChannelID) } -func (p *Portal) InsertUser(utk UserTeamKey) { - query := "INSERT INTO user_team_portal" + - " (matrix_user_id, slack_user_id, slack_team_id, portal_channel_id)" + - " VALUES ($1, $2, $3, $4)" + - " ON CONFLICT DO NOTHING" - - _, err := p.db.Exec(query, utk.MXID, utk.SlackID, utk.TeamID, p.Key.ChannelID) - if err != nil { - p.log.Warnfln("Failed to insert userteam %s: %v", utk, err) - } +func (p *Portal) InsertUser(ctx context.Context, utk UserTeamMXIDKey) error { + return p.qh.Exec(ctx, insertUserTeamPortalQuery, utk.TeamID, utk.UserID, p.ChannelID, utk.UserMXID) } -func (p *Portal) DeleteUser(utk UserTeamKey) { - query := "DELETE FROM user_team_portal WHERE matrix_user_id=$1 AND slack_user_id=$2" + - " slack_team_id=$3 AND portal_channel_id=$4" - _, err := p.db.Exec(query, utk.MXID, utk.SlackID, utk.TeamID, p.Key.ChannelID) - if err != nil { - p.log.Warnfln("Failed to delete userteam %s: %v", utk, err) - } +func (p *Portal) DeleteUser(ctx context.Context, utk UserTeamMXIDKey) error { + return p.qh.Exec(ctx, deleteUserTeamPortalQuery, utk.TeamID, utk.UserID, p.ChannelID) } diff --git a/database/portalquery.go b/database/portalquery.go deleted file mode 100644 index 8aeca16..0000000 --- a/database/portalquery.go +++ /dev/null @@ -1,92 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -const ( - portalSelect = "SELECT team_id, channel_id, mxid, type, " + - " dm_user_id, plain_name, name, name_set, topic, topic_set," + - " avatar, avatar_url, avatar_set, first_event_id," + - " encrypted, next_batch_id, first_slack_id, in_space FROM portal" -) - -type PortalQuery struct { - db *Database - log log.Logger -} - -func (pq *PortalQuery) New() *Portal { - return &Portal{ - db: pq.db, - log: pq.log, - } -} - -func (pq *PortalQuery) GetAll() []*Portal { - return pq.getAll(portalSelect) -} - -func (pq *PortalQuery) GetByID(key PortalKey) *Portal { - return pq.get(portalSelect+" WHERE team_id=$1 AND channel_id=$2", key.TeamID, key.ChannelID) -} - -func (pq *PortalQuery) GetByMXID(mxid id.RoomID) *Portal { - return pq.get(portalSelect+" WHERE mxid=$1", mxid) -} - -// func (pq *PortalQuery) GetAllByID(teamID, userID string) []*Portal { -// return pq.getAll(portalSelect+" WHERE team_id=$1 AND user_id=$2", teamID, userID) -// } - -func (pq *PortalQuery) GetAllForUserTeam(utk UserTeamKey) []*Portal { - return pq.getAll(portalSelect+" WHERE EXISTS (SELECT * FROM user_team_portal WHERE"+ - " user_team_portal.matrix_user_id=$1 AND user_team_portal.slack_user_id=$2 AND user_team_portal.slack_team_id=$3"+ - " AND user_team_portal.portal_channel_id=portal.channel_id)", - utk.MXID, utk.SlackID, utk.TeamID) -} - -func (pq *PortalQuery) FindPrivateChatsWith(id string) []*Portal { - return pq.getAll(portalSelect+" WHERE dm_user_id=$1 AND type=$2", id, ChannelTypeDM) -} - -func (pq *PortalQuery) getAll(query string, args ...interface{}) []*Portal { - rows, err := pq.db.Query(query, args...) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - - portals := []*Portal{} - for rows.Next() { - portals = append(portals, pq.New().Scan(rows)) - } - - return portals -} - -func (pq *PortalQuery) get(query string, args ...interface{}) *Portal { - row := pq.db.QueryRow(query, args...) - if row == nil { - return nil - } - - return pq.New().Scan(row) -} diff --git a/database/puppet.go b/database/puppet.go index 275d94d..81b2f25 100644 --- a/database/puppet.go +++ b/database/puppet.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,106 +17,98 @@ package database import ( + "context" "database/sql" - log "maunium.net/go/maulogger/v2" - + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" -) - -const ( - puppetSelect = "SELECT team_id, user_id, name, name_set, avatar," + - " avatar_url, avatar_set, enable_presence, custom_mxid, access_token," + - " next_batch, is_bot, enable_receipts, contact_info_set" + - " FROM puppet " ) -type Puppet struct { - db *Database - log log.Logger - - TeamID string - UserID string +type PuppetQuery struct { + *dbutil.QueryHelper[*Puppet] +} - Name string - NameSet bool +func newPuppet(qh *dbutil.QueryHelper[*Puppet]) *Puppet { + return &Puppet{qh: qh} +} - Avatar string - AvatarURL id.ContentURI - AvatarSet bool +const ( + insertPuppetQuery = ` + INSERT INTO puppet ( + team_id, user_id, + name, avatar, avatar_mxc, is_bot, + name_set, avatar_set, contact_info_set + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ` + updatePuppetQuery = ` + UPDATE puppet + SET name=$3, avatar=$4, avatar_mxc=$5, is_bot=$6, + name_set=$7, avatar_set=$8, contact_info_set=$9 + WHERE team_id=$1 AND user_id=$2 + ` + getAllPuppetsQuery = ` + SELECT team_id, user_id, name, avatar, avatar_mxc, is_bot, + name_set, avatar_set, contact_info_set + FROM puppet + ` + getAllPuppetsForTeamQuery = getAllPuppetsQuery + `WHERE team_id=$1` + getPuppetByIDQuery = getAllPuppetsForTeamQuery + ` AND user_id=$2` +) - EnablePresence bool +func (pq *PuppetQuery) Get(ctx context.Context, key UserTeamKey) (*Puppet, error) { + return pq.QueryOne(ctx, getPuppetByIDQuery, key.TeamID, key.UserID) +} - CustomMXID id.UserID - AccessToken string +func (pq *PuppetQuery) GetAll(ctx context.Context) ([]*Puppet, error) { + return pq.QueryMany(ctx, getAllPuppetsQuery) +} - NextBatch string +func (pq *PuppetQuery) GetAllForTeam(ctx context.Context, teamID string) ([]*Puppet, error) { + return pq.QueryMany(ctx, getAllPuppetsForTeamQuery, teamID) +} - IsBot bool +type Puppet struct { + qh *dbutil.QueryHelper[*Puppet] - EnableReceipts bool + UserTeamKey + Name string + Avatar string + AvatarMXC id.ContentURI + IsBot bool + NameSet bool + AvatarSet bool ContactInfoSet bool } -func (p *Puppet) Scan(row dbutil.Scannable) *Puppet { - var teamID, userID, avatar, avatarURL sql.NullString - var enablePresence sql.NullBool - var customMXID, accessToken, nextBatch sql.NullString - - err := row.Scan(&teamID, &userID, &p.Name, &p.NameSet, &avatar, &avatarURL, - &p.AvatarSet, &enablePresence, &customMXID, &accessToken, &nextBatch, - &p.IsBot, &p.EnableReceipts, &p.ContactInfoSet) - +func (p *Puppet) Scan(row dbutil.Scannable) (*Puppet, error) { + var avatarURL sql.NullString + err := row.Scan( + &p.TeamID, &p.UserID, + &p.Name, &p.NameSet, &p.Avatar, &avatarURL, &p.IsBot, + &p.NameSet, &p.AvatarSet, &p.ContactInfoSet, + ) if err != nil { - if err != sql.ErrNoRows { - p.log.Errorln("Database scan failed:", err) - } - - return nil + return nil, err } - p.TeamID = teamID.String - p.UserID = userID.String - p.Avatar = avatar.String - p.AvatarURL, _ = id.ParseContentURI(avatarURL.String) - p.EnablePresence = enablePresence.Bool - p.CustomMXID = id.UserID(customMXID.String) - p.AccessToken = accessToken.String - p.NextBatch = nextBatch.String - - return p + p.AvatarMXC, _ = id.ParseContentURI(avatarURL.String) + return p, nil } -func (p *Puppet) Insert() { - query := "INSERT INTO puppet" + - " (team_id, user_id, name, name_set, avatar, avatar_url, avatar_set," + - " enable_presence, custom_mxid, access_token, next_batch," + - " is_bot, enable_receipts, contact_info_set)" + - " VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" - - _, err := p.db.Exec(query, p.TeamID, p.UserID, p.Name, p.NameSet, p.Avatar, - p.AvatarURL.String(), p.AvatarSet, p.EnablePresence, p.CustomMXID, - p.AccessToken, p.NextBatch, p.IsBot, p.EnableReceipts, p.ContactInfoSet) - - if err != nil { - p.log.Warnfln("Failed to insert %s-%s: %v", p.TeamID, p.UserID, err) +func (p *Puppet) sqlVariables() []any { + return []any{ + p.TeamID, p.UserID, + p.Name, p.Avatar, dbutil.StrPtr(p.AvatarMXC.String()), p.IsBot, + p.NameSet, p.AvatarSet, p.ContactInfoSet, } } -func (p *Puppet) Update() { - query := "UPDATE puppet" + - " SET name=$1, name_set=$2, avatar=$3, avatar_url=$4, avatar_set=$5," + - " enable_presence=$6, custom_mxid=$7, access_token=$8," + - " next_batch=$9, is_bot=$10, enable_receipts=$11, contact_info_set=$12" + - " WHERE team_id=$13 AND user_id=$14" - - _, err := p.db.Exec(query, p.Name, p.NameSet, p.Avatar, - p.AvatarURL.String(), p.AvatarSet, p.EnablePresence, p.CustomMXID, - p.AccessToken, p.NextBatch, p.IsBot, p.EnableReceipts, p.ContactInfoSet, p.TeamID, p.UserID) +func (p *Puppet) Insert(ctx context.Context) error { + return p.qh.Exec(ctx, insertPuppetQuery, p.sqlVariables()...) +} - if err != nil { - p.log.Warnfln("Failed to update %s-%s: %v", p.TeamID, p.UserID, err) - } +func (p *Puppet) Update(ctx context.Context) error { + return p.qh.Exec(ctx, updatePuppetQuery, p.sqlVariables()...) } diff --git a/database/puppetquery.go b/database/puppetquery.go deleted file mode 100644 index 9828c52..0000000 --- a/database/puppetquery.go +++ /dev/null @@ -1,80 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type PuppetQuery struct { - db *Database - log log.Logger -} - -func (pq *PuppetQuery) New() *Puppet { - return &Puppet{ - db: pq.db, - log: pq.log, - - EnablePresence: true, - } -} - -func (pq *PuppetQuery) Get(teamID, userID string) *Puppet { - return pq.get(puppetSelect+" WHERE team_id=$1 AND user_id=$2", teamID, userID) -} - -func (pq *PuppetQuery) GetByCustomMXID(mxid id.UserID) *Puppet { - return pq.get(puppetSelect+" WHERE custom_mxid=$1", mxid) -} - -func (pq *PuppetQuery) get(query string, args ...interface{}) *Puppet { - row := pq.db.QueryRow(query, args...) - if row == nil { - return nil - } - - return pq.New().Scan(row) -} - -func (pq *PuppetQuery) GetAll() []*Puppet { - return pq.getAll(puppetSelect) -} - -func (pq *PuppetQuery) GetAllForTeam(teamID string) []*Puppet { - return pq.getAll(puppetSelect+" WHERE team_id=$1", teamID) -} - -func (pq *PuppetQuery) GetAllWithCustomMXID() []*Puppet { - return pq.getAll(puppetSelect + " WHERE custom_mxid<>''") -} - -func (pq *PuppetQuery) getAll(query string, args ...interface{}) []*Puppet { - rows, err := pq.db.Query(query, args...) - if err != nil || rows == nil { - return nil - } - defer rows.Close() - - puppets := []*Puppet{} - for rows.Next() { - puppets = append(puppets, pq.New().Scan(rows)) - } - - return puppets -} diff --git a/database/reaction.go b/database/reaction.go index d3bd19e..d249c18 100644 --- a/database/reaction.go +++ b/database/reaction.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,110 +17,78 @@ package database import ( - "database/sql" - "errors" - - log "maunium.net/go/maulogger/v2" + "context" + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" ) -type Reaction struct { - db *Database - log log.Logger - - Channel PortalKey +type ReactionQuery struct { + *dbutil.QueryHelper[*Reaction] +} - SlackMessageID string - MatrixEventID id.EventID +func newReaction(qh *dbutil.QueryHelper[*Reaction]) *Reaction { + return &Reaction{qh: qh} +} - // The slack ID of who create this reaction - AuthorID string +const ( + getReactionBaseQuery = ` + SELECT team_id, channel_id, message_id, msg_first_part_id, author_id, emoji_id, mxid FROM reaction + ` + getReactionBySlackIDQuery = getReactionBaseQuery + ` + WHERE team_id=$1 AND channel_id=$2 AND message_id=$3 AND author_id=$4 AND emoji_id=$5 + ` + getReactionByMXIDQuery = getReactionBaseQuery + ` + WHERE mxid=$1 + ` + insertReactionQuery = ` + INSERT INTO reaction (team_id, channel_id, message_id, msg_first_part_id, author_id, emoji_id, mxid) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ` + deleteReactionQuery = ` + DELETE FROM reaction WHERE team_id=$1 AND channel_id=$2 AND message_id=$3 AND author_id=$4 AND emoji_id=$5 + ` +) - MatrixName string - MatrixURL string // Used for custom emoji +func (rq *ReactionQuery) GetBySlackID(ctx context.Context, key PortalKey, messageID, authorID, emojiID string) (*Reaction, error) { + return rq.QueryOne(ctx, getReactionBySlackIDQuery, key.ChannelID, key.TeamID, messageID, authorID, emojiID) +} - SlackName string // The id or unicode of the emoji for slack +func (rq *ReactionQuery) GetByMXID(ctx context.Context, eventID id.EventID) (*Reaction, error) { + return rq.QueryOne(ctx, getReactionByMXIDQuery, eventID) } -func (r *Reaction) Scan(row dbutil.Scannable) *Reaction { - var slackName sql.NullString +type Reaction struct { + qh *dbutil.QueryHelper[*Reaction] + + PortalKey + MessageID string + MessageFirstPart PartID + AuthorID string + EmojiID string + MXID id.EventID +} - err := row.Scan( - &r.Channel.TeamID, &r.Channel.ChannelID, - &r.SlackMessageID, &r.MatrixEventID, +func (r *Reaction) Scan(row dbutil.Scannable) (*Reaction, error) { + return dbutil.ValueOrErr(r, row.Scan( + &r.TeamID, + &r.ChannelID, + &r.MessageID, + &r.MessageFirstPart, &r.AuthorID, - &r.MatrixName, &r.MatrixURL, - &slackName) - - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - r.log.Errorln("Database scan failed:", err) - } - - return nil - } - - r.SlackName = slackName.String - - return r + &r.EmojiID, + &r.MXID, + )) } -func (r *Reaction) Insert(txn dbutil.Transaction) { - query := "INSERT INTO reaction" + - " (team_id, channel_id, slack_message_id, matrix_event_id," + - " author_id, matrix_name, matrix_url, slack_name)" + - " VALUES($1, $2, $3, $4, $5, $6, $7, $8);" - - var slackName sql.NullString - - if r.SlackName != "" { - slackName = sql.NullString{String: r.SlackName, Valid: true} - } - - args := []interface{}{r.Channel.TeamID, r.Channel.ChannelID, - r.SlackMessageID, r.MatrixEventID, - r.AuthorID, - r.MatrixName, r.MatrixURL, - slackName, - } - - var err error - if txn != nil { - _, err = txn.Exec(query, args...) - } else { - _, err = r.db.Exec(query, args...) - } - - if err != nil { - r.log.Warnfln("Failed to insert reaction for %s@%s: %v", r.Channel, r.SlackMessageID, err) - } +func (r *Reaction) sqlVariables() []any { + return []any{r.TeamID, r.ChannelID, r.MessageID, r.MessageFirstPart, r.AuthorID, r.EmojiID, r.MXID} } -func (r *Reaction) Update() { - // TODO: determine if we need this. The only scenario I can think of that - // would require this is if we insert a custom emoji before uploading to - // the homeserver? +func (r *Reaction) Insert(ctx context.Context) error { + return r.qh.Exec(ctx, insertReactionQuery, r.sqlVariables()...) } -func (r *Reaction) Delete() { - query := "DELETE FROM reaction WHERE" + - " team_id=$1 AND channel_id=$2 AND slack_message_id=$3 AND author_id=$4 AND slack_name=$5" - - var slackName sql.NullString - if r.SlackName != "" { - slackName = sql.NullString{String: r.SlackName, Valid: true} - } - - _, err := r.db.Exec( - query, - r.Channel.TeamID, r.Channel.ChannelID, - r.SlackMessageID, r.AuthorID, - slackName, - ) - - if err != nil { - r.log.Warnfln("Failed to delete reaction for %s@%s: %v", r.Channel, r.SlackMessageID, err) - } +func (r *Reaction) Delete(ctx context.Context) error { + return r.qh.Exec(ctx, deleteReactionQuery, r.TeamID, r.ChannelID, r.MessageID, r.AuthorID, r.EmojiID) } diff --git a/database/reactionquery.go b/database/reactionquery.go deleted file mode 100644 index 9b890e9..0000000 --- a/database/reactionquery.go +++ /dev/null @@ -1,81 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type ReactionQuery struct { - db *Database - log log.Logger -} - -const ( - reactionSelect = "SELECT team_id, channel_id, slack_message_id," + - " matrix_event_id, author_id, matrix_name, matrix_url, " + - " slack_name FROM reaction" -) - -func (rq *ReactionQuery) New() *Reaction { - return &Reaction{ - db: rq.db, - log: rq.log, - } -} - -func (rq *ReactionQuery) GetAllByMatrixID(key PortalKey, matrixEventID id.EventID) []*Reaction { - query := reactionSelect + " WHERE team_id=$1 AND channel_id=$2 AND matrix_event_id=$3" - - return rq.getAll(query, key.TeamID, key.ChannelID, matrixEventID) -} - -func (rq *ReactionQuery) getAll(query string, args ...interface{}) []*Reaction { - rows, err := rq.db.Query(query) - if err != nil || rows == nil { - return nil - } - - reactions := []*Reaction{} - for rows.Next() { - reactions = append(reactions, rq.New().Scan(rows)) - } - - return reactions -} - -func (rq *ReactionQuery) GetBySlackID(key PortalKey, slackAuthor, slackMessageID, slackName string) *Reaction { - query := reactionSelect + " WHERE team_id=$1 AND channel_id=$2 AND author_id=$3 AND slack_message_id=$4 AND slack_name=$5" - - return rq.get(query, key.TeamID, key.ChannelID, slackAuthor, slackMessageID, slackName) -} - -func (rq *ReactionQuery) GetByMatrixID(key PortalKey, matrixEventID id.EventID) *Reaction { - query := reactionSelect + " WHERE team_id=$1 AND channel_id=$2 AND matrix_event_id=$3" - - return rq.get(query, key.TeamID, key.ChannelID, matrixEventID) -} - -func (rq *ReactionQuery) get(query string, args ...interface{}) *Reaction { - row := rq.db.QueryRow(query, args...) - if row == nil { - return nil - } - - return rq.New().Scan(row) -} diff --git a/database/teaminfo.go b/database/teaminfo.go index 642f62c..6ee5f2e 100644 --- a/database/teaminfo.go +++ b/database/teaminfo.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,127 +17,89 @@ package database import ( + "context" "database/sql" - log "maunium.net/go/maulogger/v2" - + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" ) -type TeamInfoQuery struct { - db *Database - log log.Logger +type TeamPortalQuery struct { + *dbutil.QueryHelper[*TeamPortal] } -func (tiq *TeamInfoQuery) New() *TeamInfo { - return &TeamInfo{ - db: tiq.db, - log: tiq.log, - } +func newTeamPortal(qh *dbutil.QueryHelper[*TeamPortal]) *TeamPortal { + return &TeamPortal{qh: qh} } -func (tiq *TeamInfoQuery) GetBySlackTeam(team string) *TeamInfo { - query := `SELECT team_id, team_domain, team_url, team_name, avatar, avatar_url, space_room, name_set, avatar_set FROM team_info WHERE team_id=$1` - - row := tiq.db.QueryRow(query, team) - if row == nil { - return nil - } +const ( + getTeamPortalBaseQuery = ` + SELECT id, mxid, domain, url, name, avatar, avatar_mxc, name_set, avatar_set FROM team_portal + ` + getTeamPortalByIDQuery = getTeamPortalBaseQuery + " WHERE id=$1" + getTeamPortalByMXIDQuery = getTeamPortalBaseQuery + " WHERE mxid=$1" + insertTeamPortalQuery = ` + INSERT INTO team_portal (id, mxid, domain, url, name, avatar, avatar_mxc, name_set, avatar_set) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ` + updateTeamPortalQuery = ` + UPDATE team_portal + SET mxid=$2, domain=$3, url=$4, name=$5, avatar=$6, avatar_mxc=$7, name_set=$8, avatar_set=$9 + WHERE id=$1 + ` +) - return tiq.New().Scan(row) +func (tpq *TeamPortalQuery) GetBySlackID(ctx context.Context, teamID string) (*TeamPortal, error) { + return tpq.QueryOne(ctx, getTeamPortalByIDQuery, teamID) } -func (tiq *TeamInfoQuery) GetByMXID(mxid id.RoomID) *TeamInfo { - query := `SELECT team_id, team_domain, team_url, team_name, avatar, avatar_url, space_room, name_set, avatar_set FROM team_info WHERE space_room=$1` - - row := tiq.db.QueryRow(query, mxid) - if row == nil { - return nil - } - - return tiq.New().Scan(row) +func (tpq *TeamPortalQuery) GetByMXID(ctx context.Context, mxid id.RoomID) (*TeamPortal, error) { + return tpq.QueryOne(ctx, getTeamPortalByMXIDQuery, mxid) } -type TeamInfo struct { - db *Database - log log.Logger - - TeamID string - TeamDomain string - TeamUrl string - TeamName string - Avatar string - AvatarUrl id.ContentURI - SpaceRoom id.RoomID - NameSet bool - AvatarSet bool +type TeamPortal struct { + qh *dbutil.QueryHelper[*TeamPortal] + + ID string + MXID id.RoomID + Domain string + URL string + Name string + Avatar string + AvatarMXC id.ContentURI + NameSet bool + AvatarSet bool } -func (ti *TeamInfo) Scan(row dbutil.Scannable) *TeamInfo { - var teamDomain sql.NullString - var teamUrl sql.NullString - var teamName sql.NullString - var avatar sql.NullString - var avatarUrl sql.NullString - var spaceRoom sql.NullString - - err := row.Scan(&ti.TeamID, &teamDomain, &teamUrl, &teamName, &avatar, &avatarUrl, &spaceRoom, &ti.NameSet, &ti.AvatarSet) +func (tp *TeamPortal) Scan(row dbutil.Scannable) (*TeamPortal, error) { + var mxid, avatarMXC sql.NullString + err := row.Scan(&tp.ID, &mxid, &tp.Domain, &tp.URL, &tp.NameSet, &tp.AvatarSet, &avatarMXC, &tp.NameSet, &tp.AvatarSet) if err != nil { - if err != sql.ErrNoRows { - ti.log.Errorln("Database scan failed:", err) - } - - return nil + return nil, err } + tp.MXID = id.RoomID(mxid.String) + tp.AvatarMXC, _ = id.ParseContentURI(avatarMXC.String) + return tp, nil +} - if teamDomain.Valid { - ti.TeamDomain = teamDomain.String - } - if teamUrl.Valid { - ti.TeamUrl = teamUrl.String - } - if teamName.Valid { - ti.TeamName = teamName.String - } - if avatar.Valid { - ti.Avatar = avatar.String +func (tp *TeamPortal) sqlVariables() []any { + return []any{ + tp.ID, + dbutil.StrPtr(tp.MXID), + tp.Domain, + tp.URL, + tp.Name, + tp.Avatar, + dbutil.StrPtr(tp.AvatarMXC.String()), + tp.NameSet, + tp.AvatarSet, } - if avatarUrl.Valid { - ti.AvatarUrl, _ = id.ParseContentURI(avatarUrl.String) - } - if spaceRoom.Valid { - ti.SpaceRoom = id.RoomID(spaceRoom.String) - } - - return ti } -func (ti *TeamInfo) Upsert() { - query := ` - INSERT INTO team_info (team_id, team_domain, team_url, team_name, avatar, avatar_url, space_room, name_set, avatar_set) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (team_id) DO UPDATE - SET team_domain=excluded.team_domain, - team_url=excluded.team_url, - team_name=excluded.team_name, - avatar=excluded.avatar, - avatar_url=excluded.avatar_url, - space_room=excluded.space_room, - name_set=excluded.name_set, - avatar_set=excluded.avatar_set - ` - - teamDomain := sqlNullString(ti.TeamDomain) - teamUrl := sqlNullString(ti.TeamUrl) - teamName := sqlNullString(ti.TeamName) - avatar := sqlNullString(ti.Avatar) - avatarUrl := sqlNullString(ti.AvatarUrl.String()) - spaceRoom := sqlNullString(ti.SpaceRoom.String()) - - _, err := ti.db.Exec(query, ti.TeamID, teamDomain, teamUrl, teamName, avatar, avatarUrl, spaceRoom, ti.NameSet, ti.AvatarSet) +func (tp *TeamPortal) Insert(ctx context.Context) error { + return tp.qh.Exec(ctx, insertTeamPortalQuery, tp.sqlVariables()...) +} - if err != nil { - ti.log.Warnfln("Failed to upsert team %s: %v", ti.TeamID, err) - } +func (tp *TeamPortal) Update(ctx context.Context) error { + return tp.qh.Exec(ctx, updateTeamPortalQuery, tp.sqlVariables()...) } diff --git a/database/upgrades/00-latest-revision.sql b/database/upgrades/00-latest-revision.sql index 08779f5..70d7ec7 100644 --- a/database/upgrades/00-latest-revision.sql +++ b/database/upgrades/00-latest-revision.sql @@ -1,164 +1,159 @@ --- v0 -> v16: Latest revision +-- v0 -> v17: Latest revision + +CREATE TABLE team_portal ( + id TEXT NOT NULL, + mxid TEXT, + domain TEXT NOT NULL, + url TEXT NOT NULL, + name TEXT NOT NULL, + avatar TEXT NOT NULL, + avatar_mxc TEXT, + name_set BOOLEAN NOT NULL DEFAULT false, + avatar_set BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY (id), + CONSTRAINT team_portal_mxid_unique UNIQUE (mxid) +); CREATE TABLE portal ( - team_id TEXT, - channel_id TEXT, - mxid TEXT UNIQUE, - - type INT DEFAULT 0, - dm_user_id TEXT, - - plain_name TEXT NOT NULL, - name TEXT NOT NULL, - name_set BOOLEAN DEFAULT false, - topic TEXT NOT NULL, - topic_set BOOLEAN DEFAULT false, - avatar TEXT NOT NULL, - avatar_url TEXT, - avatar_set BOOLEAN DEFAULT FALSE, - - encrypted BOOLEAN NOT NULL DEFAULT false, - - first_event_id TEXT, - next_batch_id TEXT, - first_slack_id TEXT, - - in_space BOOLEAN DEFAULT false, - - PRIMARY KEY (team_id, channel_id) + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + receiver TEXT NOT NULL, + mxid TEXT, + + type INT NOT NULL DEFAULT 0, + dm_user_id TEXT, + + plain_name TEXT NOT NULL, + name TEXT NOT NULL, + name_set BOOLEAN NOT NULL DEFAULT false, + topic TEXT NOT NULL, + topic_set BOOLEAN NOT NULL DEFAULT false, + avatar TEXT NOT NULL, + avatar_mxc TEXT, + avatar_set BOOLEAN NOT NULL DEFAULT false, + encrypted BOOLEAN NOT NULL DEFAULT false, + in_space BOOLEAN NOT NULL DEFAULT false, + + first_slack_id TEXT, + + PRIMARY KEY (team_id, channel_id, receiver), + CONSTRAINT portal_mxid_unique UNIQUE (mxid), + CONSTRAINT portal_team_fkey FOREIGN KEY (team_id) REFERENCES team_portal (id) + ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE puppet ( - team_id TEXT NOT NULL, - user_id TEXT NOT NULL, - - name TEXT NOT NULL, - name_set BOOLEAN DEFAULT false, - - avatar TEXT, - avatar_url TEXT, - avatar_set BOOLEAN DEFAULT false, - - enable_presence BOOLEAN NOT NULL DEFAULT true, - enable_receipts BOOLEAN NOT NULL DEFAULT true, - - custom_mxid TEXT, - access_token TEXT, - next_batch TEXT, - - is_bot BOOLEAN NOT NULL DEFAULT false, - - contact_info_set BOOLEAN NOT NULL DEFAULT false, - - PRIMARY KEY(team_id, user_id) + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + + name TEXT NOT NULL, + avatar TEXT NOT NULL, + avatar_mxc TEXT, + is_bot BOOLEAN NOT NULL DEFAULT false, + name_set BOOLEAN NOT NULL DEFAULT false, + avatar_set BOOLEAN NOT NULL DEFAULT false, + contact_info_set BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY (team_id, user_id) ); CREATE TABLE "user" ( - mxid TEXT PRIMARY KEY, + mxid TEXT PRIMARY KEY, - management_room TEXT, - space_room TEXT + management_room TEXT, + space_room TEXT, + access_token TEXT ); -CREATE TABLE "user_team" ( - mxid TEXT NOT NULL, - - slack_email TEXT NOT NULL, - slack_id TEXT NOT NULL, +CREATE TABLE user_team ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, - team_name TEXT NOT NULL, - team_id TEXT NOT NULL, + email TEXT NOT NULL, + token TEXT NOT NULL, + cookie_token TEXT NOT NULL, - token TEXT, - cookie_token TEXT, + in_space BOOLEAN NOT NULL DEFAULT false, - in_space BOOLEAN DEFAULT false, - - PRIMARY KEY(mxid, slack_id, team_id) + PRIMARY KEY (team_id, user_id), + CONSTRAINT user_team_mxid_unique UNIQUE (team_id, user_mxid), + CONSTRAINT user_team_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT user_team_team_fkey FOREIGN KEY (team_id) REFERENCES team_portal (id) + ON DELETE CASCADE ON UPDATE CASCADE ); +CREATE INDEX user_team_user_idx ON user_team (user_mxid); CREATE TABLE user_team_portal ( - matrix_user_id TEXT NOT NULL, - slack_user_id TEXT NOT NULL, - slack_team_id TEXT NOT NULL, - portal_channel_id TEXT NOT NULL, - FOREIGN KEY(matrix_user_id, slack_user_id, slack_team_id) REFERENCES "user_team"(mxid, slack_id, team_id) ON DELETE CASCADE, - FOREIGN KEY(slack_team_id, portal_channel_id) REFERENCES portal(team_id, channel_id) ON DELETE CASCADE + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + PRIMARY KEY (team_id, user_id, channel_id), + CONSTRAINT utp_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT utp_ut_fkey FOREIGN KEY (team_id, user_id) REFERENCES user_team (team_id, user_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT utp_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE ); +CREATE INDEX user_team_portal_user_idx ON user_team_portal (user_mxid); +CREATE INDEX user_team_portal_portal_idx ON user_team_portal (team_id, channel_id); CREATE TABLE message ( - team_id TEXT NOT NULL, - channel_id TEXT NOT NULL, - - slack_message_id TEXT NOT NULL, - slack_thread_id TEXT, - matrix_message_id TEXT NOT NULL UNIQUE, - - author_id TEXT NOT NULL, - - PRIMARY KEY(slack_message_id, team_id, channel_id), - FOREIGN KEY(team_id, channel_id) REFERENCES portal(team_id, channel_id) ON DELETE CASCADE + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + part_id TEXT NOT NULL, + + thread_id TEXT NOT NULL, + author_id TEXT NOT NULL, + mxid TEXT NOT NULL, + + PRIMARY KEY (team_id, channel_id, message_id, part_id), + CONSTRAINT message_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_mxid_unique UNIQUE (mxid) ); CREATE TABLE reaction ( - team_id TEXT NOT NULL, - channel_id TEXT NOT NULL, - - slack_message_id TEXT NOT NULL, - matrix_event_id TEXT NOT NULL UNIQUE, - - author_id TEXT NOT NULL, - - matrix_name TEXT, - matrix_url TEXT, - - slack_name TEXT, - - UNIQUE (slack_name, author_id, slack_message_id, team_id, channel_id), - FOREIGN KEY(team_id, channel_id) REFERENCES portal(team_id, channel_id) ON DELETE CASCADE -); - -CREATE TABLE attachment ( - team_id TEXT NOT NULL, - channel_id TEXT NOT NULL, - - slack_message_id TEXT NOT NULL, - slack_file_id TEXT NOT NULL, - matrix_event_id TEXT NOT NULL UNIQUE, - slack_thread_id TEXT, - - PRIMARY KEY(slack_message_id, slack_file_id, matrix_event_id), - FOREIGN KEY(team_id, channel_id) REFERENCES portal(team_id, channel_id) ON DELETE CASCADE -); - -CREATE TABLE "team_info" ( - team_id TEXT NOT NULL UNIQUE, - team_domain TEXT, - team_url TEXT, - team_name TEXT, - avatar TEXT, - avatar_url TEXT, - space_room TEXT, - name_set BOOLEAN DEFAULT false, - avatar_set BOOLEAN DEFAULT false + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + msg_first_part_id TEXT NOT NULL, + author_id TEXT NOT NULL, + emoji_id TEXT NOT NULL, + + mxid TEXT NOT NULL, + + PRIMARY KEY (team_id, channel_id, message_id, author_id, emoji_id), + CONSTRAINT reaction_mxid_unique UNIQUE (mxid), + CONSTRAINT reaction_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_message_fkey FOREIGN KEY (team_id, channel_id, message_id, msg_first_part_id) + REFERENCES message (team_id, channel_id, message_id, part_id) + ON DELETE CASCADE ON UPDATE CASCADE ); CREATE TABLE backfill_state ( team_id TEXT, channel_id TEXT, backfill_complete BOOLEAN, - dispatched BOOLEAN, - message_count INTEGER, - immediate_complete BOOLEAN, + dispatched BOOLEAN, + message_count INTEGER, + immediate_complete BOOLEAN, PRIMARY KEY (team_id, channel_id), FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) ON DELETE CASCADE ); CREATE TABLE emoji ( - slack_id TEXT NOT NULL, - slack_team TEXT NOT NULL, - alias TEXT, - image_url TEXT, + team_id TEXT NOT NULL, + emoji_id TEXT NOT NULL, + value TEXT NOT NULL, + alias TEXT, + image_mxc TEXT, - PRIMARY KEY (slack_id, slack_team) + PRIMARY KEY (team_id, emoji_id) ); diff --git a/database/upgrades/17-refactor.postgres.sql b/database/upgrades/17-refactor.postgres.sql new file mode 100644 index 0000000..5ce96fc --- /dev/null +++ b/database/upgrades/17-refactor.postgres.sql @@ -0,0 +1,164 @@ +-- v17: Refactor database +ALTER TABLE message RENAME COLUMN slack_message_id TO message_id; +ALTER TABLE message RENAME COLUMN slack_thread_id TO thread_id; +ALTER TABLE message RENAME COLUMN matrix_message_id TO mxid; +ALTER TABLE message DROP CONSTRAINT message_team_id_channel_id_fkey; +ALTER TABLE message ADD CONSTRAINT message_portal_fkey FOREIGN KEY (team_id, channel_id) + REFERENCES portal (team_id, channel_id) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE message RENAME CONSTRAINT message_matrix_message_id_key TO message_mxid_unique; +ALTER TABLE message DROP CONSTRAINT message_pkey; +UPDATE message SET thread_id='' WHERE thread_id IS NULL; +ALTER TABLE message ALTER COLUMN thread_id SET NOT NULL; +ALTER TABLE message ADD COLUMN part_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE message ALTER COLUMN part_id DROP DEFAULT; +ALTER TABLE message ADD PRIMARY KEY (team_id, channel_id, message_id, part_id); + +INSERT INTO message (team_id, channel_id, message_id, part_id, thread_id, author_id, mxid) +SELECT team_id, + channel_id, + slack_message_id, + 'file:0:' || slack_file_id, + COALESCE(slack_thread_id, ''), + COALESCE( + (SELECT author_id + FROM message targetmsg + WHERE targetmsg.team_id = attachment.team_id + AND targetmsg.channel_id = attachment.channel_id + AND targetmsg.message_id = attachment.slack_message_id), + '' + ), + matrix_event_id +FROM attachment; +DROP TABLE attachment; + +ALTER TABLE reaction RENAME COLUMN slack_message_id TO message_id; +ALTER TABLE reaction RENAME COLUMN matrix_event_id TO mxid; +ALTER TABLE reaction RENAME COLUMN slack_name TO emoji_id; +ALTER TABLE reaction DROP COLUMN matrix_name; +ALTER TABLE reaction DROP COLUMN matrix_url; +ALTER TABLE reaction RENAME CONSTRAINT reaction_matrix_event_id_key TO reaction_mxid_unique; +ALTER TABLE reaction DROP CONSTRAINT reaction_slack_name_author_id_slack_message_id_team_id_chan_key; +ALTER TABLE reaction DROP CONSTRAINT reaction_team_id_channel_id_fkey; +ALTER TABLE reaction ADD PRIMARY KEY (team_id, channel_id, message_id, author_id, emoji_id); +ALTER TABLE reaction ADD COLUMN msg_first_part_id TEXT; +UPDATE reaction SET msg_first_part_id=( + SELECT part_id + FROM message + WHERE message.team_id = reaction.team_id + AND message.channel_id = reaction.channel_id + AND message.message_id = reaction.message_id + ORDER BY part_id + LIMIT 1 +); +DELETE FROM reaction WHERE msg_first_part_id IS NULL; +ALTER TABLE reaction ALTER COLUMN msg_first_part_id SET NOT NULL; +ALTER TABLE reaction ADD CONSTRAINT reaction_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE reaction ADD CONSTRAINT reaction_message_fkey FOREIGN KEY (team_id, channel_id, message_id, msg_first_part_id) + REFERENCES message(team_id, channel_id, message_id, part_id) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE portal ALTER COLUMN type SET NOT NULL; +ALTER TABLE portal ALTER COLUMN name_set SET NOT NULL; +ALTER TABLE portal ALTER COLUMN topic_set SET NOT NULL; +ALTER TABLE portal ALTER COLUMN avatar_set SET NOT NULL; +ALTER TABLE portal RENAME COLUMN avatar_url TO avatar_mxc; +ALTER TABLE portal ALTER COLUMN in_space SET NOT NULL; +ALTER TABLE portal RENAME CONSTRAINT portal_mxid_key TO portal_mxid_unique; +ALTER TABLE portal ADD COLUMN receiver TEXT NOT NULL DEFAULT ''; +ALTER TABLE portal ALTER COLUMN receiver DROP DEFAULT; + +ALTER TABLE portal DROP COLUMN first_event_id; +ALTER TABLE portal DROP COLUMN next_batch_id; + +ALTER TABLE puppet ALTER COLUMN name_set SET NOT NULL; +ALTER TABLE puppet ALTER COLUMN avatar_set SET NOT NULL; +UPDATE puppet SET avatar='' WHERE avatar IS NULL; +ALTER TABLE puppet ALTER COLUMN avatar SET NOT NULL; +ALTER TABLE puppet RENAME COLUMN avatar_url TO avatar_mxc; +ALTER TABLE puppet DROP COLUMN enable_presence; +ALTER TABLE puppet DROP COLUMN enable_receipts; +ALTER TABLE puppet DROP COLUMN next_batch; +ALTER TABLE puppet DROP COLUMN custom_mxid; +ALTER TABLE puppet DROP COLUMN access_token; + +ALTER TABLE team_info RENAME TO team_portal; +UPDATE team_portal SET team_domain='' WHERE team_domain IS NULL; +UPDATE team_portal SET team_url='' WHERE team_url IS NULL; +UPDATE team_portal SET team_name='' WHERE team_name IS NULL; +UPDATE team_portal SET avatar='' WHERE avatar IS NULL; +ALTER TABLE team_portal ALTER COLUMN team_domain SET NOT NULL; +ALTER TABLE team_portal ALTER COLUMN team_url SET NOT NULL; +ALTER TABLE team_portal ALTER COLUMN team_name SET NOT NULL; +ALTER TABLE team_portal ALTER COLUMN name_set SET NOT NULL; +ALTER TABLE team_portal ALTER COLUMN avatar_set SET NOT NULL; +ALTER TABLE team_portal RENAME COLUMN team_id TO id; +ALTER TABLE team_portal RENAME COLUMN team_domain TO domain; +ALTER TABLE team_portal RENAME COLUMN team_url TO url; +ALTER TABLE team_portal RENAME COLUMN team_name TO name; +ALTER TABLE team_portal RENAME COLUMN space_room TO mxid; +ALTER TABLE team_portal ALTER COLUMN avatar SET NOT NULL; +ALTER TABLE team_portal RENAME COLUMN avatar_url TO avatar_mxc; +ALTER TABLE team_portal DROP CONSTRAINT team_info_team_id_key; +ALTER TABLE team_portal ADD PRIMARY KEY (id); +ALTER TABLE team_portal ADD CONSTRAINT team_portal_mxid_unique UNIQUE (mxid); + +INSERT INTO team_portal (id, domain, url, name, avatar) +SELECT DISTINCT team_id, '', '', '', '' FROM user_team +ON CONFLICT (id) DO NOTHING; +INSERT INTO team_portal (id, domain, url, name, avatar) +SELECT DISTINCT team_id, '', '', '', '' FROM portal +ON CONFLICT (id) DO NOTHING; +ALTER TABLE portal ADD CONSTRAINT portal_team_fkey FOREIGN KEY (team_id) REFERENCES team_portal(id) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE user_team RENAME COLUMN mxid TO user_mxid; +ALTER TABLE user_team ADD CONSTRAINT user_team_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE user_team ADD CONSTRAINT user_team_team_fkey FOREIGN KEY (team_id) REFERENCES team_portal(id) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE user_team RENAME COLUMN slack_id TO user_id; +ALTER TABLE user_team RENAME COLUMN slack_email TO email; +ALTER TABLE user_team ALTER COLUMN in_space SET NOT NULL; +UPDATE user_team SET token='' WHERE token IS NULL; +UPDATE user_team SET cookie_token='' WHERE cookie_token IS NULL; +ALTER TABLE user_team ALTER COLUMN token SET NOT NULL; +ALTER TABLE user_team ALTER COLUMN cookie_token SET NOT NULL; +ALTER TABLE user_team DROP COLUMN team_name; +ALTER TABLE user_team_portal DROP CONSTRAINT user_team_portal_slack_team_id_portal_channel_id_fkey; +ALTER TABLE user_team_portal DROP CONSTRAINT user_team_portal_matrix_user_id_slack_user_id_slack_team_i_fkey; +ALTER TABLE user_team DROP CONSTRAINT user_team_pkey; +ALTER TABLE user_team ADD PRIMARY KEY (team_id, user_id); +ALTER TABLE user_team ADD CONSTRAINT user_team_mxid_unique UNIQUE(team_id, user_mxid); +ALTER TABLE user_team_portal RENAME COLUMN matrix_user_id TO user_mxid; +ALTER TABLE user_team_portal RENAME COLUMN slack_user_id TO user_id; +ALTER TABLE user_team_portal RENAME COLUMN slack_team_id TO team_id; +ALTER TABLE user_team_portal RENAME COLUMN portal_channel_id TO channel_id; +DELETE FROM user_team_portal out +WHERE EXISTS ( + SELECT FROM user_team_portal inn + WHERE inn.team_id=out.team_id + AND inn.user_id=out.user_id + AND inn.channel_id=out.channel_id + AND inn.ctid < out.ctid +); +ALTER TABLE user_team_portal ADD PRIMARY KEY (team_id, user_id, channel_id); +ALTER TABLE user_team_portal ADD CONSTRAINT utp_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE user_team_portal ADD CONSTRAINT utp_ut_fkey FOREIGN KEY (team_id, user_id) REFERENCES user_team(team_id, user_id) + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE user_team_portal ADD CONSTRAINT utp_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal(team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE; +CREATE INDEX user_team_user_idx ON user_team (user_mxid); +CREATE INDEX user_team_portal_user_idx ON user_team_portal (user_mxid); +CREATE INDEX user_team_portal_portal_idx ON user_team_portal (team_id, channel_id); + +ALTER TABLE emoji RENAME COLUMN slack_team TO team_id; +ALTER TABLE emoji RENAME COLUMN slack_id TO emoji_id; +ALTER TABLE emoji RENAME COLUMN image_url TO image_mxc; +ALTER TABLE emoji ADD COLUMN value TEXT NOT NULL DEFAULT ''; +ALTER TABLE emoji ALTER COLUMN value DROP DEFAULT; +ALTER TABLE emoji DROP CONSTRAINT emoji_pkey; +ALTER TABLE emoji ADD PRIMARY KEY (team_id, emoji_id); + +ALTER TABLE "user" ADD COLUMN access_token TEXT; diff --git a/database/upgrades/17-refactor.sqlite.sql b/database/upgrades/17-refactor.sqlite.sql new file mode 100644 index 0000000..b07d8fa --- /dev/null +++ b/database/upgrades/17-refactor.sqlite.sql @@ -0,0 +1,165 @@ +-- v17: Refactor database +CREATE TABLE message_new ( + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + part_id TEXT, + + thread_id TEXT, + author_id TEXT NOT NULL, + + mxid TEXT NOT NULL UNIQUE, + + PRIMARY KEY (team_id, channel_id, message_id, part_id), + CONSTRAINT message_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT message_mxid_unique UNIQUE (mxid) +); + +INSERT INTO message_new (team_id, channel_id, message_id, part_id, thread_id, author_id, mxid) +SELECT team_id, channel_id, slack_message_id, '', slack_thread_id, author_id, matrix_message_id +FROM message; +INSERT INTO message_new (team_id, channel_id, message_id, part_id, thread_id, author_id, mxid) +SELECT team_id, + channel_id, + slack_message_id, + 'file:0:' || slack_file_id, + slack_thread_id, + COALESCE( + (SELECT author_id + FROM message targetmsg + WHERE targetmsg.team_id = attachment.team_id + AND targetmsg.channel_id = attachment.channel_id + AND targetmsg.slack_message_id = attachment.slack_message_id), + '' + ), + matrix_event_id +FROM attachment; +DROP TABLE message; +DROP TABLE attachment; +ALTER TABLE message_new RENAME TO message; + +CREATE TABLE reaction_new ( + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + msg_first_part_id TEXT NOT NULL, + author_id TEXT NOT NULL, + emoji_id TEXT NOT NULL, + + mxid TEXT NOT NULL, + + PRIMARY KEY (team_id, channel_id, message_id, author_id, emoji_id), + CONSTRAINT reaction_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT reaction_message_fkey FOREIGN KEY (team_id, channel_id, message_id, msg_first_part_id) + REFERENCES message (team_id, channel_id, message_id, part_id) + ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO reaction_new (team_id, channel_id, message_id, msg_first_part_id, author_id, emoji_id, mxid) +SELECT team_id, + channel_id, + slack_message_id, + (SELECT part_id + FROM message + WHERE message.team_id = reaction.team_id + AND message.channel_id = reaction.channel_id + AND message.message_id = reaction.slack_message_id + ORDER BY part_id + LIMIT 1), + author_id, + slack_name, + matrix_event_id +FROM reaction; +DROP TABLE reaction; +ALTER TABLE reaction_new RENAME TO reaction; + +ALTER TABLE portal RENAME COLUMN avatar_url TO avatar_mxc; +ALTER TABLE portal DROP COLUMN first_event_id; +ALTER TABLE portal DROP COLUMN next_batch_id; +ALTER TABLE portal ADD COLUMN receiver TEXT NOT NULL DEFAULT ''; +UPDATE puppet SET avatar='' WHERE avatar IS NULL; +ALTER TABLE puppet RENAME COLUMN avatar_url TO avatar_mxc; +ALTER TABLE puppet DROP COLUMN enable_presence; +ALTER TABLE puppet DROP COLUMN enable_receipts; +ALTER TABLE puppet DROP COLUMN next_batch; +ALTER TABLE puppet DROP COLUMN custom_mxid; +ALTER TABLE puppet DROP COLUMN access_token; + +INSERT INTO team_portal (team_id) +SELECT DISTINCT team_id FROM user_team +ON CONFLICT (team_id) DO NOTHING; + +ALTER TABLE emoji RENAME COLUMN slack_team TO team_id; +ALTER TABLE emoji RENAME COLUMN slack_id TO emoji_id; +ALTER TABLE emoji RENAME COLUMN image_url TO image_mxc; +ALTER TABLE emoji ADD COLUMN value TEXT NOT NULL DEFAULT ''; + +ALTER TABLE "user" ADD COLUMN access_token TEXT; + +ALTER TABLE team_info RENAME TO team_portal; +UPDATE team_portal SET team_domain='' WHERE team_domain IS NULL; +UPDATE team_portal SET team_url='' WHERE team_url IS NULL; +UPDATE team_portal SET team_name='' WHERE team_name IS NULL; +UPDATE team_portal SET avatar='' WHERE avatar IS NULL; +ALTER TABLE team_portal RENAME COLUMN team_id TO id; +ALTER TABLE team_portal RENAME COLUMN team_domain TO domain; +ALTER TABLE team_portal RENAME COLUMN team_url TO url; +ALTER TABLE team_portal RENAME COLUMN team_name TO name; +ALTER TABLE team_portal RENAME COLUMN avatar_url TO avatar_mxc; + +CREATE TABLE user_team_portal_tmp ( + user_mxid TEXT NOT NULL, + user_id TEXT NOT NULL, + team_id TEXT NOT NULL, + channel_id TEXT NOT NULL +); +INSERT INTO user_team_portal_tmp (user_mxid, user_id, team_id, channel_id) +SELECT matrix_user_id, slack_user_id, slack_team_id, portal_channel_id +FROM user_team_portal; +DROP TABLE user_team_portal; + +CREATE TABLE user_team_new ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + + email TEXT NOT NULL, + token TEXT NOT NULL, + cookie_token TEXT NOT NULL, + + in_space BOOLEAN NOT NULL DEFAULT false, + + PRIMARY KEY (team_id, user_id), + CONSTRAINT user_team_mxid_unique UNIQUE (team_id, user_mxid), + CONSTRAINT user_team_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT user_team_team_fkey FOREIGN KEY (team_id) REFERENCES team_portal (id) + ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO user_team_new (team_id, user_id, user_mxid, email, token, cookie_token, in_space) +SELECT team_id, slack_id, mxid, slack_email, token, cookie_token, in_space +FROM user_team; +DROP TABLE user_team; +ALTER TABLE user_team_new RENAME TO user_team; +CREATE INDEX user_team_user_idx ON user_team (user_mxid); + +CREATE TABLE user_team_portal ( + team_id TEXT NOT NULL, + user_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_mxid TEXT NOT NULL, + PRIMARY KEY (team_id, user_id, channel_id), + CONSTRAINT utp_user_fkey FOREIGN KEY (user_mxid) REFERENCES "user" (mxid) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT utp_ut_fkey FOREIGN KEY (team_id, user_id) REFERENCES user_team (team_id, user_id) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT utp_portal_fkey FOREIGN KEY (team_id, channel_id) REFERENCES portal (team_id, channel_id) + ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO user_team_portal (team_id, user_id, channel_id, user_mxid) +SELECT team_id, user_id, channel_id, user_mxid +FROM user_team_portal_tmp; +DROP TABLE user_team_portal_tmp; +CREATE INDEX user_team_portal_user_idx ON user_team_portal (user_mxid); +CREATE INDEX user_team_portal_portal_idx ON user_team_portal (team_id, channel_id); diff --git a/database/upgrades/upgrades.go b/database/upgrades/upgrades.go index 38921a8..3e5d39c 100644 --- a/database/upgrades/upgrades.go +++ b/database/upgrades/upgrades.go @@ -17,10 +17,11 @@ package upgrades import ( + "context" "embed" "errors" - "maunium.net/go/mautrix/util/dbutil" + "go.mau.fi/util/dbutil" ) var Table dbutil.UpgradeTable @@ -29,7 +30,7 @@ var Table dbutil.UpgradeTable var rawUpgrades embed.FS func init() { - Table.Register(-1, 7, 0, "Unsupported version", false, func(tx dbutil.Execable, database *dbutil.Database) error { + Table.Register(-1, 7, 0, "Unsupported version", false, func(ctx context.Context, database *dbutil.Database) error { return errors.New("data from old dev version of mautrix-slack not supported, please delete the bridge database and set up bridge again") }) Table.RegisterFS(rawUpgrades) diff --git a/database/user.go b/database/user.go index e2514ef..0ebd922 100644 --- a/database/user.go +++ b/database/user.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,120 +17,68 @@ package database import ( + "context" "database/sql" - "sync" - - log "maunium.net/go/maulogger/v2" + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" ) -type User struct { - db *Database - log log.Logger - - MXID id.UserID - ManagementRoom id.RoomID - SpaceRoom id.RoomID - - TeamsLock sync.Mutex - Teams map[string]*UserTeam +type UserQuery struct { + *dbutil.QueryHelper[*User] } -func (user *User) loadTeams() { - user.TeamsLock.Lock() - defer user.TeamsLock.Unlock() - - for _, userTeam := range user.db.UserTeam.GetAllByMXIDWithToken(user.MXID) { - user.Teams[userTeam.Key.TeamID] = userTeam - } +func newUser(qh *dbutil.QueryHelper[*User]) *User { + return &User{qh: qh} } -func (u *User) Scan(row dbutil.Scannable) *User { - var spaceRoom sql.NullString - - err := row.Scan(&u.MXID, &u.ManagementRoom, &spaceRoom) - if err != nil { - if err != sql.ErrNoRows { - u.log.Errorln("Database scan failed:", err) - } - - return nil - } - - u.SpaceRoom = id.RoomID(spaceRoom.String) - - u.loadTeams() +const ( + getUserByMXIDQuery = `SELECT mxid, management_room, space_room, access_token FROM "user" WHERE mxid=$1` + getAllUsersWithAccessTokenQuery = `SELECT mxid, management_room, space_room, access_token FROM "user" WHERE access_token<>''` + insertUserQuery = `INSERT INTO "user" (mxid, management_room, space_room, access_token) VALUES ($1, $2, $3, $4)` + updateUserQuery = `UPDATE "user" SET management_room=$2, space_room=$3, access_token=$4 WHERE mxid=$1` +) - return u +func (uq *UserQuery) GetByMXID(ctx context.Context, userID id.UserID) (*User, error) { + return uq.QueryOne(ctx, getUserByMXIDQuery, userID) } -func (u *User) SyncTeams() { - u.TeamsLock.Lock() - defer u.TeamsLock.Unlock() - - for _, userteam := range u.Teams { - userteam.Upsert() - } - - // Delete not logged in teams from the database. - query := "DELETE FROM user_team WHERE mxid=$1 AND token=NULL" - - _, err := u.db.Exec(query, u.MXID) - if err != nil { - u.log.Warnfln("Failed to prune old teams for %s: %v", u.MXID, err) - } +func (uq *UserQuery) GetAllWithAccessToken(ctx context.Context) ([]*User, error) { + return uq.QueryMany(ctx, getAllUsersWithAccessTokenQuery) } -func (u *User) Insert() { - query := "INSERT INTO \"user\" (mxid, management_room, space_room) VALUES ($1, $2, $3);" - - _, err := u.db.Exec(query, u.MXID, u.ManagementRoom, u.SpaceRoom) - - if err != nil { - u.log.Warnfln("Failed to insert %s: %v", u.MXID, err) - } +type User struct { + qh *dbutil.QueryHelper[*User] - u.SyncTeams() + MXID id.UserID + ManagementRoom id.RoomID + SpaceRoom id.RoomID + AccessToken string } -func (u *User) Update() { - query := "UPDATE \"user\" SET management_room=$1, space_room=$2 WHERE mxid=$3;" - - _, err := u.db.Exec(query, u.ManagementRoom, u.SpaceRoom, u.MXID) +func (u *User) Scan(row dbutil.Scannable) (*User, error) { + var managementRoom, spaceRoom, accessToken sql.NullString + err := row.Scan(&u.MXID, &managementRoom, &spaceRoom, &accessToken) if err != nil { - u.log.Warnfln("Failed to update %q: %v", u.MXID, err) + return nil, err } - u.SyncTeams() -} - -func (u *User) TeamLoggedIn(email, domain string) bool { - u.TeamsLock.Lock() - defer u.TeamsLock.Unlock() - - for _, team := range u.Teams { - if team.SlackEmail == email && team.TeamName == domain { - return true - } - } + u.SpaceRoom = id.RoomID(spaceRoom.String) + u.ManagementRoom = id.RoomID(managementRoom.String) + u.AccessToken = accessToken.String - return false + return u, err } -func (u *User) GetLoggedInTeams() []*UserTeam { - u.TeamsLock.Lock() - defer u.TeamsLock.Unlock() - - teams := []*UserTeam{} +func (u *User) sqlVariables() []any { + return []any{u.MXID, dbutil.StrPtr(u.ManagementRoom), dbutil.StrPtr(u.SpaceRoom), dbutil.StrPtr(u.AccessToken)} +} - for _, team := range u.Teams { - if team.Token != "" { - teams = append(teams, team) - } - } +func (u *User) Insert(ctx context.Context) error { + return u.qh.Exec(ctx, insertUserQuery, u.sqlVariables()...) +} - return teams +func (u *User) Update(ctx context.Context) error { + return u.qh.Exec(ctx, updateUserQuery, u.sqlVariables()...) } diff --git a/database/userquery.go b/database/userquery.go deleted file mode 100644 index 1ece391..0000000 --- a/database/userquery.go +++ /dev/null @@ -1,73 +0,0 @@ -// mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package database - -import ( - log "maunium.net/go/maulogger/v2" - "maunium.net/go/mautrix/id" -) - -type UserQuery struct { - db *Database - log log.Logger -} - -func (uq *UserQuery) New() *User { - return &User{ - db: uq.db, - log: uq.log, - Teams: map[string]*UserTeam{}, - } -} - -func (uq *UserQuery) GetByMXID(userID id.UserID) *User { - query := `SELECT mxid, management_room, space_room FROM "user" WHERE mxid=$1` - row := uq.db.QueryRow(query, userID) - if row == nil { - return nil - } - - return uq.New().Scan(row) -} - -func (uq *UserQuery) GetBySlackID(teamID, userID string) *User { - query := `SELECT u.mxid, u.management_room, u.space_room FROM "user" u` + - ` INNER JOIN user_team ut ON u.mxid = ut.mxid` + - ` WHERE ut.team_id=$1 AND ut.slack_id=$2` - row := uq.db.QueryRow(query, teamID, userID) - if row == nil { - return nil - } - - return uq.New().Scan(row) -} - -func (uq *UserQuery) GetAll() []*User { - rows, err := uq.db.Query(`SELECT mxid, management_room, space_room FROM "user"`) - if err != nil || rows == nil { - return nil - } - - defer rows.Close() - - users := []*User{} - for rows.Next() { - users = append(users, uq.New().Scan(rows)) - } - - return users -} diff --git a/database/userteam.go b/database/userteam.go index 5b57710..038b1f3 100644 --- a/database/userteam.go +++ b/database/userteam.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,181 +17,142 @@ package database import ( + "context" "database/sql" - "fmt" - - log "maunium.net/go/maulogger/v2" + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" - - "github.com/slack-go/slack" ) type UserTeamQuery struct { - db *Database - log log.Logger + *dbutil.QueryHelper[*UserTeam] } -func (utq *UserTeamQuery) New() *UserTeam { - return &UserTeam{ - db: utq.db, - log: utq.log, - } +func newUserTeam(qh *dbutil.QueryHelper[*UserTeam]) *UserTeam { + return &UserTeam{qh: qh} } -const userTeamSelect = "SELECT ut.mxid, ut.slack_email, ut.slack_id, ut.team_name, ut.team_id, ut.token, ut.cookie_token, ut.in_space FROM user_team ut " - -func (utq *UserTeamQuery) GetBySlackDomain(userID id.UserID, email, domain string) *UserTeam { - query := userTeamSelect + "WHERE ut.mxid=$1 AND ut.slack_email=$2 AND ut.team_id=(SELECT team_id FROM team_info WHERE team_domain=$3)" - - row := utq.db.QueryRow(query, userID, email, domain) - if row == nil { - return nil - } +const ( + getAllUserTeamsForUserQuery = ` + SELECT team_id, user_id, user_mxid, email, token, cookie_token, in_space FROM user_team WHERE user_mxid = $1 + ` + getAllUserTeamsByTeamIDQuery = ` + SELECT team_id, user_id, user_mxid, email, token, cookie_token, in_space FROM user_team WHERE team_id=$1 + ` + getUserTeamByIDQuery = ` + SELECT team_id, user_id, user_mxid, email, token, cookie_token, in_space FROM user_team WHERE team_id=$1 AND user_id = $2 + ` + getAllUserTeamsWithTokenQuery = ` + SELECT team_id, user_id, user_mxid, email, token, cookie_token, in_space FROM user_team WHERE token<>'' + ` + getFirstUserTeamForPortalQuery = ` + SELECT ut.team_id, ut.user_id, ut.user_mxid, ut.email, ut.token, ut.cookie_token, ut.in_space FROM user_team ut + JOIN user_team_portal utp ON utp.team_id = ut.team_id AND utp.user_id = ut.user_id + WHERE utp.team_id = $1 + AND utp.channel_id = $2 + AND ut.token<>'' + LIMIT 1 + ` + insertUserTeamQuery = ` + INSERT INTO user_team (team_id, user_id, user_mxid, email, token, cookie_token, in_space) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (team_id, user_id) DO UPDATE + SET user_mxid=excluded.user_mxid, + email=excluded.email, + token=excluded.token, + cookie_token=excluded.cookie_token, + in_space=excluded.in_space + ` + updateUserTeamQuery = ` + UPDATE user_team + SET email=$4, + token=$5, + cookie_token=$6, + in_space=$7 + WHERE team_id=$1 AND user_id=$2 + ` + deleteUserTeamQuery = ` + DELETE FROM user_team WHERE team_id=$1 AND user_id=$2 + ` +) - return utq.New().Scan(row) +func (utq *UserTeamQuery) GetByID(ctx context.Context, key UserTeamKey) (*UserTeam, error) { + return utq.QueryOne(ctx, getUserTeamByIDQuery, key.TeamID, key.UserID) } -func (utq *UserTeamQuery) GetAllByMXIDWithToken(userID id.UserID) []*UserTeam { - query := userTeamSelect + "WHERE ut.mxid=$1 AND ut.token IS NOT NULL" - - rows, err := utq.db.Query(query, userID) - if err != nil || rows == nil { - return nil - } - - defer rows.Close() - - tokens := []*UserTeam{} - for rows.Next() { - tokens = append(tokens, utq.New().Scan(rows)) - } - - return tokens +func (utq *UserTeamQuery) GetAllForUser(ctx context.Context, userID id.UserID) ([]*UserTeam, error) { + return utq.QueryMany(ctx, getAllUserTeamsForUserQuery, userID) } -func (utq *UserTeamQuery) GetAllBySlackTeamID(teamID string) []*UserTeam { - query := userTeamSelect + "WHERE ut.team_id=$1" - - rows, err := utq.db.Query(query, teamID) - if err != nil || rows == nil { - return nil - } - - defer rows.Close() - - tokens := []*UserTeam{} - for rows.Next() { - tokens = append(tokens, utq.New().Scan(rows)) - } - - return tokens +func (utq *UserTeamQuery) GetAllWithToken(ctx context.Context) ([]*UserTeam, error) { + return utq.QueryMany(ctx, getAllUserTeamsWithTokenQuery) } -func (utq *UserTeamQuery) GetFirstUserTeamForPortal(portal *PortalKey) *UserTeam { - query := userTeamSelect + ` - JOIN user_team_portal utp ON utp.matrix_user_id = ut.mxid - AND utp.slack_team_id = ut.team_id - AND utp.slack_user_id = ut.slack_id - WHERE utp.slack_team_id = $1 - AND utp.portal_channel_id = $2 - AND ut.token IS NOT NULL - LIMIT 1` - - row := utq.db.QueryRow(query, portal.TeamID, portal.ChannelID) - if row == nil { - return nil - } - - return utq.New().Scan(row) -} - -type UserTeamKey struct { - MXID id.UserID - SlackID string - TeamID string +func (utq *UserTeamQuery) GetAllInTeam(ctx context.Context, teamID string) ([]*UserTeam, error) { + return utq.QueryMany(ctx, getAllUserTeamsByTeamIDQuery, teamID) } -func (utk UserTeamKey) String() string { - return fmt.Sprintf("%s-%s", utk.TeamID, utk.SlackID) +func (utq *UserTeamQuery) GetFirstUserTeamForPortal(ctx context.Context, portal *PortalKey) (*UserTeam, error) { + return utq.QueryOne(ctx, getFirstUserTeamForPortalQuery, portal.TeamID, portal.ChannelID) } -type UserTeam struct { - db *Database - log log.Logger - - Key UserTeamKey - - SlackEmail string - TeamName string - - Token string - CookieToken string - - InSpace bool - - Client *slack.Client - RTM *slack.RTM +type UserTeamKey struct { + TeamID string + UserID string } -func (ut *UserTeam) GetMXID() id.UserID { - return ut.Key.MXID +func (utk UserTeamKey) MarshalZerologObject(e *zerolog.Event) { + e.Str("team_id", utk.TeamID).Str("user_id", utk.UserID) } -func (ut *UserTeam) GetRemoteID() string { - return ut.Key.TeamID +type UserTeamMXIDKey struct { + UserTeamKey + UserMXID id.UserID } -func (ut *UserTeam) GetRemoteName() string { - return ut.TeamName +func (utk UserTeamMXIDKey) MarshalZerologObject(e *zerolog.Event) { + e.Str("team_id", utk.TeamID).Str("user_id", utk.UserID).Stringer("user_mxid", utk.UserMXID) } -func (ut *UserTeam) IsLoggedIn() bool { - return ut.Token != "" -} +type UserTeam struct { + qh *dbutil.QueryHelper[*UserTeam] -func (ut *UserTeam) IsConnected() bool { - return ut.Client != nil && ut.RTM != nil + UserTeamMXIDKey + Email string + Token string + CookieToken string + InSpace bool } -func (ut *UserTeam) Scan(row dbutil.Scannable) *UserTeam { +func (ut *UserTeam) Scan(row dbutil.Scannable) (*UserTeam, error) { var token sql.NullString var cookieToken sql.NullString - err := row.Scan(&ut.Key.MXID, &ut.SlackEmail, &ut.Key.SlackID, &ut.TeamName, &ut.Key.TeamID, &token, &cookieToken, &ut.InSpace) + err := row.Scan(&ut.TeamID, &ut.UserID, &ut.UserMXID, &ut.Email, &token, &cookieToken, &ut.InSpace) if err != nil { - if err != sql.ErrNoRows { - ut.log.Errorln("Database scan failed:", err) - } - - return nil - } - - if token.Valid { - ut.Token = token.String - } - if cookieToken.Valid { - ut.CookieToken = cookieToken.String + return nil, err } - return ut + ut.Token = token.String + ut.CookieToken = cookieToken.String + return ut, nil } -func (ut *UserTeam) Upsert() { - query := ` - INSERT INTO user_team (mxid, slack_email, slack_id, team_name, team_id, token, cookie_token, in_space) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (mxid, slack_id, team_id) DO UPDATE - SET slack_email=excluded.slack_email, team_name=excluded.team_name, token=excluded.token, cookie_token=excluded.cookie_token, in_space=excluded.in_space - ` +func (ut *UserTeam) sqlVariables() []any { + return []any{ + ut.TeamID, ut.UserID, ut.UserMXID, ut.Email, ut.Token, ut.CookieToken, ut.InSpace, + } +} - token := sqlNullString(ut.Token) - cookieToken := sqlNullString(ut.CookieToken) +func (ut *UserTeam) Insert(ctx context.Context) error { + return ut.qh.Exec(ctx, insertUserTeamQuery, ut.sqlVariables()...) +} - _, err := ut.db.Exec(query, ut.Key.MXID, ut.SlackEmail, ut.Key.SlackID, ut.TeamName, ut.Key.TeamID, token, cookieToken, ut.InSpace) +func (ut *UserTeam) Update(ctx context.Context) error { + return ut.qh.Exec(ctx, updateUserTeamQuery, ut.sqlVariables()...) +} - if err != nil { - ut.log.Warnfln("Failed to upsert %s/%s/%s: %v", ut.Key.MXID, ut.Key.SlackID, ut.Key.TeamID, err) - } +func (ut *UserTeam) Delete(ctx context.Context) error { + return ut.qh.Exec(ctx, deleteUserTeamQuery, ut.TeamID, ut.UserID) } diff --git a/emoji.go b/emoji.go index ea91649..18e6a8d 100644 --- a/emoji.go +++ b/emoji.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Max Sandholm +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,145 +17,219 @@ package main import ( + "context" _ "embed" - "encoding/json" - "regexp" "strings" + "github.com/rs/zerolog" "maunium.net/go/mautrix/id" + "github.com/slack-go/slack" "go.mau.fi/mautrix-slack/database" + "go.mau.fi/mautrix-slack/msgconv/emoji" ) -//go:embed resources/emoji.json -var emojiFileData []byte -var emojis map[string]string - -var re regexp.Regexp = *regexp.MustCompile(`:[^:\s]*:`) - -func replaceShortcodesWithEmojis(text string) string { - return re.ReplaceAllStringFunc(text, shortcodeToEmoji) -} - -func convertSlackReaction(text string) string { - var converted string - emoji := strings.Split(text, "::") - for _, e := range emoji { - converted += shortcodeToEmoji(e) +func (ut *UserTeam) handleEmojiChange(ctx context.Context, evt *slack.EmojiChangedEvent) { + ut.Team.emojiLock.Lock() + defer ut.Team.emojiLock.Unlock() + log := zerolog.Ctx(ctx) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("subtype", evt.SubType) + }) + switch evt.SubType { + case "add": + ut.addEmoji(ctx, evt.Name, evt.Value) + case "remove": + err := ut.bridge.DB.Emoji.DeleteMany(ctx, ut.TeamID, evt.Names...) + if err != nil { + log.Err(err).Strs("emoji_ids", evt.Names).Msg("Failed to delete emojis from database") + } + case "rename": + dbEmoji, err := ut.bridge.DB.Emoji.GetBySlackID(ctx, ut.TeamID, evt.OldName) + if err != nil { + log.Err(err).Msg("Failed to get emoji from database for renaming") + } else if dbEmoji == nil || dbEmoji.Value != evt.Value { + log.Warn().Msg("Old emoji not found for renaming, adding new one") + ut.addEmoji(ctx, evt.NewName, evt.Value) + } else if err = dbEmoji.Rename(ctx, evt.NewName); err != nil { + log.Err(err).Msg("Failed to rename emoji in database") + } + default: + log.Warn().Msg("Unknown emoji change subtype, resyncing emojis") + err := ut.syncEmojis(ctx, false) + if err != nil { + log.Err(err).Msg("Failed to resync emojis") + } } - return converted } -func shortcodeToEmoji(code string) string { - strippedCode := strings.Trim(code, ":") - emoji, found := emojis[strippedCode] - if found { - return emoji - } else { - return code +func (ut *UserTeam) addEmoji(ctx context.Context, emojiName, emojiValue string) *database.Emoji { + log := zerolog.Ctx(ctx) + dbEmoji, err := ut.bridge.DB.Emoji.GetBySlackID(ctx, ut.TeamID, emojiName) + if err != nil { + log.Err(err). + Str("emoji_name", emojiName). + Str("emoji_value", emojiValue). + Msg("Failed to check if emoji already exists") + return nil } -} - -func emojiToShortcode(emoji string) string { - var partCodes []string - for _, r := range withoutVariationSelector(emoji) { - for code, e := range emojis { - if string(r) == withoutVariationSelector(e) { - partCodes = append(partCodes, code) - continue + var newAlias string + var newImageMXC id.ContentURI + if strings.HasPrefix(emojiValue, "alias:") { + newAlias = ut.TryGetEmoji(ctx, strings.TrimPrefix(emojiValue, "alias:")) + if strings.HasPrefix(newAlias, "mxc://") { + newImageMXC, _ = id.ParseContentURI(newAlias) + } + if dbEmoji != nil && dbEmoji.Value == emojiValue && dbEmoji.Alias == newAlias && dbEmoji.ImageMXC == newImageMXC { + return dbEmoji + } + } else { + if dbEmoji != nil && dbEmoji.Value == emojiValue { + return dbEmoji + } + // Don't reupload emojis that are only missing the value column (but do set the value column so it's there in the future) + if dbEmoji == nil || dbEmoji.Value != "" || dbEmoji.ImageMXC.IsEmpty() { + newImageMXC, err = uploadPlainFile(ctx, ut.bridge.Bot, emojiValue) + if err != nil { + log.Err(err). + Str("emoji_name", emojiName). + Str("emoji_value", emojiValue). + Msg("Failed to reupload emoji") + return nil } } } - return strings.Join(partCodes, "::") + if dbEmoji == nil { + dbEmoji = ut.bridge.DB.Emoji.New() + dbEmoji.TeamID = ut.TeamID + dbEmoji.EmojiID = emojiName + } + dbEmoji.Value = emojiValue + dbEmoji.Alias = newAlias + dbEmoji.ImageMXC = newImageMXC + err = dbEmoji.Upsert(ctx) + if err != nil { + log.Err(err). + Str("emoji_name", emojiName). + Str("emoji_value", emojiValue). + Msg("Failed to save custom emoji to database") + } + return dbEmoji } -func withoutVariationSelector(str string) string { - return strings.Map(func(r rune) rune { - if r == '\ufe0f' { - return -1 - } - return r - }, str) +func (ut *UserTeam) ResyncEmojisDueToNotFound(ctx context.Context) bool { + if !ut.Team.emojiLock.TryLock() { + return false + } + defer ut.Team.emojiLock.Unlock() + err := ut.syncEmojis(ctx, false) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to sync emojis after emoji wasn't found") + return false + } + return true } -func init() { - json.Unmarshal(emojiFileData, &emojis) +func (ut *UserTeam) SyncEmojis(ctx context.Context) { + ut.Team.emojiLock.Lock() + defer ut.Team.emojiLock.Lock() + err := ut.syncEmojis(ctx, true) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to sync emojis") + } } -func (br *SlackBridge) ImportEmojis(userTeam *database.UserTeam, list *map[string]string, overwrite bool) error { - if list == nil { - resp, err := userTeam.Client.GetEmoji() +func (ut *UserTeam) syncEmojis(ctx context.Context, onlyIfCountMismatch bool) error { + log := zerolog.Ctx(ctx).With().Str("action", "sync emojis").Logger() + resp, err := ut.Client.GetEmojiContext(ctx) + if err != nil { + log.Err(err).Msg("Failed to fetch emoji list") + return err + } + if onlyIfCountMismatch { + emojiCount, err := ut.bridge.DB.Emoji.GetEmojiCount(ctx, ut.TeamID) if err != nil { - br.ZLog.Err(err).Msg("failed to fetch emoji list from Slack") - return err + log.Err(err).Msg("Failed to get emoji count from database") + return nil + } else if emojiCount == len(resp) { + return nil } - list = &resp } - deferredAliases := map[string]string{} - uploaded := map[string]id.ContentURI{} - - for key, url := range *list { - existing := br.DB.Emoji.GetBySlackID(key, userTeam.Key.TeamID) - if existing == nil || overwrite { - if strings.HasPrefix(url, "alias:") { - deferredAliases[key] = strings.TrimPrefix(url, "alias:") - continue + deferredAliases := make(map[string]string) + uploaded := make(map[string]id.ContentURI, len(resp)) + existingIDs := make([]string, 0, len(resp)) + + for key, url := range resp { + existingIDs = append(existingIDs, key) + if strings.HasPrefix(url, "alias:") { + deferredAliases[key] = strings.TrimPrefix(url, "alias:") + } else { + addedEmoji := ut.addEmoji(ctx, key, url) + if addedEmoji != nil && !addedEmoji.ImageMXC.IsEmpty() { + uploaded[key] = addedEmoji.ImageMXC } - - uri, err := uploadPlainFile(br.AS.BotIntent(), url) - if err != nil { - br.ZLog.Err(err).Str("url", url).Msg("failed to upload emoji to matrix") - continue - } - - uploaded[key] = uri - - dbEmoji := br.DB.Emoji.New() - dbEmoji.SlackID = key - dbEmoji.SlackTeam = userTeam.Key.TeamID - dbEmoji.ImageURL = uri - dbEmoji.Upsert(nil) } } for key, alias := range deferredAliases { + dbEmoji := ut.bridge.DB.Emoji.New() + dbEmoji.EmojiID = key + dbEmoji.TeamID = ut.TeamID if uri, ok := uploaded[alias]; ok { - dbEmoji := br.DB.Emoji.New() - dbEmoji.SlackID = key - dbEmoji.SlackTeam = userTeam.Key.TeamID dbEmoji.Alias = alias - dbEmoji.ImageURL = uri - dbEmoji.Upsert(nil) - } else if unicode := shortcodeToEmoji(alias); unicode != alias { - dbEmoji := br.DB.Emoji.New() - dbEmoji.SlackID = key - dbEmoji.SlackTeam = userTeam.Key.TeamID + dbEmoji.ImageMXC = uri + } else if unicode, ok := emoji.ShortcodeToUnicodeMap[alias]; ok { dbEmoji.Alias = unicode - dbEmoji.Upsert(nil) + } + err = dbEmoji.Upsert(ctx) + if err != nil { + log.Err(err). + Str("emoji_id", key). + Str("alias", alias). + Msg("Failed to save deferred emoji alias to database") + } + } + + emojiCount, err := ut.bridge.DB.Emoji.GetEmojiCount(ctx, ut.TeamID) + if err != nil { + log.Err(err).Msg("Failed to get emoji count from database to check if emojis need to be pruned") + } else if emojiCount > len(resp) { + err = ut.bridge.DB.Emoji.Prune(ctx, ut.TeamID, existingIDs...) + if err != nil { + log.Err(err).Msg("Failed to prune removed emojis from database") } } return nil } -func (br *SlackBridge) GetEmoji(shortcode string, userTeam *database.UserTeam) string { - converted := convertSlackReaction(shortcode) - if converted != shortcode { - return converted +func (ut *UserTeam) TryGetEmoji(ctx context.Context, shortcode string) string { + unicode, ok := emoji.ShortcodeToUnicodeMap[shortcode] + if ok { + return unicode } - dbEmoji := br.DB.Emoji.GetBySlackID(shortcode, userTeam.Key.TeamID) - if dbEmoji == nil { - br.ImportEmojis(userTeam, nil, false) - dbEmoji = br.DB.Emoji.GetBySlackID(shortcode, userTeam.Key.TeamID) - } - - if dbEmoji != nil && !dbEmoji.ImageURL.IsEmpty() { - return dbEmoji.ImageURL.String() + dbEmoji, err := ut.bridge.DB.Emoji.GetBySlackID(ctx, ut.TeamID, shortcode) + if err != nil { + zerolog.Ctx(ctx).Err(err).Str("shortcode", shortcode).Msg("Failed to get emoji from database") + return "" + } else if dbEmoji != nil && !dbEmoji.ImageMXC.IsEmpty() { + return dbEmoji.ImageMXC.String() } else if dbEmoji != nil { return dbEmoji.Alias } else { - return shortcode + return "" + } +} + +func (ut *UserTeam) GetEmoji(ctx context.Context, shortcode string) string { + emojiVal := ut.TryGetEmoji(ctx, shortcode) + if emojiVal == "" && ut.ResyncEmojisDueToNotFound(ctx) { + emojiVal = ut.TryGetEmoji(ctx, shortcode) + } + if emojiVal == "" { + emojiVal = shortcode } + return emojiVal } diff --git a/example-config.yaml b/example-config.yaml index db7abde..165ccf4 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -82,11 +82,47 @@ bridge: # Localpart template of MXIDs for Slack users. # {{.}} is replaced with the internal ID of the Slack user. username_template: slack_{{.}} - # Displayname template for Slack users. - # TODO: document variables - displayname_template: '{{.RealName}} (S)' - bot_displayname_template: '{{.Name}} (bot)' - channel_name_template: '#{{.Name}}' + # Displayname template for Slack users. Available variables: + # .Name - The displayname of the user + # .ID - The internal ID of the user + # .IsBot - Whether the user is a bot + # Variables only available for users (not bots): + # .TeamID - The internal ID of the workspace the user is in + # .TZ - The timezone region of the user (e.g. Europe/London) + # .TZLabel - The label of the timezone of the user (e.g. Greenwich Mean Time) + # .TZOffset - The UTC offset of the timezone of the user (e.g. 0) + # .Profile.RealName - The real name of the user + # .Profile.FirstName - The first name of the user + # .Profile.LastName - The last name of the user + # .Profile.Title - The job title of the user + # .Profile.Pronouns - The pronouns of the user + # .Profile.Email - The email address of the user + # .Profile.Phone - The formatted phone number of the user + displayname_template: '{{.Name}}{{if .IsBot}} (bot){{end}}' + # Channel name template for Slack channels (all types). Available variables: + # .Name - The name of the channel + # .TeamName - The name of the team the channel is in + # .TeamDomain - The Slack subdomain of the team the channel is in + # .ID - The internal ID of the channel + # .IsGeneral - Whether the channel is the #general channel + # .IsChannel - Whether the channel is a channel (rather than a DM) + # .IsPrivate - Whether the channel is private + # .IsIM - Whether the channel is an one-to-one DM + # .IsMpIM - Whether the channel is a group DM + # .IsShared - Whether the channel is shared with another workspace. + # .IsExtShared - Whether the channel is shared with an external organization. + # .IsOrgShared - Whether the channel is shared with an organization in the same enterprise grid. + channel_name_template: '{{if .IsChannel}}{{if .IsPrivate}}🔒️{{else}}#{{end}}{{end}}{{.Name}}' + # Displayname template for Slack workspaces. Available variables: + # .Name - The name of the team + # .Domain - The Slack subdomain of the team + # .ID - The internal ID of the team + team_name_template: "{{ .Name }}" + # Whether to explicitly set the avatar and room name for private chat portal rooms. + # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. + # If set to `always`, all DM rooms will have explicit names and avatars set. + # If set to `never`, DM rooms will never have names and avatars set. + private_chat_portal_meta: default portal_message_buffer: 128 @@ -99,6 +135,15 @@ bridge: # Should incoming custom emoji reactions be bridged as mxc:// URIs? # If set to false, custom emoji reactions will be bridged as the shortcode instead, and the image won't be available. custom_emoji_reactions: true + # Should Matrix users be kicked from rooms when they log out or are removed from a workspace? + kick_on_logout: true + # Should channels and group DMs have the workspace icon as the Matrix room avatar? + workspace_avatar_in_rooms: false + # Number of participants to sync in channels (doesn't affect group DMs) + participant_sync_count: 5 + # Should channel participants only be synced when creating the room? + # If you want participants to always be accurately synced, set participant_sync_count to a high value and this to false. + participant_sync_only_on_create: true # Should the bridge sync with double puppeting to receive EDUs that aren't normally sent to appservices. sync_with_custom_puppets: false @@ -109,11 +154,6 @@ bridge: # Whether or not created rooms should have federation enabled. # If false, created portal rooms will never be federated. federate_rooms: true - # Whether to explicitly set the avatar and room name for private chat portal rooms. - # If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms. - # If set to `always`, all DM rooms will have explicit names and avatars set. - # If set to `never`, DM rooms will never have names and avatars set. - private_chat_portal_meta: default # Servers to always allow double puppeting from double_puppet_server_map: diff --git a/go.mod b/go.mod index f8a9f5a..1a8f026 100644 --- a/go.mod +++ b/go.mod @@ -1,35 +1,39 @@ module go.mau.fi/mautrix-slack -go 1.19 +go 1.21 require ( - github.com/gorilla/websocket v1.5.0 + github.com/beeper/libserv v0.0.0-20231231202820-c7303abfc32c github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 github.com/slack-go/slack v0.10.3 - github.com/yuin/goldmark v1.5.4 + github.com/yuin/goldmark v1.7.1 + go.mau.fi/util v0.4.2-0.20240318211948-d27d5a4cda9e maunium.net/go/maulogger/v2 v2.4.1 - maunium.net/go/mautrix v0.15.4 + maunium.net/go/mautrix v0.18.1-0.20240413105730-423d32ddf6d6 ) require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/gorilla/mux v1.8.0 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect - github.com/rs/zerolog v1.29.1 // indirect - github.com/tidwall/gjson v1.14.4 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/rs/xid v1.5.0 // indirect + github.com/rs/zerolog v1.32.0 + github.com/tidwall/gjson v1.17.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect go.mau.fi/zeroconfig v0.1.2 // indirect - golang.org/x/crypto v0.11.0 // indirect - golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 - golang.org/x/net v0.12.0 // indirect - golang.org/x/sys v0.10.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f + golang.org/x/net v0.22.0 // indirect + golang.org/x/sys v0.18.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) -replace github.com/slack-go/slack => github.com/beeper/slackgo v0.11.3-0.20230919144304-36e35976c9c3 +//replace github.com/slack-go/slack => github.com/beeper/slackgo v0.11.3-0.20230919144304-36e35976c9c3 +replace github.com/slack-go/slack => ../../Go/slackgo diff --git a/go.sum b/go.sum index cfca50e..9d172e5 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ -github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= -github.com/beeper/slackgo v0.11.3-0.20230919144304-36e35976c9c3 h1:jtszdZhFmxxstuHffDZ3r5SKtRQ4uHanv8+ODgCmArc= -github.com/beeper/slackgo v0.11.3-0.20230919144304-36e35976c9c3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/beeper/libserv v0.0.0-20231231202820-c7303abfc32c h1:WqjRVgUO039eiISCjsZC4F9onOEV93DJAk6v33rsZzY= +github.com/beeper/libserv v0.0.0-20231231202820-c7303abfc32c/go.mod h1:b9FFm9y4mEm36G8ytVmS1vkNzJa0KepmcdVY+qf7qRU= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -10,6 +11,7 @@ github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -17,43 +19,49 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc= -github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU= +github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= +github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= -github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= +github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= -github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +go.mau.fi/util v0.4.2-0.20240318211948-d27d5a4cda9e h1:2jdYsZTTIwSo4TGmVrqLgeCqaxexJ9nY2Tuj1MzDIwc= +go.mau.fi/util v0.4.2-0.20240318211948-d27d5a4cda9e/go.mod h1:GjkTEBsehYZbSh2LlE6cWEn+6ZIZTGrTMM/5DMNlmFY= go.mau.fi/zeroconfig v0.1.2 h1:DKOydWnhPMn65GbXZOafgkPm11BvFashZWLct0dGFto= go.mau.fi/zeroconfig v0.1.2/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= -golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f h1:3CW0unweImhOzd5FmYuRsD4Y4oQFKZIjAnKbjV4WIrw= +golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -65,5 +73,5 @@ maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8= maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho= -maunium.net/go/mautrix v0.15.4 h1:Ug3n2Mo+9Yb94AjZTWJQSNHmShaksEzZi85EPl3S3P0= -maunium.net/go/mautrix v0.15.4/go.mod h1:dBaDmsnOOBM4a+gKcgefXH73pHGXm+MCJzCs1dXFgrw= +maunium.net/go/mautrix v0.18.1-0.20240413105730-423d32ddf6d6 h1:zSGNGm31EzKYDmKJG2VS5wYqYDAT5pJhFfGauR6r4yU= +maunium.net/go/mautrix v0.18.1-0.20240413105730-423d32ddf6d6/go.mod h1:STwJZ+6CAeiEQs7fYCkd5aC12XR5DXANE6Swy/PBKGo= diff --git a/historysync.go b/historysync.go.dis similarity index 92% rename from historysync.go rename to historysync.go.dis index 531c08d..aea068c 100644 --- a/historysync.go +++ b/historysync.go.dis @@ -25,43 +25,43 @@ import ( "github.com/slack-go/slack" + "go.mau.fi/util/dbutil" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" "go.mau.fi/mautrix-slack/database" ) // region history sync handling -func (bridge *SlackBridge) handleHistorySyncsLoop() { - if !bridge.Config.Bridge.Backfill.Enable { +func (br *SlackBridge) handleHistorySyncsLoop() { + if !br.Config.Bridge.Backfill.Enable { return } // Backfills shouldn't be marked as dispatched during startup, this gives them a chance to retry - bridge.DB.Backfill.UndispatchAll() + br.DB.Backfill.UndispatchAll() - bridge.HandleBackfillRequestsLoop() + br.HandleBackfillRequestsLoop() } -func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillState, portal *Portal) { +func (br *SlackBridge) backfillInChunks(backfillState *database.BackfillState, portal *Portal) { portal.backfillLock.Lock() defer portal.backfillLock.Unlock() backfillState.SetDispatched(true) defer backfillState.SetDispatched(false) - maxMessages := bridge.Config.Bridge.Backfill.Incremental.MaxMessages.GetMaxMessagesFor(portal.Type) + maxMessages := br.Config.Bridge.Backfill.Incremental.MaxMessages.GetMaxMessagesFor(portal.Type) if maxMessages > 0 && backfillState.MessageCount >= maxMessages { backfillState.BackfillComplete = true backfillState.Upsert() - bridge.Log.Infofln("Backfilling complete for portal %s, not filling any more", portal.Key) + br.Log.Infofln("Backfilling complete for portal %s, not filling any more", portal.Key) return } @@ -87,9 +87,9 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat defer portal.latestEventBackfillLock.Unlock() } - userTeam := bridge.DB.UserTeam.GetFirstUserTeamForPortal(&portal.Key) + userTeam := br.DB.UserTeam.GetFirstUserTeamForPortal(&portal.Key) if userTeam == nil { - bridge.Log.Errorfln("Couldn't find logged in user with access to %s for backfilling!", portal.Key) + br.Log.Errorfln("Couldn't find logged in user with access to %s for backfilling!", portal.Key) backfillState.BackfillComplete = true backfillState.Upsert() return @@ -103,7 +103,7 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat // Fetch actual messages from Slack. resp, err := userTeam.Client.GetConversationHistory(&slackReqParams) if err != nil { - bridge.Log.Errorfln("Error fetching Slack messages for backfilling %s: %v", portal.Key, err) + br.Log.Errorfln("Error fetching Slack messages for backfilling %s: %v", portal.Key, err) backfillState.BackfillComplete = true backfillState.Upsert() return @@ -111,7 +111,7 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat allMsgs := resp.Messages if len(allMsgs) == 0 { - bridge.Log.Debugfln("Not backfilling %s: no bridgeable messages found", portal.Key) + br.Log.Debugfln("Not backfilling %s: no bridgeable messages found", portal.Key) backfillState.BackfillComplete = true backfillState.Upsert() return @@ -124,10 +124,10 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat if !backfillState.ImmediateComplete { maxBatchEvents = -1 } else { - maxBatchEvents = bridge.Config.Bridge.Backfill.Incremental.MessagesPerBatch + maxBatchEvents = br.Config.Bridge.Backfill.Incremental.MessagesPerBatch } - bridge.Log.Infofln("Backfilling %d messages in %s, %d messages at a time", len(allMsgs), portal.Key, maxBatchEvents) + br.Log.Infofln("Backfilling %d messages in %s, %d messages at a time", len(allMsgs), portal.Key, maxBatchEvents) toBackfill := allMsgs[0:] for len(toBackfill) > 0 { var msgs []slack.Message @@ -140,8 +140,8 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat } if len(msgs) > 0 { - time.Sleep(time.Duration(bridge.Config.Bridge.Backfill.Incremental.PostBatchDelay) * time.Second) - bridge.Log.Debugfln("Backfilling %d messages in %s", len(msgs), portal.Key) + time.Sleep(time.Duration(br.Config.Bridge.Backfill.Incremental.PostBatchDelay) * time.Second) + br.Log.Debugfln("Backfilling %d messages in %s", len(msgs), portal.Key) resp, err := portal.backfill(userTeam, msgs, !backfillState.ImmediateComplete) if resp != nil && (resp.BaseInsertionEventID != "" || !isLatestEvents) { backfillState.MessageCount += len(msgs) @@ -158,7 +158,7 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat } } } - bridge.Log.Debugfln("Finished backfilling %d messages in %s", len(allMsgs), portal.Key) + br.Log.Debugfln("Finished backfilling %d messages in %s", len(allMsgs), portal.Key) backfillState.MessageCount += len(allMsgs) @@ -175,7 +175,7 @@ func (bridge *SlackBridge) backfillInChunks(backfillState *database.BackfillStat backfillState.Upsert() // TODO: add these config options - // if bridge.Config.Bridge.HistorySync.UnreadHoursThreshold > 0 && conv.LastMessageTimestamp.Before(time.Now().Add(time.Duration(-bridge.Config.Bridge.HistorySync.UnreadHoursThreshold)*time.Hour)) { + // if br.Config.Bridge.HistorySync.UnreadHoursThreshold > 0 && conv.LastMessageTimestamp.Before(time.Now().Add(time.Duration(-br.Config.Bridge.HistorySync.UnreadHoursThreshold)*time.Hour)) { // user.markSelfReadFull(portal) // } } @@ -391,7 +391,7 @@ func (portal *Portal) backfill(userTeam *database.UserTeam, messages []slack.Mes content := event.MemberEventContent{ Membership: event.MembershipJoin, Displayname: puppet.Name, - AvatarURL: puppet.AvatarURL.CUString(), + AvatarURL: puppet.AvatarMXC.CUString(), } inviteContent := content inviteContent.Membership = event.MembershipInvite diff --git a/log.go b/log.go deleted file mode 100644 index 15e985b..0000000 --- a/log.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import "maunium.net/go/maulogger/v2" - -type SlackgoLogger struct { - maulogger.Logger -} - -// This makes slack-go able to use the logger to log debug output -func (l SlackgoLogger) Output(i int, s string) error { - l.Debugln(s) - return nil -} diff --git a/main.go b/main.go index c3c7e20..dbacd54 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -20,11 +20,11 @@ import ( _ "embed" "sync" + "go.mau.fi/util/configupgrade" + "maunium.net/go/mautrix/bridge" "maunium.net/go/mautrix/bridge/commands" - "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/configupgrade" "go.mau.fi/mautrix-slack/config" "go.mau.fi/mautrix-slack/database" @@ -49,29 +49,22 @@ type SlackBridge struct { provisioning *ProvisioningAPI - MatrixHTMLParser *format.HTMLParser - - BackfillQueue *BackfillQueue - historySyncLoopStarted bool + //BackfillQueue *BackfillQueue + //historySyncLoopStarted bool - usersByMXID map[id.UserID]*User - usersByID map[string]*User // the key is teamID-userID - usersLock sync.Mutex - - managementRooms map[id.RoomID]*User - managementRoomsLock sync.Mutex + usersByMXID map[id.UserID]*User + managementRooms map[id.RoomID]*User + userTeamsByID map[database.UserTeamKey]*UserTeam + teamsByMXID map[id.RoomID]*Team + teamsByID map[string]*Team + userAndTeamLock sync.Mutex portalsByMXID map[id.RoomID]*Portal portalsByID map[database.PortalKey]*Portal portalsLock sync.Mutex - teamsByMXID map[id.RoomID]*Team - teamsByID map[string]*Team - teamsLock sync.Mutex - - puppets map[string]*Puppet - puppetsByCustomMXID map[id.UserID]*Puppet - puppetsLock sync.Mutex + puppets map[database.UserTeamKey]*Puppet + puppetsLock sync.Mutex } func (br *SlackBridge) GetExampleConfig() string { @@ -90,9 +83,7 @@ func (br *SlackBridge) Init() { br.CommandProcessor = commands.NewProcessor(&br.Bridge) br.RegisterCommands() - br.DB = database.New(br.Bridge.DB, br.Log.Sub("Database")) - - br.MatrixHTMLParser = NewParser(br) + br.DB = database.New(br.Bridge.DB) } func (br *SlackBridge) Start() { @@ -100,20 +91,20 @@ func (br *SlackBridge) Start() { br.provisioning = newProvisioningAPI(br) } - br.BackfillQueue = &BackfillQueue{ - BackfillQuery: br.DB.Backfill, - reCheckChannels: []chan bool{}, - log: br.Log.Sub("BackfillQueue"), - } + //br.BackfillQueue = &BackfillQueue{ + // BackfillQuery: br.DB.Backfill, + // reCheckChannels: []chan bool{}, + // log: br.Log.Sub("BackfillQueue"), + //} br.WaitWebsocketConnected() go br.startUsers() } func (br *SlackBridge) Stop() { - for _, user := range br.usersByMXID { - br.Log.Debugln("Disconnecting", user.MXID) - user.Disconnect() + for _, userTeam := range br.userTeamsByID { + userTeam.Log.Debug().Msg("Disconnecting team") + userTeam.Disconnect() } } @@ -134,7 +125,7 @@ func (br *SlackBridge) GetIUser(mxid id.UserID, create bool) bridge.User { } func (br *SlackBridge) IsGhost(mxid id.UserID) bool { - _, _, isGhost := br.ParsePuppetMXID(mxid) + _, isGhost := br.ParsePuppetMXID(mxid) return isGhost } @@ -152,19 +143,16 @@ func (br *SlackBridge) CreatePrivatePortal(id id.RoomID, user bridge.User, ghost func main() { br := &SlackBridge{ - usersByMXID: make(map[id.UserID]*User), - usersByID: make(map[string]*User), - + usersByMXID: make(map[id.UserID]*User), managementRooms: make(map[id.RoomID]*User), + userTeamsByID: make(map[database.UserTeamKey]*UserTeam), + teamsByMXID: make(map[id.RoomID]*Team), + teamsByID: make(map[string]*Team), portalsByMXID: make(map[id.RoomID]*Portal), portalsByID: make(map[database.PortalKey]*Portal), - teamsByMXID: make(map[id.RoomID]*Team), - teamsByID: make(map[string]*Team), - - puppets: make(map[string]*Puppet), - puppetsByCustomMXID: make(map[id.UserID]*Puppet), + puppets: make(map[database.UserTeamKey]*Puppet), } br.Bridge = bridge.Bridge{ Name: "mautrix-slack", diff --git a/messagetracking.go b/messagetracking.go index 181f219..7ed6bb0 100644 --- a/messagetracking.go +++ b/messagetracking.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -29,25 +29,21 @@ import ( "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + "go.mau.fi/mautrix-slack/msgconv" ) var ( - errUserNotLoggedIn = errors.New("user is not logged in to this Slack team") - errMNoticeDisabled = errors.New("bridging m.notice messages is disabled") - errUnexpectedParsedContentType = errors.New("unexpected parsed content type") - errUnknownMsgType = errors.New("unknown msgtype") - errUnexpectedRelatesTo = errors.New("unexpected relation type") - errMediaDownloadFailed = errors.New("failed to download media") - errMediaSlackUploadFailed = errors.New("failed to upload media to Slack") - errMediaUnsupportedType = errors.New("unsupported media type") - errTargetNotFound = errors.New("target event not found") - errEmojiShortcodeNotFound = errors.New("emoji shortcode not found") - errUnknownEmoji = errors.New("unknown emoji") - errReactionDatabaseNotFound = errors.New("reaction database entry not found") - errReactionTargetNotFound = errors.New("reaction target message not found") - errTargetIsFake = errors.New("target is a fake event") - errReactionSentBySomeoneElse = errors.New("target reaction was sent by someone else") - errDMSentByOtherUser = errors.New("target message was sent by the other user in a DM") + errUserNotLoggedIn = errors.New("user is not logged in to this Slack team") + errMNoticeDisabled = errors.New("bridging m.notice messages is disabled") + errUnexpectedRelatesTo = errors.New("unexpected relation type") + errMediaSlackUploadFailed = errors.New("failed to upload media to Slack") + errMediaUnsupportedType = errors.New("unsupported media type") + errEmojiShortcodeNotFound = errors.New("emoji shortcode not found") + errDuplicateReaction = errors.New("duplicate reaction") + errUnknownEmoji = errors.New("unknown emoji") + errReactionTargetNotFound = errors.New("reaction target message not found") + errMessageInWrongRoom = errors.New("message is in another room") errMessageTakingLong = errors.New("bridging the message is taking longer than usual") errTimeoutBeforeHandling = errors.New("message timed out before handling was started") @@ -55,8 +51,8 @@ var ( func errorToStatusReason(err error) (reason event.MessageStatusReason, status event.MessageStatus, isCertain, sendNotice bool, humanMessage string) { switch { - case errors.Is(err, errUnexpectedParsedContentType), - errors.Is(err, errUnknownMsgType): + case errors.Is(err, msgconv.ErrUnexpectedParsedContentType), + errors.Is(err, msgconv.ErrUnknownMsgType): return event.MessageStatusUnsupported, event.MessageStatusFail, true, true, "" case errors.Is(err, errMNoticeDisabled): return event.MessageStatusUnsupported, event.MessageStatusFail, true, false, "" @@ -68,19 +64,20 @@ func errorToStatusReason(err error) (reason event.MessageStatusReason, status ev return event.MessageStatusTooOld, event.MessageStatusRetriable, false, true, "handling the message took too long and was cancelled" case errors.Is(err, errMessageTakingLong): return event.MessageStatusTooOld, event.MessageStatusPending, false, true, err.Error() - case errors.Is(err, errTargetNotFound), - errors.Is(err, errTargetIsFake), - errors.Is(err, errReactionDatabaseNotFound), - errors.Is(err, errReactionTargetNotFound), - errors.Is(err, errReactionSentBySomeoneElse), - errors.Is(err, errDMSentByOtherUser): - return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "" + case errors.Is(err, msgconv.ErrEditTargetNotFound): + return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "edit target is not known" + case errors.Is(err, msgconv.ErrThreadRootNotFound): + return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "reply target is not known" + case errors.Is(err, msgconv.ErrMediaOnlyEditCaption): + return event.MessageStatusGenericError, event.MessageStatusFail, true, false, err.Error() + case errors.Is(err, errReactionTargetNotFound): + return event.MessageStatusGenericError, event.MessageStatusFail, true, false, "reaction target is not known" default: return event.MessageStatusGenericError, event.MessageStatusRetriable, false, true, "" } } -func (portal *Portal) sendErrorMessage(evt *event.Event, err error, confirmed bool, editID id.EventID) id.EventID { +func (portal *Portal) sendErrorMessage(ctx context.Context, evt *event.Event, err error, confirmed bool, editID id.EventID) id.EventID { if !portal.bridge.Config.Bridge.MessageErrorNotices { return "" } @@ -101,7 +98,7 @@ func (portal *Portal) sendErrorMessage(evt *event.Event, err error, confirmed bo } else { content.SetReply(evt) } - resp, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) + resp, err := portal.sendMatrixMessage(ctx, portal.MainIntent(), event.EventMessage, content, nil, 0) if err != nil { portal.log.Warnfln("Failed to send bridging error message:", err) return "" @@ -109,7 +106,7 @@ func (portal *Portal) sendErrorMessage(evt *event.Event, err error, confirmed bo return resp.EventID } -func (portal *Portal) sendStatusEvent(evtID, lastRetry id.EventID, err error) { +func (portal *Portal) sendStatusEvent(ctx context.Context, evtID, lastRetry id.EventID, err error) { if !portal.bridge.Config.Bridge.MessageStatusEvents { return } @@ -135,22 +132,22 @@ func (portal *Portal) sendStatusEvent(evtID, lastRetry id.EventID, err error) { content.Reason, content.Status, _, _, content.Message = errorToStatusReason(err) content.Error = err.Error() } - _, err = intent.SendMessageEvent(portal.MXID, event.BeeperMessageStatus, &content) + _, err = intent.SendMessageEvent(ctx, portal.MXID, event.BeeperMessageStatus, &content) if err != nil { portal.log.Warnln("Failed to send message status event:", err) } } -func (portal *Portal) sendDeliveryReceipt(eventID id.EventID) { +func (portal *Portal) sendDeliveryReceipt(ctx context.Context, eventID id.EventID) { if portal.bridge.Config.Bridge.DeliveryReceipts { - err := portal.bridge.Bot.MarkRead(portal.MXID, eventID) + err := portal.bridge.Bot.MarkRead(ctx, portal.MXID, eventID) if err != nil { portal.log.Debugfln("Failed to send delivery receipt for %s: %v", eventID, err) } } } -func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part string, ms *metricSender) { +func (portal *Portal) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, ms *metricSender) { var msgType string switch evt.Type { case event.EventMessage: @@ -180,16 +177,16 @@ func (portal *Portal) sendMessageMetrics(evt *event.Event, err error, part strin checkpointStatus := status.ReasonToCheckpointStatus(reason, statusCode) portal.bridge.SendMessageCheckpoint(evt, status.MsgStepRemote, err, checkpointStatus, ms.getRetryNum()) if sendNotice { - ms.setNoticeID(portal.sendErrorMessage(evt, err, isCertain, ms.getNoticeID())) + ms.setNoticeID(portal.sendErrorMessage(ctx, evt, err, isCertain, ms.getNoticeID())) } - portal.sendStatusEvent(origEvtID, evt.ID, err) + portal.sendStatusEvent(ctx, origEvtID, evt.ID, err) } else { portal.log.Debugfln("Handled Matrix %s %s", msgType, evtDescription) - portal.sendDeliveryReceipt(evt.ID) + portal.sendDeliveryReceipt(ctx, evt.ID) portal.bridge.SendMessageSuccessCheckpoint(evt, status.MsgStepRemote, ms.getRetryNum()) - portal.sendStatusEvent(origEvtID, evt.ID, nil) + portal.sendStatusEvent(ctx, origEvtID, evt.ID, nil) if prevNotice := ms.popNoticeID(); prevNotice != "" { - _, _ = portal.MainIntent().RedactEvent(portal.MXID, prevNotice, mautrix.ReqRedact{ + _, _ = portal.MainIntent().RedactEvent(ctx, portal.MXID, prevNotice, mautrix.ReqRedact{ Reason: "error resolved", }) } @@ -273,13 +270,13 @@ func (ms *metricSender) setNoticeID(evtID id.EventID) { } } -func (ms *metricSender) sendMessageMetrics(evt *event.Event, err error, part string, completed bool) { +func (ms *metricSender) sendMessageMetrics(ctx context.Context, evt *event.Event, err error, part string, completed bool) { ms.lock.Lock() defer ms.lock.Unlock() if !completed && ms.completed { return } - ms.portal.sendMessageMetrics(evt, err, part, ms) + ms.portal.sendMessageMetrics(ctx, evt, err, part, ms) ms.retryNum++ ms.completed = completed } diff --git a/msgconv.go b/msgconv.go new file mode 100644 index 0000000..6a5ce56 --- /dev/null +++ b/msgconv.go @@ -0,0 +1,149 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "context" + "fmt" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/appservice" + "maunium.net/go/mautrix/id" + + "github.com/slack-go/slack" + "go.mau.fi/mautrix-slack/database" + "go.mau.fi/mautrix-slack/msgconv" +) + +type convertContextKey int + +const ( + convertContextKeySource convertContextKey = iota + convertContextKeyIntent +) + +var _ msgconv.PortalMethods = (*Portal)(nil) + +func (portal *Portal) GetEmoji(ctx context.Context, shortcode string) string { + return ctx.Value(convertContextKeySource).(*UserTeam).GetEmoji(ctx, shortcode) +} + +func (portal *Portal) GetMessageInfo(ctx context.Context, eventID id.EventID) (*database.Message, error) { + msg, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID) + if msg != nil && msg.PortalKey != portal.PortalKey { + return nil, errMessageInWrongRoom + } + return msg, err +} + +func (portal *Portal) GetClient(ctx context.Context) *slack.Client { + return ctx.Value(convertContextKeySource).(*UserTeam).Client +} + +func (portal *Portal) GetData(ctx context.Context) *database.Portal { + return portal.Portal +} + +func (portal *Portal) UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) { + intent := ctx.Value(convertContextKeyIntent).(*appservice.IntentAPI) + req := mautrix.ReqUploadMedia{ + ContentBytes: data, + ContentType: contentType, + } + if portal.bridge.Config.Homeserver.AsyncMedia { + uploaded, err := intent.UploadAsync(ctx, req) + if err != nil { + return "", err + } + return uploaded.ContentURI.CUString(), nil + } else { + uploaded, err := intent.UploadMedia(ctx, req) + if err != nil { + return "", err + } + return uploaded.ContentURI.CUString(), nil + } +} + +func (portal *Portal) DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error) { + parsedURI, err := uri.Parse() + if err != nil { + return nil, err + } + return portal.MainIntent().DownloadBytes(ctx, parsedURI) +} + +func (portal *Portal) GetMentionedChannelID(_ context.Context, roomID id.RoomID) string { + mentionedPortal := portal.bridge.GetPortalByMXID(roomID) + if mentionedPortal != nil { + return mentionedPortal.ChannelID + } + return "" +} + +func (portal *Portal) GetMentionedUserID(_ context.Context, userID id.UserID) string { + utk, ok := portal.bridge.ParsePuppetMXID(userID) + if ok { + return utk.UserID + } + userTeam := portal.Team.GetCachedUserByMXID(userID) + if userTeam != nil { + return userTeam.UserID + } + return "" +} + +func (portal *Portal) GetMentionedUserInfo(ctx context.Context, userID string) (mxid id.UserID, name string) { + user := portal.Team.GetCachedUserByID(userID) + puppet := portal.Team.GetPuppetByID(userID) + if puppet == nil { + return + } else if user != nil { + name = puppet.Name + if name == "" { + name = user.UserMXID.String() + } + return user.UserMXID, name + } else { + if puppet.Name == "" { + ut, ok := ctx.Value(convertContextKeySource).(*UserTeam) + if ok { + puppet.UpdateInfoIfNecessary(ctx, ut) + } + } + return puppet.MXID, puppet.Name + } +} + +func (portal *Portal) GetMentionedRoomInfo(ctx context.Context, channelID string) (mxid id.RoomID, alias id.RoomAlias, name string) { + mentionedPortal := portal.Team.GetPortalByID(channelID) + if mentionedPortal == nil { + return + } + mxid = mentionedPortal.MXID + if mentionedPortal.Name == "" { + ut, ok := ctx.Value(convertContextKeySource).(*UserTeam) + if ok { + mentionedPortal.UpdateInfo(ctx, ut, nil, false) + } + } + name = mentionedPortal.Name + if name == "" { + name = fmt.Sprintf("#%s", channelID) + } + return +} diff --git a/blocks.go b/msgconv/blocks.go similarity index 57% rename from blocks.go rename to msgconv/blocks.go index 7bae57d..96960cf 100644 --- a/blocks.go +++ b/msgconv/blocks.go @@ -1,5 +1,6 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. // Copyright (C) 2022 Max Sandholm +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -14,38 +15,41 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package main +package msgconv import ( + "context" "fmt" - "io/ioutil" + "html" + "io" "net/http" "path" "strconv" "strings" "time" - "github.com/slack-go/slack" - "github.com/yuin/goldmark" + "github.com/rs/zerolog" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" - "go.mau.fi/mautrix-slack/database" + "github.com/slack-go/slack" + "go.mau.fi/mautrix-slack/msgconv/mrkdwn" ) -func (portal *Portal) renderImageBlock(block slack.ImageBlock) (*event.MessageEventContent, error) { +func (mc *MessageConverter) renderImageBlock(ctx context.Context, block slack.ImageBlock) (*ConvertedMessagePart, error) { + log := zerolog.Ctx(ctx) client := http.Client{} resp, err := client.Get(block.ImageURL) if err != nil { - portal.log.Errorfln("Error fetching image: %v", err) + log.Err(err).Msg("Failed to fetch image block") return nil, err } else if resp.StatusCode != 200 { - portal.log.Errorfln("HTTP error %d fetching image", resp.StatusCode) + log.Error().Int("status_code", resp.StatusCode).Msg("Unexpected status code fetching image block") return nil, fmt.Errorf(resp.Status) } - bytes, err := ioutil.ReadAll(resp.Body) + bytes, err := io.ReadAll(resp.Body) if err != nil { - portal.log.Errorfln("Error fetching image: %v", err) + log.Err(err).Msg("Failed to read image block get response") return nil, err } filename := path.Base(resp.Request.URL.Path) @@ -58,93 +62,86 @@ func (portal *Portal) renderImageBlock(block slack.ImageBlock) (*event.MessageEv Size: len(bytes), }, } - err = portal.uploadMedia(portal.MainIntent(), bytes, &content) + err = mc.uploadMedia(ctx, bytes, &content) if err != nil { - portal.log.Errorfln("Error uploading media: %v", err) + log.Err(err).Msg("Failed to reupload image block media") return nil, err } - return &content, nil + return &ConvertedMessagePart{ + Type: event.EventMessage, + Content: &content, + }, nil } -func (portal *Portal) mrkdwnToMatrixHtml(mrkdwn string) string { - mrkdwn = replaceShortcodesWithEmojis(mrkdwn) - - mrkdwn = escapeFixer.ReplaceAllStringFunc(mrkdwn, func(s string) string { - return s[:2] + `\` + s[2:] - }) - - mdRenderer := goldmark.New( - format.Extensions, format.HTMLOptions, - goldmark.WithExtensions(&SlackTag{portal}), - ) - - var buf strings.Builder - mdRenderer.Convert([]byte(mrkdwn), &buf) - - return format.UnwrapSingleParagraph(buf.String()) +func (mc *MessageConverter) mrkdwnToMatrixHtml(ctx context.Context, inputMrkdwn string) string { + output, _ := mc.SlackMrkdwnParser.Parse(ctx, inputMrkdwn) + return output } -func (portal *Portal) renderSlackTextBlock(block slack.TextBlockObject) string { +func (mc *MessageConverter) renderSlackTextBlock(ctx context.Context, block slack.TextBlockObject) string { if block.Type == slack.PlainTextType { return event.TextToHTML(block.Text) } else if block.Type == slack.MarkdownType { - return portal.mrkdwnToMatrixHtml(block.Text) + return mc.mrkdwnToMatrixHtml(ctx, block.Text) } else { return "" } } -func (portal *Portal) renderRichTextSectionElements(elements []slack.RichTextSectionElement, userTeam *database.UserTeam) string { +func openingTags(out io.StringWriter, style *slack.RichTextSectionTextStyle) { + if style == nil { + return + } + if style.Bold { + _, _ = out.WriteString("") + } + if style.Italic { + _, _ = out.WriteString("") + } + if style.Strike { + _, _ = out.WriteString("") + } + if style.Code { + _, _ = out.WriteString("") + } +} + +func closingTags(out io.StringWriter, style *slack.RichTextSectionTextStyle) { + if style == nil { + return + } + if style.Code { + _, _ = out.WriteString("") + } + if style.Strike { + _, _ = out.WriteString("") + } + if style.Italic { + _, _ = out.WriteString("") + } + if style.Bold { + _, _ = out.WriteString("") + } +} + +func (mc *MessageConverter) renderRichTextSectionElements(ctx context.Context, elements []slack.RichTextSectionElement) string { var htmlText strings.Builder for _, element := range elements { switch e := element.(type) { case *slack.RichTextSectionTextElement: - if e.Style != nil { - if e.Style.Bold { - htmlText.WriteString("") - } - if e.Style.Italic { - htmlText.WriteString("") - } - if e.Style.Strike { - htmlText.WriteString("") - } - if e.Style.Code { - htmlText.WriteString("") - } - } + openingTags(&htmlText, e.Style) htmlText.WriteString(event.TextToHTML(e.Text)) - if e.Style != nil { - if e.Style.Code { - htmlText.WriteString("") - } - if e.Style.Strike { - htmlText.WriteString("") - } - if e.Style.Italic { - htmlText.WriteString("") - } - if e.Style.Bold { - htmlText.WriteString("") - } - } + closingTags(&htmlText, e.Style) case *slack.RichTextSectionUserElement: - puppet := portal.bridge.GetPuppetByID(portal.Key.TeamID, e.UserID) - if puppet != nil && puppet.GetCustomOrGhostMXID() != "" { - htmlText.WriteString(fmt.Sprintf(`%s`, puppet.GetCustomOrGhostMXID(), puppet.Name)) - } else { // TODO: register puppet and get info if not exist - htmlText.WriteString(fmt.Sprintf("@%s", e.UserID)) - } + mxid, name := mc.GetMentionedUserInfo(ctx, e.UserID) + openingTags(&htmlText, e.Style) + mrkdwn.UserMentionToHTML(&htmlText, e.UserID, mxid, name) + closingTags(&htmlText, e.Style) case *slack.RichTextSectionChannelElement: - p := portal.bridge.DB.Portal.GetByID(database.PortalKey{ - TeamID: portal.Key.TeamID, - ChannelID: e.ChannelID, - }) - if p != nil && p.MXID != "" { - htmlText.WriteString(fmt.Sprintf(`%s`, p.MXID, portal.bridge.AS.HomeserverDomain, p.Name)) - } else { // TODO: get portal info if not exist - htmlText.WriteString(fmt.Sprintf("#%s", e.ChannelID)) - } + mxid, alias, name := mc.GetMentionedRoomInfo(ctx, e.ChannelID) + openingTags(&htmlText, e.Style) + mrkdwn.RoomMentionToHTML(&htmlText, e.ChannelID, mxid, alias, name, mc.ServerName) + closingTags(&htmlText, e.Style) case *slack.RichTextSectionLinkElement: var linkText string if e.Text != "" { @@ -152,48 +149,54 @@ func (portal *Portal) renderRichTextSectionElements(elements []slack.RichTextSec } else { linkText = e.URL } - htmlText.WriteString(fmt.Sprintf(`%s`, e.URL, event.TextToHTML(linkText))) + openingTags(&htmlText, e.Style) + _, _ = fmt.Fprintf(&htmlText, `%s`, html.EscapeString(e.URL), event.TextToHTML(linkText)) + closingTags(&htmlText, e.Style) case *slack.RichTextSectionBroadcastElement: htmlText.WriteString("@room") case *slack.RichTextSectionEmojiElement: + openingTags(&htmlText, e.Style) if e.Unicode != "" { codepoints := strings.Split(e.Unicode, "-") for _, codepoint := range codepoints { codepointInt, _ := strconv.ParseInt(codepoint, 16, 32) - unquoted := string(rune(codepointInt)) - htmlText.WriteString(unquoted) + htmlText.WriteRune(rune(codepointInt)) } } else { - emoji := portal.bridge.GetEmoji(e.Name, userTeam) + emoji := mc.GetEmoji(ctx, e.Name) if strings.HasPrefix(emoji, "mxc://") { - htmlText.WriteString(fmt.Sprintf(`%[2]s`, emoji, e.Name)) + htmlText.WriteString(fmt.Sprintf(`:%[2]s:`, emoji, e.Name)) } else if emoji != e.Name { htmlText.WriteString(emoji) } else { htmlText.WriteString(fmt.Sprintf(":%s:", e.Name)) } } + closingTags(&htmlText, e.Style) case *slack.RichTextSectionColorElement: htmlText.WriteString(e.Value) case *slack.RichTextSectionDateElement: htmlText.WriteString(e.Timestamp.String()) default: - portal.log.Warnfln("Slack rich text section contained unknown element %s", e.RichTextSectionElementType()) + zerolog.Ctx(ctx).Debug(). + Type("section_type", e). + Str("section_type_name", string(e.RichTextSectionElementType())). + Msg("Unsupported Slack rich text section") } } return htmlText.String() } -func (portal *Portal) renderSlackBlock(block slack.Block, userTeam *database.UserTeam) (string, bool) { +func (mc *MessageConverter) renderSlackBlock(ctx context.Context, block slack.Block) (string, bool) { switch b := block.(type) { case *slack.HeaderBlock: - return fmt.Sprintf("

%s

", portal.renderSlackTextBlock(*b.Text)), false + return fmt.Sprintf("

%s

", mc.renderSlackTextBlock(ctx, *b.Text)), false case *slack.DividerBlock: return "
", false case *slack.SectionBlock: var htmlParts []string if b.Text != nil { - htmlParts = append(htmlParts, portal.renderSlackTextBlock(*b.Text)) + htmlParts = append(htmlParts, mc.renderSlackTextBlock(ctx, *b.Text)) } if len(b.Fields) > 0 { var fieldTable strings.Builder @@ -202,7 +205,7 @@ func (portal *Portal) renderSlackBlock(block slack.Block, userTeam *database.Use if i%2 == 0 { fieldTable.WriteString("") } - fieldTable.WriteString(fmt.Sprintf("%s", portal.mrkdwnToMatrixHtml(field.Text))) + fieldTable.WriteString(fmt.Sprintf("%s", mc.mrkdwnToMatrixHtml(ctx, field.Text))) if i%2 != 0 || i == len(b.Fields)-1 { fieldTable.WriteString("") } @@ -214,7 +217,7 @@ func (portal *Portal) renderSlackBlock(block slack.Block, userTeam *database.Use case *slack.RichTextBlock: var htmlText strings.Builder for _, element := range b.Elements { - htmlText.WriteString(portal.renderSlackRichTextElement(len(b.Elements), element, userTeam)) + htmlText.WriteString(mc.renderSlackRichTextElement(ctx, len(b.Elements), element)) } return format.UnwrapSingleParagraph(htmlText.String()), false case *slack.ContextBlock: @@ -222,21 +225,27 @@ func (portal *Portal) renderSlackBlock(block slack.Block, userTeam *database.Use var unsupported bool = false for _, element := range b.ContextElements.Elements { if mrkdwnElem, ok := element.(*slack.TextBlockObject); ok { - htmlText.WriteString(fmt.Sprintf("%s", portal.mrkdwnToMatrixHtml(mrkdwnElem.Text))) + htmlText.WriteString(fmt.Sprintf("%s", mc.mrkdwnToMatrixHtml(ctx, mrkdwnElem.Text))) } else { - portal.log.Debugfln("Unsupported Slack block element: %s", element.MixedElementType()) + zerolog.Ctx(ctx).Debug(). + Type("element_type", element). + Type("element_type_name", element.MixedElementType()). + Msg("Unsupported Slack block element") htmlText.WriteString("Slack message contains unsupported elements.") unsupported = true } } return htmlText.String(), unsupported default: - portal.log.Debugfln("Unsupported Slack block: %s", b.BlockType()) + zerolog.Ctx(ctx).Debug(). + Type("block_type", b). + Type("block_type_name", b.BlockType()). + Msg("Unsupported Slack block") return "Slack message contains unsupported elements.", true } } -func (portal *Portal) renderSlackRichTextElement(numElements int, element slack.RichTextElement, userTeam *database.UserTeam) string { +func (mc *MessageConverter) renderSlackRichTextElement(ctx context.Context, numElements int, element slack.RichTextElement) string { switch e := element.(type) { case *slack.RichTextSection: var htmlTag string @@ -251,7 +260,7 @@ func (portal *Portal) renderSlackRichTextElement(numElements int, element slack. htmlTag = "

" htmlCloseTag = "

" } - return fmt.Sprintf("%s%s%s", htmlTag, portal.renderRichTextSectionElements(e.Elements, userTeam), htmlCloseTag) + return fmt.Sprintf("%s%s%s", htmlTag, mc.renderRichTextSectionElements(ctx, e.Elements), htmlCloseTag) case *slack.RichTextList: var htmlText strings.Builder var htmlTag string @@ -265,27 +274,27 @@ func (portal *Portal) renderSlackRichTextElement(numElements int, element slack. } htmlText.WriteString(htmlTag) for _, e := range e.Elements { - htmlText.WriteString(fmt.Sprintf("
  • %s
  • ", portal.renderSlackRichTextElement(1, &e, userTeam))) + htmlText.WriteString(fmt.Sprintf("
  • %s
  • ", mc.renderSlackRichTextElement(ctx, 1, &e))) } htmlText.WriteString(htmlCloseTag) return htmlText.String() default: - portal.log.Debugfln("Unsupported Slack section: %T", e) + zerolog.Ctx(ctx).Debug().Type("element_type", e).Msg("Unsupported Slack rich text element") return fmt.Sprintf("Unsupported section %s in Slack text.", e.RichTextElementType()) } } -func (portal *Portal) blocksToHtml(blocks slack.Blocks, alwaysWrap bool, userTeam *database.UserTeam) string { +func (mc *MessageConverter) blocksToHTML(ctx context.Context, blocks slack.Blocks, alwaysWrap bool) string { var htmlText strings.Builder if len(blocks.BlockSet) == 1 && !alwaysWrap { // don't wrap in

    tag if there's only one block - text, _ := portal.renderSlackBlock(blocks.BlockSet[0], userTeam) + text, _ := mc.renderSlackBlock(ctx, blocks.BlockSet[0]) htmlText.WriteString(text) } else { var lastBlockWasUnsupported bool = false for _, block := range blocks.BlockSet { - text, unsupported := portal.renderSlackBlock(block, userTeam) + text, unsupported := mc.renderSlackBlock(ctx, block) if !(unsupported && lastBlockWasUnsupported) { htmlText.WriteString(fmt.Sprintf("

    %s

    ", text)) } @@ -296,19 +305,34 @@ func (portal *Portal) blocksToHtml(blocks slack.Blocks, alwaysWrap bool, userTea return htmlText.String() } -func (portal *Portal) SlackBlocksToMatrix(blocks slack.Blocks, attachments []slack.Attachment, userTeam *database.UserTeam) (*event.MessageEventContent, error) { +func (mc *MessageConverter) trySlackBlocksToMatrix(ctx context.Context, blocks slack.Blocks, attachments []slack.Attachment) *ConvertedMessagePart { + converted, err := mc.slackBlocksToMatrix(ctx, blocks, attachments) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to render Slack blocks") + return &ConvertedMessagePart{ + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: "Failed to convert Slack message blocks", + }, + } + } + return converted +} + +func (mc *MessageConverter) slackBlocksToMatrix(ctx context.Context, blocks slack.Blocks, attachments []slack.Attachment) (*ConvertedMessagePart, error) { // Special case for bots like the Giphy bot which send images in a specific format if len(blocks.BlockSet) == 2 && blocks.BlockSet[0].BlockType() == slack.MBTImage && blocks.BlockSet[1].BlockType() == slack.MBTContext { imageBlock := blocks.BlockSet[0].(*slack.ImageBlock) - return portal.renderImageBlock(*imageBlock) + return mc.renderImageBlock(ctx, *imageBlock) } var htmlText strings.Builder - htmlText.WriteString(portal.blocksToHtml(blocks, false, userTeam)) + htmlText.WriteString(mc.blocksToHTML(ctx, blocks, false)) if len(attachments) > 0 && htmlText.String() != "" { htmlText.WriteString("
    ") @@ -317,18 +341,18 @@ func (portal *Portal) SlackBlocksToMatrix(blocks slack.Blocks, attachments []sla for _, attachment := range attachments { if attachment.IsMsgUnfurl { for _, message_block := range attachment.MessageBlocks { - renderedAttachment := portal.blocksToHtml(message_block.Message.Blocks, true, userTeam) + renderedAttachment := mc.blocksToHTML(ctx, message_block.Message.Blocks, true) htmlText.WriteString(fmt.Sprintf("
    %s
    %s%s
    ", attachment.AuthorName, renderedAttachment, attachment.FromURL, attachment.Footer)) } } else if len(attachment.Blocks.BlockSet) > 0 { for _, message_block := range attachment.Blocks.BlockSet { - renderedAttachment, _ := portal.renderSlackBlock(message_block, userTeam) + renderedAttachment, _ := mc.renderSlackBlock(ctx, message_block) htmlText.WriteString(fmt.Sprintf("
    %s
    ", renderedAttachment)) } } else { if len(attachment.Pretext) > 0 { - htmlText.WriteString(fmt.Sprintf("

    %s

    ", portal.mrkdwnToMatrixHtml(attachment.Pretext))) + htmlText.WriteString(fmt.Sprintf("

    %s

    ", mc.mrkdwnToMatrixHtml(ctx, attachment.Pretext))) } var attachParts []string if len(attachment.AuthorName) > 0 { @@ -342,15 +366,15 @@ func (portal *Portal) SlackBlocksToMatrix(blocks slack.Blocks, attachments []sla if len(attachment.Title) > 0 { if len(attachment.TitleLink) > 0 { attachParts = append(attachParts, fmt.Sprintf("%s", - attachment.TitleLink, portal.mrkdwnToMatrixHtml(attachment.Title))) + attachment.TitleLink, mc.mrkdwnToMatrixHtml(ctx, attachment.Title))) } else { - attachParts = append(attachParts, fmt.Sprintf("%s", portal.mrkdwnToMatrixHtml(attachment.Title))) + attachParts = append(attachParts, fmt.Sprintf("%s", mc.mrkdwnToMatrixHtml(ctx, attachment.Title))) } } if len(attachment.Text) > 0 { - attachParts = append(attachParts, portal.mrkdwnToMatrixHtml(attachment.Text)) + attachParts = append(attachParts, mc.mrkdwnToMatrixHtml(ctx, attachment.Text)) } else if len(attachment.Fallback) > 0 { - attachParts = append(attachParts, portal.mrkdwnToMatrixHtml(attachment.Fallback)) + attachParts = append(attachParts, mc.mrkdwnToMatrixHtml(ctx, attachment.Fallback)) } htmlText.WriteString(fmt.Sprintf("
    %s", strings.Join(attachParts, "
    "))) if len(attachment.Fields) > 0 { @@ -361,7 +385,7 @@ func (portal *Portal) SlackBlocksToMatrix(blocks slack.Blocks, attachments []sla fieldBody += "" } fieldBody += fmt.Sprintf("%s
    %s", - field.Title, portal.mrkdwnToMatrixHtml(field.Value)) + field.Title, mc.mrkdwnToMatrixHtml(ctx, field.Value)) short = !short && field.Short if !short { fieldBody += "" @@ -373,7 +397,7 @@ func (portal *Portal) SlackBlocksToMatrix(blocks slack.Blocks, attachments []sla } var footerParts []string if len(attachment.Footer) > 0 { - footerParts = append(footerParts, portal.mrkdwnToMatrixHtml(attachment.Footer)) + footerParts = append(footerParts, mc.mrkdwnToMatrixHtml(ctx, attachment.Footer)) } if len(attachment.Ts) > 0 { ts, _ := attachment.Ts.Int64() @@ -388,5 +412,8 @@ func (portal *Portal) SlackBlocksToMatrix(blocks slack.Blocks, attachments []sla } content := format.HTMLToContent(htmlText.String()) - return &content, nil + return &ConvertedMessagePart{ + Type: event.EventMessage, + Content: &content, + }, nil } diff --git a/msgconv/emoji/emoji-generate.go b/msgconv/emoji/emoji-generate.go new file mode 100644 index 0000000..610b0b8 --- /dev/null +++ b/msgconv/emoji/emoji-generate.go @@ -0,0 +1,121 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "go.mau.fi/util/exerrors" +) + +type SkinVariation struct { + Unified string `json:"unified"` + NonQualified *string `json:"non_qualified"` + Image string `json:"image"` + SheetX int `json:"sheet_x"` + SheetY int `json:"sheet_y"` + AddedIn string `json:"added_in"` + HasImgApple bool `json:"has_img_apple"` + HasImgGoogle bool `json:"has_img_google"` + HasImgTwitter bool `json:"has_img_twitter"` + HasImgFacebook bool `json:"has_img_facebook"` + Obsoletes string `json:"obsoletes,omitempty"` + ObsoletedBy string `json:"obsoleted_by,omitempty"` +} + +type Emoji struct { + Name string `json:"name"` + Unified string `json:"unified"` + NonQualified *string `json:"non_qualified"` + Docomo *string `json:"docomo"` + Au *string `json:"au"` + Softbank *string `json:"softbank"` + Google *string `json:"google"` + Image string `json:"image"` + SheetX int `json:"sheet_x"` + SheetY int `json:"sheet_y"` + ShortName string `json:"short_name"` + ShortNames []string `json:"short_names"` + Text *string `json:"text"` + Texts []string `json:"texts"` + Category string `json:"category"` + Subcategory string `json:"subcategory"` + SortOrder int `json:"sort_order"` + AddedIn string `json:"added_in"` + HasImgApple bool `json:"has_img_apple"` + HasImgGoogle bool `json:"has_img_google"` + HasImgTwitter bool `json:"has_img_twitter"` + HasImgFacebook bool `json:"has_img_facebook"` + SkinVariations map[string]*SkinVariation `json:"skin_variations,omitempty"` + Obsoletes string `json:"obsoletes,omitempty"` + ObsoletedBy string `json:"obsoleted_by,omitempty"` +} + +func unifiedToUnicode(input string) string { + parts := strings.Split(input, "-") + output := make([]rune, len(parts)) + for i, part := range parts { + output[i] = rune(exerrors.Must(strconv.ParseInt(part, 16, 32))) + } + return string(output) +} + +var skinToneIDs = map[string]string{ + "1F3FB": "2", + "1F3FC": "3", + "1F3FD": "4", + "1F3FE": "5", + "1F3FF": "6", +} + +func unifiedToSkinToneID(input string) string { + parts := strings.Split(input, "-") + var ok bool + for i, part := range parts { + parts[i], ok = skinToneIDs[part] + if !ok { + panic("unknown skin tone " + input) + } + } + return "skin-tone-" + strings.Join(parts, "-") +} + +func main() { + var emojis []Emoji + resp := exerrors.Must(http.Get("https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json")) + exerrors.PanicIfNotNil(json.NewDecoder(resp.Body).Decode(&emojis)) + + shortcodeToEmoji := make(map[string]string) + for _, emoji := range emojis { + shortcodeToEmoji[emoji.ShortName] = unifiedToUnicode(emoji.Unified) + for skinToneKey, stEmoji := range emoji.SkinVariations { + shortcodeToEmoji[fmt.Sprintf("%s::%s", emoji.ShortName, unifiedToSkinToneID(skinToneKey))] = unifiedToUnicode(stEmoji.Unified) + } + } + file := exerrors.Must(os.OpenFile("emoji.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)) + enc := json.NewEncoder(file) + enc.SetIndent("", " ") + exerrors.PanicIfNotNil(enc.Encode(shortcodeToEmoji)) + exerrors.PanicIfNotNil(file.Close()) +} diff --git a/msgconv/emoji/emoji.go b/msgconv/emoji/emoji.go new file mode 100644 index 0000000..ee01daa --- /dev/null +++ b/msgconv/emoji/emoji.go @@ -0,0 +1,55 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package emoji + +import ( + _ "embed" + "encoding/json" + "regexp" + "strings" + + "go.mau.fi/util/exerrors" +) + +//go:generate go run ./emoji-generate.go +//go:embed emoji.json +var emojiFileData []byte + +var ShortcodeToUnicodeMap map[string]string +var UnicodeToShortcodeMap map[string]string + +func init() { + exerrors.PanicIfNotNil(json.Unmarshal(emojiFileData, &ShortcodeToUnicodeMap)) + UnicodeToShortcodeMap = make(map[string]string, len(ShortcodeToUnicodeMap)) + for shortcode, emoji := range ShortcodeToUnicodeMap { + UnicodeToShortcodeMap[emoji] = shortcode + } +} + +var ShortcodeRegex = regexp.MustCompile(`:[^:\s]*:`) + +func ReplaceShortcodesWithUnicode(text string) string { + return ShortcodeRegex.ReplaceAllStringFunc(text, func(code string) string { + strippedCode := strings.Trim(code, ":") + emoji, found := ShortcodeToUnicodeMap[strippedCode] + if found { + return emoji + } else { + return code + } + }) +} diff --git a/msgconv/emoji/emoji.json b/msgconv/emoji/emoji.json new file mode 100644 index 0000000..f809d5c --- /dev/null +++ b/msgconv/emoji/emoji.json @@ -0,0 +1,3780 @@ +{ + "+1": "👍", + "+1::skin-tone-2": "👍🏻", + "+1::skin-tone-3": "👍🏼", + "+1::skin-tone-4": "👍🏽", + "+1::skin-tone-5": "👍🏾", + "+1::skin-tone-6": "👍🏿", + "-1": "👎", + "-1::skin-tone-2": "👎🏻", + "-1::skin-tone-3": "👎🏼", + "-1::skin-tone-4": "👎🏽", + "-1::skin-tone-5": "👎🏾", + "-1::skin-tone-6": "👎🏿", + "100": "💯", + "1234": "🔢", + "8ball": "🎱", + "a": "🅰️", + "ab": "🆎", + "abacus": "🧮", + "abc": "🔤", + "abcd": "🔡", + "accept": "🉑", + "accordion": "🪗", + "adhesive_bandage": "🩹", + "admission_tickets": "🎟️", + "adult": "🧑", + "adult::skin-tone-2": "🧑🏻", + "adult::skin-tone-3": "🧑🏼", + "adult::skin-tone-4": "🧑🏽", + "adult::skin-tone-5": "🧑🏾", + "adult::skin-tone-6": "🧑🏿", + "aerial_tramway": "🚡", + "airplane": "✈️", + "airplane_arriving": "🛬", + "airplane_departure": "🛫", + "alarm_clock": "⏰", + "alembic": "⚗️", + "alien": "👽", + "ambulance": "🚑", + "amphora": "🏺", + "anatomical_heart": "🫀", + "anchor": "⚓", + "angel": "👼", + "angel::skin-tone-2": "👼🏻", + "angel::skin-tone-3": "👼🏼", + "angel::skin-tone-4": "👼🏽", + "angel::skin-tone-5": "👼🏾", + "angel::skin-tone-6": "👼🏿", + "anger": "💢", + "angry": "😠", + "anguished": "😧", + "ant": "🐜", + "apple": "🍎", + "aquarius": "♒", + "aries": "♈", + "arrow_backward": "◀️", + "arrow_double_down": "⏬", + "arrow_double_up": "⏫", + "arrow_down": "⬇️", + "arrow_down_small": "🔽", + "arrow_forward": "▶️", + "arrow_heading_down": "⤵️", + "arrow_heading_up": "⤴️", + "arrow_left": "⬅️", + "arrow_lower_left": "↙️", + "arrow_lower_right": "↘️", + "arrow_right": "➡️", + "arrow_right_hook": "↪️", + "arrow_up": "⬆️", + "arrow_up_down": "↕️", + "arrow_up_small": "🔼", + "arrow_upper_left": "↖️", + "arrow_upper_right": "↗️", + "arrows_clockwise": "🔃", + "arrows_counterclockwise": "🔄", + "art": "🎨", + "articulated_lorry": "🚛", + "artist": "🧑‍🎨", + "artist::skin-tone-2": "🧑🏻‍🎨", + "artist::skin-tone-3": "🧑🏼‍🎨", + "artist::skin-tone-4": "🧑🏽‍🎨", + "artist::skin-tone-5": "🧑🏾‍🎨", + "artist::skin-tone-6": "🧑🏿‍🎨", + "astonished": "😲", + "astronaut": "🧑‍🚀", + "astronaut::skin-tone-2": "🧑🏻‍🚀", + "astronaut::skin-tone-3": "🧑🏼‍🚀", + "astronaut::skin-tone-4": "🧑🏽‍🚀", + "astronaut::skin-tone-5": "🧑🏾‍🚀", + "astronaut::skin-tone-6": "🧑🏿‍🚀", + "athletic_shoe": "👟", + "atm": "🏧", + "atom_symbol": "⚛️", + "auto_rickshaw": "🛺", + "avocado": "🥑", + "axe": "🪓", + "b": "🅱️", + "baby": "👶", + "baby::skin-tone-2": "👶🏻", + "baby::skin-tone-3": "👶🏼", + "baby::skin-tone-4": "👶🏽", + "baby::skin-tone-5": "👶🏾", + "baby::skin-tone-6": "👶🏿", + "baby_bottle": "🍼", + "baby_chick": "🐤", + "baby_symbol": "🚼", + "back": "🔙", + "bacon": "🥓", + "badger": "🦡", + "badminton_racquet_and_shuttlecock": "🏸", + "bagel": "🥯", + "baggage_claim": "🛄", + "baguette_bread": "🥖", + "bald_man": "👨‍🦲", + "bald_man::skin-tone-2": "👨🏻‍🦲", + "bald_man::skin-tone-3": "👨🏼‍🦲", + "bald_man::skin-tone-4": "👨🏽‍🦲", + "bald_man::skin-tone-5": "👨🏾‍🦲", + "bald_man::skin-tone-6": "👨🏿‍🦲", + "bald_person": "🧑‍🦲", + "bald_person::skin-tone-2": "🧑🏻‍🦲", + "bald_person::skin-tone-3": "🧑🏼‍🦲", + "bald_person::skin-tone-4": "🧑🏽‍🦲", + "bald_person::skin-tone-5": "🧑🏾‍🦲", + "bald_person::skin-tone-6": "🧑🏿‍🦲", + "bald_woman": "👩‍🦲", + "bald_woman::skin-tone-2": "👩🏻‍🦲", + "bald_woman::skin-tone-3": "👩🏼‍🦲", + "bald_woman::skin-tone-4": "👩🏽‍🦲", + "bald_woman::skin-tone-5": "👩🏾‍🦲", + "bald_woman::skin-tone-6": "👩🏿‍🦲", + "ballet_shoes": "🩰", + "balloon": "🎈", + "ballot_box_with_ballot": "🗳️", + "ballot_box_with_check": "☑️", + "bamboo": "🎍", + "banana": "🍌", + "bangbang": "‼️", + "banjo": "🪕", + "bank": "🏦", + "bar_chart": "📊", + "barber": "💈", + "barely_sunny": "🌥️", + "baseball": "⚾", + "basket": "🧺", + "basketball": "🏀", + "bat": "🦇", + "bath": "🛀", + "bath::skin-tone-2": "🛀🏻", + "bath::skin-tone-3": "🛀🏼", + "bath::skin-tone-4": "🛀🏽", + "bath::skin-tone-5": "🛀🏾", + "bath::skin-tone-6": "🛀🏿", + "bathtub": "🛁", + "battery": "🔋", + "beach_with_umbrella": "🏖️", + "beans": "🫘", + "bear": "🐻", + "bearded_person": "🧔", + "bearded_person::skin-tone-2": "🧔🏻", + "bearded_person::skin-tone-3": "🧔🏼", + "bearded_person::skin-tone-4": "🧔🏽", + "bearded_person::skin-tone-5": "🧔🏾", + "bearded_person::skin-tone-6": "🧔🏿", + "beaver": "🦫", + "bed": "🛏️", + "bee": "🐝", + "beer": "🍺", + "beers": "🍻", + "beetle": "🪲", + "beginner": "🔰", + "bell": "🔔", + "bell_pepper": "🫑", + "bellhop_bell": "🛎️", + "bento": "🍱", + "beverage_box": "🧃", + "bicyclist": "🚴", + "bicyclist::skin-tone-2": "🚴🏻", + "bicyclist::skin-tone-3": "🚴🏼", + "bicyclist::skin-tone-4": "🚴🏽", + "bicyclist::skin-tone-5": "🚴🏾", + "bicyclist::skin-tone-6": "🚴🏿", + "bike": "🚲", + "bikini": "👙", + "billed_cap": "🧢", + "biohazard_sign": "☣️", + "bird": "🐦", + "birthday": "🎂", + "bison": "🦬", + "biting_lip": "🫦", + "black_bird": "🐦‍⬛", + "black_cat": "🐈‍⬛", + "black_circle": "⚫", + "black_circle_for_record": "⏺️", + "black_heart": "🖤", + "black_joker": "🃏", + "black_large_square": "⬛", + "black_left_pointing_double_triangle_with_vertical_bar": "⏮️", + "black_medium_small_square": "◾", + "black_medium_square": "◼️", + "black_nib": "✒️", + "black_right_pointing_double_triangle_with_vertical_bar": "⏭️", + "black_right_pointing_triangle_with_double_vertical_bar": "⏯️", + "black_small_square": "▪️", + "black_square_button": "🔲", + "black_square_for_stop": "⏹️", + "blond-haired-man": "👱‍♂️", + "blond-haired-man::skin-tone-2": "👱🏻‍♂️", + "blond-haired-man::skin-tone-3": "👱🏼‍♂️", + "blond-haired-man::skin-tone-4": "👱🏽‍♂️", + "blond-haired-man::skin-tone-5": "👱🏾‍♂️", + "blond-haired-man::skin-tone-6": "👱🏿‍♂️", + "blond-haired-woman": "👱‍♀️", + "blond-haired-woman::skin-tone-2": "👱🏻‍♀️", + "blond-haired-woman::skin-tone-3": "👱🏼‍♀️", + "blond-haired-woman::skin-tone-4": "👱🏽‍♀️", + "blond-haired-woman::skin-tone-5": "👱🏾‍♀️", + "blond-haired-woman::skin-tone-6": "👱🏿‍♀️", + "blossom": "🌼", + "blowfish": "🐡", + "blue_book": "📘", + "blue_car": "🚙", + "blue_heart": "💙", + "blueberries": "🫐", + "blush": "😊", + "boar": "🐗", + "boat": "⛵", + "bomb": "💣", + "bone": "🦴", + "book": "📖", + "bookmark": "🔖", + "bookmark_tabs": "📑", + "books": "📚", + "boom": "💥", + "boomerang": "🪃", + "boot": "👢", + "bouquet": "💐", + "bow": "🙇", + "bow::skin-tone-2": "🙇🏻", + "bow::skin-tone-3": "🙇🏼", + "bow::skin-tone-4": "🙇🏽", + "bow::skin-tone-5": "🙇🏾", + "bow::skin-tone-6": "🙇🏿", + "bow_and_arrow": "🏹", + "bowl_with_spoon": "🥣", + "bowling": "🎳", + "boxing_glove": "🥊", + "boy": "👦", + "boy::skin-tone-2": "👦🏻", + "boy::skin-tone-3": "👦🏼", + "boy::skin-tone-4": "👦🏽", + "boy::skin-tone-5": "👦🏾", + "boy::skin-tone-6": "👦🏿", + "brain": "🧠", + "bread": "🍞", + "breast-feeding": "🤱", + "breast-feeding::skin-tone-2": "🤱🏻", + "breast-feeding::skin-tone-3": "🤱🏼", + "breast-feeding::skin-tone-4": "🤱🏽", + "breast-feeding::skin-tone-5": "🤱🏾", + "breast-feeding::skin-tone-6": "🤱🏿", + "bricks": "🧱", + "bride_with_veil": "👰", + "bride_with_veil::skin-tone-2": "👰🏻", + "bride_with_veil::skin-tone-3": "👰🏼", + "bride_with_veil::skin-tone-4": "👰🏽", + "bride_with_veil::skin-tone-5": "👰🏾", + "bride_with_veil::skin-tone-6": "👰🏿", + "bridge_at_night": "🌉", + "briefcase": "💼", + "briefs": "🩲", + "broccoli": "🥦", + "broken_chain": "⛓️‍💥", + "broken_heart": "💔", + "broom": "🧹", + "brown_heart": "🤎", + "brown_mushroom": "🍄‍🟫", + "bubble_tea": "🧋", + "bubbles": "🫧", + "bucket": "🪣", + "bug": "🐛", + "building_construction": "🏗️", + "bulb": "💡", + "bullettrain_front": "🚅", + "bullettrain_side": "🚄", + "burrito": "🌯", + "bus": "🚌", + "busstop": "🚏", + "bust_in_silhouette": "👤", + "busts_in_silhouette": "👥", + "butter": "🧈", + "butterfly": "🦋", + "cactus": "🌵", + "cake": "🍰", + "calendar": "📆", + "call_me_hand": "🤙", + "call_me_hand::skin-tone-2": "🤙🏻", + "call_me_hand::skin-tone-3": "🤙🏼", + "call_me_hand::skin-tone-4": "🤙🏽", + "call_me_hand::skin-tone-5": "🤙🏾", + "call_me_hand::skin-tone-6": "🤙🏿", + "calling": "📲", + "camel": "🐫", + "camera": "📷", + "camera_with_flash": "📸", + "camping": "🏕️", + "cancer": "♋", + "candle": "🕯️", + "candy": "🍬", + "canned_food": "🥫", + "canoe": "🛶", + "capital_abcd": "🔠", + "capricorn": "♑", + "car": "🚗", + "card_file_box": "🗃️", + "card_index": "📇", + "card_index_dividers": "🗂️", + "carousel_horse": "🎠", + "carpentry_saw": "🪚", + "carrot": "🥕", + "cat": "🐱", + "cat2": "🐈", + "cd": "💿", + "chains": "⛓️", + "chair": "🪑", + "champagne": "🍾", + "chart": "💹", + "chart_with_downwards_trend": "📉", + "chart_with_upwards_trend": "📈", + "checkered_flag": "🏁", + "cheese_wedge": "🧀", + "cherries": "🍒", + "cherry_blossom": "🌸", + "chess_pawn": "♟️", + "chestnut": "🌰", + "chicken": "🐔", + "child": "🧒", + "child::skin-tone-2": "🧒🏻", + "child::skin-tone-3": "🧒🏼", + "child::skin-tone-4": "🧒🏽", + "child::skin-tone-5": "🧒🏾", + "child::skin-tone-6": "🧒🏿", + "children_crossing": "🚸", + "chipmunk": "🐿️", + "chocolate_bar": "🍫", + "chopsticks": "🥢", + "christmas_tree": "🎄", + "church": "⛪", + "cinema": "🎦", + "circus_tent": "🎪", + "city_sunrise": "🌇", + "city_sunset": "🌆", + "cityscape": "🏙️", + "cl": "🆑", + "clap": "👏", + "clap::skin-tone-2": "👏🏻", + "clap::skin-tone-3": "👏🏼", + "clap::skin-tone-4": "👏🏽", + "clap::skin-tone-5": "👏🏾", + "clap::skin-tone-6": "👏🏿", + "clapper": "🎬", + "classical_building": "🏛️", + "clinking_glasses": "🥂", + "clipboard": "📋", + "clock1": "🕐", + "clock10": "🕙", + "clock1030": "🕥", + "clock11": "🕚", + "clock1130": "🕦", + "clock12": "🕛", + "clock1230": "🕧", + "clock130": "🕜", + "clock2": "🕑", + "clock230": "🕝", + "clock3": "🕒", + "clock330": "🕞", + "clock4": "🕓", + "clock430": "🕟", + "clock5": "🕔", + "clock530": "🕠", + "clock6": "🕕", + "clock630": "🕡", + "clock7": "🕖", + "clock730": "🕢", + "clock8": "🕗", + "clock830": "🕣", + "clock9": "🕘", + "clock930": "🕤", + "closed_book": "📕", + "closed_lock_with_key": "🔐", + "closed_umbrella": "🌂", + "cloud": "☁️", + "clown_face": "🤡", + "clubs": "♣️", + "cn": "🇨🇳", + "coat": "🧥", + "cockroach": "🪳", + "cocktail": "🍸", + "coconut": "🥥", + "coffee": "☕", + "coffin": "⚰️", + "coin": "🪙", + "cold_face": "🥶", + "cold_sweat": "😰", + "comet": "☄️", + "compass": "🧭", + "compression": "🗜️", + "computer": "💻", + "confetti_ball": "🎊", + "confounded": "😖", + "confused": "😕", + "congratulations": "㊗️", + "construction": "🚧", + "construction_worker": "👷", + "construction_worker::skin-tone-2": "👷🏻", + "construction_worker::skin-tone-3": "👷🏼", + "construction_worker::skin-tone-4": "👷🏽", + "construction_worker::skin-tone-5": "👷🏾", + "construction_worker::skin-tone-6": "👷🏿", + "control_knobs": "🎛️", + "convenience_store": "🏪", + "cook": "🧑‍🍳", + "cook::skin-tone-2": "🧑🏻‍🍳", + "cook::skin-tone-3": "🧑🏼‍🍳", + "cook::skin-tone-4": "🧑🏽‍🍳", + "cook::skin-tone-5": "🧑🏾‍🍳", + "cook::skin-tone-6": "🧑🏿‍🍳", + "cookie": "🍪", + "cool": "🆒", + "cop": "👮", + "cop::skin-tone-2": "👮🏻", + "cop::skin-tone-3": "👮🏼", + "cop::skin-tone-4": "👮🏽", + "cop::skin-tone-5": "👮🏾", + "cop::skin-tone-6": "👮🏿", + "copyright": "©️", + "coral": "🪸", + "corn": "🌽", + "couch_and_lamp": "🛋️", + "couple_with_heart": "💑", + "couple_with_heart::skin-tone-2": "💑🏻", + "couple_with_heart::skin-tone-2-3": "🧑🏻‍❤️‍🧑🏼", + "couple_with_heart::skin-tone-2-4": "🧑🏻‍❤️‍🧑🏽", + "couple_with_heart::skin-tone-2-5": "🧑🏻‍❤️‍🧑🏾", + "couple_with_heart::skin-tone-2-6": "🧑🏻‍❤️‍🧑🏿", + "couple_with_heart::skin-tone-3": "💑🏼", + "couple_with_heart::skin-tone-3-2": "🧑🏼‍❤️‍🧑🏻", + "couple_with_heart::skin-tone-3-4": "🧑🏼‍❤️‍🧑🏽", + "couple_with_heart::skin-tone-3-5": "🧑🏼‍❤️‍🧑🏾", + "couple_with_heart::skin-tone-3-6": "🧑🏼‍❤️‍🧑🏿", + "couple_with_heart::skin-tone-4": "💑🏽", + "couple_with_heart::skin-tone-4-2": "🧑🏽‍❤️‍🧑🏻", + "couple_with_heart::skin-tone-4-3": "🧑🏽‍❤️‍🧑🏼", + "couple_with_heart::skin-tone-4-5": "🧑🏽‍❤️‍🧑🏾", + "couple_with_heart::skin-tone-4-6": "🧑🏽‍❤️‍🧑🏿", + "couple_with_heart::skin-tone-5": "💑🏾", + "couple_with_heart::skin-tone-5-2": "🧑🏾‍❤️‍🧑🏻", + "couple_with_heart::skin-tone-5-3": "🧑🏾‍❤️‍🧑🏼", + "couple_with_heart::skin-tone-5-4": "🧑🏾‍❤️‍🧑🏽", + "couple_with_heart::skin-tone-5-6": "🧑🏾‍❤️‍🧑🏿", + "couple_with_heart::skin-tone-6": "💑🏿", + "couple_with_heart::skin-tone-6-2": "🧑🏿‍❤️‍🧑🏻", + "couple_with_heart::skin-tone-6-3": "🧑🏿‍❤️‍🧑🏼", + "couple_with_heart::skin-tone-6-4": "🧑🏿‍❤️‍🧑🏽", + "couple_with_heart::skin-tone-6-5": "🧑🏿‍❤️‍🧑🏾", + "couplekiss": "💏", + "couplekiss::skin-tone-2": "💏🏻", + "couplekiss::skin-tone-2-3": "🧑🏻‍❤️‍💋‍🧑🏼", + "couplekiss::skin-tone-2-4": "🧑🏻‍❤️‍💋‍🧑🏽", + "couplekiss::skin-tone-2-5": "🧑🏻‍❤️‍💋‍🧑🏾", + "couplekiss::skin-tone-2-6": "🧑🏻‍❤️‍💋‍🧑🏿", + "couplekiss::skin-tone-3": "💏🏼", + "couplekiss::skin-tone-3-2": "🧑🏼‍❤️‍💋‍🧑🏻", + "couplekiss::skin-tone-3-4": "🧑🏼‍❤️‍💋‍🧑🏽", + "couplekiss::skin-tone-3-5": "🧑🏼‍❤️‍💋‍🧑🏾", + "couplekiss::skin-tone-3-6": "🧑🏼‍❤️‍💋‍🧑🏿", + "couplekiss::skin-tone-4": "💏🏽", + "couplekiss::skin-tone-4-2": "🧑🏽‍❤️‍💋‍🧑🏻", + "couplekiss::skin-tone-4-3": "🧑🏽‍❤️‍💋‍🧑🏼", + "couplekiss::skin-tone-4-5": "🧑🏽‍❤️‍💋‍🧑🏾", + "couplekiss::skin-tone-4-6": "🧑🏽‍❤️‍💋‍🧑🏿", + "couplekiss::skin-tone-5": "💏🏾", + "couplekiss::skin-tone-5-2": "🧑🏾‍❤️‍💋‍🧑🏻", + "couplekiss::skin-tone-5-3": "🧑🏾‍❤️‍💋‍🧑🏼", + "couplekiss::skin-tone-5-4": "🧑🏾‍❤️‍💋‍🧑🏽", + "couplekiss::skin-tone-5-6": "🧑🏾‍❤️‍💋‍🧑🏿", + "couplekiss::skin-tone-6": "💏🏿", + "couplekiss::skin-tone-6-2": "🧑🏿‍❤️‍💋‍🧑🏻", + "couplekiss::skin-tone-6-3": "🧑🏿‍❤️‍💋‍🧑🏼", + "couplekiss::skin-tone-6-4": "🧑🏿‍❤️‍💋‍🧑🏽", + "couplekiss::skin-tone-6-5": "🧑🏿‍❤️‍💋‍🧑🏾", + "cow": "🐮", + "cow2": "🐄", + "crab": "🦀", + "credit_card": "💳", + "crescent_moon": "🌙", + "cricket": "🦗", + "cricket_bat_and_ball": "🏏", + "crocodile": "🐊", + "croissant": "🥐", + "crossed_fingers": "🤞", + "crossed_fingers::skin-tone-2": "🤞🏻", + "crossed_fingers::skin-tone-3": "🤞🏼", + "crossed_fingers::skin-tone-4": "🤞🏽", + "crossed_fingers::skin-tone-5": "🤞🏾", + "crossed_fingers::skin-tone-6": "🤞🏿", + "crossed_flags": "🎌", + "crossed_swords": "⚔️", + "crown": "👑", + "crutch": "🩼", + "cry": "😢", + "crying_cat_face": "😿", + "crystal_ball": "🔮", + "cucumber": "🥒", + "cup_with_straw": "🥤", + "cupcake": "🧁", + "cupid": "💘", + "curling_stone": "🥌", + "curly_haired_man": "👨‍🦱", + "curly_haired_man::skin-tone-2": "👨🏻‍🦱", + "curly_haired_man::skin-tone-3": "👨🏼‍🦱", + "curly_haired_man::skin-tone-4": "👨🏽‍🦱", + "curly_haired_man::skin-tone-5": "👨🏾‍🦱", + "curly_haired_man::skin-tone-6": "👨🏿‍🦱", + "curly_haired_person": "🧑‍🦱", + "curly_haired_person::skin-tone-2": "🧑🏻‍🦱", + "curly_haired_person::skin-tone-3": "🧑🏼‍🦱", + "curly_haired_person::skin-tone-4": "🧑🏽‍🦱", + "curly_haired_person::skin-tone-5": "🧑🏾‍🦱", + "curly_haired_person::skin-tone-6": "🧑🏿‍🦱", + "curly_haired_woman": "👩‍🦱", + "curly_haired_woman::skin-tone-2": "👩🏻‍🦱", + "curly_haired_woman::skin-tone-3": "👩🏼‍🦱", + "curly_haired_woman::skin-tone-4": "👩🏽‍🦱", + "curly_haired_woman::skin-tone-5": "👩🏾‍🦱", + "curly_haired_woman::skin-tone-6": "👩🏿‍🦱", + "curly_loop": "➰", + "currency_exchange": "💱", + "curry": "🍛", + "custard": "🍮", + "customs": "🛃", + "cut_of_meat": "🥩", + "cyclone": "🌀", + "dagger_knife": "🗡️", + "dancer": "💃", + "dancer::skin-tone-2": "💃🏻", + "dancer::skin-tone-3": "💃🏼", + "dancer::skin-tone-4": "💃🏽", + "dancer::skin-tone-5": "💃🏾", + "dancer::skin-tone-6": "💃🏿", + "dancers": "👯", + "dango": "🍡", + "dark_sunglasses": "🕶️", + "dart": "🎯", + "dash": "💨", + "date": "📅", + "de": "🇩🇪", + "deaf_man": "🧏‍♂️", + "deaf_man::skin-tone-2": "🧏🏻‍♂️", + "deaf_man::skin-tone-3": "🧏🏼‍♂️", + "deaf_man::skin-tone-4": "🧏🏽‍♂️", + "deaf_man::skin-tone-5": "🧏🏾‍♂️", + "deaf_man::skin-tone-6": "🧏🏿‍♂️", + "deaf_person": "🧏", + "deaf_person::skin-tone-2": "🧏🏻", + "deaf_person::skin-tone-3": "🧏🏼", + "deaf_person::skin-tone-4": "🧏🏽", + "deaf_person::skin-tone-5": "🧏🏾", + "deaf_person::skin-tone-6": "🧏🏿", + "deaf_woman": "🧏‍♀️", + "deaf_woman::skin-tone-2": "🧏🏻‍♀️", + "deaf_woman::skin-tone-3": "🧏🏼‍♀️", + "deaf_woman::skin-tone-4": "🧏🏽‍♀️", + "deaf_woman::skin-tone-5": "🧏🏾‍♀️", + "deaf_woman::skin-tone-6": "🧏🏿‍♀️", + "deciduous_tree": "🌳", + "deer": "🦌", + "department_store": "🏬", + "derelict_house_building": "🏚️", + "desert": "🏜️", + "desert_island": "🏝️", + "desktop_computer": "🖥️", + "diamond_shape_with_a_dot_inside": "💠", + "diamonds": "♦️", + "disappointed": "😞", + "disappointed_relieved": "😥", + "disguised_face": "🥸", + "diving_mask": "🤿", + "diya_lamp": "🪔", + "dizzy": "💫", + "dizzy_face": "😵", + "dna": "🧬", + "do_not_litter": "🚯", + "dodo": "🦤", + "dog": "🐶", + "dog2": "🐕", + "dollar": "💵", + "dolls": "🎎", + "dolphin": "🐬", + "donkey": "🫏", + "door": "🚪", + "dotted_line_face": "🫥", + "double_vertical_bar": "⏸️", + "doughnut": "🍩", + "dove_of_peace": "🕊️", + "dragon": "🐉", + "dragon_face": "🐲", + "dress": "👗", + "dromedary_camel": "🐪", + "drooling_face": "🤤", + "drop_of_blood": "🩸", + "droplet": "💧", + "drum_with_drumsticks": "🥁", + "duck": "🦆", + "dumpling": "🥟", + "dvd": "📀", + "e-mail": "📧", + "eagle": "🦅", + "ear": "👂", + "ear::skin-tone-2": "👂🏻", + "ear::skin-tone-3": "👂🏼", + "ear::skin-tone-4": "👂🏽", + "ear::skin-tone-5": "👂🏾", + "ear::skin-tone-6": "👂🏿", + "ear_of_rice": "🌾", + "ear_with_hearing_aid": "🦻", + "ear_with_hearing_aid::skin-tone-2": "🦻🏻", + "ear_with_hearing_aid::skin-tone-3": "🦻🏼", + "ear_with_hearing_aid::skin-tone-4": "🦻🏽", + "ear_with_hearing_aid::skin-tone-5": "🦻🏾", + "ear_with_hearing_aid::skin-tone-6": "🦻🏿", + "earth_africa": "🌍", + "earth_americas": "🌎", + "earth_asia": "🌏", + "egg": "🥚", + "eggplant": "🍆", + "eight": "8️⃣", + "eight_pointed_black_star": "✴️", + "eight_spoked_asterisk": "✳️", + "eject": "⏏️", + "electric_plug": "🔌", + "elephant": "🐘", + "elevator": "🛗", + "elf": "🧝", + "elf::skin-tone-2": "🧝🏻", + "elf::skin-tone-3": "🧝🏼", + "elf::skin-tone-4": "🧝🏽", + "elf::skin-tone-5": "🧝🏾", + "elf::skin-tone-6": "🧝🏿", + "email": "✉️", + "empty_nest": "🪹", + "end": "🔚", + "envelope_with_arrow": "📩", + "es": "🇪🇸", + "euro": "💶", + "european_castle": "🏰", + "european_post_office": "🏤", + "evergreen_tree": "🌲", + "exclamation": "❗", + "exploding_head": "🤯", + "expressionless": "😑", + "eye": "👁️", + "eye-in-speech-bubble": "👁️‍🗨️", + "eyeglasses": "👓", + "eyes": "👀", + "face_exhaling": "😮‍💨", + "face_holding_back_tears": "🥹", + "face_in_clouds": "😶‍🌫️", + "face_palm": "🤦", + "face_palm::skin-tone-2": "🤦🏻", + "face_palm::skin-tone-3": "🤦🏼", + "face_palm::skin-tone-4": "🤦🏽", + "face_palm::skin-tone-5": "🤦🏾", + "face_palm::skin-tone-6": "🤦🏿", + "face_vomiting": "🤮", + "face_with_cowboy_hat": "🤠", + "face_with_diagonal_mouth": "🫤", + "face_with_hand_over_mouth": "🤭", + "face_with_head_bandage": "🤕", + "face_with_monocle": "🧐", + "face_with_open_eyes_and_hand_over_mouth": "🫢", + "face_with_peeking_eye": "🫣", + "face_with_raised_eyebrow": "🤨", + "face_with_rolling_eyes": "🙄", + "face_with_spiral_eyes": "😵‍💫", + "face_with_symbols_on_mouth": "🤬", + "face_with_thermometer": "🤒", + "facepunch": "👊", + "facepunch::skin-tone-2": "👊🏻", + "facepunch::skin-tone-3": "👊🏼", + "facepunch::skin-tone-4": "👊🏽", + "facepunch::skin-tone-5": "👊🏾", + "facepunch::skin-tone-6": "👊🏿", + "factory": "🏭", + "factory_worker": "🧑‍🏭", + "factory_worker::skin-tone-2": "🧑🏻‍🏭", + "factory_worker::skin-tone-3": "🧑🏼‍🏭", + "factory_worker::skin-tone-4": "🧑🏽‍🏭", + "factory_worker::skin-tone-5": "🧑🏾‍🏭", + "factory_worker::skin-tone-6": "🧑🏿‍🏭", + "fairy": "🧚", + "fairy::skin-tone-2": "🧚🏻", + "fairy::skin-tone-3": "🧚🏼", + "fairy::skin-tone-4": "🧚🏽", + "fairy::skin-tone-5": "🧚🏾", + "fairy::skin-tone-6": "🧚🏿", + "falafel": "🧆", + "fallen_leaf": "🍂", + "family": "👪", + "family_adult_adult_child": "🧑‍🧑‍🧒", + "family_adult_adult_child_child": "🧑‍🧑‍🧒‍🧒", + "family_adult_child": "🧑‍🧒", + "family_adult_child_child": "🧑‍🧒‍🧒", + "farmer": "🧑‍🌾", + "farmer::skin-tone-2": "🧑🏻‍🌾", + "farmer::skin-tone-3": "🧑🏼‍🌾", + "farmer::skin-tone-4": "🧑🏽‍🌾", + "farmer::skin-tone-5": "🧑🏾‍🌾", + "farmer::skin-tone-6": "🧑🏿‍🌾", + "fast_forward": "⏩", + "fax": "📠", + "fearful": "😨", + "feather": "🪶", + "feet": "🐾", + "female-artist": "👩‍🎨", + "female-artist::skin-tone-2": "👩🏻‍🎨", + "female-artist::skin-tone-3": "👩🏼‍🎨", + "female-artist::skin-tone-4": "👩🏽‍🎨", + "female-artist::skin-tone-5": "👩🏾‍🎨", + "female-artist::skin-tone-6": "👩🏿‍🎨", + "female-astronaut": "👩‍🚀", + "female-astronaut::skin-tone-2": "👩🏻‍🚀", + "female-astronaut::skin-tone-3": "👩🏼‍🚀", + "female-astronaut::skin-tone-4": "👩🏽‍🚀", + "female-astronaut::skin-tone-5": "👩🏾‍🚀", + "female-astronaut::skin-tone-6": "👩🏿‍🚀", + "female-construction-worker": "👷‍♀️", + "female-construction-worker::skin-tone-2": "👷🏻‍♀️", + "female-construction-worker::skin-tone-3": "👷🏼‍♀️", + "female-construction-worker::skin-tone-4": "👷🏽‍♀️", + "female-construction-worker::skin-tone-5": "👷🏾‍♀️", + "female-construction-worker::skin-tone-6": "👷🏿‍♀️", + "female-cook": "👩‍🍳", + "female-cook::skin-tone-2": "👩🏻‍🍳", + "female-cook::skin-tone-3": "👩🏼‍🍳", + "female-cook::skin-tone-4": "👩🏽‍🍳", + "female-cook::skin-tone-5": "👩🏾‍🍳", + "female-cook::skin-tone-6": "👩🏿‍🍳", + "female-detective": "🕵️‍♀️", + "female-detective::skin-tone-2": "🕵🏻‍♀️", + "female-detective::skin-tone-3": "🕵🏼‍♀️", + "female-detective::skin-tone-4": "🕵🏽‍♀️", + "female-detective::skin-tone-5": "🕵🏾‍♀️", + "female-detective::skin-tone-6": "🕵🏿‍♀️", + "female-doctor": "👩‍⚕️", + "female-doctor::skin-tone-2": "👩🏻‍⚕️", + "female-doctor::skin-tone-3": "👩🏼‍⚕️", + "female-doctor::skin-tone-4": "👩🏽‍⚕️", + "female-doctor::skin-tone-5": "👩🏾‍⚕️", + "female-doctor::skin-tone-6": "👩🏿‍⚕️", + "female-factory-worker": "👩‍🏭", + "female-factory-worker::skin-tone-2": "👩🏻‍🏭", + "female-factory-worker::skin-tone-3": "👩🏼‍🏭", + "female-factory-worker::skin-tone-4": "👩🏽‍🏭", + "female-factory-worker::skin-tone-5": "👩🏾‍🏭", + "female-factory-worker::skin-tone-6": "👩🏿‍🏭", + "female-farmer": "👩‍🌾", + "female-farmer::skin-tone-2": "👩🏻‍🌾", + "female-farmer::skin-tone-3": "👩🏼‍🌾", + "female-farmer::skin-tone-4": "👩🏽‍🌾", + "female-farmer::skin-tone-5": "👩🏾‍🌾", + "female-farmer::skin-tone-6": "👩🏿‍🌾", + "female-firefighter": "👩‍🚒", + "female-firefighter::skin-tone-2": "👩🏻‍🚒", + "female-firefighter::skin-tone-3": "👩🏼‍🚒", + "female-firefighter::skin-tone-4": "👩🏽‍🚒", + "female-firefighter::skin-tone-5": "👩🏾‍🚒", + "female-firefighter::skin-tone-6": "👩🏿‍🚒", + "female-guard": "💂‍♀️", + "female-guard::skin-tone-2": "💂🏻‍♀️", + "female-guard::skin-tone-3": "💂🏼‍♀️", + "female-guard::skin-tone-4": "💂🏽‍♀️", + "female-guard::skin-tone-5": "💂🏾‍♀️", + "female-guard::skin-tone-6": "💂🏿‍♀️", + "female-judge": "👩‍⚖️", + "female-judge::skin-tone-2": "👩🏻‍⚖️", + "female-judge::skin-tone-3": "👩🏼‍⚖️", + "female-judge::skin-tone-4": "👩🏽‍⚖️", + "female-judge::skin-tone-5": "👩🏾‍⚖️", + "female-judge::skin-tone-6": "👩🏿‍⚖️", + "female-mechanic": "👩‍🔧", + "female-mechanic::skin-tone-2": "👩🏻‍🔧", + "female-mechanic::skin-tone-3": "👩🏼‍🔧", + "female-mechanic::skin-tone-4": "👩🏽‍🔧", + "female-mechanic::skin-tone-5": "👩🏾‍🔧", + "female-mechanic::skin-tone-6": "👩🏿‍🔧", + "female-office-worker": "👩‍💼", + "female-office-worker::skin-tone-2": "👩🏻‍💼", + "female-office-worker::skin-tone-3": "👩🏼‍💼", + "female-office-worker::skin-tone-4": "👩🏽‍💼", + "female-office-worker::skin-tone-5": "👩🏾‍💼", + "female-office-worker::skin-tone-6": "👩🏿‍💼", + "female-pilot": "👩‍✈️", + "female-pilot::skin-tone-2": "👩🏻‍✈️", + "female-pilot::skin-tone-3": "👩🏼‍✈️", + "female-pilot::skin-tone-4": "👩🏽‍✈️", + "female-pilot::skin-tone-5": "👩🏾‍✈️", + "female-pilot::skin-tone-6": "👩🏿‍✈️", + "female-police-officer": "👮‍♀️", + "female-police-officer::skin-tone-2": "👮🏻‍♀️", + "female-police-officer::skin-tone-3": "👮🏼‍♀️", + "female-police-officer::skin-tone-4": "👮🏽‍♀️", + "female-police-officer::skin-tone-5": "👮🏾‍♀️", + "female-police-officer::skin-tone-6": "👮🏿‍♀️", + "female-scientist": "👩‍🔬", + "female-scientist::skin-tone-2": "👩🏻‍🔬", + "female-scientist::skin-tone-3": "👩🏼‍🔬", + "female-scientist::skin-tone-4": "👩🏽‍🔬", + "female-scientist::skin-tone-5": "👩🏾‍🔬", + "female-scientist::skin-tone-6": "👩🏿‍🔬", + "female-singer": "👩‍🎤", + "female-singer::skin-tone-2": "👩🏻‍🎤", + "female-singer::skin-tone-3": "👩🏼‍🎤", + "female-singer::skin-tone-4": "👩🏽‍🎤", + "female-singer::skin-tone-5": "👩🏾‍🎤", + "female-singer::skin-tone-6": "👩🏿‍🎤", + "female-student": "👩‍🎓", + "female-student::skin-tone-2": "👩🏻‍🎓", + "female-student::skin-tone-3": "👩🏼‍🎓", + "female-student::skin-tone-4": "👩🏽‍🎓", + "female-student::skin-tone-5": "👩🏾‍🎓", + "female-student::skin-tone-6": "👩🏿‍🎓", + "female-teacher": "👩‍🏫", + "female-teacher::skin-tone-2": "👩🏻‍🏫", + "female-teacher::skin-tone-3": "👩🏼‍🏫", + "female-teacher::skin-tone-4": "👩🏽‍🏫", + "female-teacher::skin-tone-5": "👩🏾‍🏫", + "female-teacher::skin-tone-6": "👩🏿‍🏫", + "female-technologist": "👩‍💻", + "female-technologist::skin-tone-2": "👩🏻‍💻", + "female-technologist::skin-tone-3": "👩🏼‍💻", + "female-technologist::skin-tone-4": "👩🏽‍💻", + "female-technologist::skin-tone-5": "👩🏾‍💻", + "female-technologist::skin-tone-6": "👩🏿‍💻", + "female_elf": "🧝‍♀️", + "female_elf::skin-tone-2": "🧝🏻‍♀️", + "female_elf::skin-tone-3": "🧝🏼‍♀️", + "female_elf::skin-tone-4": "🧝🏽‍♀️", + "female_elf::skin-tone-5": "🧝🏾‍♀️", + "female_elf::skin-tone-6": "🧝🏿‍♀️", + "female_fairy": "🧚‍♀️", + "female_fairy::skin-tone-2": "🧚🏻‍♀️", + "female_fairy::skin-tone-3": "🧚🏼‍♀️", + "female_fairy::skin-tone-4": "🧚🏽‍♀️", + "female_fairy::skin-tone-5": "🧚🏾‍♀️", + "female_fairy::skin-tone-6": "🧚🏿‍♀️", + "female_genie": "🧞‍♀️", + "female_mage": "🧙‍♀️", + "female_mage::skin-tone-2": "🧙🏻‍♀️", + "female_mage::skin-tone-3": "🧙🏼‍♀️", + "female_mage::skin-tone-4": "🧙🏽‍♀️", + "female_mage::skin-tone-5": "🧙🏾‍♀️", + "female_mage::skin-tone-6": "🧙🏿‍♀️", + "female_sign": "♀️", + "female_superhero": "🦸‍♀️", + "female_superhero::skin-tone-2": "🦸🏻‍♀️", + "female_superhero::skin-tone-3": "🦸🏼‍♀️", + "female_superhero::skin-tone-4": "🦸🏽‍♀️", + "female_superhero::skin-tone-5": "🦸🏾‍♀️", + "female_superhero::skin-tone-6": "🦸🏿‍♀️", + "female_supervillain": "🦹‍♀️", + "female_supervillain::skin-tone-2": "🦹🏻‍♀️", + "female_supervillain::skin-tone-3": "🦹🏼‍♀️", + "female_supervillain::skin-tone-4": "🦹🏽‍♀️", + "female_supervillain::skin-tone-5": "🦹🏾‍♀️", + "female_supervillain::skin-tone-6": "🦹🏿‍♀️", + "female_vampire": "🧛‍♀️", + "female_vampire::skin-tone-2": "🧛🏻‍♀️", + "female_vampire::skin-tone-3": "🧛🏼‍♀️", + "female_vampire::skin-tone-4": "🧛🏽‍♀️", + "female_vampire::skin-tone-5": "🧛🏾‍♀️", + "female_vampire::skin-tone-6": "🧛🏿‍♀️", + "female_zombie": "🧟‍♀️", + "fencer": "🤺", + "ferris_wheel": "🎡", + "ferry": "⛴️", + "field_hockey_stick_and_ball": "🏑", + "file_cabinet": "🗄️", + "file_folder": "📁", + "film_frames": "🎞️", + "film_projector": "📽️", + "fire": "🔥", + "fire_engine": "🚒", + "fire_extinguisher": "🧯", + "firecracker": "🧨", + "firefighter": "🧑‍🚒", + "firefighter::skin-tone-2": "🧑🏻‍🚒", + "firefighter::skin-tone-3": "🧑🏼‍🚒", + "firefighter::skin-tone-4": "🧑🏽‍🚒", + "firefighter::skin-tone-5": "🧑🏾‍🚒", + "firefighter::skin-tone-6": "🧑🏿‍🚒", + "fireworks": "🎆", + "first_place_medal": "🥇", + "first_quarter_moon": "🌓", + "first_quarter_moon_with_face": "🌛", + "fish": "🐟", + "fish_cake": "🍥", + "fishing_pole_and_fish": "🎣", + "fist": "✊", + "fist::skin-tone-2": "✊🏻", + "fist::skin-tone-3": "✊🏼", + "fist::skin-tone-4": "✊🏽", + "fist::skin-tone-5": "✊🏾", + "fist::skin-tone-6": "✊🏿", + "five": "5️⃣", + "flag-ac": "🇦🇨", + "flag-ad": "🇦🇩", + "flag-ae": "🇦🇪", + "flag-af": "🇦🇫", + "flag-ag": "🇦🇬", + "flag-ai": "🇦🇮", + "flag-al": "🇦🇱", + "flag-am": "🇦🇲", + "flag-ao": "🇦🇴", + "flag-aq": "🇦🇶", + "flag-ar": "🇦🇷", + "flag-as": "🇦🇸", + "flag-at": "🇦🇹", + "flag-au": "🇦🇺", + "flag-aw": "🇦🇼", + "flag-ax": "🇦🇽", + "flag-az": "🇦🇿", + "flag-ba": "🇧🇦", + "flag-bb": "🇧🇧", + "flag-bd": "🇧🇩", + "flag-be": "🇧🇪", + "flag-bf": "🇧🇫", + "flag-bg": "🇧🇬", + "flag-bh": "🇧🇭", + "flag-bi": "🇧🇮", + "flag-bj": "🇧🇯", + "flag-bl": "🇧🇱", + "flag-bm": "🇧🇲", + "flag-bn": "🇧🇳", + "flag-bo": "🇧🇴", + "flag-bq": "🇧🇶", + "flag-br": "🇧🇷", + "flag-bs": "🇧🇸", + "flag-bt": "🇧🇹", + "flag-bv": "🇧🇻", + "flag-bw": "🇧🇼", + "flag-by": "🇧🇾", + "flag-bz": "🇧🇿", + "flag-ca": "🇨🇦", + "flag-cc": "🇨🇨", + "flag-cd": "🇨🇩", + "flag-cf": "🇨🇫", + "flag-cg": "🇨🇬", + "flag-ch": "🇨🇭", + "flag-ci": "🇨🇮", + "flag-ck": "🇨🇰", + "flag-cl": "🇨🇱", + "flag-cm": "🇨🇲", + "flag-co": "🇨🇴", + "flag-cp": "🇨🇵", + "flag-cr": "🇨🇷", + "flag-cu": "🇨🇺", + "flag-cv": "🇨🇻", + "flag-cw": "🇨🇼", + "flag-cx": "🇨🇽", + "flag-cy": "🇨🇾", + "flag-cz": "🇨🇿", + "flag-dg": "🇩🇬", + "flag-dj": "🇩🇯", + "flag-dk": "🇩🇰", + "flag-dm": "🇩🇲", + "flag-do": "🇩🇴", + "flag-dz": "🇩🇿", + "flag-ea": "🇪🇦", + "flag-ec": "🇪🇨", + "flag-ee": "🇪🇪", + "flag-eg": "🇪🇬", + "flag-eh": "🇪🇭", + "flag-england": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + "flag-er": "🇪🇷", + "flag-et": "🇪🇹", + "flag-eu": "🇪🇺", + "flag-fi": "🇫🇮", + "flag-fj": "🇫🇯", + "flag-fk": "🇫🇰", + "flag-fm": "🇫🇲", + "flag-fo": "🇫🇴", + "flag-ga": "🇬🇦", + "flag-gd": "🇬🇩", + "flag-ge": "🇬🇪", + "flag-gf": "🇬🇫", + "flag-gg": "🇬🇬", + "flag-gh": "🇬🇭", + "flag-gi": "🇬🇮", + "flag-gl": "🇬🇱", + "flag-gm": "🇬🇲", + "flag-gn": "🇬🇳", + "flag-gp": "🇬🇵", + "flag-gq": "🇬🇶", + "flag-gr": "🇬🇷", + "flag-gs": "🇬🇸", + "flag-gt": "🇬🇹", + "flag-gu": "🇬🇺", + "flag-gw": "🇬🇼", + "flag-gy": "🇬🇾", + "flag-hk": "🇭🇰", + "flag-hm": "🇭🇲", + "flag-hn": "🇭🇳", + "flag-hr": "🇭🇷", + "flag-ht": "🇭🇹", + "flag-hu": "🇭🇺", + "flag-ic": "🇮🇨", + "flag-id": "🇮🇩", + "flag-ie": "🇮🇪", + "flag-il": "🇮🇱", + "flag-im": "🇮🇲", + "flag-in": "🇮🇳", + "flag-io": "🇮🇴", + "flag-iq": "🇮🇶", + "flag-ir": "🇮🇷", + "flag-is": "🇮🇸", + "flag-je": "🇯🇪", + "flag-jm": "🇯🇲", + "flag-jo": "🇯🇴", + "flag-ke": "🇰🇪", + "flag-kg": "🇰🇬", + "flag-kh": "🇰🇭", + "flag-ki": "🇰🇮", + "flag-km": "🇰🇲", + "flag-kn": "🇰🇳", + "flag-kp": "🇰🇵", + "flag-kw": "🇰🇼", + "flag-ky": "🇰🇾", + "flag-kz": "🇰🇿", + "flag-la": "🇱🇦", + "flag-lb": "🇱🇧", + "flag-lc": "🇱🇨", + "flag-li": "🇱🇮", + "flag-lk": "🇱🇰", + "flag-lr": "🇱🇷", + "flag-ls": "🇱🇸", + "flag-lt": "🇱🇹", + "flag-lu": "🇱🇺", + "flag-lv": "🇱🇻", + "flag-ly": "🇱🇾", + "flag-ma": "🇲🇦", + "flag-mc": "🇲🇨", + "flag-md": "🇲🇩", + "flag-me": "🇲🇪", + "flag-mf": "🇲🇫", + "flag-mg": "🇲🇬", + "flag-mh": "🇲🇭", + "flag-mk": "🇲🇰", + "flag-ml": "🇲🇱", + "flag-mm": "🇲🇲", + "flag-mn": "🇲🇳", + "flag-mo": "🇲🇴", + "flag-mp": "🇲🇵", + "flag-mq": "🇲🇶", + "flag-mr": "🇲🇷", + "flag-ms": "🇲🇸", + "flag-mt": "🇲🇹", + "flag-mu": "🇲🇺", + "flag-mv": "🇲🇻", + "flag-mw": "🇲🇼", + "flag-mx": "🇲🇽", + "flag-my": "🇲🇾", + "flag-mz": "🇲🇿", + "flag-na": "🇳🇦", + "flag-nc": "🇳🇨", + "flag-ne": "🇳🇪", + "flag-nf": "🇳🇫", + "flag-ng": "🇳🇬", + "flag-ni": "🇳🇮", + "flag-nl": "🇳🇱", + "flag-no": "🇳🇴", + "flag-np": "🇳🇵", + "flag-nr": "🇳🇷", + "flag-nu": "🇳🇺", + "flag-nz": "🇳🇿", + "flag-om": "🇴🇲", + "flag-pa": "🇵🇦", + "flag-pe": "🇵🇪", + "flag-pf": "🇵🇫", + "flag-pg": "🇵🇬", + "flag-ph": "🇵🇭", + "flag-pk": "🇵🇰", + "flag-pl": "🇵🇱", + "flag-pm": "🇵🇲", + "flag-pn": "🇵🇳", + "flag-pr": "🇵🇷", + "flag-ps": "🇵🇸", + "flag-pt": "🇵🇹", + "flag-pw": "🇵🇼", + "flag-py": "🇵🇾", + "flag-qa": "🇶🇦", + "flag-re": "🇷🇪", + "flag-ro": "🇷🇴", + "flag-rs": "🇷🇸", + "flag-rw": "🇷🇼", + "flag-sa": "🇸🇦", + "flag-sb": "🇸🇧", + "flag-sc": "🇸🇨", + "flag-scotland": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + "flag-sd": "🇸🇩", + "flag-se": "🇸🇪", + "flag-sg": "🇸🇬", + "flag-sh": "🇸🇭", + "flag-si": "🇸🇮", + "flag-sj": "🇸🇯", + "flag-sk": "🇸🇰", + "flag-sl": "🇸🇱", + "flag-sm": "🇸🇲", + "flag-sn": "🇸🇳", + "flag-so": "🇸🇴", + "flag-sr": "🇸🇷", + "flag-ss": "🇸🇸", + "flag-st": "🇸🇹", + "flag-sv": "🇸🇻", + "flag-sx": "🇸🇽", + "flag-sy": "🇸🇾", + "flag-sz": "🇸🇿", + "flag-ta": "🇹🇦", + "flag-tc": "🇹🇨", + "flag-td": "🇹🇩", + "flag-tf": "🇹🇫", + "flag-tg": "🇹🇬", + "flag-th": "🇹🇭", + "flag-tj": "🇹🇯", + "flag-tk": "🇹🇰", + "flag-tl": "🇹🇱", + "flag-tm": "🇹🇲", + "flag-tn": "🇹🇳", + "flag-to": "🇹🇴", + "flag-tr": "🇹🇷", + "flag-tt": "🇹🇹", + "flag-tv": "🇹🇻", + "flag-tw": "🇹🇼", + "flag-tz": "🇹🇿", + "flag-ua": "🇺🇦", + "flag-ug": "🇺🇬", + "flag-um": "🇺🇲", + "flag-un": "🇺🇳", + "flag-uy": "🇺🇾", + "flag-uz": "🇺🇿", + "flag-va": "🇻🇦", + "flag-vc": "🇻🇨", + "flag-ve": "🇻🇪", + "flag-vg": "🇻🇬", + "flag-vi": "🇻🇮", + "flag-vn": "🇻🇳", + "flag-vu": "🇻🇺", + "flag-wales": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + "flag-wf": "🇼🇫", + "flag-ws": "🇼🇸", + "flag-xk": "🇽🇰", + "flag-ye": "🇾🇪", + "flag-yt": "🇾🇹", + "flag-za": "🇿🇦", + "flag-zm": "🇿🇲", + "flag-zw": "🇿🇼", + "flags": "🎏", + "flamingo": "🦩", + "flashlight": "🔦", + "flatbread": "🫓", + "fleur_de_lis": "⚜️", + "floppy_disk": "💾", + "flower_playing_cards": "🎴", + "flushed": "😳", + "flute": "🪈", + "fly": "🪰", + "flying_disc": "🥏", + "flying_saucer": "🛸", + "fog": "🌫️", + "foggy": "🌁", + "folding_hand_fan": "🪭", + "fondue": "🫕", + "foot": "🦶", + "foot::skin-tone-2": "🦶🏻", + "foot::skin-tone-3": "🦶🏼", + "foot::skin-tone-4": "🦶🏽", + "foot::skin-tone-5": "🦶🏾", + "foot::skin-tone-6": "🦶🏿", + "football": "🏈", + "footprints": "👣", + "fork_and_knife": "🍴", + "fortune_cookie": "🥠", + "fountain": "⛲", + "four": "4️⃣", + "four_leaf_clover": "🍀", + "fox_face": "🦊", + "fr": "🇫🇷", + "frame_with_picture": "🖼️", + "free": "🆓", + "fried_egg": "🍳", + "fried_shrimp": "🍤", + "fries": "🍟", + "frog": "🐸", + "frowning": "😦", + "fuelpump": "⛽", + "full_moon": "🌕", + "full_moon_with_face": "🌝", + "funeral_urn": "⚱️", + "game_die": "🎲", + "garlic": "🧄", + "gb": "🇬🇧", + "gear": "⚙️", + "gem": "💎", + "gemini": "♊", + "genie": "🧞", + "ghost": "👻", + "gift": "🎁", + "gift_heart": "💝", + "ginger_root": "🫚", + "giraffe_face": "🦒", + "girl": "👧", + "girl::skin-tone-2": "👧🏻", + "girl::skin-tone-3": "👧🏼", + "girl::skin-tone-4": "👧🏽", + "girl::skin-tone-5": "👧🏾", + "girl::skin-tone-6": "👧🏿", + "glass_of_milk": "🥛", + "globe_with_meridians": "🌐", + "gloves": "🧤", + "goal_net": "🥅", + "goat": "🐐", + "goggles": "🥽", + "golf": "⛳", + "golfer": "🏌️", + "golfer::skin-tone-2": "🏌🏻", + "golfer::skin-tone-3": "🏌🏼", + "golfer::skin-tone-4": "🏌🏽", + "golfer::skin-tone-5": "🏌🏾", + "golfer::skin-tone-6": "🏌🏿", + "goose": "🪿", + "gorilla": "🦍", + "grapes": "🍇", + "green_apple": "🍏", + "green_book": "📗", + "green_heart": "💚", + "green_salad": "🥗", + "grey_exclamation": "❕", + "grey_heart": "🩶", + "grey_question": "❔", + "grimacing": "😬", + "grin": "😁", + "grinning": "😀", + "guardsman": "💂", + "guardsman::skin-tone-2": "💂🏻", + "guardsman::skin-tone-3": "💂🏼", + "guardsman::skin-tone-4": "💂🏽", + "guardsman::skin-tone-5": "💂🏾", + "guardsman::skin-tone-6": "💂🏿", + "guide_dog": "🦮", + "guitar": "🎸", + "gun": "🔫", + "hair_pick": "🪮", + "haircut": "💇", + "haircut::skin-tone-2": "💇🏻", + "haircut::skin-tone-3": "💇🏼", + "haircut::skin-tone-4": "💇🏽", + "haircut::skin-tone-5": "💇🏾", + "haircut::skin-tone-6": "💇🏿", + "hamburger": "🍔", + "hammer": "🔨", + "hammer_and_pick": "⚒️", + "hammer_and_wrench": "🛠️", + "hamsa": "🪬", + "hamster": "🐹", + "hand": "✋", + "hand::skin-tone-2": "✋🏻", + "hand::skin-tone-3": "✋🏼", + "hand::skin-tone-4": "✋🏽", + "hand::skin-tone-5": "✋🏾", + "hand::skin-tone-6": "✋🏿", + "hand_with_index_finger_and_thumb_crossed": "🫰", + "hand_with_index_finger_and_thumb_crossed::skin-tone-2": "🫰🏻", + "hand_with_index_finger_and_thumb_crossed::skin-tone-3": "🫰🏼", + "hand_with_index_finger_and_thumb_crossed::skin-tone-4": "🫰🏽", + "hand_with_index_finger_and_thumb_crossed::skin-tone-5": "🫰🏾", + "hand_with_index_finger_and_thumb_crossed::skin-tone-6": "🫰🏿", + "handbag": "👜", + "handball": "🤾", + "handball::skin-tone-2": "🤾🏻", + "handball::skin-tone-3": "🤾🏼", + "handball::skin-tone-4": "🤾🏽", + "handball::skin-tone-5": "🤾🏾", + "handball::skin-tone-6": "🤾🏿", + "handshake": "🤝", + "handshake::skin-tone-2": "🤝🏻", + "handshake::skin-tone-2-3": "🫱🏻‍🫲🏼", + "handshake::skin-tone-2-4": "🫱🏻‍🫲🏽", + "handshake::skin-tone-2-5": "🫱🏻‍🫲🏾", + "handshake::skin-tone-2-6": "🫱🏻‍🫲🏿", + "handshake::skin-tone-3": "🤝🏼", + "handshake::skin-tone-3-2": "🫱🏼‍🫲🏻", + "handshake::skin-tone-3-4": "🫱🏼‍🫲🏽", + "handshake::skin-tone-3-5": "🫱🏼‍🫲🏾", + "handshake::skin-tone-3-6": "🫱🏼‍🫲🏿", + "handshake::skin-tone-4": "🤝🏽", + "handshake::skin-tone-4-2": "🫱🏽‍🫲🏻", + "handshake::skin-tone-4-3": "🫱🏽‍🫲🏼", + "handshake::skin-tone-4-5": "🫱🏽‍🫲🏾", + "handshake::skin-tone-4-6": "🫱🏽‍🫲🏿", + "handshake::skin-tone-5": "🤝🏾", + "handshake::skin-tone-5-2": "🫱🏾‍🫲🏻", + "handshake::skin-tone-5-3": "🫱🏾‍🫲🏼", + "handshake::skin-tone-5-4": "🫱🏾‍🫲🏽", + "handshake::skin-tone-5-6": "🫱🏾‍🫲🏿", + "handshake::skin-tone-6": "🤝🏿", + "handshake::skin-tone-6-2": "🫱🏿‍🫲🏻", + "handshake::skin-tone-6-3": "🫱🏿‍🫲🏼", + "handshake::skin-tone-6-4": "🫱🏿‍🫲🏽", + "handshake::skin-tone-6-5": "🫱🏿‍🫲🏾", + "hankey": "💩", + "hash": "#️⃣", + "hatched_chick": "🐥", + "hatching_chick": "🐣", + "head_shaking_horizontally": "🙂‍↔️", + "head_shaking_vertically": "🙂‍↕️", + "headphones": "🎧", + "headstone": "🪦", + "health_worker": "🧑‍⚕️", + "health_worker::skin-tone-2": "🧑🏻‍⚕️", + "health_worker::skin-tone-3": "🧑🏼‍⚕️", + "health_worker::skin-tone-4": "🧑🏽‍⚕️", + "health_worker::skin-tone-5": "🧑🏾‍⚕️", + "health_worker::skin-tone-6": "🧑🏿‍⚕️", + "hear_no_evil": "🙉", + "heart": "❤️", + "heart_decoration": "💟", + "heart_eyes": "😍", + "heart_eyes_cat": "😻", + "heart_hands": "🫶", + "heart_hands::skin-tone-2": "🫶🏻", + "heart_hands::skin-tone-3": "🫶🏼", + "heart_hands::skin-tone-4": "🫶🏽", + "heart_hands::skin-tone-5": "🫶🏾", + "heart_hands::skin-tone-6": "🫶🏿", + "heart_on_fire": "❤️‍🔥", + "heartbeat": "💓", + "heartpulse": "💗", + "hearts": "♥️", + "heavy_check_mark": "✔️", + "heavy_division_sign": "➗", + "heavy_dollar_sign": "💲", + "heavy_equals_sign": "🟰", + "heavy_heart_exclamation_mark_ornament": "❣️", + "heavy_minus_sign": "➖", + "heavy_multiplication_x": "✖️", + "heavy_plus_sign": "➕", + "hedgehog": "🦔", + "helicopter": "🚁", + "helmet_with_white_cross": "⛑️", + "herb": "🌿", + "hibiscus": "🌺", + "high_brightness": "🔆", + "high_heel": "👠", + "hiking_boot": "🥾", + "hindu_temple": "🛕", + "hippopotamus": "🦛", + "hocho": "🔪", + "hole": "🕳️", + "honey_pot": "🍯", + "hook": "🪝", + "horse": "🐴", + "horse_racing": "🏇", + "horse_racing::skin-tone-2": "🏇🏻", + "horse_racing::skin-tone-3": "🏇🏼", + "horse_racing::skin-tone-4": "🏇🏽", + "horse_racing::skin-tone-5": "🏇🏾", + "horse_racing::skin-tone-6": "🏇🏿", + "hospital": "🏥", + "hot_face": "🥵", + "hot_pepper": "🌶️", + "hotdog": "🌭", + "hotel": "🏨", + "hotsprings": "♨️", + "hourglass": "⌛", + "hourglass_flowing_sand": "⏳", + "house": "🏠", + "house_buildings": "🏘️", + "house_with_garden": "🏡", + "hugging_face": "🤗", + "hushed": "😯", + "hut": "🛖", + "hyacinth": "🪻", + "i_love_you_hand_sign": "🤟", + "i_love_you_hand_sign::skin-tone-2": "🤟🏻", + "i_love_you_hand_sign::skin-tone-3": "🤟🏼", + "i_love_you_hand_sign::skin-tone-4": "🤟🏽", + "i_love_you_hand_sign::skin-tone-5": "🤟🏾", + "i_love_you_hand_sign::skin-tone-6": "🤟🏿", + "ice_cream": "🍨", + "ice_cube": "🧊", + "ice_hockey_stick_and_puck": "🏒", + "ice_skate": "⛸️", + "icecream": "🍦", + "id": "🆔", + "identification_card": "🪪", + "ideograph_advantage": "🉐", + "imp": "👿", + "inbox_tray": "📥", + "incoming_envelope": "📨", + "index_pointing_at_the_viewer": "🫵", + "index_pointing_at_the_viewer::skin-tone-2": "🫵🏻", + "index_pointing_at_the_viewer::skin-tone-3": "🫵🏼", + "index_pointing_at_the_viewer::skin-tone-4": "🫵🏽", + "index_pointing_at_the_viewer::skin-tone-5": "🫵🏾", + "index_pointing_at_the_viewer::skin-tone-6": "🫵🏿", + "infinity": "♾️", + "information_desk_person": "💁", + "information_desk_person::skin-tone-2": "💁🏻", + "information_desk_person::skin-tone-3": "💁🏼", + "information_desk_person::skin-tone-4": "💁🏽", + "information_desk_person::skin-tone-5": "💁🏾", + "information_desk_person::skin-tone-6": "💁🏿", + "information_source": "ℹ️", + "innocent": "😇", + "interrobang": "⁉️", + "iphone": "📱", + "it": "🇮🇹", + "izakaya_lantern": "🏮", + "jack_o_lantern": "🎃", + "japan": "🗾", + "japanese_castle": "🏯", + "japanese_goblin": "👺", + "japanese_ogre": "👹", + "jar": "🫙", + "jeans": "👖", + "jellyfish": "🪼", + "jigsaw": "🧩", + "joy": "😂", + "joy_cat": "😹", + "joystick": "🕹️", + "jp": "🇯🇵", + "judge": "🧑‍⚖️", + "judge::skin-tone-2": "🧑🏻‍⚖️", + "judge::skin-tone-3": "🧑🏼‍⚖️", + "judge::skin-tone-4": "🧑🏽‍⚖️", + "judge::skin-tone-5": "🧑🏾‍⚖️", + "judge::skin-tone-6": "🧑🏿‍⚖️", + "juggling": "🤹", + "juggling::skin-tone-2": "🤹🏻", + "juggling::skin-tone-3": "🤹🏼", + "juggling::skin-tone-4": "🤹🏽", + "juggling::skin-tone-5": "🤹🏾", + "juggling::skin-tone-6": "🤹🏿", + "kaaba": "🕋", + "kangaroo": "🦘", + "key": "🔑", + "keyboard": "⌨️", + "keycap_star": "*️⃣", + "keycap_ten": "🔟", + "khanda": "🪯", + "kimono": "👘", + "kiss": "💋", + "kissing": "😗", + "kissing_cat": "😽", + "kissing_closed_eyes": "😚", + "kissing_heart": "😘", + "kissing_smiling_eyes": "😙", + "kite": "🪁", + "kiwifruit": "🥝", + "kneeling_person": "🧎", + "kneeling_person::skin-tone-2": "🧎🏻", + "kneeling_person::skin-tone-3": "🧎🏼", + "kneeling_person::skin-tone-4": "🧎🏽", + "kneeling_person::skin-tone-5": "🧎🏾", + "kneeling_person::skin-tone-6": "🧎🏿", + "knife_fork_plate": "🍽️", + "knot": "🪢", + "koala": "🐨", + "koko": "🈁", + "kr": "🇰🇷", + "lab_coat": "🥼", + "label": "🏷️", + "lacrosse": "🥍", + "ladder": "🪜", + "ladybug": "🐞", + "large_blue_circle": "🔵", + "large_blue_diamond": "🔷", + "large_blue_square": "🟦", + "large_brown_circle": "🟤", + "large_brown_square": "🟫", + "large_green_circle": "🟢", + "large_green_square": "🟩", + "large_orange_circle": "🟠", + "large_orange_diamond": "🔶", + "large_orange_square": "🟧", + "large_purple_circle": "🟣", + "large_purple_square": "🟪", + "large_red_square": "🟥", + "large_yellow_circle": "🟡", + "large_yellow_square": "🟨", + "last_quarter_moon": "🌗", + "last_quarter_moon_with_face": "🌜", + "latin_cross": "✝️", + "laughing": "😆", + "leafy_green": "🥬", + "leaves": "🍃", + "ledger": "📒", + "left-facing_fist": "🤛", + "left-facing_fist::skin-tone-2": "🤛🏻", + "left-facing_fist::skin-tone-3": "🤛🏼", + "left-facing_fist::skin-tone-4": "🤛🏽", + "left-facing_fist::skin-tone-5": "🤛🏾", + "left-facing_fist::skin-tone-6": "🤛🏿", + "left_luggage": "🛅", + "left_right_arrow": "↔️", + "left_speech_bubble": "🗨️", + "leftwards_arrow_with_hook": "↩️", + "leftwards_hand": "🫲", + "leftwards_hand::skin-tone-2": "🫲🏻", + "leftwards_hand::skin-tone-3": "🫲🏼", + "leftwards_hand::skin-tone-4": "🫲🏽", + "leftwards_hand::skin-tone-5": "🫲🏾", + "leftwards_hand::skin-tone-6": "🫲🏿", + "leftwards_pushing_hand": "🫷", + "leftwards_pushing_hand::skin-tone-2": "🫷🏻", + "leftwards_pushing_hand::skin-tone-3": "🫷🏼", + "leftwards_pushing_hand::skin-tone-4": "🫷🏽", + "leftwards_pushing_hand::skin-tone-5": "🫷🏾", + "leftwards_pushing_hand::skin-tone-6": "🫷🏿", + "leg": "🦵", + "leg::skin-tone-2": "🦵🏻", + "leg::skin-tone-3": "🦵🏼", + "leg::skin-tone-4": "🦵🏽", + "leg::skin-tone-5": "🦵🏾", + "leg::skin-tone-6": "🦵🏿", + "lemon": "🍋", + "leo": "♌", + "leopard": "🐆", + "level_slider": "🎚️", + "libra": "♎", + "light_blue_heart": "🩵", + "light_rail": "🚈", + "lightning": "🌩️", + "lime": "🍋‍🟩", + "link": "🔗", + "linked_paperclips": "🖇️", + "lion_face": "🦁", + "lips": "👄", + "lipstick": "💄", + "lizard": "🦎", + "llama": "🦙", + "lobster": "🦞", + "lock": "🔒", + "lock_with_ink_pen": "🔏", + "lollipop": "🍭", + "long_drum": "🪘", + "loop": "➿", + "lotion_bottle": "🧴", + "lotus": "🪷", + "loud_sound": "🔊", + "loudspeaker": "📢", + "love_hotel": "🏩", + "love_letter": "💌", + "low_battery": "🪫", + "low_brightness": "🔅", + "lower_left_ballpoint_pen": "🖊️", + "lower_left_crayon": "🖍️", + "lower_left_fountain_pen": "🖋️", + "lower_left_paintbrush": "🖌️", + "luggage": "🧳", + "lungs": "🫁", + "lying_face": "🤥", + "m": "Ⓜ️", + "mag": "🔍", + "mag_right": "🔎", + "mage": "🧙", + "mage::skin-tone-2": "🧙🏻", + "mage::skin-tone-3": "🧙🏼", + "mage::skin-tone-4": "🧙🏽", + "mage::skin-tone-5": "🧙🏾", + "mage::skin-tone-6": "🧙🏿", + "magic_wand": "🪄", + "magnet": "🧲", + "mahjong": "🀄", + "mailbox": "📫", + "mailbox_closed": "📪", + "mailbox_with_mail": "📬", + "mailbox_with_no_mail": "📭", + "male-artist": "👨‍🎨", + "male-artist::skin-tone-2": "👨🏻‍🎨", + "male-artist::skin-tone-3": "👨🏼‍🎨", + "male-artist::skin-tone-4": "👨🏽‍🎨", + "male-artist::skin-tone-5": "👨🏾‍🎨", + "male-artist::skin-tone-6": "👨🏿‍🎨", + "male-astronaut": "👨‍🚀", + "male-astronaut::skin-tone-2": "👨🏻‍🚀", + "male-astronaut::skin-tone-3": "👨🏼‍🚀", + "male-astronaut::skin-tone-4": "👨🏽‍🚀", + "male-astronaut::skin-tone-5": "👨🏾‍🚀", + "male-astronaut::skin-tone-6": "👨🏿‍🚀", + "male-construction-worker": "👷‍♂️", + "male-construction-worker::skin-tone-2": "👷🏻‍♂️", + "male-construction-worker::skin-tone-3": "👷🏼‍♂️", + "male-construction-worker::skin-tone-4": "👷🏽‍♂️", + "male-construction-worker::skin-tone-5": "👷🏾‍♂️", + "male-construction-worker::skin-tone-6": "👷🏿‍♂️", + "male-cook": "👨‍🍳", + "male-cook::skin-tone-2": "👨🏻‍🍳", + "male-cook::skin-tone-3": "👨🏼‍🍳", + "male-cook::skin-tone-4": "👨🏽‍🍳", + "male-cook::skin-tone-5": "👨🏾‍🍳", + "male-cook::skin-tone-6": "👨🏿‍🍳", + "male-detective": "🕵️‍♂️", + "male-detective::skin-tone-2": "🕵🏻‍♂️", + "male-detective::skin-tone-3": "🕵🏼‍♂️", + "male-detective::skin-tone-4": "🕵🏽‍♂️", + "male-detective::skin-tone-5": "🕵🏾‍♂️", + "male-detective::skin-tone-6": "🕵🏿‍♂️", + "male-doctor": "👨‍⚕️", + "male-doctor::skin-tone-2": "👨🏻‍⚕️", + "male-doctor::skin-tone-3": "👨🏼‍⚕️", + "male-doctor::skin-tone-4": "👨🏽‍⚕️", + "male-doctor::skin-tone-5": "👨🏾‍⚕️", + "male-doctor::skin-tone-6": "👨🏿‍⚕️", + "male-factory-worker": "👨‍🏭", + "male-factory-worker::skin-tone-2": "👨🏻‍🏭", + "male-factory-worker::skin-tone-3": "👨🏼‍🏭", + "male-factory-worker::skin-tone-4": "👨🏽‍🏭", + "male-factory-worker::skin-tone-5": "👨🏾‍🏭", + "male-factory-worker::skin-tone-6": "👨🏿‍🏭", + "male-farmer": "👨‍🌾", + "male-farmer::skin-tone-2": "👨🏻‍🌾", + "male-farmer::skin-tone-3": "👨🏼‍🌾", + "male-farmer::skin-tone-4": "👨🏽‍🌾", + "male-farmer::skin-tone-5": "👨🏾‍🌾", + "male-farmer::skin-tone-6": "👨🏿‍🌾", + "male-firefighter": "👨‍🚒", + "male-firefighter::skin-tone-2": "👨🏻‍🚒", + "male-firefighter::skin-tone-3": "👨🏼‍🚒", + "male-firefighter::skin-tone-4": "👨🏽‍🚒", + "male-firefighter::skin-tone-5": "👨🏾‍🚒", + "male-firefighter::skin-tone-6": "👨🏿‍🚒", + "male-guard": "💂‍♂️", + "male-guard::skin-tone-2": "💂🏻‍♂️", + "male-guard::skin-tone-3": "💂🏼‍♂️", + "male-guard::skin-tone-4": "💂🏽‍♂️", + "male-guard::skin-tone-5": "💂🏾‍♂️", + "male-guard::skin-tone-6": "💂🏿‍♂️", + "male-judge": "👨‍⚖️", + "male-judge::skin-tone-2": "👨🏻‍⚖️", + "male-judge::skin-tone-3": "👨🏼‍⚖️", + "male-judge::skin-tone-4": "👨🏽‍⚖️", + "male-judge::skin-tone-5": "👨🏾‍⚖️", + "male-judge::skin-tone-6": "👨🏿‍⚖️", + "male-mechanic": "👨‍🔧", + "male-mechanic::skin-tone-2": "👨🏻‍🔧", + "male-mechanic::skin-tone-3": "👨🏼‍🔧", + "male-mechanic::skin-tone-4": "👨🏽‍🔧", + "male-mechanic::skin-tone-5": "👨🏾‍🔧", + "male-mechanic::skin-tone-6": "👨🏿‍🔧", + "male-office-worker": "👨‍💼", + "male-office-worker::skin-tone-2": "👨🏻‍💼", + "male-office-worker::skin-tone-3": "👨🏼‍💼", + "male-office-worker::skin-tone-4": "👨🏽‍💼", + "male-office-worker::skin-tone-5": "👨🏾‍💼", + "male-office-worker::skin-tone-6": "👨🏿‍💼", + "male-pilot": "👨‍✈️", + "male-pilot::skin-tone-2": "👨🏻‍✈️", + "male-pilot::skin-tone-3": "👨🏼‍✈️", + "male-pilot::skin-tone-4": "👨🏽‍✈️", + "male-pilot::skin-tone-5": "👨🏾‍✈️", + "male-pilot::skin-tone-6": "👨🏿‍✈️", + "male-police-officer": "👮‍♂️", + "male-police-officer::skin-tone-2": "👮🏻‍♂️", + "male-police-officer::skin-tone-3": "👮🏼‍♂️", + "male-police-officer::skin-tone-4": "👮🏽‍♂️", + "male-police-officer::skin-tone-5": "👮🏾‍♂️", + "male-police-officer::skin-tone-6": "👮🏿‍♂️", + "male-scientist": "👨‍🔬", + "male-scientist::skin-tone-2": "👨🏻‍🔬", + "male-scientist::skin-tone-3": "👨🏼‍🔬", + "male-scientist::skin-tone-4": "👨🏽‍🔬", + "male-scientist::skin-tone-5": "👨🏾‍🔬", + "male-scientist::skin-tone-6": "👨🏿‍🔬", + "male-singer": "👨‍🎤", + "male-singer::skin-tone-2": "👨🏻‍🎤", + "male-singer::skin-tone-3": "👨🏼‍🎤", + "male-singer::skin-tone-4": "👨🏽‍🎤", + "male-singer::skin-tone-5": "👨🏾‍🎤", + "male-singer::skin-tone-6": "👨🏿‍🎤", + "male-student": "👨‍🎓", + "male-student::skin-tone-2": "👨🏻‍🎓", + "male-student::skin-tone-3": "👨🏼‍🎓", + "male-student::skin-tone-4": "👨🏽‍🎓", + "male-student::skin-tone-5": "👨🏾‍🎓", + "male-student::skin-tone-6": "👨🏿‍🎓", + "male-teacher": "👨‍🏫", + "male-teacher::skin-tone-2": "👨🏻‍🏫", + "male-teacher::skin-tone-3": "👨🏼‍🏫", + "male-teacher::skin-tone-4": "👨🏽‍🏫", + "male-teacher::skin-tone-5": "👨🏾‍🏫", + "male-teacher::skin-tone-6": "👨🏿‍🏫", + "male-technologist": "👨‍💻", + "male-technologist::skin-tone-2": "👨🏻‍💻", + "male-technologist::skin-tone-3": "👨🏼‍💻", + "male-technologist::skin-tone-4": "👨🏽‍💻", + "male-technologist::skin-tone-5": "👨🏾‍💻", + "male-technologist::skin-tone-6": "👨🏿‍💻", + "male_elf": "🧝‍♂️", + "male_elf::skin-tone-2": "🧝🏻‍♂️", + "male_elf::skin-tone-3": "🧝🏼‍♂️", + "male_elf::skin-tone-4": "🧝🏽‍♂️", + "male_elf::skin-tone-5": "🧝🏾‍♂️", + "male_elf::skin-tone-6": "🧝🏿‍♂️", + "male_fairy": "🧚‍♂️", + "male_fairy::skin-tone-2": "🧚🏻‍♂️", + "male_fairy::skin-tone-3": "🧚🏼‍♂️", + "male_fairy::skin-tone-4": "🧚🏽‍♂️", + "male_fairy::skin-tone-5": "🧚🏾‍♂️", + "male_fairy::skin-tone-6": "🧚🏿‍♂️", + "male_genie": "🧞‍♂️", + "male_mage": "🧙‍♂️", + "male_mage::skin-tone-2": "🧙🏻‍♂️", + "male_mage::skin-tone-3": "🧙🏼‍♂️", + "male_mage::skin-tone-4": "🧙🏽‍♂️", + "male_mage::skin-tone-5": "🧙🏾‍♂️", + "male_mage::skin-tone-6": "🧙🏿‍♂️", + "male_sign": "♂️", + "male_superhero": "🦸‍♂️", + "male_superhero::skin-tone-2": "🦸🏻‍♂️", + "male_superhero::skin-tone-3": "🦸🏼‍♂️", + "male_superhero::skin-tone-4": "🦸🏽‍♂️", + "male_superhero::skin-tone-5": "🦸🏾‍♂️", + "male_superhero::skin-tone-6": "🦸🏿‍♂️", + "male_supervillain": "🦹‍♂️", + "male_supervillain::skin-tone-2": "🦹🏻‍♂️", + "male_supervillain::skin-tone-3": "🦹🏼‍♂️", + "male_supervillain::skin-tone-4": "🦹🏽‍♂️", + "male_supervillain::skin-tone-5": "🦹🏾‍♂️", + "male_supervillain::skin-tone-6": "🦹🏿‍♂️", + "male_vampire": "🧛‍♂️", + "male_vampire::skin-tone-2": "🧛🏻‍♂️", + "male_vampire::skin-tone-3": "🧛🏼‍♂️", + "male_vampire::skin-tone-4": "🧛🏽‍♂️", + "male_vampire::skin-tone-5": "🧛🏾‍♂️", + "male_vampire::skin-tone-6": "🧛🏿‍♂️", + "male_zombie": "🧟‍♂️", + "mammoth": "🦣", + "man": "👨", + "man-biking": "🚴‍♂️", + "man-biking::skin-tone-2": "🚴🏻‍♂️", + "man-biking::skin-tone-3": "🚴🏼‍♂️", + "man-biking::skin-tone-4": "🚴🏽‍♂️", + "man-biking::skin-tone-5": "🚴🏾‍♂️", + "man-biking::skin-tone-6": "🚴🏿‍♂️", + "man-bouncing-ball": "⛹️‍♂️", + "man-bouncing-ball::skin-tone-2": "⛹🏻‍♂️", + "man-bouncing-ball::skin-tone-3": "⛹🏼‍♂️", + "man-bouncing-ball::skin-tone-4": "⛹🏽‍♂️", + "man-bouncing-ball::skin-tone-5": "⛹🏾‍♂️", + "man-bouncing-ball::skin-tone-6": "⛹🏿‍♂️", + "man-bowing": "🙇‍♂️", + "man-bowing::skin-tone-2": "🙇🏻‍♂️", + "man-bowing::skin-tone-3": "🙇🏼‍♂️", + "man-bowing::skin-tone-4": "🙇🏽‍♂️", + "man-bowing::skin-tone-5": "🙇🏾‍♂️", + "man-bowing::skin-tone-6": "🙇🏿‍♂️", + "man-boy": "👨‍👦", + "man-boy-boy": "👨‍👦‍👦", + "man-cartwheeling": "🤸‍♂️", + "man-cartwheeling::skin-tone-2": "🤸🏻‍♂️", + "man-cartwheeling::skin-tone-3": "🤸🏼‍♂️", + "man-cartwheeling::skin-tone-4": "🤸🏽‍♂️", + "man-cartwheeling::skin-tone-5": "🤸🏾‍♂️", + "man-cartwheeling::skin-tone-6": "🤸🏿‍♂️", + "man-facepalming": "🤦‍♂️", + "man-facepalming::skin-tone-2": "🤦🏻‍♂️", + "man-facepalming::skin-tone-3": "🤦🏼‍♂️", + "man-facepalming::skin-tone-4": "🤦🏽‍♂️", + "man-facepalming::skin-tone-5": "🤦🏾‍♂️", + "man-facepalming::skin-tone-6": "🤦🏿‍♂️", + "man-frowning": "🙍‍♂️", + "man-frowning::skin-tone-2": "🙍🏻‍♂️", + "man-frowning::skin-tone-3": "🙍🏼‍♂️", + "man-frowning::skin-tone-4": "🙍🏽‍♂️", + "man-frowning::skin-tone-5": "🙍🏾‍♂️", + "man-frowning::skin-tone-6": "🙍🏿‍♂️", + "man-gesturing-no": "🙅‍♂️", + "man-gesturing-no::skin-tone-2": "🙅🏻‍♂️", + "man-gesturing-no::skin-tone-3": "🙅🏼‍♂️", + "man-gesturing-no::skin-tone-4": "🙅🏽‍♂️", + "man-gesturing-no::skin-tone-5": "🙅🏾‍♂️", + "man-gesturing-no::skin-tone-6": "🙅🏿‍♂️", + "man-gesturing-ok": "🙆‍♂️", + "man-gesturing-ok::skin-tone-2": "🙆🏻‍♂️", + "man-gesturing-ok::skin-tone-3": "🙆🏼‍♂️", + "man-gesturing-ok::skin-tone-4": "🙆🏽‍♂️", + "man-gesturing-ok::skin-tone-5": "🙆🏾‍♂️", + "man-gesturing-ok::skin-tone-6": "🙆🏿‍♂️", + "man-getting-haircut": "💇‍♂️", + "man-getting-haircut::skin-tone-2": "💇🏻‍♂️", + "man-getting-haircut::skin-tone-3": "💇🏼‍♂️", + "man-getting-haircut::skin-tone-4": "💇🏽‍♂️", + "man-getting-haircut::skin-tone-5": "💇🏾‍♂️", + "man-getting-haircut::skin-tone-6": "💇🏿‍♂️", + "man-getting-massage": "💆‍♂️", + "man-getting-massage::skin-tone-2": "💆🏻‍♂️", + "man-getting-massage::skin-tone-3": "💆🏼‍♂️", + "man-getting-massage::skin-tone-4": "💆🏽‍♂️", + "man-getting-massage::skin-tone-5": "💆🏾‍♂️", + "man-getting-massage::skin-tone-6": "💆🏿‍♂️", + "man-girl": "👨‍👧", + "man-girl-boy": "👨‍👧‍👦", + "man-girl-girl": "👨‍👧‍👧", + "man-golfing": "🏌️‍♂️", + "man-golfing::skin-tone-2": "🏌🏻‍♂️", + "man-golfing::skin-tone-3": "🏌🏼‍♂️", + "man-golfing::skin-tone-4": "🏌🏽‍♂️", + "man-golfing::skin-tone-5": "🏌🏾‍♂️", + "man-golfing::skin-tone-6": "🏌🏿‍♂️", + "man-heart-man": "👨‍❤️‍👨", + "man-heart-man::skin-tone-2-2": "👨🏻‍❤️‍👨🏻", + "man-heart-man::skin-tone-2-3": "👨🏻‍❤️‍👨🏼", + "man-heart-man::skin-tone-2-4": "👨🏻‍❤️‍👨🏽", + "man-heart-man::skin-tone-2-5": "👨🏻‍❤️‍👨🏾", + "man-heart-man::skin-tone-2-6": "👨🏻‍❤️‍👨🏿", + "man-heart-man::skin-tone-3-2": "👨🏼‍❤️‍👨🏻", + "man-heart-man::skin-tone-3-3": "👨🏼‍❤️‍👨🏼", + "man-heart-man::skin-tone-3-4": "👨🏼‍❤️‍👨🏽", + "man-heart-man::skin-tone-3-5": "👨🏼‍❤️‍👨🏾", + "man-heart-man::skin-tone-3-6": "👨🏼‍❤️‍👨🏿", + "man-heart-man::skin-tone-4-2": "👨🏽‍❤️‍👨🏻", + "man-heart-man::skin-tone-4-3": "👨🏽‍❤️‍👨🏼", + "man-heart-man::skin-tone-4-4": "👨🏽‍❤️‍👨🏽", + "man-heart-man::skin-tone-4-5": "👨🏽‍❤️‍👨🏾", + "man-heart-man::skin-tone-4-6": "👨🏽‍❤️‍👨🏿", + "man-heart-man::skin-tone-5-2": "👨🏾‍❤️‍👨🏻", + "man-heart-man::skin-tone-5-3": "👨🏾‍❤️‍👨🏼", + "man-heart-man::skin-tone-5-4": "👨🏾‍❤️‍👨🏽", + "man-heart-man::skin-tone-5-5": "👨🏾‍❤️‍👨🏾", + "man-heart-man::skin-tone-5-6": "👨🏾‍❤️‍👨🏿", + "man-heart-man::skin-tone-6-2": "👨🏿‍❤️‍👨🏻", + "man-heart-man::skin-tone-6-3": "👨🏿‍❤️‍👨🏼", + "man-heart-man::skin-tone-6-4": "👨🏿‍❤️‍👨🏽", + "man-heart-man::skin-tone-6-5": "👨🏿‍❤️‍👨🏾", + "man-heart-man::skin-tone-6-6": "👨🏿‍❤️‍👨🏿", + "man-juggling": "🤹‍♂️", + "man-juggling::skin-tone-2": "🤹🏻‍♂️", + "man-juggling::skin-tone-3": "🤹🏼‍♂️", + "man-juggling::skin-tone-4": "🤹🏽‍♂️", + "man-juggling::skin-tone-5": "🤹🏾‍♂️", + "man-juggling::skin-tone-6": "🤹🏿‍♂️", + "man-kiss-man": "👨‍❤️‍💋‍👨", + "man-kiss-man::skin-tone-2-2": "👨🏻‍❤️‍💋‍👨🏻", + "man-kiss-man::skin-tone-2-3": "👨🏻‍❤️‍💋‍👨🏼", + "man-kiss-man::skin-tone-2-4": "👨🏻‍❤️‍💋‍👨🏽", + "man-kiss-man::skin-tone-2-5": "👨🏻‍❤️‍💋‍👨🏾", + "man-kiss-man::skin-tone-2-6": "👨🏻‍❤️‍💋‍👨🏿", + "man-kiss-man::skin-tone-3-2": "👨🏼‍❤️‍💋‍👨🏻", + "man-kiss-man::skin-tone-3-3": "👨🏼‍❤️‍💋‍👨🏼", + "man-kiss-man::skin-tone-3-4": "👨🏼‍❤️‍💋‍👨🏽", + "man-kiss-man::skin-tone-3-5": "👨🏼‍❤️‍💋‍👨🏾", + "man-kiss-man::skin-tone-3-6": "👨🏼‍❤️‍💋‍👨🏿", + "man-kiss-man::skin-tone-4-2": "👨🏽‍❤️‍💋‍👨🏻", + "man-kiss-man::skin-tone-4-3": "👨🏽‍❤️‍💋‍👨🏼", + "man-kiss-man::skin-tone-4-4": "👨🏽‍❤️‍💋‍👨🏽", + "man-kiss-man::skin-tone-4-5": "👨🏽‍❤️‍💋‍👨🏾", + "man-kiss-man::skin-tone-4-6": "👨🏽‍❤️‍💋‍👨🏿", + "man-kiss-man::skin-tone-5-2": "👨🏾‍❤️‍💋‍👨🏻", + "man-kiss-man::skin-tone-5-3": "👨🏾‍❤️‍💋‍👨🏼", + "man-kiss-man::skin-tone-5-4": "👨🏾‍❤️‍💋‍👨🏽", + "man-kiss-man::skin-tone-5-5": "👨🏾‍❤️‍💋‍👨🏾", + "man-kiss-man::skin-tone-5-6": "👨🏾‍❤️‍💋‍👨🏿", + "man-kiss-man::skin-tone-6-2": "👨🏿‍❤️‍💋‍👨🏻", + "man-kiss-man::skin-tone-6-3": "👨🏿‍❤️‍💋‍👨🏼", + "man-kiss-man::skin-tone-6-4": "👨🏿‍❤️‍💋‍👨🏽", + "man-kiss-man::skin-tone-6-5": "👨🏿‍❤️‍💋‍👨🏾", + "man-kiss-man::skin-tone-6-6": "👨🏿‍❤️‍💋‍👨🏿", + "man-lifting-weights": "🏋️‍♂️", + "man-lifting-weights::skin-tone-2": "🏋🏻‍♂️", + "man-lifting-weights::skin-tone-3": "🏋🏼‍♂️", + "man-lifting-weights::skin-tone-4": "🏋🏽‍♂️", + "man-lifting-weights::skin-tone-5": "🏋🏾‍♂️", + "man-lifting-weights::skin-tone-6": "🏋🏿‍♂️", + "man-man-boy": "👨‍👨‍👦", + "man-man-boy-boy": "👨‍👨‍👦‍👦", + "man-man-girl": "👨‍👨‍👧", + "man-man-girl-boy": "👨‍👨‍👧‍👦", + "man-man-girl-girl": "👨‍👨‍👧‍👧", + "man-mountain-biking": "🚵‍♂️", + "man-mountain-biking::skin-tone-2": "🚵🏻‍♂️", + "man-mountain-biking::skin-tone-3": "🚵🏼‍♂️", + "man-mountain-biking::skin-tone-4": "🚵🏽‍♂️", + "man-mountain-biking::skin-tone-5": "🚵🏾‍♂️", + "man-mountain-biking::skin-tone-6": "🚵🏿‍♂️", + "man-playing-handball": "🤾‍♂️", + "man-playing-handball::skin-tone-2": "🤾🏻‍♂️", + "man-playing-handball::skin-tone-3": "🤾🏼‍♂️", + "man-playing-handball::skin-tone-4": "🤾🏽‍♂️", + "man-playing-handball::skin-tone-5": "🤾🏾‍♂️", + "man-playing-handball::skin-tone-6": "🤾🏿‍♂️", + "man-playing-water-polo": "🤽‍♂️", + "man-playing-water-polo::skin-tone-2": "🤽🏻‍♂️", + "man-playing-water-polo::skin-tone-3": "🤽🏼‍♂️", + "man-playing-water-polo::skin-tone-4": "🤽🏽‍♂️", + "man-playing-water-polo::skin-tone-5": "🤽🏾‍♂️", + "man-playing-water-polo::skin-tone-6": "🤽🏿‍♂️", + "man-pouting": "🙎‍♂️", + "man-pouting::skin-tone-2": "🙎🏻‍♂️", + "man-pouting::skin-tone-3": "🙎🏼‍♂️", + "man-pouting::skin-tone-4": "🙎🏽‍♂️", + "man-pouting::skin-tone-5": "🙎🏾‍♂️", + "man-pouting::skin-tone-6": "🙎🏿‍♂️", + "man-raising-hand": "🙋‍♂️", + "man-raising-hand::skin-tone-2": "🙋🏻‍♂️", + "man-raising-hand::skin-tone-3": "🙋🏼‍♂️", + "man-raising-hand::skin-tone-4": "🙋🏽‍♂️", + "man-raising-hand::skin-tone-5": "🙋🏾‍♂️", + "man-raising-hand::skin-tone-6": "🙋🏿‍♂️", + "man-rowing-boat": "🚣‍♂️", + "man-rowing-boat::skin-tone-2": "🚣🏻‍♂️", + "man-rowing-boat::skin-tone-3": "🚣🏼‍♂️", + "man-rowing-boat::skin-tone-4": "🚣🏽‍♂️", + "man-rowing-boat::skin-tone-5": "🚣🏾‍♂️", + "man-rowing-boat::skin-tone-6": "🚣🏿‍♂️", + "man-running": "🏃‍♂️", + "man-running::skin-tone-2": "🏃🏻‍♂️", + "man-running::skin-tone-3": "🏃🏼‍♂️", + "man-running::skin-tone-4": "🏃🏽‍♂️", + "man-running::skin-tone-5": "🏃🏾‍♂️", + "man-running::skin-tone-6": "🏃🏿‍♂️", + "man-shrugging": "🤷‍♂️", + "man-shrugging::skin-tone-2": "🤷🏻‍♂️", + "man-shrugging::skin-tone-3": "🤷🏼‍♂️", + "man-shrugging::skin-tone-4": "🤷🏽‍♂️", + "man-shrugging::skin-tone-5": "🤷🏾‍♂️", + "man-shrugging::skin-tone-6": "🤷🏿‍♂️", + "man-surfing": "🏄‍♂️", + "man-surfing::skin-tone-2": "🏄🏻‍♂️", + "man-surfing::skin-tone-3": "🏄🏼‍♂️", + "man-surfing::skin-tone-4": "🏄🏽‍♂️", + "man-surfing::skin-tone-5": "🏄🏾‍♂️", + "man-surfing::skin-tone-6": "🏄🏿‍♂️", + "man-swimming": "🏊‍♂️", + "man-swimming::skin-tone-2": "🏊🏻‍♂️", + "man-swimming::skin-tone-3": "🏊🏼‍♂️", + "man-swimming::skin-tone-4": "🏊🏽‍♂️", + "man-swimming::skin-tone-5": "🏊🏾‍♂️", + "man-swimming::skin-tone-6": "🏊🏿‍♂️", + "man-tipping-hand": "💁‍♂️", + "man-tipping-hand::skin-tone-2": "💁🏻‍♂️", + "man-tipping-hand::skin-tone-3": "💁🏼‍♂️", + "man-tipping-hand::skin-tone-4": "💁🏽‍♂️", + "man-tipping-hand::skin-tone-5": "💁🏾‍♂️", + "man-tipping-hand::skin-tone-6": "💁🏿‍♂️", + "man-walking": "🚶‍♂️", + "man-walking::skin-tone-2": "🚶🏻‍♂️", + "man-walking::skin-tone-3": "🚶🏼‍♂️", + "man-walking::skin-tone-4": "🚶🏽‍♂️", + "man-walking::skin-tone-5": "🚶🏾‍♂️", + "man-walking::skin-tone-6": "🚶🏿‍♂️", + "man-wearing-turban": "👳‍♂️", + "man-wearing-turban::skin-tone-2": "👳🏻‍♂️", + "man-wearing-turban::skin-tone-3": "👳🏼‍♂️", + "man-wearing-turban::skin-tone-4": "👳🏽‍♂️", + "man-wearing-turban::skin-tone-5": "👳🏾‍♂️", + "man-wearing-turban::skin-tone-6": "👳🏿‍♂️", + "man-woman-boy": "👨‍👩‍👦", + "man-woman-boy-boy": "👨‍👩‍👦‍👦", + "man-woman-girl": "👨‍👩‍👧", + "man-woman-girl-boy": "👨‍👩‍👧‍👦", + "man-woman-girl-girl": "👨‍👩‍👧‍👧", + "man-wrestling": "🤼‍♂️", + "man::skin-tone-2": "👨🏻", + "man::skin-tone-3": "👨🏼", + "man::skin-tone-4": "👨🏽", + "man::skin-tone-5": "👨🏾", + "man::skin-tone-6": "👨🏿", + "man_and_woman_holding_hands": "👫", + "man_and_woman_holding_hands::skin-tone-2": "👫🏻", + "man_and_woman_holding_hands::skin-tone-2-3": "👩🏻‍🤝‍👨🏼", + "man_and_woman_holding_hands::skin-tone-2-4": "👩🏻‍🤝‍👨🏽", + "man_and_woman_holding_hands::skin-tone-2-5": "👩🏻‍🤝‍👨🏾", + "man_and_woman_holding_hands::skin-tone-2-6": "👩🏻‍🤝‍👨🏿", + "man_and_woman_holding_hands::skin-tone-3": "👫🏼", + "man_and_woman_holding_hands::skin-tone-3-2": "👩🏼‍🤝‍👨🏻", + "man_and_woman_holding_hands::skin-tone-3-4": "👩🏼‍🤝‍👨🏽", + "man_and_woman_holding_hands::skin-tone-3-5": "👩🏼‍🤝‍👨🏾", + "man_and_woman_holding_hands::skin-tone-3-6": "👩🏼‍🤝‍👨🏿", + "man_and_woman_holding_hands::skin-tone-4": "👫🏽", + "man_and_woman_holding_hands::skin-tone-4-2": "👩🏽‍🤝‍👨🏻", + "man_and_woman_holding_hands::skin-tone-4-3": "👩🏽‍🤝‍👨🏼", + "man_and_woman_holding_hands::skin-tone-4-5": "👩🏽‍🤝‍👨🏾", + "man_and_woman_holding_hands::skin-tone-4-6": "👩🏽‍🤝‍👨🏿", + "man_and_woman_holding_hands::skin-tone-5": "👫🏾", + "man_and_woman_holding_hands::skin-tone-5-2": "👩🏾‍🤝‍👨🏻", + "man_and_woman_holding_hands::skin-tone-5-3": "👩🏾‍🤝‍👨🏼", + "man_and_woman_holding_hands::skin-tone-5-4": "👩🏾‍🤝‍👨🏽", + "man_and_woman_holding_hands::skin-tone-5-6": "👩🏾‍🤝‍👨🏿", + "man_and_woman_holding_hands::skin-tone-6": "👫🏿", + "man_and_woman_holding_hands::skin-tone-6-2": "👩🏿‍🤝‍👨🏻", + "man_and_woman_holding_hands::skin-tone-6-3": "👩🏿‍🤝‍👨🏼", + "man_and_woman_holding_hands::skin-tone-6-4": "👩🏿‍🤝‍👨🏽", + "man_and_woman_holding_hands::skin-tone-6-5": "👩🏿‍🤝‍👨🏾", + "man_climbing": "🧗‍♂️", + "man_climbing::skin-tone-2": "🧗🏻‍♂️", + "man_climbing::skin-tone-3": "🧗🏼‍♂️", + "man_climbing::skin-tone-4": "🧗🏽‍♂️", + "man_climbing::skin-tone-5": "🧗🏾‍♂️", + "man_climbing::skin-tone-6": "🧗🏿‍♂️", + "man_dancing": "🕺", + "man_dancing::skin-tone-2": "🕺🏻", + "man_dancing::skin-tone-3": "🕺🏼", + "man_dancing::skin-tone-4": "🕺🏽", + "man_dancing::skin-tone-5": "🕺🏾", + "man_dancing::skin-tone-6": "🕺🏿", + "man_feeding_baby": "👨‍🍼", + "man_feeding_baby::skin-tone-2": "👨🏻‍🍼", + "man_feeding_baby::skin-tone-3": "👨🏼‍🍼", + "man_feeding_baby::skin-tone-4": "👨🏽‍🍼", + "man_feeding_baby::skin-tone-5": "👨🏾‍🍼", + "man_feeding_baby::skin-tone-6": "👨🏿‍🍼", + "man_in_business_suit_levitating": "🕴️", + "man_in_business_suit_levitating::skin-tone-2": "🕴🏻", + "man_in_business_suit_levitating::skin-tone-3": "🕴🏼", + "man_in_business_suit_levitating::skin-tone-4": "🕴🏽", + "man_in_business_suit_levitating::skin-tone-5": "🕴🏾", + "man_in_business_suit_levitating::skin-tone-6": "🕴🏿", + "man_in_lotus_position": "🧘‍♂️", + "man_in_lotus_position::skin-tone-2": "🧘🏻‍♂️", + "man_in_lotus_position::skin-tone-3": "🧘🏼‍♂️", + "man_in_lotus_position::skin-tone-4": "🧘🏽‍♂️", + "man_in_lotus_position::skin-tone-5": "🧘🏾‍♂️", + "man_in_lotus_position::skin-tone-6": "🧘🏿‍♂️", + "man_in_manual_wheelchair": "👨‍🦽", + "man_in_manual_wheelchair::skin-tone-2": "👨🏻‍🦽", + "man_in_manual_wheelchair::skin-tone-3": "👨🏼‍🦽", + "man_in_manual_wheelchair::skin-tone-4": "👨🏽‍🦽", + "man_in_manual_wheelchair::skin-tone-5": "👨🏾‍🦽", + "man_in_manual_wheelchair::skin-tone-6": "👨🏿‍🦽", + "man_in_manual_wheelchair_facing_right": "👨‍🦽‍➡️", + "man_in_manual_wheelchair_facing_right::skin-tone-2": "👨🏻‍🦽‍➡️", + "man_in_manual_wheelchair_facing_right::skin-tone-3": "👨🏼‍🦽‍➡️", + "man_in_manual_wheelchair_facing_right::skin-tone-4": "👨🏽‍🦽‍➡️", + "man_in_manual_wheelchair_facing_right::skin-tone-5": "👨🏾‍🦽‍➡️", + "man_in_manual_wheelchair_facing_right::skin-tone-6": "👨🏿‍🦽‍➡️", + "man_in_motorized_wheelchair": "👨‍🦼", + "man_in_motorized_wheelchair::skin-tone-2": "👨🏻‍🦼", + "man_in_motorized_wheelchair::skin-tone-3": "👨🏼‍🦼", + "man_in_motorized_wheelchair::skin-tone-4": "👨🏽‍🦼", + "man_in_motorized_wheelchair::skin-tone-5": "👨🏾‍🦼", + "man_in_motorized_wheelchair::skin-tone-6": "👨🏿‍🦼", + "man_in_motorized_wheelchair_facing_right": "👨‍🦼‍➡️", + "man_in_motorized_wheelchair_facing_right::skin-tone-2": "👨🏻‍🦼‍➡️", + "man_in_motorized_wheelchair_facing_right::skin-tone-3": "👨🏼‍🦼‍➡️", + "man_in_motorized_wheelchair_facing_right::skin-tone-4": "👨🏽‍🦼‍➡️", + "man_in_motorized_wheelchair_facing_right::skin-tone-5": "👨🏾‍🦼‍➡️", + "man_in_motorized_wheelchair_facing_right::skin-tone-6": "👨🏿‍🦼‍➡️", + "man_in_steamy_room": "🧖‍♂️", + "man_in_steamy_room::skin-tone-2": "🧖🏻‍♂️", + "man_in_steamy_room::skin-tone-3": "🧖🏼‍♂️", + "man_in_steamy_room::skin-tone-4": "🧖🏽‍♂️", + "man_in_steamy_room::skin-tone-5": "🧖🏾‍♂️", + "man_in_steamy_room::skin-tone-6": "🧖🏿‍♂️", + "man_in_tuxedo": "🤵‍♂️", + "man_in_tuxedo::skin-tone-2": "🤵🏻‍♂️", + "man_in_tuxedo::skin-tone-3": "🤵🏼‍♂️", + "man_in_tuxedo::skin-tone-4": "🤵🏽‍♂️", + "man_in_tuxedo::skin-tone-5": "🤵🏾‍♂️", + "man_in_tuxedo::skin-tone-6": "🤵🏿‍♂️", + "man_kneeling": "🧎‍♂️", + "man_kneeling::skin-tone-2": "🧎🏻‍♂️", + "man_kneeling::skin-tone-3": "🧎🏼‍♂️", + "man_kneeling::skin-tone-4": "🧎🏽‍♂️", + "man_kneeling::skin-tone-5": "🧎🏾‍♂️", + "man_kneeling::skin-tone-6": "🧎🏿‍♂️", + "man_kneeling_facing_right": "🧎‍♂️‍➡️", + "man_kneeling_facing_right::skin-tone-2": "🧎🏻‍♂️‍➡️", + "man_kneeling_facing_right::skin-tone-3": "🧎🏼‍♂️‍➡️", + "man_kneeling_facing_right::skin-tone-4": "🧎🏽‍♂️‍➡️", + "man_kneeling_facing_right::skin-tone-5": "🧎🏾‍♂️‍➡️", + "man_kneeling_facing_right::skin-tone-6": "🧎🏿‍♂️‍➡️", + "man_running_facing_right": "🏃‍♂️‍➡️", + "man_running_facing_right::skin-tone-2": "🏃🏻‍♂️‍➡️", + "man_running_facing_right::skin-tone-3": "🏃🏼‍♂️‍➡️", + "man_running_facing_right::skin-tone-4": "🏃🏽‍♂️‍➡️", + "man_running_facing_right::skin-tone-5": "🏃🏾‍♂️‍➡️", + "man_running_facing_right::skin-tone-6": "🏃🏿‍♂️‍➡️", + "man_standing": "🧍‍♂️", + "man_standing::skin-tone-2": "🧍🏻‍♂️", + "man_standing::skin-tone-3": "🧍🏼‍♂️", + "man_standing::skin-tone-4": "🧍🏽‍♂️", + "man_standing::skin-tone-5": "🧍🏾‍♂️", + "man_standing::skin-tone-6": "🧍🏿‍♂️", + "man_walking_facing_right": "🚶‍♂️‍➡️", + "man_walking_facing_right::skin-tone-2": "🚶🏻‍♂️‍➡️", + "man_walking_facing_right::skin-tone-3": "🚶🏼‍♂️‍➡️", + "man_walking_facing_right::skin-tone-4": "🚶🏽‍♂️‍➡️", + "man_walking_facing_right::skin-tone-5": "🚶🏾‍♂️‍➡️", + "man_walking_facing_right::skin-tone-6": "🚶🏿‍♂️‍➡️", + "man_with_beard": "🧔‍♂️", + "man_with_beard::skin-tone-2": "🧔🏻‍♂️", + "man_with_beard::skin-tone-3": "🧔🏼‍♂️", + "man_with_beard::skin-tone-4": "🧔🏽‍♂️", + "man_with_beard::skin-tone-5": "🧔🏾‍♂️", + "man_with_beard::skin-tone-6": "🧔🏿‍♂️", + "man_with_gua_pi_mao": "👲", + "man_with_gua_pi_mao::skin-tone-2": "👲🏻", + "man_with_gua_pi_mao::skin-tone-3": "👲🏼", + "man_with_gua_pi_mao::skin-tone-4": "👲🏽", + "man_with_gua_pi_mao::skin-tone-5": "👲🏾", + "man_with_gua_pi_mao::skin-tone-6": "👲🏿", + "man_with_probing_cane": "👨‍🦯", + "man_with_probing_cane::skin-tone-2": "👨🏻‍🦯", + "man_with_probing_cane::skin-tone-3": "👨🏼‍🦯", + "man_with_probing_cane::skin-tone-4": "👨🏽‍🦯", + "man_with_probing_cane::skin-tone-5": "👨🏾‍🦯", + "man_with_probing_cane::skin-tone-6": "👨🏿‍🦯", + "man_with_turban": "👳", + "man_with_turban::skin-tone-2": "👳🏻", + "man_with_turban::skin-tone-3": "👳🏼", + "man_with_turban::skin-tone-4": "👳🏽", + "man_with_turban::skin-tone-5": "👳🏾", + "man_with_turban::skin-tone-6": "👳🏿", + "man_with_veil": "👰‍♂️", + "man_with_veil::skin-tone-2": "👰🏻‍♂️", + "man_with_veil::skin-tone-3": "👰🏼‍♂️", + "man_with_veil::skin-tone-4": "👰🏽‍♂️", + "man_with_veil::skin-tone-5": "👰🏾‍♂️", + "man_with_veil::skin-tone-6": "👰🏿‍♂️", + "man_with_white_cane_facing_right": "👨‍🦯‍➡️", + "man_with_white_cane_facing_right::skin-tone-2": "👨🏻‍🦯‍➡️", + "man_with_white_cane_facing_right::skin-tone-3": "👨🏼‍🦯‍➡️", + "man_with_white_cane_facing_right::skin-tone-4": "👨🏽‍🦯‍➡️", + "man_with_white_cane_facing_right::skin-tone-5": "👨🏾‍🦯‍➡️", + "man_with_white_cane_facing_right::skin-tone-6": "👨🏿‍🦯‍➡️", + "mango": "🥭", + "mans_shoe": "👞", + "mantelpiece_clock": "🕰️", + "manual_wheelchair": "🦽", + "maple_leaf": "🍁", + "maracas": "🪇", + "martial_arts_uniform": "🥋", + "mask": "😷", + "massage": "💆", + "massage::skin-tone-2": "💆🏻", + "massage::skin-tone-3": "💆🏼", + "massage::skin-tone-4": "💆🏽", + "massage::skin-tone-5": "💆🏾", + "massage::skin-tone-6": "💆🏿", + "mate_drink": "🧉", + "meat_on_bone": "🍖", + "mechanic": "🧑‍🔧", + "mechanic::skin-tone-2": "🧑🏻‍🔧", + "mechanic::skin-tone-3": "🧑🏼‍🔧", + "mechanic::skin-tone-4": "🧑🏽‍🔧", + "mechanic::skin-tone-5": "🧑🏾‍🔧", + "mechanic::skin-tone-6": "🧑🏿‍🔧", + "mechanical_arm": "🦾", + "mechanical_leg": "🦿", + "medal": "🎖️", + "medical_symbol": "⚕️", + "mega": "📣", + "melon": "🍈", + "melting_face": "🫠", + "memo": "📝", + "men-with-bunny-ears-partying": "👯‍♂️", + "mending_heart": "❤️‍🩹", + "menorah_with_nine_branches": "🕎", + "mens": "🚹", + "mermaid": "🧜‍♀️", + "mermaid::skin-tone-2": "🧜🏻‍♀️", + "mermaid::skin-tone-3": "🧜🏼‍♀️", + "mermaid::skin-tone-4": "🧜🏽‍♀️", + "mermaid::skin-tone-5": "🧜🏾‍♀️", + "mermaid::skin-tone-6": "🧜🏿‍♀️", + "merman": "🧜‍♂️", + "merman::skin-tone-2": "🧜🏻‍♂️", + "merman::skin-tone-3": "🧜🏼‍♂️", + "merman::skin-tone-4": "🧜🏽‍♂️", + "merman::skin-tone-5": "🧜🏾‍♂️", + "merman::skin-tone-6": "🧜🏿‍♂️", + "merperson": "🧜", + "merperson::skin-tone-2": "🧜🏻", + "merperson::skin-tone-3": "🧜🏼", + "merperson::skin-tone-4": "🧜🏽", + "merperson::skin-tone-5": "🧜🏾", + "merperson::skin-tone-6": "🧜🏿", + "metro": "🚇", + "microbe": "🦠", + "microphone": "🎤", + "microscope": "🔬", + "middle_finger": "🖕", + "middle_finger::skin-tone-2": "🖕🏻", + "middle_finger::skin-tone-3": "🖕🏼", + "middle_finger::skin-tone-4": "🖕🏽", + "middle_finger::skin-tone-5": "🖕🏾", + "middle_finger::skin-tone-6": "🖕🏿", + "military_helmet": "🪖", + "milky_way": "🌌", + "minibus": "🚐", + "minidisc": "💽", + "mirror": "🪞", + "mirror_ball": "🪩", + "mobile_phone_off": "📴", + "money_mouth_face": "🤑", + "money_with_wings": "💸", + "moneybag": "💰", + "monkey": "🐒", + "monkey_face": "🐵", + "monorail": "🚝", + "moon": "🌔", + "moon_cake": "🥮", + "moose": "🫎", + "mortar_board": "🎓", + "mosque": "🕌", + "mosquito": "🦟", + "mostly_sunny": "🌤️", + "motor_boat": "🛥️", + "motor_scooter": "🛵", + "motorized_wheelchair": "🦼", + "motorway": "🛣️", + "mount_fuji": "🗻", + "mountain": "⛰️", + "mountain_bicyclist": "🚵", + "mountain_bicyclist::skin-tone-2": "🚵🏻", + "mountain_bicyclist::skin-tone-3": "🚵🏼", + "mountain_bicyclist::skin-tone-4": "🚵🏽", + "mountain_bicyclist::skin-tone-5": "🚵🏾", + "mountain_bicyclist::skin-tone-6": "🚵🏿", + "mountain_cableway": "🚠", + "mountain_railway": "🚞", + "mouse": "🐭", + "mouse2": "🐁", + "mouse_trap": "🪤", + "movie_camera": "🎥", + "moyai": "🗿", + "mrs_claus": "🤶", + "mrs_claus::skin-tone-2": "🤶🏻", + "mrs_claus::skin-tone-3": "🤶🏼", + "mrs_claus::skin-tone-4": "🤶🏽", + "mrs_claus::skin-tone-5": "🤶🏾", + "mrs_claus::skin-tone-6": "🤶🏿", + "muscle": "💪", + "muscle::skin-tone-2": "💪🏻", + "muscle::skin-tone-3": "💪🏼", + "muscle::skin-tone-4": "💪🏽", + "muscle::skin-tone-5": "💪🏾", + "muscle::skin-tone-6": "💪🏿", + "mushroom": "🍄", + "musical_keyboard": "🎹", + "musical_note": "🎵", + "musical_score": "🎼", + "mute": "🔇", + "mx_claus": "🧑‍🎄", + "mx_claus::skin-tone-2": "🧑🏻‍🎄", + "mx_claus::skin-tone-3": "🧑🏼‍🎄", + "mx_claus::skin-tone-4": "🧑🏽‍🎄", + "mx_claus::skin-tone-5": "🧑🏾‍🎄", + "mx_claus::skin-tone-6": "🧑🏿‍🎄", + "nail_care": "💅", + "nail_care::skin-tone-2": "💅🏻", + "nail_care::skin-tone-3": "💅🏼", + "nail_care::skin-tone-4": "💅🏽", + "nail_care::skin-tone-5": "💅🏾", + "nail_care::skin-tone-6": "💅🏿", + "name_badge": "📛", + "national_park": "🏞️", + "nauseated_face": "🤢", + "nazar_amulet": "🧿", + "necktie": "👔", + "negative_squared_cross_mark": "❎", + "nerd_face": "🤓", + "nest_with_eggs": "🪺", + "nesting_dolls": "🪆", + "neutral_face": "😐", + "new": "🆕", + "new_moon": "🌑", + "new_moon_with_face": "🌚", + "newspaper": "📰", + "ng": "🆖", + "night_with_stars": "🌃", + "nine": "9️⃣", + "ninja": "🥷", + "ninja::skin-tone-2": "🥷🏻", + "ninja::skin-tone-3": "🥷🏼", + "ninja::skin-tone-4": "🥷🏽", + "ninja::skin-tone-5": "🥷🏾", + "ninja::skin-tone-6": "🥷🏿", + "no_bell": "🔕", + "no_bicycles": "🚳", + "no_entry": "⛔", + "no_entry_sign": "🚫", + "no_good": "🙅", + "no_good::skin-tone-2": "🙅🏻", + "no_good::skin-tone-3": "🙅🏼", + "no_good::skin-tone-4": "🙅🏽", + "no_good::skin-tone-5": "🙅🏾", + "no_good::skin-tone-6": "🙅🏿", + "no_mobile_phones": "📵", + "no_mouth": "😶", + "no_pedestrians": "🚷", + "no_smoking": "🚭", + "non-potable_water": "🚱", + "nose": "👃", + "nose::skin-tone-2": "👃🏻", + "nose::skin-tone-3": "👃🏼", + "nose::skin-tone-4": "👃🏽", + "nose::skin-tone-5": "👃🏾", + "nose::skin-tone-6": "👃🏿", + "notebook": "📓", + "notebook_with_decorative_cover": "📔", + "notes": "🎶", + "nut_and_bolt": "🔩", + "o": "⭕", + "o2": "🅾️", + "ocean": "🌊", + "octagonal_sign": "🛑", + "octopus": "🐙", + "oden": "🍢", + "office": "🏢", + "office_worker": "🧑‍💼", + "office_worker::skin-tone-2": "🧑🏻‍💼", + "office_worker::skin-tone-3": "🧑🏼‍💼", + "office_worker::skin-tone-4": "🧑🏽‍💼", + "office_worker::skin-tone-5": "🧑🏾‍💼", + "office_worker::skin-tone-6": "🧑🏿‍💼", + "oil_drum": "🛢️", + "ok": "🆗", + "ok_hand": "👌", + "ok_hand::skin-tone-2": "👌🏻", + "ok_hand::skin-tone-3": "👌🏼", + "ok_hand::skin-tone-4": "👌🏽", + "ok_hand::skin-tone-5": "👌🏾", + "ok_hand::skin-tone-6": "👌🏿", + "ok_woman": "🙆", + "ok_woman::skin-tone-2": "🙆🏻", + "ok_woman::skin-tone-3": "🙆🏼", + "ok_woman::skin-tone-4": "🙆🏽", + "ok_woman::skin-tone-5": "🙆🏾", + "ok_woman::skin-tone-6": "🙆🏿", + "old_key": "🗝️", + "older_adult": "🧓", + "older_adult::skin-tone-2": "🧓🏻", + "older_adult::skin-tone-3": "🧓🏼", + "older_adult::skin-tone-4": "🧓🏽", + "older_adult::skin-tone-5": "🧓🏾", + "older_adult::skin-tone-6": "🧓🏿", + "older_man": "👴", + "older_man::skin-tone-2": "👴🏻", + "older_man::skin-tone-3": "👴🏼", + "older_man::skin-tone-4": "👴🏽", + "older_man::skin-tone-5": "👴🏾", + "older_man::skin-tone-6": "👴🏿", + "older_woman": "👵", + "older_woman::skin-tone-2": "👵🏻", + "older_woman::skin-tone-3": "👵🏼", + "older_woman::skin-tone-4": "👵🏽", + "older_woman::skin-tone-5": "👵🏾", + "older_woman::skin-tone-6": "👵🏿", + "olive": "🫒", + "om_symbol": "🕉️", + "on": "🔛", + "oncoming_automobile": "🚘", + "oncoming_bus": "🚍", + "oncoming_police_car": "🚔", + "oncoming_taxi": "🚖", + "one": "1️⃣", + "one-piece_swimsuit": "🩱", + "onion": "🧅", + "open_file_folder": "📂", + "open_hands": "👐", + "open_hands::skin-tone-2": "👐🏻", + "open_hands::skin-tone-3": "👐🏼", + "open_hands::skin-tone-4": "👐🏽", + "open_hands::skin-tone-5": "👐🏾", + "open_hands::skin-tone-6": "👐🏿", + "open_mouth": "😮", + "ophiuchus": "⛎", + "orange_book": "📙", + "orange_heart": "🧡", + "orangutan": "🦧", + "orthodox_cross": "☦️", + "otter": "🦦", + "outbox_tray": "📤", + "owl": "🦉", + "ox": "🐂", + "oyster": "🦪", + "package": "📦", + "page_facing_up": "📄", + "page_with_curl": "📃", + "pager": "📟", + "palm_down_hand": "🫳", + "palm_down_hand::skin-tone-2": "🫳🏻", + "palm_down_hand::skin-tone-3": "🫳🏼", + "palm_down_hand::skin-tone-4": "🫳🏽", + "palm_down_hand::skin-tone-5": "🫳🏾", + "palm_down_hand::skin-tone-6": "🫳🏿", + "palm_tree": "🌴", + "palm_up_hand": "🫴", + "palm_up_hand::skin-tone-2": "🫴🏻", + "palm_up_hand::skin-tone-3": "🫴🏼", + "palm_up_hand::skin-tone-4": "🫴🏽", + "palm_up_hand::skin-tone-5": "🫴🏾", + "palm_up_hand::skin-tone-6": "🫴🏿", + "palms_up_together": "🤲", + "palms_up_together::skin-tone-2": "🤲🏻", + "palms_up_together::skin-tone-3": "🤲🏼", + "palms_up_together::skin-tone-4": "🤲🏽", + "palms_up_together::skin-tone-5": "🤲🏾", + "palms_up_together::skin-tone-6": "🤲🏿", + "pancakes": "🥞", + "panda_face": "🐼", + "paperclip": "📎", + "parachute": "🪂", + "parking": "🅿️", + "parrot": "🦜", + "part_alternation_mark": "〽️", + "partly_sunny": "⛅", + "partly_sunny_rain": "🌦️", + "partying_face": "🥳", + "passenger_ship": "🛳️", + "passport_control": "🛂", + "pea_pod": "🫛", + "peace_symbol": "☮️", + "peach": "🍑", + "peacock": "🦚", + "peanuts": "🥜", + "pear": "🍐", + "pencil2": "✏️", + "penguin": "🐧", + "pensive": "😔", + "people_holding_hands": "🧑‍🤝‍🧑", + "people_holding_hands::skin-tone-2-2": "🧑🏻‍🤝‍🧑🏻", + "people_holding_hands::skin-tone-2-3": "🧑🏻‍🤝‍🧑🏼", + "people_holding_hands::skin-tone-2-4": "🧑🏻‍🤝‍🧑🏽", + "people_holding_hands::skin-tone-2-5": "🧑🏻‍🤝‍🧑🏾", + "people_holding_hands::skin-tone-2-6": "🧑🏻‍🤝‍🧑🏿", + "people_holding_hands::skin-tone-3-2": "🧑🏼‍🤝‍🧑🏻", + "people_holding_hands::skin-tone-3-3": "🧑🏼‍🤝‍🧑🏼", + "people_holding_hands::skin-tone-3-4": "🧑🏼‍🤝‍🧑🏽", + "people_holding_hands::skin-tone-3-5": "🧑🏼‍🤝‍🧑🏾", + "people_holding_hands::skin-tone-3-6": "🧑🏼‍🤝‍🧑🏿", + "people_holding_hands::skin-tone-4-2": "🧑🏽‍🤝‍🧑🏻", + "people_holding_hands::skin-tone-4-3": "🧑🏽‍🤝‍🧑🏼", + "people_holding_hands::skin-tone-4-4": "🧑🏽‍🤝‍🧑🏽", + "people_holding_hands::skin-tone-4-5": "🧑🏽‍🤝‍🧑🏾", + "people_holding_hands::skin-tone-4-6": "🧑🏽‍🤝‍🧑🏿", + "people_holding_hands::skin-tone-5-2": "🧑🏾‍🤝‍🧑🏻", + "people_holding_hands::skin-tone-5-3": "🧑🏾‍🤝‍🧑🏼", + "people_holding_hands::skin-tone-5-4": "🧑🏾‍🤝‍🧑🏽", + "people_holding_hands::skin-tone-5-5": "🧑🏾‍🤝‍🧑🏾", + "people_holding_hands::skin-tone-5-6": "🧑🏾‍🤝‍🧑🏿", + "people_holding_hands::skin-tone-6-2": "🧑🏿‍🤝‍🧑🏻", + "people_holding_hands::skin-tone-6-3": "🧑🏿‍🤝‍🧑🏼", + "people_holding_hands::skin-tone-6-4": "🧑🏿‍🤝‍🧑🏽", + "people_holding_hands::skin-tone-6-5": "🧑🏿‍🤝‍🧑🏾", + "people_holding_hands::skin-tone-6-6": "🧑🏿‍🤝‍🧑🏿", + "people_hugging": "🫂", + "performing_arts": "🎭", + "persevere": "😣", + "person_climbing": "🧗", + "person_climbing::skin-tone-2": "🧗🏻", + "person_climbing::skin-tone-3": "🧗🏼", + "person_climbing::skin-tone-4": "🧗🏽", + "person_climbing::skin-tone-5": "🧗🏾", + "person_climbing::skin-tone-6": "🧗🏿", + "person_doing_cartwheel": "🤸", + "person_doing_cartwheel::skin-tone-2": "🤸🏻", + "person_doing_cartwheel::skin-tone-3": "🤸🏼", + "person_doing_cartwheel::skin-tone-4": "🤸🏽", + "person_doing_cartwheel::skin-tone-5": "🤸🏾", + "person_doing_cartwheel::skin-tone-6": "🤸🏿", + "person_feeding_baby": "🧑‍🍼", + "person_feeding_baby::skin-tone-2": "🧑🏻‍🍼", + "person_feeding_baby::skin-tone-3": "🧑🏼‍🍼", + "person_feeding_baby::skin-tone-4": "🧑🏽‍🍼", + "person_feeding_baby::skin-tone-5": "🧑🏾‍🍼", + "person_feeding_baby::skin-tone-6": "🧑🏿‍🍼", + "person_frowning": "🙍", + "person_frowning::skin-tone-2": "🙍🏻", + "person_frowning::skin-tone-3": "🙍🏼", + "person_frowning::skin-tone-4": "🙍🏽", + "person_frowning::skin-tone-5": "🙍🏾", + "person_frowning::skin-tone-6": "🙍🏿", + "person_in_lotus_position": "🧘", + "person_in_lotus_position::skin-tone-2": "🧘🏻", + "person_in_lotus_position::skin-tone-3": "🧘🏼", + "person_in_lotus_position::skin-tone-4": "🧘🏽", + "person_in_lotus_position::skin-tone-5": "🧘🏾", + "person_in_lotus_position::skin-tone-6": "🧘🏿", + "person_in_manual_wheelchair": "🧑‍🦽", + "person_in_manual_wheelchair::skin-tone-2": "🧑🏻‍🦽", + "person_in_manual_wheelchair::skin-tone-3": "🧑🏼‍🦽", + "person_in_manual_wheelchair::skin-tone-4": "🧑🏽‍🦽", + "person_in_manual_wheelchair::skin-tone-5": "🧑🏾‍🦽", + "person_in_manual_wheelchair::skin-tone-6": "🧑🏿‍🦽", + "person_in_manual_wheelchair_facing_right": "🧑‍🦽‍➡️", + "person_in_manual_wheelchair_facing_right::skin-tone-2": "🧑🏻‍🦽‍➡️", + "person_in_manual_wheelchair_facing_right::skin-tone-3": "🧑🏼‍🦽‍➡️", + "person_in_manual_wheelchair_facing_right::skin-tone-4": "🧑🏽‍🦽‍➡️", + "person_in_manual_wheelchair_facing_right::skin-tone-5": "🧑🏾‍🦽‍➡️", + "person_in_manual_wheelchair_facing_right::skin-tone-6": "🧑🏿‍🦽‍➡️", + "person_in_motorized_wheelchair": "🧑‍🦼", + "person_in_motorized_wheelchair::skin-tone-2": "🧑🏻‍🦼", + "person_in_motorized_wheelchair::skin-tone-3": "🧑🏼‍🦼", + "person_in_motorized_wheelchair::skin-tone-4": "🧑🏽‍🦼", + "person_in_motorized_wheelchair::skin-tone-5": "🧑🏾‍🦼", + "person_in_motorized_wheelchair::skin-tone-6": "🧑🏿‍🦼", + "person_in_motorized_wheelchair_facing_right": "🧑‍🦼‍➡️", + "person_in_motorized_wheelchair_facing_right::skin-tone-2": "🧑🏻‍🦼‍➡️", + "person_in_motorized_wheelchair_facing_right::skin-tone-3": "🧑🏼‍🦼‍➡️", + "person_in_motorized_wheelchair_facing_right::skin-tone-4": "🧑🏽‍🦼‍➡️", + "person_in_motorized_wheelchair_facing_right::skin-tone-5": "🧑🏾‍🦼‍➡️", + "person_in_motorized_wheelchair_facing_right::skin-tone-6": "🧑🏿‍🦼‍➡️", + "person_in_steamy_room": "🧖", + "person_in_steamy_room::skin-tone-2": "🧖🏻", + "person_in_steamy_room::skin-tone-3": "🧖🏼", + "person_in_steamy_room::skin-tone-4": "🧖🏽", + "person_in_steamy_room::skin-tone-5": "🧖🏾", + "person_in_steamy_room::skin-tone-6": "🧖🏿", + "person_in_tuxedo": "🤵", + "person_in_tuxedo::skin-tone-2": "🤵🏻", + "person_in_tuxedo::skin-tone-3": "🤵🏼", + "person_in_tuxedo::skin-tone-4": "🤵🏽", + "person_in_tuxedo::skin-tone-5": "🤵🏾", + "person_in_tuxedo::skin-tone-6": "🤵🏿", + "person_kneeling_facing_right": "🧎‍➡️", + "person_kneeling_facing_right::skin-tone-2": "🧎🏻‍➡️", + "person_kneeling_facing_right::skin-tone-3": "🧎🏼‍➡️", + "person_kneeling_facing_right::skin-tone-4": "🧎🏽‍➡️", + "person_kneeling_facing_right::skin-tone-5": "🧎🏾‍➡️", + "person_kneeling_facing_right::skin-tone-6": "🧎🏿‍➡️", + "person_running_facing_right": "🏃‍➡️", + "person_running_facing_right::skin-tone-2": "🏃🏻‍➡️", + "person_running_facing_right::skin-tone-3": "🏃🏼‍➡️", + "person_running_facing_right::skin-tone-4": "🏃🏽‍➡️", + "person_running_facing_right::skin-tone-5": "🏃🏾‍➡️", + "person_running_facing_right::skin-tone-6": "🏃🏿‍➡️", + "person_walking_facing_right": "🚶‍➡️", + "person_walking_facing_right::skin-tone-2": "🚶🏻‍➡️", + "person_walking_facing_right::skin-tone-3": "🚶🏼‍➡️", + "person_walking_facing_right::skin-tone-4": "🚶🏽‍➡️", + "person_walking_facing_right::skin-tone-5": "🚶🏾‍➡️", + "person_walking_facing_right::skin-tone-6": "🚶🏿‍➡️", + "person_with_ball": "⛹️", + "person_with_ball::skin-tone-2": "⛹🏻", + "person_with_ball::skin-tone-3": "⛹🏼", + "person_with_ball::skin-tone-4": "⛹🏽", + "person_with_ball::skin-tone-5": "⛹🏾", + "person_with_ball::skin-tone-6": "⛹🏿", + "person_with_blond_hair": "👱", + "person_with_blond_hair::skin-tone-2": "👱🏻", + "person_with_blond_hair::skin-tone-3": "👱🏼", + "person_with_blond_hair::skin-tone-4": "👱🏽", + "person_with_blond_hair::skin-tone-5": "👱🏾", + "person_with_blond_hair::skin-tone-6": "👱🏿", + "person_with_crown": "🫅", + "person_with_crown::skin-tone-2": "🫅🏻", + "person_with_crown::skin-tone-3": "🫅🏼", + "person_with_crown::skin-tone-4": "🫅🏽", + "person_with_crown::skin-tone-5": "🫅🏾", + "person_with_crown::skin-tone-6": "🫅🏿", + "person_with_headscarf": "🧕", + "person_with_headscarf::skin-tone-2": "🧕🏻", + "person_with_headscarf::skin-tone-3": "🧕🏼", + "person_with_headscarf::skin-tone-4": "🧕🏽", + "person_with_headscarf::skin-tone-5": "🧕🏾", + "person_with_headscarf::skin-tone-6": "🧕🏿", + "person_with_pouting_face": "🙎", + "person_with_pouting_face::skin-tone-2": "🙎🏻", + "person_with_pouting_face::skin-tone-3": "🙎🏼", + "person_with_pouting_face::skin-tone-4": "🙎🏽", + "person_with_pouting_face::skin-tone-5": "🙎🏾", + "person_with_pouting_face::skin-tone-6": "🙎🏿", + "person_with_probing_cane": "🧑‍🦯", + "person_with_probing_cane::skin-tone-2": "🧑🏻‍🦯", + "person_with_probing_cane::skin-tone-3": "🧑🏼‍🦯", + "person_with_probing_cane::skin-tone-4": "🧑🏽‍🦯", + "person_with_probing_cane::skin-tone-5": "🧑🏾‍🦯", + "person_with_probing_cane::skin-tone-6": "🧑🏿‍🦯", + "person_with_white_cane_facing_right": "🧑‍🦯‍➡️", + "person_with_white_cane_facing_right::skin-tone-2": "🧑🏻‍🦯‍➡️", + "person_with_white_cane_facing_right::skin-tone-3": "🧑🏼‍🦯‍➡️", + "person_with_white_cane_facing_right::skin-tone-4": "🧑🏽‍🦯‍➡️", + "person_with_white_cane_facing_right::skin-tone-5": "🧑🏾‍🦯‍➡️", + "person_with_white_cane_facing_right::skin-tone-6": "🧑🏿‍🦯‍➡️", + "petri_dish": "🧫", + "phoenix": "🐦‍🔥", + "phone": "☎️", + "pick": "⛏️", + "pickup_truck": "🛻", + "pie": "🥧", + "pig": "🐷", + "pig2": "🐖", + "pig_nose": "🐽", + "pill": "💊", + "pilot": "🧑‍✈️", + "pilot::skin-tone-2": "🧑🏻‍✈️", + "pilot::skin-tone-3": "🧑🏼‍✈️", + "pilot::skin-tone-4": "🧑🏽‍✈️", + "pilot::skin-tone-5": "🧑🏾‍✈️", + "pilot::skin-tone-6": "🧑🏿‍✈️", + "pinata": "🪅", + "pinched_fingers": "🤌", + "pinched_fingers::skin-tone-2": "🤌🏻", + "pinched_fingers::skin-tone-3": "🤌🏼", + "pinched_fingers::skin-tone-4": "🤌🏽", + "pinched_fingers::skin-tone-5": "🤌🏾", + "pinched_fingers::skin-tone-6": "🤌🏿", + "pinching_hand": "🤏", + "pinching_hand::skin-tone-2": "🤏🏻", + "pinching_hand::skin-tone-3": "🤏🏼", + "pinching_hand::skin-tone-4": "🤏🏽", + "pinching_hand::skin-tone-5": "🤏🏾", + "pinching_hand::skin-tone-6": "🤏🏿", + "pineapple": "🍍", + "pink_heart": "🩷", + "pirate_flag": "🏴‍☠️", + "pisces": "♓", + "pizza": "🍕", + "placard": "🪧", + "place_of_worship": "🛐", + "playground_slide": "🛝", + "pleading_face": "🥺", + "plunger": "🪠", + "point_down": "👇", + "point_down::skin-tone-2": "👇🏻", + "point_down::skin-tone-3": "👇🏼", + "point_down::skin-tone-4": "👇🏽", + "point_down::skin-tone-5": "👇🏾", + "point_down::skin-tone-6": "👇🏿", + "point_left": "👈", + "point_left::skin-tone-2": "👈🏻", + "point_left::skin-tone-3": "👈🏼", + "point_left::skin-tone-4": "👈🏽", + "point_left::skin-tone-5": "👈🏾", + "point_left::skin-tone-6": "👈🏿", + "point_right": "👉", + "point_right::skin-tone-2": "👉🏻", + "point_right::skin-tone-3": "👉🏼", + "point_right::skin-tone-4": "👉🏽", + "point_right::skin-tone-5": "👉🏾", + "point_right::skin-tone-6": "👉🏿", + "point_up": "☝️", + "point_up::skin-tone-2": "☝🏻", + "point_up::skin-tone-3": "☝🏼", + "point_up::skin-tone-4": "☝🏽", + "point_up::skin-tone-5": "☝🏾", + "point_up::skin-tone-6": "☝🏿", + "point_up_2": "👆", + "point_up_2::skin-tone-2": "👆🏻", + "point_up_2::skin-tone-3": "👆🏼", + "point_up_2::skin-tone-4": "👆🏽", + "point_up_2::skin-tone-5": "👆🏾", + "point_up_2::skin-tone-6": "👆🏿", + "polar_bear": "🐻‍❄️", + "police_car": "🚓", + "poodle": "🐩", + "popcorn": "🍿", + "post_office": "🏣", + "postal_horn": "📯", + "postbox": "📮", + "potable_water": "🚰", + "potato": "🥔", + "potted_plant": "🪴", + "pouch": "👝", + "poultry_leg": "🍗", + "pound": "💷", + "pouring_liquid": "🫗", + "pouting_cat": "😾", + "pray": "🙏", + "pray::skin-tone-2": "🙏🏻", + "pray::skin-tone-3": "🙏🏼", + "pray::skin-tone-4": "🙏🏽", + "pray::skin-tone-5": "🙏🏾", + "pray::skin-tone-6": "🙏🏿", + "prayer_beads": "📿", + "pregnant_man": "🫃", + "pregnant_man::skin-tone-2": "🫃🏻", + "pregnant_man::skin-tone-3": "🫃🏼", + "pregnant_man::skin-tone-4": "🫃🏽", + "pregnant_man::skin-tone-5": "🫃🏾", + "pregnant_man::skin-tone-6": "🫃🏿", + "pregnant_person": "🫄", + "pregnant_person::skin-tone-2": "🫄🏻", + "pregnant_person::skin-tone-3": "🫄🏼", + "pregnant_person::skin-tone-4": "🫄🏽", + "pregnant_person::skin-tone-5": "🫄🏾", + "pregnant_person::skin-tone-6": "🫄🏿", + "pregnant_woman": "🤰", + "pregnant_woman::skin-tone-2": "🤰🏻", + "pregnant_woman::skin-tone-3": "🤰🏼", + "pregnant_woman::skin-tone-4": "🤰🏽", + "pregnant_woman::skin-tone-5": "🤰🏾", + "pregnant_woman::skin-tone-6": "🤰🏿", + "pretzel": "🥨", + "prince": "🤴", + "prince::skin-tone-2": "🤴🏻", + "prince::skin-tone-3": "🤴🏼", + "prince::skin-tone-4": "🤴🏽", + "prince::skin-tone-5": "🤴🏾", + "prince::skin-tone-6": "🤴🏿", + "princess": "👸", + "princess::skin-tone-2": "👸🏻", + "princess::skin-tone-3": "👸🏼", + "princess::skin-tone-4": "👸🏽", + "princess::skin-tone-5": "👸🏾", + "princess::skin-tone-6": "👸🏿", + "printer": "🖨️", + "probing_cane": "🦯", + "purple_heart": "💜", + "purse": "👛", + "pushpin": "📌", + "put_litter_in_its_place": "🚮", + "question": "❓", + "rabbit": "🐰", + "rabbit2": "🐇", + "raccoon": "🦝", + "racehorse": "🐎", + "racing_car": "🏎️", + "racing_motorcycle": "🏍️", + "radio": "📻", + "radio_button": "🔘", + "radioactive_sign": "☢️", + "rage": "😡", + "railway_car": "🚃", + "railway_track": "🛤️", + "rain_cloud": "🌧️", + "rainbow": "🌈", + "rainbow-flag": "🏳️‍🌈", + "raised_back_of_hand": "🤚", + "raised_back_of_hand::skin-tone-2": "🤚🏻", + "raised_back_of_hand::skin-tone-3": "🤚🏼", + "raised_back_of_hand::skin-tone-4": "🤚🏽", + "raised_back_of_hand::skin-tone-5": "🤚🏾", + "raised_back_of_hand::skin-tone-6": "🤚🏿", + "raised_hand_with_fingers_splayed": "🖐️", + "raised_hand_with_fingers_splayed::skin-tone-2": "🖐🏻", + "raised_hand_with_fingers_splayed::skin-tone-3": "🖐🏼", + "raised_hand_with_fingers_splayed::skin-tone-4": "🖐🏽", + "raised_hand_with_fingers_splayed::skin-tone-5": "🖐🏾", + "raised_hand_with_fingers_splayed::skin-tone-6": "🖐🏿", + "raised_hands": "🙌", + "raised_hands::skin-tone-2": "🙌🏻", + "raised_hands::skin-tone-3": "🙌🏼", + "raised_hands::skin-tone-4": "🙌🏽", + "raised_hands::skin-tone-5": "🙌🏾", + "raised_hands::skin-tone-6": "🙌🏿", + "raising_hand": "🙋", + "raising_hand::skin-tone-2": "🙋🏻", + "raising_hand::skin-tone-3": "🙋🏼", + "raising_hand::skin-tone-4": "🙋🏽", + "raising_hand::skin-tone-5": "🙋🏾", + "raising_hand::skin-tone-6": "🙋🏿", + "ram": "🐏", + "ramen": "🍜", + "rat": "🐀", + "razor": "🪒", + "receipt": "🧾", + "recycle": "♻️", + "red_circle": "🔴", + "red_envelope": "🧧", + "red_haired_man": "👨‍🦰", + "red_haired_man::skin-tone-2": "👨🏻‍🦰", + "red_haired_man::skin-tone-3": "👨🏼‍🦰", + "red_haired_man::skin-tone-4": "👨🏽‍🦰", + "red_haired_man::skin-tone-5": "👨🏾‍🦰", + "red_haired_man::skin-tone-6": "👨🏿‍🦰", + "red_haired_person": "🧑‍🦰", + "red_haired_person::skin-tone-2": "🧑🏻‍🦰", + "red_haired_person::skin-tone-3": "🧑🏼‍🦰", + "red_haired_person::skin-tone-4": "🧑🏽‍🦰", + "red_haired_person::skin-tone-5": "🧑🏾‍🦰", + "red_haired_person::skin-tone-6": "🧑🏿‍🦰", + "red_haired_woman": "👩‍🦰", + "red_haired_woman::skin-tone-2": "👩🏻‍🦰", + "red_haired_woman::skin-tone-3": "👩🏼‍🦰", + "red_haired_woman::skin-tone-4": "👩🏽‍🦰", + "red_haired_woman::skin-tone-5": "👩🏾‍🦰", + "red_haired_woman::skin-tone-6": "👩🏿‍🦰", + "registered": "®️", + "relaxed": "☺️", + "relieved": "😌", + "reminder_ribbon": "🎗️", + "repeat": "🔁", + "repeat_one": "🔂", + "restroom": "🚻", + "revolving_hearts": "💞", + "rewind": "⏪", + "rhinoceros": "🦏", + "ribbon": "🎀", + "rice": "🍚", + "rice_ball": "🍙", + "rice_cracker": "🍘", + "rice_scene": "🎑", + "right-facing_fist": "🤜", + "right-facing_fist::skin-tone-2": "🤜🏻", + "right-facing_fist::skin-tone-3": "🤜🏼", + "right-facing_fist::skin-tone-4": "🤜🏽", + "right-facing_fist::skin-tone-5": "🤜🏾", + "right-facing_fist::skin-tone-6": "🤜🏿", + "right_anger_bubble": "🗯️", + "rightwards_hand": "🫱", + "rightwards_hand::skin-tone-2": "🫱🏻", + "rightwards_hand::skin-tone-3": "🫱🏼", + "rightwards_hand::skin-tone-4": "🫱🏽", + "rightwards_hand::skin-tone-5": "🫱🏾", + "rightwards_hand::skin-tone-6": "🫱🏿", + "rightwards_pushing_hand": "🫸", + "rightwards_pushing_hand::skin-tone-2": "🫸🏻", + "rightwards_pushing_hand::skin-tone-3": "🫸🏼", + "rightwards_pushing_hand::skin-tone-4": "🫸🏽", + "rightwards_pushing_hand::skin-tone-5": "🫸🏾", + "rightwards_pushing_hand::skin-tone-6": "🫸🏿", + "ring": "💍", + "ring_buoy": "🛟", + "ringed_planet": "🪐", + "robot_face": "🤖", + "rock": "🪨", + "rocket": "🚀", + "roll_of_paper": "🧻", + "rolled_up_newspaper": "🗞️", + "roller_coaster": "🎢", + "roller_skate": "🛼", + "rolling_on_the_floor_laughing": "🤣", + "rooster": "🐓", + "rose": "🌹", + "rosette": "🏵️", + "rotating_light": "🚨", + "round_pushpin": "📍", + "rowboat": "🚣", + "rowboat::skin-tone-2": "🚣🏻", + "rowboat::skin-tone-3": "🚣🏼", + "rowboat::skin-tone-4": "🚣🏽", + "rowboat::skin-tone-5": "🚣🏾", + "rowboat::skin-tone-6": "🚣🏿", + "ru": "🇷🇺", + "rugby_football": "🏉", + "runner": "🏃", + "runner::skin-tone-2": "🏃🏻", + "runner::skin-tone-3": "🏃🏼", + "runner::skin-tone-4": "🏃🏽", + "runner::skin-tone-5": "🏃🏾", + "runner::skin-tone-6": "🏃🏿", + "running_shirt_with_sash": "🎽", + "sa": "🈂️", + "safety_pin": "🧷", + "safety_vest": "🦺", + "sagittarius": "♐", + "sake": "🍶", + "salt": "🧂", + "saluting_face": "🫡", + "sandal": "👡", + "sandwich": "🥪", + "santa": "🎅", + "santa::skin-tone-2": "🎅🏻", + "santa::skin-tone-3": "🎅🏼", + "santa::skin-tone-4": "🎅🏽", + "santa::skin-tone-5": "🎅🏾", + "santa::skin-tone-6": "🎅🏿", + "sari": "🥻", + "satellite": "🛰️", + "satellite_antenna": "📡", + "sauropod": "🦕", + "saxophone": "🎷", + "scales": "⚖️", + "scarf": "🧣", + "school": "🏫", + "school_satchel": "🎒", + "scientist": "🧑‍🔬", + "scientist::skin-tone-2": "🧑🏻‍🔬", + "scientist::skin-tone-3": "🧑🏼‍🔬", + "scientist::skin-tone-4": "🧑🏽‍🔬", + "scientist::skin-tone-5": "🧑🏾‍🔬", + "scientist::skin-tone-6": "🧑🏿‍🔬", + "scissors": "✂️", + "scooter": "🛴", + "scorpion": "🦂", + "scorpius": "♏", + "scream": "😱", + "scream_cat": "🙀", + "screwdriver": "🪛", + "scroll": "📜", + "seal": "🦭", + "seat": "💺", + "second_place_medal": "🥈", + "secret": "㊙️", + "see_no_evil": "🙈", + "seedling": "🌱", + "selfie": "🤳", + "selfie::skin-tone-2": "🤳🏻", + "selfie::skin-tone-3": "🤳🏼", + "selfie::skin-tone-4": "🤳🏽", + "selfie::skin-tone-5": "🤳🏾", + "selfie::skin-tone-6": "🤳🏿", + "service_dog": "🐕‍🦺", + "seven": "7️⃣", + "sewing_needle": "🪡", + "shaking_face": "🫨", + "shallow_pan_of_food": "🥘", + "shamrock": "☘️", + "shark": "🦈", + "shaved_ice": "🍧", + "sheep": "🐑", + "shell": "🐚", + "shield": "🛡️", + "shinto_shrine": "⛩️", + "ship": "🚢", + "shirt": "👕", + "shopping_bags": "🛍️", + "shopping_trolley": "🛒", + "shorts": "🩳", + "shower": "🚿", + "shrimp": "🦐", + "shrug": "🤷", + "shrug::skin-tone-2": "🤷🏻", + "shrug::skin-tone-3": "🤷🏼", + "shrug::skin-tone-4": "🤷🏽", + "shrug::skin-tone-5": "🤷🏾", + "shrug::skin-tone-6": "🤷🏿", + "shushing_face": "🤫", + "signal_strength": "📶", + "singer": "🧑‍🎤", + "singer::skin-tone-2": "🧑🏻‍🎤", + "singer::skin-tone-3": "🧑🏼‍🎤", + "singer::skin-tone-4": "🧑🏽‍🎤", + "singer::skin-tone-5": "🧑🏾‍🎤", + "singer::skin-tone-6": "🧑🏿‍🎤", + "six": "6️⃣", + "six_pointed_star": "🔯", + "skateboard": "🛹", + "ski": "🎿", + "skier": "⛷️", + "skin-tone-2": "🏻", + "skin-tone-3": "🏼", + "skin-tone-4": "🏽", + "skin-tone-5": "🏾", + "skin-tone-6": "🏿", + "skull": "💀", + "skull_and_crossbones": "☠️", + "skunk": "🦨", + "sled": "🛷", + "sleeping": "😴", + "sleeping_accommodation": "🛌", + "sleeping_accommodation::skin-tone-2": "🛌🏻", + "sleeping_accommodation::skin-tone-3": "🛌🏼", + "sleeping_accommodation::skin-tone-4": "🛌🏽", + "sleeping_accommodation::skin-tone-5": "🛌🏾", + "sleeping_accommodation::skin-tone-6": "🛌🏿", + "sleepy": "😪", + "sleuth_or_spy": "🕵️", + "sleuth_or_spy::skin-tone-2": "🕵🏻", + "sleuth_or_spy::skin-tone-3": "🕵🏼", + "sleuth_or_spy::skin-tone-4": "🕵🏽", + "sleuth_or_spy::skin-tone-5": "🕵🏾", + "sleuth_or_spy::skin-tone-6": "🕵🏿", + "slightly_frowning_face": "🙁", + "slightly_smiling_face": "🙂", + "slot_machine": "🎰", + "sloth": "🦥", + "small_airplane": "🛩️", + "small_blue_diamond": "🔹", + "small_orange_diamond": "🔸", + "small_red_triangle": "🔺", + "small_red_triangle_down": "🔻", + "smile": "😄", + "smile_cat": "😸", + "smiley": "😃", + "smiley_cat": "😺", + "smiling_face_with_3_hearts": "🥰", + "smiling_face_with_tear": "🥲", + "smiling_imp": "😈", + "smirk": "😏", + "smirk_cat": "😼", + "smoking": "🚬", + "snail": "🐌", + "snake": "🐍", + "sneezing_face": "🤧", + "snow_capped_mountain": "🏔️", + "snow_cloud": "🌨️", + "snowboarder": "🏂", + "snowboarder::skin-tone-2": "🏂🏻", + "snowboarder::skin-tone-3": "🏂🏼", + "snowboarder::skin-tone-4": "🏂🏽", + "snowboarder::skin-tone-5": "🏂🏾", + "snowboarder::skin-tone-6": "🏂🏿", + "snowflake": "❄️", + "snowman": "☃️", + "snowman_without_snow": "⛄", + "soap": "🧼", + "sob": "😭", + "soccer": "⚽", + "socks": "🧦", + "softball": "🥎", + "soon": "🔜", + "sos": "🆘", + "sound": "🔉", + "space_invader": "👾", + "spades": "♠️", + "spaghetti": "🍝", + "sparkle": "❇️", + "sparkler": "🎇", + "sparkles": "✨", + "sparkling_heart": "💖", + "speak_no_evil": "🙊", + "speaker": "🔈", + "speaking_head_in_silhouette": "🗣️", + "speech_balloon": "💬", + "speedboat": "🚤", + "spider": "🕷️", + "spider_web": "🕸️", + "spiral_calendar_pad": "🗓️", + "spiral_note_pad": "🗒️", + "spock-hand": "🖖", + "spock-hand::skin-tone-2": "🖖🏻", + "spock-hand::skin-tone-3": "🖖🏼", + "spock-hand::skin-tone-4": "🖖🏽", + "spock-hand::skin-tone-5": "🖖🏾", + "spock-hand::skin-tone-6": "🖖🏿", + "sponge": "🧽", + "spoon": "🥄", + "sports_medal": "🏅", + "squid": "🦑", + "stadium": "🏟️", + "standing_person": "🧍", + "standing_person::skin-tone-2": "🧍🏻", + "standing_person::skin-tone-3": "🧍🏼", + "standing_person::skin-tone-4": "🧍🏽", + "standing_person::skin-tone-5": "🧍🏾", + "standing_person::skin-tone-6": "🧍🏿", + "star": "⭐", + "star-struck": "🤩", + "star2": "🌟", + "star_and_crescent": "☪️", + "star_of_david": "✡️", + "stars": "🌠", + "station": "🚉", + "statue_of_liberty": "🗽", + "steam_locomotive": "🚂", + "stethoscope": "🩺", + "stew": "🍲", + "stopwatch": "⏱️", + "straight_ruler": "📏", + "strawberry": "🍓", + "stuck_out_tongue": "😛", + "stuck_out_tongue_closed_eyes": "😝", + "stuck_out_tongue_winking_eye": "😜", + "student": "🧑‍🎓", + "student::skin-tone-2": "🧑🏻‍🎓", + "student::skin-tone-3": "🧑🏼‍🎓", + "student::skin-tone-4": "🧑🏽‍🎓", + "student::skin-tone-5": "🧑🏾‍🎓", + "student::skin-tone-6": "🧑🏿‍🎓", + "studio_microphone": "🎙️", + "stuffed_flatbread": "🥙", + "sun_with_face": "🌞", + "sunflower": "🌻", + "sunglasses": "😎", + "sunny": "☀️", + "sunrise": "🌅", + "sunrise_over_mountains": "🌄", + "superhero": "🦸", + "superhero::skin-tone-2": "🦸🏻", + "superhero::skin-tone-3": "🦸🏼", + "superhero::skin-tone-4": "🦸🏽", + "superhero::skin-tone-5": "🦸🏾", + "superhero::skin-tone-6": "🦸🏿", + "supervillain": "🦹", + "supervillain::skin-tone-2": "🦹🏻", + "supervillain::skin-tone-3": "🦹🏼", + "supervillain::skin-tone-4": "🦹🏽", + "supervillain::skin-tone-5": "🦹🏾", + "supervillain::skin-tone-6": "🦹🏿", + "surfer": "🏄", + "surfer::skin-tone-2": "🏄🏻", + "surfer::skin-tone-3": "🏄🏼", + "surfer::skin-tone-4": "🏄🏽", + "surfer::skin-tone-5": "🏄🏾", + "surfer::skin-tone-6": "🏄🏿", + "sushi": "🍣", + "suspension_railway": "🚟", + "swan": "🦢", + "sweat": "😓", + "sweat_drops": "💦", + "sweat_smile": "😅", + "sweet_potato": "🍠", + "swimmer": "🏊", + "swimmer::skin-tone-2": "🏊🏻", + "swimmer::skin-tone-3": "🏊🏼", + "swimmer::skin-tone-4": "🏊🏽", + "swimmer::skin-tone-5": "🏊🏾", + "swimmer::skin-tone-6": "🏊🏿", + "symbols": "🔣", + "synagogue": "🕍", + "syringe": "💉", + "t-rex": "🦖", + "table_tennis_paddle_and_ball": "🏓", + "taco": "🌮", + "tada": "🎉", + "takeout_box": "🥡", + "tamale": "🫔", + "tanabata_tree": "🎋", + "tangerine": "🍊", + "taurus": "♉", + "taxi": "🚕", + "tea": "🍵", + "teacher": "🧑‍🏫", + "teacher::skin-tone-2": "🧑🏻‍🏫", + "teacher::skin-tone-3": "🧑🏼‍🏫", + "teacher::skin-tone-4": "🧑🏽‍🏫", + "teacher::skin-tone-5": "🧑🏾‍🏫", + "teacher::skin-tone-6": "🧑🏿‍🏫", + "teapot": "🫖", + "technologist": "🧑‍💻", + "technologist::skin-tone-2": "🧑🏻‍💻", + "technologist::skin-tone-3": "🧑🏼‍💻", + "technologist::skin-tone-4": "🧑🏽‍💻", + "technologist::skin-tone-5": "🧑🏾‍💻", + "technologist::skin-tone-6": "🧑🏿‍💻", + "teddy_bear": "🧸", + "telephone_receiver": "📞", + "telescope": "🔭", + "tennis": "🎾", + "tent": "⛺", + "test_tube": "🧪", + "the_horns": "🤘", + "the_horns::skin-tone-2": "🤘🏻", + "the_horns::skin-tone-3": "🤘🏼", + "the_horns::skin-tone-4": "🤘🏽", + "the_horns::skin-tone-5": "🤘🏾", + "the_horns::skin-tone-6": "🤘🏿", + "thermometer": "🌡️", + "thinking_face": "🤔", + "third_place_medal": "🥉", + "thong_sandal": "🩴", + "thought_balloon": "💭", + "thread": "🧵", + "three": "3️⃣", + "three_button_mouse": "🖱️", + "thunder_cloud_and_rain": "⛈️", + "ticket": "🎫", + "tiger": "🐯", + "tiger2": "🐅", + "timer_clock": "⏲️", + "tired_face": "😫", + "tm": "™️", + "toilet": "🚽", + "tokyo_tower": "🗼", + "tomato": "🍅", + "tongue": "👅", + "toolbox": "🧰", + "tooth": "🦷", + "toothbrush": "🪥", + "top": "🔝", + "tophat": "🎩", + "tornado": "🌪️", + "trackball": "🖲️", + "tractor": "🚜", + "traffic_light": "🚥", + "train": "🚋", + "train2": "🚆", + "tram": "🚊", + "transgender_flag": "🏳️‍⚧️", + "transgender_symbol": "⚧️", + "triangular_flag_on_post": "🚩", + "triangular_ruler": "📐", + "trident": "🔱", + "triumph": "😤", + "troll": "🧌", + "trolleybus": "🚎", + "trophy": "🏆", + "tropical_drink": "🍹", + "tropical_fish": "🐠", + "truck": "🚚", + "trumpet": "🎺", + "tulip": "🌷", + "tumbler_glass": "🥃", + "turkey": "🦃", + "turtle": "🐢", + "tv": "📺", + "twisted_rightwards_arrows": "🔀", + "two": "2️⃣", + "two_hearts": "💕", + "two_men_holding_hands": "👬", + "two_men_holding_hands::skin-tone-2": "👬🏻", + "two_men_holding_hands::skin-tone-2-3": "👨🏻‍🤝‍👨🏼", + "two_men_holding_hands::skin-tone-2-4": "👨🏻‍🤝‍👨🏽", + "two_men_holding_hands::skin-tone-2-5": "👨🏻‍🤝‍👨🏾", + "two_men_holding_hands::skin-tone-2-6": "👨🏻‍🤝‍👨🏿", + "two_men_holding_hands::skin-tone-3": "👬🏼", + "two_men_holding_hands::skin-tone-3-2": "👨🏼‍🤝‍👨🏻", + "two_men_holding_hands::skin-tone-3-4": "👨🏼‍🤝‍👨🏽", + "two_men_holding_hands::skin-tone-3-5": "👨🏼‍🤝‍👨🏾", + "two_men_holding_hands::skin-tone-3-6": "👨🏼‍🤝‍👨🏿", + "two_men_holding_hands::skin-tone-4": "👬🏽", + "two_men_holding_hands::skin-tone-4-2": "👨🏽‍🤝‍👨🏻", + "two_men_holding_hands::skin-tone-4-3": "👨🏽‍🤝‍👨🏼", + "two_men_holding_hands::skin-tone-4-5": "👨🏽‍🤝‍👨🏾", + "two_men_holding_hands::skin-tone-4-6": "👨🏽‍🤝‍👨🏿", + "two_men_holding_hands::skin-tone-5": "👬🏾", + "two_men_holding_hands::skin-tone-5-2": "👨🏾‍🤝‍👨🏻", + "two_men_holding_hands::skin-tone-5-3": "👨🏾‍🤝‍👨🏼", + "two_men_holding_hands::skin-tone-5-4": "👨🏾‍🤝‍👨🏽", + "two_men_holding_hands::skin-tone-5-6": "👨🏾‍🤝‍👨🏿", + "two_men_holding_hands::skin-tone-6": "👬🏿", + "two_men_holding_hands::skin-tone-6-2": "👨🏿‍🤝‍👨🏻", + "two_men_holding_hands::skin-tone-6-3": "👨🏿‍🤝‍👨🏼", + "two_men_holding_hands::skin-tone-6-4": "👨🏿‍🤝‍👨🏽", + "two_men_holding_hands::skin-tone-6-5": "👨🏿‍🤝‍👨🏾", + "two_women_holding_hands": "👭", + "two_women_holding_hands::skin-tone-2": "👭🏻", + "two_women_holding_hands::skin-tone-2-3": "👩🏻‍🤝‍👩🏼", + "two_women_holding_hands::skin-tone-2-4": "👩🏻‍🤝‍👩🏽", + "two_women_holding_hands::skin-tone-2-5": "👩🏻‍🤝‍👩🏾", + "two_women_holding_hands::skin-tone-2-6": "👩🏻‍🤝‍👩🏿", + "two_women_holding_hands::skin-tone-3": "👭🏼", + "two_women_holding_hands::skin-tone-3-2": "👩🏼‍🤝‍👩🏻", + "two_women_holding_hands::skin-tone-3-4": "👩🏼‍🤝‍👩🏽", + "two_women_holding_hands::skin-tone-3-5": "👩🏼‍🤝‍👩🏾", + "two_women_holding_hands::skin-tone-3-6": "👩🏼‍🤝‍👩🏿", + "two_women_holding_hands::skin-tone-4": "👭🏽", + "two_women_holding_hands::skin-tone-4-2": "👩🏽‍🤝‍👩🏻", + "two_women_holding_hands::skin-tone-4-3": "👩🏽‍🤝‍👩🏼", + "two_women_holding_hands::skin-tone-4-5": "👩🏽‍🤝‍👩🏾", + "two_women_holding_hands::skin-tone-4-6": "👩🏽‍🤝‍👩🏿", + "two_women_holding_hands::skin-tone-5": "👭🏾", + "two_women_holding_hands::skin-tone-5-2": "👩🏾‍🤝‍👩🏻", + "two_women_holding_hands::skin-tone-5-3": "👩🏾‍🤝‍👩🏼", + "two_women_holding_hands::skin-tone-5-4": "👩🏾‍🤝‍👩🏽", + "two_women_holding_hands::skin-tone-5-6": "👩🏾‍🤝‍👩🏿", + "two_women_holding_hands::skin-tone-6": "👭🏿", + "two_women_holding_hands::skin-tone-6-2": "👩🏿‍🤝‍👩🏻", + "two_women_holding_hands::skin-tone-6-3": "👩🏿‍🤝‍👩🏼", + "two_women_holding_hands::skin-tone-6-4": "👩🏿‍🤝‍👩🏽", + "two_women_holding_hands::skin-tone-6-5": "👩🏿‍🤝‍👩🏾", + "u5272": "🈹", + "u5408": "🈴", + "u55b6": "🈺", + "u6307": "🈯", + "u6708": "🈷️", + "u6709": "🈶", + "u6e80": "🈵", + "u7121": "🈚", + "u7533": "🈸", + "u7981": "🈲", + "u7a7a": "🈳", + "umbrella": "☂️", + "umbrella_on_ground": "⛱️", + "umbrella_with_rain_drops": "☔", + "unamused": "😒", + "underage": "🔞", + "unicorn_face": "🦄", + "unlock": "🔓", + "up": "🆙", + "upside_down_face": "🙃", + "us": "🇺🇸", + "v": "✌️", + "v::skin-tone-2": "✌🏻", + "v::skin-tone-3": "✌🏼", + "v::skin-tone-4": "✌🏽", + "v::skin-tone-5": "✌🏾", + "v::skin-tone-6": "✌🏿", + "vampire": "🧛", + "vampire::skin-tone-2": "🧛🏻", + "vampire::skin-tone-3": "🧛🏼", + "vampire::skin-tone-4": "🧛🏽", + "vampire::skin-tone-5": "🧛🏾", + "vampire::skin-tone-6": "🧛🏿", + "vertical_traffic_light": "🚦", + "vhs": "📼", + "vibration_mode": "📳", + "video_camera": "📹", + "video_game": "🎮", + "violin": "🎻", + "virgo": "♍", + "volcano": "🌋", + "volleyball": "🏐", + "vs": "🆚", + "waffle": "🧇", + "walking": "🚶", + "walking::skin-tone-2": "🚶🏻", + "walking::skin-tone-3": "🚶🏼", + "walking::skin-tone-4": "🚶🏽", + "walking::skin-tone-5": "🚶🏾", + "walking::skin-tone-6": "🚶🏿", + "waning_crescent_moon": "🌘", + "waning_gibbous_moon": "🌖", + "warning": "⚠️", + "wastebasket": "🗑️", + "watch": "⌚", + "water_buffalo": "🐃", + "water_polo": "🤽", + "water_polo::skin-tone-2": "🤽🏻", + "water_polo::skin-tone-3": "🤽🏼", + "water_polo::skin-tone-4": "🤽🏽", + "water_polo::skin-tone-5": "🤽🏾", + "water_polo::skin-tone-6": "🤽🏿", + "watermelon": "🍉", + "wave": "👋", + "wave::skin-tone-2": "👋🏻", + "wave::skin-tone-3": "👋🏼", + "wave::skin-tone-4": "👋🏽", + "wave::skin-tone-5": "👋🏾", + "wave::skin-tone-6": "👋🏿", + "waving_black_flag": "🏴", + "waving_white_flag": "🏳️", + "wavy_dash": "〰️", + "waxing_crescent_moon": "🌒", + "wc": "🚾", + "weary": "😩", + "wedding": "💒", + "weight_lifter": "🏋️", + "weight_lifter::skin-tone-2": "🏋🏻", + "weight_lifter::skin-tone-3": "🏋🏼", + "weight_lifter::skin-tone-4": "🏋🏽", + "weight_lifter::skin-tone-5": "🏋🏾", + "weight_lifter::skin-tone-6": "🏋🏿", + "whale": "🐳", + "whale2": "🐋", + "wheel": "🛞", + "wheel_of_dharma": "☸️", + "wheelchair": "♿", + "white_check_mark": "✅", + "white_circle": "⚪", + "white_flower": "💮", + "white_frowning_face": "☹️", + "white_haired_man": "👨‍🦳", + "white_haired_man::skin-tone-2": "👨🏻‍🦳", + "white_haired_man::skin-tone-3": "👨🏼‍🦳", + "white_haired_man::skin-tone-4": "👨🏽‍🦳", + "white_haired_man::skin-tone-5": "👨🏾‍🦳", + "white_haired_man::skin-tone-6": "👨🏿‍🦳", + "white_haired_person": "🧑‍🦳", + "white_haired_person::skin-tone-2": "🧑🏻‍🦳", + "white_haired_person::skin-tone-3": "🧑🏼‍🦳", + "white_haired_person::skin-tone-4": "🧑🏽‍🦳", + "white_haired_person::skin-tone-5": "🧑🏾‍🦳", + "white_haired_person::skin-tone-6": "🧑🏿‍🦳", + "white_haired_woman": "👩‍🦳", + "white_haired_woman::skin-tone-2": "👩🏻‍🦳", + "white_haired_woman::skin-tone-3": "👩🏼‍🦳", + "white_haired_woman::skin-tone-4": "👩🏽‍🦳", + "white_haired_woman::skin-tone-5": "👩🏾‍🦳", + "white_haired_woman::skin-tone-6": "👩🏿‍🦳", + "white_heart": "🤍", + "white_large_square": "⬜", + "white_medium_small_square": "◽", + "white_medium_square": "◻️", + "white_small_square": "▫️", + "white_square_button": "🔳", + "wilted_flower": "🥀", + "wind_blowing_face": "🌬️", + "wind_chime": "🎐", + "window": "🪟", + "wine_glass": "🍷", + "wing": "🪽", + "wink": "😉", + "wireless": "🛜", + "wolf": "🐺", + "woman": "👩", + "woman-biking": "🚴‍♀️", + "woman-biking::skin-tone-2": "🚴🏻‍♀️", + "woman-biking::skin-tone-3": "🚴🏼‍♀️", + "woman-biking::skin-tone-4": "🚴🏽‍♀️", + "woman-biking::skin-tone-5": "🚴🏾‍♀️", + "woman-biking::skin-tone-6": "🚴🏿‍♀️", + "woman-bouncing-ball": "⛹️‍♀️", + "woman-bouncing-ball::skin-tone-2": "⛹🏻‍♀️", + "woman-bouncing-ball::skin-tone-3": "⛹🏼‍♀️", + "woman-bouncing-ball::skin-tone-4": "⛹🏽‍♀️", + "woman-bouncing-ball::skin-tone-5": "⛹🏾‍♀️", + "woman-bouncing-ball::skin-tone-6": "⛹🏿‍♀️", + "woman-bowing": "🙇‍♀️", + "woman-bowing::skin-tone-2": "🙇🏻‍♀️", + "woman-bowing::skin-tone-3": "🙇🏼‍♀️", + "woman-bowing::skin-tone-4": "🙇🏽‍♀️", + "woman-bowing::skin-tone-5": "🙇🏾‍♀️", + "woman-bowing::skin-tone-6": "🙇🏿‍♀️", + "woman-boy": "👩‍👦", + "woman-boy-boy": "👩‍👦‍👦", + "woman-cartwheeling": "🤸‍♀️", + "woman-cartwheeling::skin-tone-2": "🤸🏻‍♀️", + "woman-cartwheeling::skin-tone-3": "🤸🏼‍♀️", + "woman-cartwheeling::skin-tone-4": "🤸🏽‍♀️", + "woman-cartwheeling::skin-tone-5": "🤸🏾‍♀️", + "woman-cartwheeling::skin-tone-6": "🤸🏿‍♀️", + "woman-facepalming": "🤦‍♀️", + "woman-facepalming::skin-tone-2": "🤦🏻‍♀️", + "woman-facepalming::skin-tone-3": "🤦🏼‍♀️", + "woman-facepalming::skin-tone-4": "🤦🏽‍♀️", + "woman-facepalming::skin-tone-5": "🤦🏾‍♀️", + "woman-facepalming::skin-tone-6": "🤦🏿‍♀️", + "woman-frowning": "🙍‍♀️", + "woman-frowning::skin-tone-2": "🙍🏻‍♀️", + "woman-frowning::skin-tone-3": "🙍🏼‍♀️", + "woman-frowning::skin-tone-4": "🙍🏽‍♀️", + "woman-frowning::skin-tone-5": "🙍🏾‍♀️", + "woman-frowning::skin-tone-6": "🙍🏿‍♀️", + "woman-gesturing-no": "🙅‍♀️", + "woman-gesturing-no::skin-tone-2": "🙅🏻‍♀️", + "woman-gesturing-no::skin-tone-3": "🙅🏼‍♀️", + "woman-gesturing-no::skin-tone-4": "🙅🏽‍♀️", + "woman-gesturing-no::skin-tone-5": "🙅🏾‍♀️", + "woman-gesturing-no::skin-tone-6": "🙅🏿‍♀️", + "woman-gesturing-ok": "🙆‍♀️", + "woman-gesturing-ok::skin-tone-2": "🙆🏻‍♀️", + "woman-gesturing-ok::skin-tone-3": "🙆🏼‍♀️", + "woman-gesturing-ok::skin-tone-4": "🙆🏽‍♀️", + "woman-gesturing-ok::skin-tone-5": "🙆🏾‍♀️", + "woman-gesturing-ok::skin-tone-6": "🙆🏿‍♀️", + "woman-getting-haircut": "💇‍♀️", + "woman-getting-haircut::skin-tone-2": "💇🏻‍♀️", + "woman-getting-haircut::skin-tone-3": "💇🏼‍♀️", + "woman-getting-haircut::skin-tone-4": "💇🏽‍♀️", + "woman-getting-haircut::skin-tone-5": "💇🏾‍♀️", + "woman-getting-haircut::skin-tone-6": "💇🏿‍♀️", + "woman-getting-massage": "💆‍♀️", + "woman-getting-massage::skin-tone-2": "💆🏻‍♀️", + "woman-getting-massage::skin-tone-3": "💆🏼‍♀️", + "woman-getting-massage::skin-tone-4": "💆🏽‍♀️", + "woman-getting-massage::skin-tone-5": "💆🏾‍♀️", + "woman-getting-massage::skin-tone-6": "💆🏿‍♀️", + "woman-girl": "👩‍👧", + "woman-girl-boy": "👩‍👧‍👦", + "woman-girl-girl": "👩‍👧‍👧", + "woman-golfing": "🏌️‍♀️", + "woman-golfing::skin-tone-2": "🏌🏻‍♀️", + "woman-golfing::skin-tone-3": "🏌🏼‍♀️", + "woman-golfing::skin-tone-4": "🏌🏽‍♀️", + "woman-golfing::skin-tone-5": "🏌🏾‍♀️", + "woman-golfing::skin-tone-6": "🏌🏿‍♀️", + "woman-heart-man": "👩‍❤️‍👨", + "woman-heart-man::skin-tone-2-2": "👩🏻‍❤️‍👨🏻", + "woman-heart-man::skin-tone-2-3": "👩🏻‍❤️‍👨🏼", + "woman-heart-man::skin-tone-2-4": "👩🏻‍❤️‍👨🏽", + "woman-heart-man::skin-tone-2-5": "👩🏻‍❤️‍👨🏾", + "woman-heart-man::skin-tone-2-6": "👩🏻‍❤️‍👨🏿", + "woman-heart-man::skin-tone-3-2": "👩🏼‍❤️‍👨🏻", + "woman-heart-man::skin-tone-3-3": "👩🏼‍❤️‍👨🏼", + "woman-heart-man::skin-tone-3-4": "👩🏼‍❤️‍👨🏽", + "woman-heart-man::skin-tone-3-5": "👩🏼‍❤️‍👨🏾", + "woman-heart-man::skin-tone-3-6": "👩🏼‍❤️‍👨🏿", + "woman-heart-man::skin-tone-4-2": "👩🏽‍❤️‍👨🏻", + "woman-heart-man::skin-tone-4-3": "👩🏽‍❤️‍👨🏼", + "woman-heart-man::skin-tone-4-4": "👩🏽‍❤️‍👨🏽", + "woman-heart-man::skin-tone-4-5": "👩🏽‍❤️‍👨🏾", + "woman-heart-man::skin-tone-4-6": "👩🏽‍❤️‍👨🏿", + "woman-heart-man::skin-tone-5-2": "👩🏾‍❤️‍👨🏻", + "woman-heart-man::skin-tone-5-3": "👩🏾‍❤️‍👨🏼", + "woman-heart-man::skin-tone-5-4": "👩🏾‍❤️‍👨🏽", + "woman-heart-man::skin-tone-5-5": "👩🏾‍❤️‍👨🏾", + "woman-heart-man::skin-tone-5-6": "👩🏾‍❤️‍👨🏿", + "woman-heart-man::skin-tone-6-2": "👩🏿‍❤️‍👨🏻", + "woman-heart-man::skin-tone-6-3": "👩🏿‍❤️‍👨🏼", + "woman-heart-man::skin-tone-6-4": "👩🏿‍❤️‍👨🏽", + "woman-heart-man::skin-tone-6-5": "👩🏿‍❤️‍👨🏾", + "woman-heart-man::skin-tone-6-6": "👩🏿‍❤️‍👨🏿", + "woman-heart-woman": "👩‍❤️‍👩", + "woman-heart-woman::skin-tone-2-2": "👩🏻‍❤️‍👩🏻", + "woman-heart-woman::skin-tone-2-3": "👩🏻‍❤️‍👩🏼", + "woman-heart-woman::skin-tone-2-4": "👩🏻‍❤️‍👩🏽", + "woman-heart-woman::skin-tone-2-5": "👩🏻‍❤️‍👩🏾", + "woman-heart-woman::skin-tone-2-6": "👩🏻‍❤️‍👩🏿", + "woman-heart-woman::skin-tone-3-2": "👩🏼‍❤️‍👩🏻", + "woman-heart-woman::skin-tone-3-3": "👩🏼‍❤️‍👩🏼", + "woman-heart-woman::skin-tone-3-4": "👩🏼‍❤️‍👩🏽", + "woman-heart-woman::skin-tone-3-5": "👩🏼‍❤️‍👩🏾", + "woman-heart-woman::skin-tone-3-6": "👩🏼‍❤️‍👩🏿", + "woman-heart-woman::skin-tone-4-2": "👩🏽‍❤️‍👩🏻", + "woman-heart-woman::skin-tone-4-3": "👩🏽‍❤️‍👩🏼", + "woman-heart-woman::skin-tone-4-4": "👩🏽‍❤️‍👩🏽", + "woman-heart-woman::skin-tone-4-5": "👩🏽‍❤️‍👩🏾", + "woman-heart-woman::skin-tone-4-6": "👩🏽‍❤️‍👩🏿", + "woman-heart-woman::skin-tone-5-2": "👩🏾‍❤️‍👩🏻", + "woman-heart-woman::skin-tone-5-3": "👩🏾‍❤️‍👩🏼", + "woman-heart-woman::skin-tone-5-4": "👩🏾‍❤️‍👩🏽", + "woman-heart-woman::skin-tone-5-5": "👩🏾‍❤️‍👩🏾", + "woman-heart-woman::skin-tone-5-6": "👩🏾‍❤️‍👩🏿", + "woman-heart-woman::skin-tone-6-2": "👩🏿‍❤️‍👩🏻", + "woman-heart-woman::skin-tone-6-3": "👩🏿‍❤️‍👩🏼", + "woman-heart-woman::skin-tone-6-4": "👩🏿‍❤️‍👩🏽", + "woman-heart-woman::skin-tone-6-5": "👩🏿‍❤️‍👩🏾", + "woman-heart-woman::skin-tone-6-6": "👩🏿‍❤️‍👩🏿", + "woman-juggling": "🤹‍♀️", + "woman-juggling::skin-tone-2": "🤹🏻‍♀️", + "woman-juggling::skin-tone-3": "🤹🏼‍♀️", + "woman-juggling::skin-tone-4": "🤹🏽‍♀️", + "woman-juggling::skin-tone-5": "🤹🏾‍♀️", + "woman-juggling::skin-tone-6": "🤹🏿‍♀️", + "woman-kiss-man": "👩‍❤️‍💋‍👨", + "woman-kiss-man::skin-tone-2-2": "👩🏻‍❤️‍💋‍👨🏻", + "woman-kiss-man::skin-tone-2-3": "👩🏻‍❤️‍💋‍👨🏼", + "woman-kiss-man::skin-tone-2-4": "👩🏻‍❤️‍💋‍👨🏽", + "woman-kiss-man::skin-tone-2-5": "👩🏻‍❤️‍💋‍👨🏾", + "woman-kiss-man::skin-tone-2-6": "👩🏻‍❤️‍💋‍👨🏿", + "woman-kiss-man::skin-tone-3-2": "👩🏼‍❤️‍💋‍👨🏻", + "woman-kiss-man::skin-tone-3-3": "👩🏼‍❤️‍💋‍👨🏼", + "woman-kiss-man::skin-tone-3-4": "👩🏼‍❤️‍💋‍👨🏽", + "woman-kiss-man::skin-tone-3-5": "👩🏼‍❤️‍💋‍👨🏾", + "woman-kiss-man::skin-tone-3-6": "👩🏼‍❤️‍💋‍👨🏿", + "woman-kiss-man::skin-tone-4-2": "👩🏽‍❤️‍💋‍👨🏻", + "woman-kiss-man::skin-tone-4-3": "👩🏽‍❤️‍💋‍👨🏼", + "woman-kiss-man::skin-tone-4-4": "👩🏽‍❤️‍💋‍👨🏽", + "woman-kiss-man::skin-tone-4-5": "👩🏽‍❤️‍💋‍👨🏾", + "woman-kiss-man::skin-tone-4-6": "👩🏽‍❤️‍💋‍👨🏿", + "woman-kiss-man::skin-tone-5-2": "👩🏾‍❤️‍💋‍👨🏻", + "woman-kiss-man::skin-tone-5-3": "👩🏾‍❤️‍💋‍👨🏼", + "woman-kiss-man::skin-tone-5-4": "👩🏾‍❤️‍💋‍👨🏽", + "woman-kiss-man::skin-tone-5-5": "👩🏾‍❤️‍💋‍👨🏾", + "woman-kiss-man::skin-tone-5-6": "👩🏾‍❤️‍💋‍👨🏿", + "woman-kiss-man::skin-tone-6-2": "👩🏿‍❤️‍💋‍👨🏻", + "woman-kiss-man::skin-tone-6-3": "👩🏿‍❤️‍💋‍👨🏼", + "woman-kiss-man::skin-tone-6-4": "👩🏿‍❤️‍💋‍👨🏽", + "woman-kiss-man::skin-tone-6-5": "👩🏿‍❤️‍💋‍👨🏾", + "woman-kiss-man::skin-tone-6-6": "👩🏿‍❤️‍💋‍👨🏿", + "woman-kiss-woman": "👩‍❤️‍💋‍👩", + "woman-kiss-woman::skin-tone-2-2": "👩🏻‍❤️‍💋‍👩🏻", + "woman-kiss-woman::skin-tone-2-3": "👩🏻‍❤️‍💋‍👩🏼", + "woman-kiss-woman::skin-tone-2-4": "👩🏻‍❤️‍💋‍👩🏽", + "woman-kiss-woman::skin-tone-2-5": "👩🏻‍❤️‍💋‍👩🏾", + "woman-kiss-woman::skin-tone-2-6": "👩🏻‍❤️‍💋‍👩🏿", + "woman-kiss-woman::skin-tone-3-2": "👩🏼‍❤️‍💋‍👩🏻", + "woman-kiss-woman::skin-tone-3-3": "👩🏼‍❤️‍💋‍👩🏼", + "woman-kiss-woman::skin-tone-3-4": "👩🏼‍❤️‍💋‍👩🏽", + "woman-kiss-woman::skin-tone-3-5": "👩🏼‍❤️‍💋‍👩🏾", + "woman-kiss-woman::skin-tone-3-6": "👩🏼‍❤️‍💋‍👩🏿", + "woman-kiss-woman::skin-tone-4-2": "👩🏽‍❤️‍💋‍👩🏻", + "woman-kiss-woman::skin-tone-4-3": "👩🏽‍❤️‍💋‍👩🏼", + "woman-kiss-woman::skin-tone-4-4": "👩🏽‍❤️‍💋‍👩🏽", + "woman-kiss-woman::skin-tone-4-5": "👩🏽‍❤️‍💋‍👩🏾", + "woman-kiss-woman::skin-tone-4-6": "👩🏽‍❤️‍💋‍👩🏿", + "woman-kiss-woman::skin-tone-5-2": "👩🏾‍❤️‍💋‍👩🏻", + "woman-kiss-woman::skin-tone-5-3": "👩🏾‍❤️‍💋‍👩🏼", + "woman-kiss-woman::skin-tone-5-4": "👩🏾‍❤️‍💋‍👩🏽", + "woman-kiss-woman::skin-tone-5-5": "👩🏾‍❤️‍💋‍👩🏾", + "woman-kiss-woman::skin-tone-5-6": "👩🏾‍❤️‍💋‍👩🏿", + "woman-kiss-woman::skin-tone-6-2": "👩🏿‍❤️‍💋‍👩🏻", + "woman-kiss-woman::skin-tone-6-3": "👩🏿‍❤️‍💋‍👩🏼", + "woman-kiss-woman::skin-tone-6-4": "👩🏿‍❤️‍💋‍👩🏽", + "woman-kiss-woman::skin-tone-6-5": "👩🏿‍❤️‍💋‍👩🏾", + "woman-kiss-woman::skin-tone-6-6": "👩🏿‍❤️‍💋‍👩🏿", + "woman-lifting-weights": "🏋️‍♀️", + "woman-lifting-weights::skin-tone-2": "🏋🏻‍♀️", + "woman-lifting-weights::skin-tone-3": "🏋🏼‍♀️", + "woman-lifting-weights::skin-tone-4": "🏋🏽‍♀️", + "woman-lifting-weights::skin-tone-5": "🏋🏾‍♀️", + "woman-lifting-weights::skin-tone-6": "🏋🏿‍♀️", + "woman-mountain-biking": "🚵‍♀️", + "woman-mountain-biking::skin-tone-2": "🚵🏻‍♀️", + "woman-mountain-biking::skin-tone-3": "🚵🏼‍♀️", + "woman-mountain-biking::skin-tone-4": "🚵🏽‍♀️", + "woman-mountain-biking::skin-tone-5": "🚵🏾‍♀️", + "woman-mountain-biking::skin-tone-6": "🚵🏿‍♀️", + "woman-playing-handball": "🤾‍♀️", + "woman-playing-handball::skin-tone-2": "🤾🏻‍♀️", + "woman-playing-handball::skin-tone-3": "🤾🏼‍♀️", + "woman-playing-handball::skin-tone-4": "🤾🏽‍♀️", + "woman-playing-handball::skin-tone-5": "🤾🏾‍♀️", + "woman-playing-handball::skin-tone-6": "🤾🏿‍♀️", + "woman-playing-water-polo": "🤽‍♀️", + "woman-playing-water-polo::skin-tone-2": "🤽🏻‍♀️", + "woman-playing-water-polo::skin-tone-3": "🤽🏼‍♀️", + "woman-playing-water-polo::skin-tone-4": "🤽🏽‍♀️", + "woman-playing-water-polo::skin-tone-5": "🤽🏾‍♀️", + "woman-playing-water-polo::skin-tone-6": "🤽🏿‍♀️", + "woman-pouting": "🙎‍♀️", + "woman-pouting::skin-tone-2": "🙎🏻‍♀️", + "woman-pouting::skin-tone-3": "🙎🏼‍♀️", + "woman-pouting::skin-tone-4": "🙎🏽‍♀️", + "woman-pouting::skin-tone-5": "🙎🏾‍♀️", + "woman-pouting::skin-tone-6": "🙎🏿‍♀️", + "woman-raising-hand": "🙋‍♀️", + "woman-raising-hand::skin-tone-2": "🙋🏻‍♀️", + "woman-raising-hand::skin-tone-3": "🙋🏼‍♀️", + "woman-raising-hand::skin-tone-4": "🙋🏽‍♀️", + "woman-raising-hand::skin-tone-5": "🙋🏾‍♀️", + "woman-raising-hand::skin-tone-6": "🙋🏿‍♀️", + "woman-rowing-boat": "🚣‍♀️", + "woman-rowing-boat::skin-tone-2": "🚣🏻‍♀️", + "woman-rowing-boat::skin-tone-3": "🚣🏼‍♀️", + "woman-rowing-boat::skin-tone-4": "🚣🏽‍♀️", + "woman-rowing-boat::skin-tone-5": "🚣🏾‍♀️", + "woman-rowing-boat::skin-tone-6": "🚣🏿‍♀️", + "woman-running": "🏃‍♀️", + "woman-running::skin-tone-2": "🏃🏻‍♀️", + "woman-running::skin-tone-3": "🏃🏼‍♀️", + "woman-running::skin-tone-4": "🏃🏽‍♀️", + "woman-running::skin-tone-5": "🏃🏾‍♀️", + "woman-running::skin-tone-6": "🏃🏿‍♀️", + "woman-shrugging": "🤷‍♀️", + "woman-shrugging::skin-tone-2": "🤷🏻‍♀️", + "woman-shrugging::skin-tone-3": "🤷🏼‍♀️", + "woman-shrugging::skin-tone-4": "🤷🏽‍♀️", + "woman-shrugging::skin-tone-5": "🤷🏾‍♀️", + "woman-shrugging::skin-tone-6": "🤷🏿‍♀️", + "woman-surfing": "🏄‍♀️", + "woman-surfing::skin-tone-2": "🏄🏻‍♀️", + "woman-surfing::skin-tone-3": "🏄🏼‍♀️", + "woman-surfing::skin-tone-4": "🏄🏽‍♀️", + "woman-surfing::skin-tone-5": "🏄🏾‍♀️", + "woman-surfing::skin-tone-6": "🏄🏿‍♀️", + "woman-swimming": "🏊‍♀️", + "woman-swimming::skin-tone-2": "🏊🏻‍♀️", + "woman-swimming::skin-tone-3": "🏊🏼‍♀️", + "woman-swimming::skin-tone-4": "🏊🏽‍♀️", + "woman-swimming::skin-tone-5": "🏊🏾‍♀️", + "woman-swimming::skin-tone-6": "🏊🏿‍♀️", + "woman-tipping-hand": "💁‍♀️", + "woman-tipping-hand::skin-tone-2": "💁🏻‍♀️", + "woman-tipping-hand::skin-tone-3": "💁🏼‍♀️", + "woman-tipping-hand::skin-tone-4": "💁🏽‍♀️", + "woman-tipping-hand::skin-tone-5": "💁🏾‍♀️", + "woman-tipping-hand::skin-tone-6": "💁🏿‍♀️", + "woman-walking": "🚶‍♀️", + "woman-walking::skin-tone-2": "🚶🏻‍♀️", + "woman-walking::skin-tone-3": "🚶🏼‍♀️", + "woman-walking::skin-tone-4": "🚶🏽‍♀️", + "woman-walking::skin-tone-5": "🚶🏾‍♀️", + "woman-walking::skin-tone-6": "🚶🏿‍♀️", + "woman-wearing-turban": "👳‍♀️", + "woman-wearing-turban::skin-tone-2": "👳🏻‍♀️", + "woman-wearing-turban::skin-tone-3": "👳🏼‍♀️", + "woman-wearing-turban::skin-tone-4": "👳🏽‍♀️", + "woman-wearing-turban::skin-tone-5": "👳🏾‍♀️", + "woman-wearing-turban::skin-tone-6": "👳🏿‍♀️", + "woman-woman-boy": "👩‍👩‍👦", + "woman-woman-boy-boy": "👩‍👩‍👦‍👦", + "woman-woman-girl": "👩‍👩‍👧", + "woman-woman-girl-boy": "👩‍👩‍👧‍👦", + "woman-woman-girl-girl": "👩‍👩‍👧‍👧", + "woman-wrestling": "🤼‍♀️", + "woman::skin-tone-2": "👩🏻", + "woman::skin-tone-3": "👩🏼", + "woman::skin-tone-4": "👩🏽", + "woman::skin-tone-5": "👩🏾", + "woman::skin-tone-6": "👩🏿", + "woman_climbing": "🧗‍♀️", + "woman_climbing::skin-tone-2": "🧗🏻‍♀️", + "woman_climbing::skin-tone-3": "🧗🏼‍♀️", + "woman_climbing::skin-tone-4": "🧗🏽‍♀️", + "woman_climbing::skin-tone-5": "🧗🏾‍♀️", + "woman_climbing::skin-tone-6": "🧗🏿‍♀️", + "woman_feeding_baby": "👩‍🍼", + "woman_feeding_baby::skin-tone-2": "👩🏻‍🍼", + "woman_feeding_baby::skin-tone-3": "👩🏼‍🍼", + "woman_feeding_baby::skin-tone-4": "👩🏽‍🍼", + "woman_feeding_baby::skin-tone-5": "👩🏾‍🍼", + "woman_feeding_baby::skin-tone-6": "👩🏿‍🍼", + "woman_in_lotus_position": "🧘‍♀️", + "woman_in_lotus_position::skin-tone-2": "🧘🏻‍♀️", + "woman_in_lotus_position::skin-tone-3": "🧘🏼‍♀️", + "woman_in_lotus_position::skin-tone-4": "🧘🏽‍♀️", + "woman_in_lotus_position::skin-tone-5": "🧘🏾‍♀️", + "woman_in_lotus_position::skin-tone-6": "🧘🏿‍♀️", + "woman_in_manual_wheelchair": "👩‍🦽", + "woman_in_manual_wheelchair::skin-tone-2": "👩🏻‍🦽", + "woman_in_manual_wheelchair::skin-tone-3": "👩🏼‍🦽", + "woman_in_manual_wheelchair::skin-tone-4": "👩🏽‍🦽", + "woman_in_manual_wheelchair::skin-tone-5": "👩🏾‍🦽", + "woman_in_manual_wheelchair::skin-tone-6": "👩🏿‍🦽", + "woman_in_manual_wheelchair_facing_right": "👩‍🦽‍➡️", + "woman_in_manual_wheelchair_facing_right::skin-tone-2": "👩🏻‍🦽‍➡️", + "woman_in_manual_wheelchair_facing_right::skin-tone-3": "👩🏼‍🦽‍➡️", + "woman_in_manual_wheelchair_facing_right::skin-tone-4": "👩🏽‍🦽‍➡️", + "woman_in_manual_wheelchair_facing_right::skin-tone-5": "👩🏾‍🦽‍➡️", + "woman_in_manual_wheelchair_facing_right::skin-tone-6": "👩🏿‍🦽‍➡️", + "woman_in_motorized_wheelchair": "👩‍🦼", + "woman_in_motorized_wheelchair::skin-tone-2": "👩🏻‍🦼", + "woman_in_motorized_wheelchair::skin-tone-3": "👩🏼‍🦼", + "woman_in_motorized_wheelchair::skin-tone-4": "👩🏽‍🦼", + "woman_in_motorized_wheelchair::skin-tone-5": "👩🏾‍🦼", + "woman_in_motorized_wheelchair::skin-tone-6": "👩🏿‍🦼", + "woman_in_motorized_wheelchair_facing_right": "👩‍🦼‍➡️", + "woman_in_motorized_wheelchair_facing_right::skin-tone-2": "👩🏻‍🦼‍➡️", + "woman_in_motorized_wheelchair_facing_right::skin-tone-3": "👩🏼‍🦼‍➡️", + "woman_in_motorized_wheelchair_facing_right::skin-tone-4": "👩🏽‍🦼‍➡️", + "woman_in_motorized_wheelchair_facing_right::skin-tone-5": "👩🏾‍🦼‍➡️", + "woman_in_motorized_wheelchair_facing_right::skin-tone-6": "👩🏿‍🦼‍➡️", + "woman_in_steamy_room": "🧖‍♀️", + "woman_in_steamy_room::skin-tone-2": "🧖🏻‍♀️", + "woman_in_steamy_room::skin-tone-3": "🧖🏼‍♀️", + "woman_in_steamy_room::skin-tone-4": "🧖🏽‍♀️", + "woman_in_steamy_room::skin-tone-5": "🧖🏾‍♀️", + "woman_in_steamy_room::skin-tone-6": "🧖🏿‍♀️", + "woman_in_tuxedo": "🤵‍♀️", + "woman_in_tuxedo::skin-tone-2": "🤵🏻‍♀️", + "woman_in_tuxedo::skin-tone-3": "🤵🏼‍♀️", + "woman_in_tuxedo::skin-tone-4": "🤵🏽‍♀️", + "woman_in_tuxedo::skin-tone-5": "🤵🏾‍♀️", + "woman_in_tuxedo::skin-tone-6": "🤵🏿‍♀️", + "woman_kneeling": "🧎‍♀️", + "woman_kneeling::skin-tone-2": "🧎🏻‍♀️", + "woman_kneeling::skin-tone-3": "🧎🏼‍♀️", + "woman_kneeling::skin-tone-4": "🧎🏽‍♀️", + "woman_kneeling::skin-tone-5": "🧎🏾‍♀️", + "woman_kneeling::skin-tone-6": "🧎🏿‍♀️", + "woman_kneeling_facing_right": "🧎‍♀️‍➡️", + "woman_kneeling_facing_right::skin-tone-2": "🧎🏻‍♀️‍➡️", + "woman_kneeling_facing_right::skin-tone-3": "🧎🏼‍♀️‍➡️", + "woman_kneeling_facing_right::skin-tone-4": "🧎🏽‍♀️‍➡️", + "woman_kneeling_facing_right::skin-tone-5": "🧎🏾‍♀️‍➡️", + "woman_kneeling_facing_right::skin-tone-6": "🧎🏿‍♀️‍➡️", + "woman_running_facing_right": "🏃‍♀️‍➡️", + "woman_running_facing_right::skin-tone-2": "🏃🏻‍♀️‍➡️", + "woman_running_facing_right::skin-tone-3": "🏃🏼‍♀️‍➡️", + "woman_running_facing_right::skin-tone-4": "🏃🏽‍♀️‍➡️", + "woman_running_facing_right::skin-tone-5": "🏃🏾‍♀️‍➡️", + "woman_running_facing_right::skin-tone-6": "🏃🏿‍♀️‍➡️", + "woman_standing": "🧍‍♀️", + "woman_standing::skin-tone-2": "🧍🏻‍♀️", + "woman_standing::skin-tone-3": "🧍🏼‍♀️", + "woman_standing::skin-tone-4": "🧍🏽‍♀️", + "woman_standing::skin-tone-5": "🧍🏾‍♀️", + "woman_standing::skin-tone-6": "🧍🏿‍♀️", + "woman_walking_facing_right": "🚶‍♀️‍➡️", + "woman_walking_facing_right::skin-tone-2": "🚶🏻‍♀️‍➡️", + "woman_walking_facing_right::skin-tone-3": "🚶🏼‍♀️‍➡️", + "woman_walking_facing_right::skin-tone-4": "🚶🏽‍♀️‍➡️", + "woman_walking_facing_right::skin-tone-5": "🚶🏾‍♀️‍➡️", + "woman_walking_facing_right::skin-tone-6": "🚶🏿‍♀️‍➡️", + "woman_with_beard": "🧔‍♀️", + "woman_with_beard::skin-tone-2": "🧔🏻‍♀️", + "woman_with_beard::skin-tone-3": "🧔🏼‍♀️", + "woman_with_beard::skin-tone-4": "🧔🏽‍♀️", + "woman_with_beard::skin-tone-5": "🧔🏾‍♀️", + "woman_with_beard::skin-tone-6": "🧔🏿‍♀️", + "woman_with_probing_cane": "👩‍🦯", + "woman_with_probing_cane::skin-tone-2": "👩🏻‍🦯", + "woman_with_probing_cane::skin-tone-3": "👩🏼‍🦯", + "woman_with_probing_cane::skin-tone-4": "👩🏽‍🦯", + "woman_with_probing_cane::skin-tone-5": "👩🏾‍🦯", + "woman_with_probing_cane::skin-tone-6": "👩🏿‍🦯", + "woman_with_veil": "👰‍♀️", + "woman_with_veil::skin-tone-2": "👰🏻‍♀️", + "woman_with_veil::skin-tone-3": "👰🏼‍♀️", + "woman_with_veil::skin-tone-4": "👰🏽‍♀️", + "woman_with_veil::skin-tone-5": "👰🏾‍♀️", + "woman_with_veil::skin-tone-6": "👰🏿‍♀️", + "woman_with_white_cane_facing_right": "👩‍🦯‍➡️", + "woman_with_white_cane_facing_right::skin-tone-2": "👩🏻‍🦯‍➡️", + "woman_with_white_cane_facing_right::skin-tone-3": "👩🏼‍🦯‍➡️", + "woman_with_white_cane_facing_right::skin-tone-4": "👩🏽‍🦯‍➡️", + "woman_with_white_cane_facing_right::skin-tone-5": "👩🏾‍🦯‍➡️", + "woman_with_white_cane_facing_right::skin-tone-6": "👩🏿‍🦯‍➡️", + "womans_clothes": "👚", + "womans_flat_shoe": "🥿", + "womans_hat": "👒", + "women-with-bunny-ears-partying": "👯‍♀️", + "womens": "🚺", + "wood": "🪵", + "woozy_face": "🥴", + "world_map": "🗺️", + "worm": "🪱", + "worried": "😟", + "wrench": "🔧", + "wrestlers": "🤼", + "writing_hand": "✍️", + "writing_hand::skin-tone-2": "✍🏻", + "writing_hand::skin-tone-3": "✍🏼", + "writing_hand::skin-tone-4": "✍🏽", + "writing_hand::skin-tone-5": "✍🏾", + "writing_hand::skin-tone-6": "✍🏿", + "x": "❌", + "x-ray": "🩻", + "yarn": "🧶", + "yawning_face": "🥱", + "yellow_heart": "💛", + "yen": "💴", + "yin_yang": "☯️", + "yo-yo": "🪀", + "yum": "😋", + "zany_face": "🤪", + "zap": "⚡", + "zebra_face": "🦓", + "zero": "0️⃣", + "zipper_mouth_face": "🤐", + "zombie": "🧟", + "zzz": "💤" +} diff --git a/msgconv/from-matrix.go b/msgconv/from-matrix.go new file mode 100644 index 0000000..e9a24da --- /dev/null +++ b/msgconv/from-matrix.go @@ -0,0 +1,200 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "strings" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/slack-go/slack" +) + +var ( + ErrUnexpectedParsedContentType = errors.New("unexpected parsed content type") + ErrUnknownMsgType = errors.New("unknown msgtype") + ErrMediaDownloadFailed = errors.New("failed to download media") + ErrMediaOnlyEditCaption = errors.New("only media message caption can be edited") + ErrEditTargetNotFound = errors.New("edit target message not found") + ErrThreadRootNotFound = errors.New("thread root message not found") +) + +func (mc *MessageConverter) ToSlack(ctx context.Context, evt *event.Event) (sendReq slack.MsgOption, fileUpload *slack.FileUploadParameters, threadRootID string, err error) { + log := zerolog.Ctx(ctx) + content, ok := evt.Content.Parsed.(*event.MessageEventContent) + if !ok { + return nil, nil, "", ErrUnexpectedParsedContentType + } + + var editTargetID string + if replaceEventID := content.RelatesTo.GetReplaceID(); replaceEventID != "" { + existing, err := mc.GetMessageInfo(ctx, replaceEventID) + if err != nil { + log.Err(err).Msg("Failed to get edit target message") + return nil, nil, "", fmt.Errorf("failed to get edit target message: %w", err) + } else if existing == nil { + return nil, nil, "", ErrEditTargetNotFound + } else { + editTargetID = existing.MessageID + if content.NewContent != nil { + content = content.NewContent + } + } + } else { + var threadMXID id.EventID + threadMXID = content.RelatesTo.GetThreadParent() + if threadMXID == "" { + threadMXID = content.RelatesTo.GetReplyTo() + } + if threadMXID != "" { + rootMessage, err := mc.GetMessageInfo(ctx, threadMXID) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to get thread root message: %w", err) + } else if rootMessage == nil { + return nil, nil, "", ErrThreadRootNotFound + } else if rootMessage.ThreadID != "" { + threadRootID = rootMessage.ThreadID + } else { + threadRootID = rootMessage.MessageID + } + } + } + + if editTargetID != "" && isMediaMsgtype(content.MsgType) { + content.MsgType = event.MsgText + if content.FileName == "" || content.FileName == content.Body { + return nil, nil, "", ErrMediaOnlyEditCaption + } + } + + switch content.MsgType { + case event.MsgText, event.MsgEmote, event.MsgNotice: + options := make([]slack.MsgOption, 0, 4) + if content.Format == event.FormatHTML { + options = append(options, slack.MsgOptionText(mc.MatrixHTMLParser.Parse(ctx, content.FormattedBody), false)) + } else { + options = append(options, + slack.MsgOptionText(content.Body, false), + slack.MsgOptionDisableMarkdown()) + } + if editTargetID != "" { + options = append(options, slack.MsgOptionUpdate(editTargetID)) + } else if threadRootID != "" { + options = append(options, slack.MsgOptionTS(threadRootID)) + } + if content.MsgType == event.MsgEmote { + options = append(options, slack.MsgOptionMeMessage()) + } + return slack.MsgOptionCompose(options...), nil, threadRootID, nil + case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: + data, err := mc.downloadMatrixAttachment(ctx, content) + if err != nil { + log.Err(err).Msg("Failed to download Matrix attachment") + return nil, nil, "", ErrMediaDownloadFailed + } + + var filename, caption string + if content.FileName == "" || content.FileName == content.Body { + filename = content.Body + } else { + filename = content.FileName + caption = content.Body + } + fileUpload = &slack.FileUploadParameters{ + Filename: filename, + Filetype: content.Info.MimeType, + Reader: bytes.NewReader(data), + Channels: []string{mc.GetData(ctx).ChannelID}, + ThreadTimestamp: threadRootID, + } + if caption != "" { + fileUpload.InitialComment = caption + } + return nil, fileUpload, threadRootID, nil + default: + return nil, nil, "", ErrUnknownMsgType + } +} + +func (mc *MessageConverter) uploadMedia(ctx context.Context, data []byte, content *event.MessageEventContent) error { + content.Info.Size = len(data) + if content.Info.Width == 0 && content.Info.Height == 0 && strings.HasPrefix(content.Info.MimeType, "image/") { + cfg, _, _ := image.DecodeConfig(bytes.NewReader(data)) + content.Info.Width, content.Info.Height = cfg.Width, cfg.Height + } + + uploadMimeType, file := mc.encryptFileInPlace(ctx, data, content.Info.MimeType) + fileName := "" + mxc, err := mc.UploadMatrixMedia(ctx, data, fileName, uploadMimeType) + if err != nil { + return err + } + + if file != nil { + file.URL = mxc + content.File = file + } else { + content.URL = mxc + } + + return nil +} + +func (mc *MessageConverter) encryptFileInPlace(ctx context.Context, data []byte, mimeType string) (string, *event.EncryptedFileInfo) { + if !mc.GetData(ctx).Encrypted { + return mimeType, nil + } + + file := &event.EncryptedFileInfo{ + EncryptedFile: *attachment.NewEncryptedFile(), + URL: "", + } + file.EncryptInPlace(data) + return "application/octet-stream", file +} + +func (mc *MessageConverter) downloadMatrixAttachment(ctx context.Context, content *event.MessageEventContent) ([]byte, error) { + var file *event.EncryptedFileInfo + rawMXC := content.URL + + if content.File != nil { + file = content.File + rawMXC = file.URL + } + + data, err := mc.DownloadMatrixMedia(ctx, rawMXC) + if err != nil { + return nil, err + } + + if file != nil { + err = file.DecryptInPlace(data) + if err != nil { + return nil, err + } + } + + return data, nil +} diff --git a/msgconv/from-slack.go b/msgconv/from-slack.go new file mode 100644 index 0000000..b835395 --- /dev/null +++ b/msgconv/from-slack.go @@ -0,0 +1,243 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "bytes" + "context" + "errors" + "fmt" + "maps" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/exmime" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/format" + + "github.com/slack-go/slack" + "go.mau.fi/mautrix-slack/database" +) + +type ConvertedMessagePart struct { + PartID database.PartID + Type event.Type + Content *event.MessageEventContent + Extra map[string]any +} + +type ConvertedMessage struct { + Parts []*ConvertedMessagePart +} + +func isMediaMsgtype(msgType event.MessageType) bool { + return msgType == event.MsgImage || msgType == event.MsgAudio || msgType == event.MsgVideo || msgType == event.MsgFile +} + +func (cm *ConvertedMessage) MergeCaption() { + if len(cm.Parts) != 2 { + return + } + textPart := cm.Parts[0] + imagePart := cm.Parts[1] + if imagePart.Content.MsgType == event.MsgText { + textPart, imagePart = imagePart, textPart + } + if textPart.PartID.String() != "" || textPart.Content.MsgType != event.MsgText || !isMediaMsgtype(imagePart.Content.MsgType) { + return + } + imagePart.Content.FileName = imagePart.Content.Body + imagePart.Content.Body = textPart.Content.Body + imagePart.Content.Format = textPart.Content.Format + imagePart.Content.FormattedBody = textPart.Content.FormattedBody + maps.Copy(imagePart.Extra, textPart.Extra) + imagePart.PartID = textPart.PartID + cm.Parts = []*ConvertedMessagePart{imagePart} +} + +func (mc *MessageConverter) ToMatrix(ctx context.Context, msg *slack.Msg) *ConvertedMessage { + var text string + if msg.Text != "" { + text = msg.Text + } + for _, attachment := range msg.Attachments { + if text != "" { + text += "\n" + } + if attachment.Text != "" { + text += attachment.Text + } else if attachment.Fallback != "" { + text += attachment.Fallback + } + } + output := &ConvertedMessage{} + var textPart *ConvertedMessagePart + if len(msg.Blocks.BlockSet) != 0 || len(msg.Attachments) != 0 { + textPart = mc.trySlackBlocksToMatrix(ctx, msg.Blocks, msg.Attachments) + } else if text != "" { + textPart = mc.slackTextToMatrix(ctx, text) + } + if textPart != nil { + switch msg.SubType { + case slack.MsgSubTypeMeMessage: + textPart.Content.MsgType = event.MsgEmote + case "huddle_thread": + data := mc.GetData(ctx) + textPart.Content.EnsureHasHTML() + textPart.Content.Body += fmt.Sprintf("\n\nJoin via the Slack app: https://app.slack.com/client/%s/%s", data.TeamID, data.ChannelID) + textPart.Content.FormattedBody += fmt.Sprintf(`

    Click here to join via the Slack app

    `, data.TeamID, data.ChannelID) + } + output.Parts = append(output.Parts, textPart) + } + for i, file := range msg.Files { + partID := database.PartID{ + Type: database.PartTypeFile, + Index: i, + ID: file.ID, + } + output.Parts = append(output.Parts, mc.slackFileToMatrix(ctx, partID, &file)) + } + return output +} + +func (mc *MessageConverter) slackTextToMatrix(ctx context.Context, text string) *ConvertedMessagePart { + content := format.HTMLToContent(mc.mrkdwnToMatrixHtml(ctx, text)) + return &ConvertedMessagePart{ + Type: event.EventMessage, + Content: &content, + } +} + +func makeErrorMessage(partID database.PartID, message string, args ...any) *ConvertedMessagePart { + if len(args) > 0 { + message = fmt.Sprintf(message, args...) + } + return &ConvertedMessagePart{ + PartID: partID, + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgNotice, + Body: message, + }, + } +} + +func (mc *MessageConverter) slackFileToMatrix(ctx context.Context, partID database.PartID, file *slack.File) *ConvertedMessagePart { + log := zerolog.Ctx(ctx).With().Str("file_id", file.ID).Logger() + if file.FileAccess == "check_file_info" { + connectFile, _, _, err := mc.GetClient(ctx).GetFileInfoContext(ctx, file.ID, 0, 0) + if err != nil || connectFile == nil { + log.Err(err).Str("file_id", file.ID).Msg("Failed to fetch slack connect file info") + return makeErrorMessage(partID, "Failed to fetch Slack Connect file") + } + file = connectFile + } + if file.Size > mc.MaxFileSize { + log.Debug().Int("file_size", file.Size).Msg("Dropping too large file") + return makeErrorMessage(partID, "Too large file (%d MB)", file.Size/1_000_000) + } + content := convertSlackFileMetadata(file) + var data bytes.Buffer + var err error + var url string + if file.URLPrivateDownload != "" { + url = file.URLPrivateDownload + } else if file.URLPrivate != "" { + url = file.URLPrivate + } + if url != "" { + err = mc.GetClient(ctx).GetFileContext(ctx, url, &data) + if bytes.HasPrefix(data.Bytes(), []byte("")) { + log.Warn().Msg("Received HTML file from Slack, retrying in 5 seconds") + time.Sleep(5 * time.Second) + data.Reset() + err = mc.GetClient(ctx).GetFileContext(ctx, file.URLPrivate, &data) + } + } else if file.PermalinkPublic != "" { + var resp *http.Response + resp, err = http.DefaultClient.Get(file.PermalinkPublic) + if err == nil { + _, err = data.ReadFrom(resp.Body) + } + } else { + log.Warn().Msg("No usable URL found in file object") + return makeErrorMessage(partID, "File URL not found") + } + if err != nil { + log.Err(err).Msg("Failed to download file from Slack") + return makeErrorMessage(partID, "Failed to download file from Slack") + } + err = mc.uploadMedia(ctx, data.Bytes(), &content) + if err != nil { + if errors.Is(err, mautrix.MTooLarge) { + log.Err(err).Msg("Homeserver rejected too large file") + } else if httpErr := (mautrix.HTTPError{}); errors.As(err, &httpErr) && httpErr.IsStatus(413) { + log.Err(err).Msg("Proxy rejected too large file") + } else { + log.Err(err).Msg("Failed to upload file to Matrix") + } + return makeErrorMessage(partID, "Failed to transfer file") + } + return &ConvertedMessagePart{ + PartID: partID, + Type: event.EventMessage, + Content: &content, + } +} + +func convertSlackFileMetadata(file *slack.File) event.MessageEventContent { + content := event.MessageEventContent{ + Info: &event.FileInfo{ + MimeType: file.Mimetype, + Size: file.Size, + }, + } + if file.OriginalW != 0 { + content.Info.Width = file.OriginalW + } + if file.OriginalH != 0 { + content.Info.Height = file.OriginalH + } + if file.Name != "" { + content.Body = file.Name + } else { + mimeClass := strings.Split(file.Mimetype, "/")[0] + switch mimeClass { + case "application": + content.Body = "file" + default: + content.Body = mimeClass + } + + content.Body += exmime.ExtensionFromMimetype(file.Mimetype) + } + + if strings.HasPrefix(file.Mimetype, "image") { + content.MsgType = event.MsgImage + } else if strings.HasPrefix(file.Mimetype, "video") { + content.MsgType = event.MsgVideo + } else if strings.HasPrefix(file.Mimetype, "audio") { + content.MsgType = event.MsgAudio + } else { + content.MsgType = event.MsgFile + } + + return content +} diff --git a/msgconv/matrixfmt/parser.go b/msgconv/matrixfmt/parser.go new file mode 100644 index 0000000..079176e --- /dev/null +++ b/msgconv/matrixfmt/parser.go @@ -0,0 +1,75 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package matrixfmt + +import ( + "context" + "fmt" + + "maunium.net/go/mautrix/format" + "maunium.net/go/mautrix/id" +) + +type Params struct { + GetUserID func(context.Context, id.UserID) string + GetChannelID func(context.Context, id.RoomID) string +} + +type MatrixHTMLParser struct { + parser *format.HTMLParser + *Params +} + +func (mhp *MatrixHTMLParser) pillConverter(displayname, mxid, eventID string, ctx format.Context) string { + switch mxid[0] { + case '@': + userID := mhp.GetUserID(ctx.Ctx, id.UserID(mxid)) + if userID != "" { + return fmt.Sprintf("<@%s>", userID) + } + case '!': + channelID := mhp.GetChannelID(ctx.Ctx, id.RoomID(mxid)) + if channelID != "" { + return fmt.Sprintf("<#%s>", channelID) + } + case '#': + // TODO add aliases for rooms so they can be mentioned easily + } + return displayname +} + +func New(params *Params) *MatrixHTMLParser { + mhp := &MatrixHTMLParser{ + Params: params, + } + mhp.parser = &format.HTMLParser{ + TabsToSpaces: 4, + Newline: "\n", + + PillConverter: mhp.pillConverter, + BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) }, + ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) }, + StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) }, + MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("`%s`", text) }, + MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) }, + } + return mhp +} + +func (mhp *MatrixHTMLParser) Parse(ctx context.Context, htmlData string) string { + return mhp.parser.Parse(htmlData, format.NewContext(ctx)) +} diff --git a/msgconv/mrkdwn/parser.go b/msgconv/mrkdwn/parser.go new file mode 100644 index 0000000..4244a07 --- /dev/null +++ b/msgconv/mrkdwn/parser.go @@ -0,0 +1,64 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package mrkdwn + +import ( + "context" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "maunium.net/go/mautrix/format" + + "go.mau.fi/mautrix-slack/msgconv/emoji" +) + +type SlackMrkdwnParser struct { + Params *Params + Markdown goldmark.Markdown +} + +func New(options *Params) *SlackMrkdwnParser { + return &SlackMrkdwnParser{ + Markdown: goldmark.New( + format.Extensions, format.HTMLOptions, + goldmark.WithExtensions(&slackTag{Params: options}), + ), + } +} + +var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) + +func (smp *SlackMrkdwnParser) Parse(ctx context.Context, input string) (string, error) { + parserCtx := parser.NewContext() + parserCtx.Set(ContextKeyContext, ctx) + + input = emoji.ReplaceShortcodesWithUnicode(input) + // TODO is this actually needed or was it just blindly copied from Discord? + input = escapeFixer.ReplaceAllStringFunc(input, func(s string) string { + return s[:2] + `\` + s[2:] + }) + + var buf strings.Builder + err := smp.Markdown.Convert([]byte(input), &buf, parser.WithContext(parserCtx)) + if err != nil { + return "", err + } + + return format.UnwrapSingleParagraph(buf.String()), nil +} diff --git a/formatter.go b/msgconv/mrkdwn/tag.go similarity index 54% rename from formatter.go rename to msgconv/mrkdwn/tag.go index 5a95ae9..455ded8 100644 --- a/formatter.go +++ b/msgconv/mrkdwn/tag.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -14,117 +14,27 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -package main +package mrkdwn import ( + "context" "fmt" + "html" + "io" "regexp" "strconv" "strings" "time" - "github.com/slack-go/slack" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/text" goldmarkUtil "github.com/yuin/goldmark/util" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util" - - "go.mau.fi/mautrix-slack/database" ) -var escapeFixer = regexp.MustCompile(`\\(__[^_]|\*\*[^*])`) - -const mentionedUsersContextKey = "fi.mau.slack.mentioned_users" - -func (portal *Portal) renderSlackMarkdown(text string) *event.MessageEventContent { - text = replaceShortcodesWithEmojis(text) - - text = escapeFixer.ReplaceAllStringFunc(text, func(s string) string { - return s[:2] + `\` + s[2:] - }) - - mdRenderer := goldmark.New( - format.Extensions, format.HTMLOptions, - goldmark.WithExtensions(&SlackTag{portal}), - ) - - content := format.RenderMarkdownCustom(text, mdRenderer) - return &content -} - -func (portal *Portal) renderSlackFile(file slack.File) event.MessageEventContent { - content := event.MessageEventContent{ - Info: &event.FileInfo{ - MimeType: file.Mimetype, - Size: int(file.Size), - }, - } - if file.OriginalW != 0 { - content.Info.Width = file.OriginalW - } - if file.OriginalH != 0 { - content.Info.Height = file.OriginalH - } - if file.Name != "" { - content.Body = file.Name - } else { - mimeClass := strings.Split(file.Mimetype, "/")[0] - switch mimeClass { - case "application": - content.Body = "file" - default: - content.Body = mimeClass - } - - content.Body += util.ExtensionFromMimetype(file.Mimetype) - } - - if strings.HasPrefix(file.Mimetype, "image") { - content.MsgType = event.MsgImage - } else if strings.HasPrefix(file.Mimetype, "video") { - content.MsgType = event.MsgVideo - } else if strings.HasPrefix(file.Mimetype, "audio") { - content.MsgType = event.MsgAudio - } else { - content.MsgType = event.MsgFile - } - - return content -} - -func (bridge *SlackBridge) ParseMatrix(html string) string { - ctx := format.NewContext() - return bridge.MatrixHTMLParser.Parse(html, ctx) -} - -func NewParser(bridge *SlackBridge) *format.HTMLParser { - return &format.HTMLParser{ - TabsToSpaces: 4, - Newline: "\n", - - PillConverter: func(displayname, mxid, eventID string, _ format.Context) string { - if mxid[0] == '@' { - _, user, success := bridge.ParsePuppetMXID(id.UserID(mxid)) - if success { - return fmt.Sprintf("<@%s>", strings.ToUpper(user)) - } - } - return fmt.Sprintf("@%s", displayname) - }, - BoldConverter: func(text string, _ format.Context) string { return fmt.Sprintf("*%s*", text) }, - ItalicConverter: func(text string, _ format.Context) string { return fmt.Sprintf("_%s_", text) }, - StrikethroughConverter: func(text string, _ format.Context) string { return fmt.Sprintf("~%s~", text) }, - MonospaceConverter: func(text string, _ format.Context) string { return fmt.Sprintf("`%s`", text) }, - MonospaceBlockConverter: func(text, language string, _ format.Context) string { return fmt.Sprintf("```%s```", text) }, - } -} - type astSlackTag struct { ast.BaseInline @@ -146,6 +56,8 @@ type astSlackUserMention struct { astSlackTag userID string + mxid id.UserID + name string } func (n *astSlackUserMention) String() string { @@ -159,7 +71,11 @@ func (n *astSlackUserMention) String() string { type astSlackChannelMention struct { astSlackTag - channelID string + serverName string + channelID string + mxid id.RoomID + alias id.RoomAlias + name string } func (n *astSlackChannelMention) String() string { @@ -190,8 +106,6 @@ type astSlackSpecialMention struct { content string } -var slackSpecialMentionRegex = regexp.MustCompile(`<(#|@|!|)([^|>]+)(\|([^|>]*))?>`) - func (n *astSlackSpecialMention) String() string { if n.label != "" { return fmt.Sprintf("", n.content, n.label) @@ -200,16 +114,25 @@ func (n *astSlackSpecialMention) String() string { } } -type slackTagParser struct{} +type Params struct { + ServerName string + GetUserInfo func(ctx context.Context, userID string) (mxid id.UserID, name string) + GetChannelInfo func(ctx context.Context, channelID string) (mxid id.RoomID, alias id.RoomAlias, name string) +} + +type slackTagParser struct { + *Params +} // Regex matching Slack docs at https://api.slack.com/reference/surfaces/formatting#retrieving-messages var slackTagRegex = regexp.MustCompile(`<(#|@|!|)([^|>]+)(\|([^|>]*))?>`) -var defaultSlackTagParser = &slackTagParser{} func (s *slackTagParser) Trigger() []byte { return []byte{'<'} } +var ContextKeyContext = parser.NewContextKey() + func (s *slackTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { //before := block.PrecendingCharacter() line, _ := block.PeekLine() @@ -224,12 +147,16 @@ func (s *slackTagParser) Parse(parent ast.Node, block text.Reader, pc parser.Con content := string(match[2]) text := string(match[4]) + ctx := pc.Get(ContextKeyContext).(context.Context) + tag := astSlackTag{label: text} switch sigil { case "@": - return &astSlackUserMention{astSlackTag: tag, userID: content} + mxid, name := s.GetUserInfo(ctx, content) + return &astSlackUserMention{astSlackTag: tag, userID: content, mxid: mxid, name: name} case "#": - return &astSlackChannelMention{astSlackTag: tag, channelID: content} + mxid, alias, name := s.GetChannelInfo(ctx, content) + return &astSlackChannelMention{astSlackTag: tag, channelID: content, serverName: s.ServerName, mxid: mxid, alias: alias, name: name} case "!": return &astSlackSpecialMention{astSlackTag: tag, content: content} case "": @@ -243,14 +170,34 @@ func (s *slackTagParser) CloseBlock(parent ast.Node, pc parser.Context) { // nothing to do } -type slackTagHTMLRenderer struct { - portal *Portal -} +type slackTagHTMLRenderer struct{} + +var defaultSlackTagHTMLRenderer = &slackTagHTMLRenderer{} func (r *slackTagHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(astKindSlackTag, r.renderSlackTag) } +func UserMentionToHTML(out io.Writer, userID string, mxid id.UserID, name string) { + if mxid != "" { + _, _ = fmt.Fprintf(out, `%s`, mxid.URI().MatrixToURL(), html.EscapeString(name)) + } else { + _, _ = fmt.Fprintf(out, "<@%s>", userID) + } +} + +func RoomMentionToHTML(out io.Writer, channelID string, mxid id.RoomID, alias id.RoomAlias, name, serverName string) { + if alias != "" { + _, _ = fmt.Fprintf(out, `%s`, alias.URI().MatrixToURL(), html.EscapeString(name)) + } else if mxid != "" { + _, _ = fmt.Fprintf(out, `%s`, mxid.URI(serverName).MatrixToURL(), html.EscapeString(name)) + } else if name != "" { + _, _ = fmt.Fprintf(out, "%s", name) + } else { + _, _ = fmt.Fprintf(out, "<#%s>", channelID) + } +} + func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source []byte, n ast.Node, entering bool) (status ast.WalkStatus, err error) { status = ast.WalkContinue if !entering { @@ -258,31 +205,10 @@ func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source [ } switch node := n.(type) { case *astSlackUserMention: - puppet := r.portal.bridge.GetPuppetByID(r.portal.Key.TeamID, node.userID) - if puppet != nil && puppet.GetCustomOrGhostMXID() != "" { - _, _ = fmt.Fprintf(w, `%s`, puppet.GetCustomOrGhostMXID(), puppet.Name) - } else { // TODO: get puppet info if not exist - if node.label != "" { - _, _ = fmt.Fprintf(w, `@%s`, node.label) - } else { - _, _ = fmt.Fprintf(w, `@%s`, node.userID) - } - } + UserMentionToHTML(w, node.userID, node.mxid, node.name) return case *astSlackChannelMention: - portal := r.portal.bridge.DB.Portal.GetByID(database.PortalKey{ - TeamID: r.portal.Key.TeamID, - ChannelID: node.channelID, - }) - if portal != nil && portal.MXID != "" { - _, _ = fmt.Fprintf(w, `%s`, portal.MXID, r.portal.bridge.AS.HomeserverDomain, portal.Name) - } else { // TODO: get portal info if not exist - if node.label != "" { - _, _ = fmt.Fprintf(w, `#%s`, node.label) - } else { - _, _ = fmt.Fprintf(w, `#%s`, node.channelID) - } - } + RoomMentionToHTML(w, node.channelID, node.mxid, node.alias, node.name, node.serverName) return case *astSlackSpecialMention: parts := strings.Split(node.content, "^") @@ -311,9 +237,9 @@ func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source [ } if len(parts) > 3 { - _, _ = fmt.Fprintf(w, `%s`, parts[3], parts[2]) + _, _ = fmt.Fprintf(w, `%s`, html.EscapeString(parts[3]), html.EscapeString(parts[2])) } else { - _, _ = w.WriteString(parts[2]) + _, _ = w.WriteString(html.EscapeString(parts[2])) } return case "channel", "everyone", "here": @@ -330,7 +256,7 @@ func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source [ if label == "" { label = node.url } - _, _ = fmt.Fprintf(w, `%s`, node.url, label) + _, _ = fmt.Fprintf(w, `%s`, html.EscapeString(node.url), html.EscapeString(label)) return } stringifiable, ok := n.(fmt.Stringer) @@ -342,15 +268,15 @@ func (r *slackTagHTMLRenderer) renderSlackTag(w goldmarkUtil.BufWriter, source [ return } -type SlackTag struct { - Portal *Portal +type slackTag struct { + *Params } -func (e *SlackTag) Extend(m goldmark.Markdown) { +func (e *slackTag) Extend(m goldmark.Markdown) { m.Parser().AddOptions(parser.WithInlineParsers( - goldmarkUtil.Prioritized(defaultSlackTagParser, 150), + goldmarkUtil.Prioritized(&slackTagParser{Params: e.Params}, 150), )) m.Renderer().AddOptions(renderer.WithNodeRenderers( - goldmarkUtil.Prioritized(&slackTagHTMLRenderer{e.Portal}, 150), + goldmarkUtil.Prioritized(defaultSlackTagHTMLRenderer, 150), )) } diff --git a/msgconv/msgconv.go b/msgconv/msgconv.go new file mode 100644 index 0000000..c20033d --- /dev/null +++ b/msgconv/msgconv.go @@ -0,0 +1,73 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package msgconv + +import ( + "context" + + "maunium.net/go/mautrix/id" + + "github.com/slack-go/slack" + "go.mau.fi/mautrix-slack/database" + "go.mau.fi/mautrix-slack/msgconv/matrixfmt" + "go.mau.fi/mautrix-slack/msgconv/mrkdwn" +) + +type PortalMethods interface { + UploadMatrixMedia(ctx context.Context, data []byte, fileName, contentType string) (id.ContentURIString, error) + DownloadMatrixMedia(ctx context.Context, uri id.ContentURIString) ([]byte, error) + + GetMentionedChannelID(ctx context.Context, roomID id.RoomID) string + GetMentionedUserID(ctx context.Context, userID id.UserID) string + GetMentionedRoomInfo(ctx context.Context, channelID string) (mxid id.RoomID, alias id.RoomAlias, name string) + GetMentionedUserInfo(ctx context.Context, userID string) (mxid id.UserID, name string) + + GetMessageInfo(ctx context.Context, eventID id.EventID) (*database.Message, error) + + GetEmoji(ctx context.Context, emojiID string) string + + GetClient(ctx context.Context) *slack.Client + GetData(ctx context.Context) *database.Portal +} + +type MessageConverter struct { + PortalMethods + + MatrixHTMLParser *matrixfmt.MatrixHTMLParser + SlackMrkdwnParser *mrkdwn.SlackMrkdwnParser + + ServerName string + MaxFileSize int +} + +func New(pm PortalMethods, serverName string, maxFileSize int) *MessageConverter { + return &MessageConverter{ + PortalMethods: pm, + MaxFileSize: maxFileSize, + ServerName: serverName, + + MatrixHTMLParser: matrixfmt.New(&matrixfmt.Params{ + GetUserID: pm.GetMentionedUserID, + GetChannelID: pm.GetMentionedChannelID, + }), + SlackMrkdwnParser: mrkdwn.New(&mrkdwn.Params{ + ServerName: serverName, + GetUserInfo: pm.GetMentionedUserInfo, + GetChannelInfo: pm.GetMentionedRoomInfo, + }), + } +} diff --git a/portal.go b/portal.go index 1e363a3..db335f9 100644 --- a/portal.go +++ b/portal.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,20 +17,24 @@ package main import ( - "bytes" "context" "errors" "fmt" - "net/http" + "reflect" "strconv" "strings" "sync" "time" + "github.com/rs/zerolog" + "go.mau.fi/util/exslices" "golang.org/x/exp/slices" log "maunium.net/go/maulogger/v2" + "maunium.net/go/maulogger/v2/maulogadapt" "github.com/slack-go/slack" + "go.mau.fi/mautrix-slack/msgconv" + "go.mau.fi/mautrix-slack/msgconv/emoji" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" @@ -38,7 +42,6 @@ import ( "maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "maunium.net/go/mautrix/util/dbutil" "go.mau.fi/mautrix-slack/config" "go.mau.fi/mautrix-slack/database" @@ -50,11 +53,20 @@ type portalMatrixMessage struct { receivedAt time.Time } +type portalSlackMessage struct { + evt any + userTeam *UserTeam +} + type Portal struct { *database.Portal + Team *Team + bridge *SlackBridge - log log.Logger + zlog zerolog.Logger + // Deprecated + log log.Logger roomCreateLock sync.Mutex encryptLock sync.Mutex @@ -62,11 +74,12 @@ type Portal struct { latestEventBackfillLock sync.Mutex matrixMessages chan portalMatrixMessage - - slackMessageLock sync.Mutex + slackMessages chan portalSlackMessage currentlyTyping []id.UserID currentlyTypingLock sync.Mutex + + MsgConv *msgconv.MessageConverter } var ( @@ -84,13 +97,18 @@ func (portal *Portal) IsEncrypted() bool { func (portal *Portal) MarkEncrypted() { portal.Encrypted = true - portal.Update(nil) + portal.Update(context.TODO()) } func (portal *Portal) shouldSetDMRoomMetadata() bool { - return !portal.IsPrivateChat() || - portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || - (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") + if portal.Type == database.ChannelTypeDM { + return portal.bridge.Config.Bridge.PrivateChatPortalMeta == "always" || + (portal.IsEncrypted() && portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never") + } else if portal.Type == database.ChannelTypeGroupDM { + return portal.bridge.Config.Bridge.PrivateChatPortalMeta != "never" + } else { + return true + } } func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { @@ -99,56 +117,54 @@ func (portal *Portal) ReceiveMatrixEvent(user bridge.User, evt *event.Event) { } } -func (portal *Portal) HandleMatrixReadReceipt(sender bridge.User, eventID id.EventID, receipt event.ReadReceipt) { - //portal.handleMatrixReadReceipt(sender.(*User), eventID, receiptTimestamp, true) - userTeam := sender.(*User).GetUserTeam(portal.Key.TeamID) - - portal.markSlackRead(sender.(*User), userTeam, eventID) -} +func (br *SlackBridge) loadPortal(ctx context.Context, dbPortal *database.Portal, key *database.PortalKey) *Portal { + if dbPortal == nil { + if key == nil { + return nil + } -func (portal *Portal) markSlackRead(user *User, userTeam *database.UserTeam, eventID id.EventID) { - if !userTeam.IsConnected() { - portal.log.Debugfln("Not marking Slack conversation %s as read by %s: not connected to Slack", portal.Key, user.MXID) - return + dbPortal = br.DB.Portal.New() + dbPortal.PortalKey = *key + err := dbPortal.Insert(ctx) + if err != nil { + br.ZLog.Err(err).Stringer("portal_key", dbPortal.PortalKey).Msg("Failed to insert new portal") + return nil + } } - message := portal.bridge.DB.Message.GetByMatrixID(portal.Key, eventID) - if message == nil { - portal.log.Debugfln("Not marking Slack channel for portal %s as read: unknown message", portal.Key) - return + portal := br.newPortal(dbPortal) + br.portalsByID[portal.PortalKey] = portal + if portal.MXID != "" { + br.portalsByMXID[portal.MXID] = portal } - userTeam.Client.MarkConversation(portal.Key.ChannelID, message.SlackID) - portal.log.Debugfln("Marked message %s as read by %s in portal %s", message.SlackID, user.MXID, portal.Key) + return portal } -var ( - portalCreationDummyEvent = event.Type{Type: "fi.mau.dummy.portal_created", Class: event.MessageEventType} -) +func (br *SlackBridge) newPortal(dbPortal *database.Portal) *Portal { + portal := &Portal{ + Portal: dbPortal, + bridge: br, -func (br *SlackBridge) loadPortal(dbPortal *database.Portal, key *database.PortalKey) *Portal { - // If we weren't given a portal we'll attempt to create it if a key was - // provided. - if dbPortal == nil { - if key == nil { - return nil - } + matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), - dbPortal = br.DB.Portal.New() - dbPortal.Key = *key - dbPortal.Insert() + Team: br.GetTeamByID(dbPortal.TeamID), } + portal.MsgConv = msgconv.New(portal, br.Config.Homeserver.Domain, int(br.MediaConfig.UploadSize)) + portal.updateLogger() + go portal.messageLoop() + go portal.slackRepeatTypingUpdater() - portal := br.NewPortal(dbPortal) + return portal +} - // No need to lock, it is assumed that our callers have already acquired - // the lock. - br.portalsByID[portal.Key] = portal +func (portal *Portal) updateLogger() { + logWith := portal.bridge.ZLog.With().Str("channel_id", portal.ChannelID).Str("team_id", portal.TeamID) if portal.MXID != "" { - br.portalsByMXID[portal.MXID] = portal + logWith = logWith.Stringer("mxid", portal.MXID) } - - return portal + portal.zlog = logWith.Logger() + portal.log = maulogadapt.ZeroAsMau(&portal.zlog) } func (br *SlackBridge) GetPortalByMXID(mxid id.RoomID) *Portal { @@ -157,7 +173,13 @@ func (br *SlackBridge) GetPortalByMXID(mxid id.RoomID) *Portal { portal, ok := br.portalsByMXID[mxid] if !ok { - return br.loadPortal(br.DB.Portal.GetByMXID(mxid), nil) + ctx := context.TODO() + dbPortal, err := br.DB.Portal.GetByMXID(ctx, mxid) + if err != nil { + br.ZLog.Err(err).Stringer("mxid", mxid).Msg("Failed to get portal by MXID") + return nil + } + return br.loadPortal(ctx, dbPortal, nil) } return portal @@ -169,14 +191,20 @@ func (br *SlackBridge) GetPortalByID(key database.PortalKey) *Portal { portal, ok := br.portalsByID[key] if !ok { - return br.loadPortal(br.DB.Portal.GetByID(key), &key) + ctx := context.TODO() + dbPortal, err := br.DB.Portal.GetByID(ctx, key) + if err != nil { + br.ZLog.Err(err).Stringer("key", key).Msg("Failed to get portal by ID") + return nil + } + return br.loadPortal(ctx, dbPortal, &key) } return portal } func (br *SlackBridge) GetAllPortals() []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAll()) + return br.dbPortalsToPortals(br.DB.Portal.GetAll(context.TODO())) } func (br *SlackBridge) GetAllIPortals() (iportals []bridge.Portal) { @@ -188,55 +216,42 @@ func (br *SlackBridge) GetAllIPortals() (iportals []bridge.Portal) { return iportals } -func (br *SlackBridge) GetAllPortalsForUserTeam(utk database.UserTeamKey) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.GetAllForUserTeam(utk)) +func (br *SlackBridge) GetAllPortalsForUserTeam(utk database.UserTeamMXIDKey) []*Portal { + return br.dbPortalsToPortals(br.DB.Portal.GetAllForUserTeam(context.TODO(), utk)) } -func (br *SlackBridge) GetDMPortalsWith(otherUserID string) []*Portal { - return br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(otherUserID)) +func (br *SlackBridge) GetDMPortalsWith(otherUserKey database.UserTeamKey) []*Portal { + return br.dbPortalsToPortals(br.DB.Portal.FindPrivateChatsWith(context.TODO(), otherUserKey)) } -func (br *SlackBridge) dbPortalsToPortals(dbPortals []*database.Portal) []*Portal { +func (br *SlackBridge) dbPortalsToPortals(dbPortals []*database.Portal, err error) []*Portal { + if err != nil { + br.ZLog.Err(err).Msg("Failed to load portals") + return nil + } br.portalsLock.Lock() defer br.portalsLock.Unlock() output := make([]*Portal, len(dbPortals)) - for index, dbPortal := range dbPortals { - if dbPortal == nil { - continue - } - - portal, ok := br.portalsByID[dbPortal.Key] - if !ok { - portal = br.loadPortal(dbPortal, nil) + for i, dbPortal := range dbPortals { + portal, ok := br.portalsByID[dbPortal.PortalKey] + if ok { + output[i] = portal + } else { + output[i] = br.loadPortal(context.TODO(), dbPortal, nil) } - - output[index] = portal } return output } -func (br *SlackBridge) NewPortal(dbPortal *database.Portal) *Portal { - portal := &Portal{ - Portal: dbPortal, - bridge: br, - log: br.Log.Sub(fmt.Sprintf("Portal/%s", dbPortal.Key)), - - matrixMessages: make(chan portalMatrixMessage, br.Config.Bridge.PortalMessageBuffer), - } - - go portal.messageLoop() - go portal.slackRepeatTypingUpdater() - - return portal -} - func (portal *Portal) messageLoop() { for { select { case msg := <-portal.matrixMessages: portal.handleMatrixMessages(msg) + case msg := <-portal.slackMessages: + portal.handleSlackEvent(msg.userTeam, msg.evt) } } } @@ -245,47 +260,19 @@ func (portal *Portal) IsPrivateChat() bool { return portal.Type == database.ChannelTypeDM } -func (portal *Portal) MainIntent() *appservice.IntentAPI { +func (portal *Portal) GetDMPuppet() *Puppet { if portal.IsPrivateChat() && portal.DMUserID != "" { - puppet := portal.bridge.GetPuppetByID(portal.Key.TeamID, portal.DMUserID) - if puppet.CustomMXID == "" { - return puppet.IntentFor(portal) - } + return portal.Team.GetPuppetByID(portal.DMUserID) } - - return portal.bridge.Bot + return nil } -func (portal *Portal) syncParticipants(source *User, sourceTeam *database.UserTeam, participants []string, invite bool) []id.UserID { - userIDs := make([]id.UserID, 0, len(participants)+1) - for _, participant := range participants { - portal.log.Infofln("Getting participant %s", participant) - puppet := portal.bridge.GetPuppetByID(sourceTeam.Key.TeamID, participant) - - puppet.UpdateInfo(sourceTeam, true, nil) - - user := portal.bridge.GetUserByID(sourceTeam.Key.TeamID, participant) - - if user != nil { - userIDs = append(userIDs, user.MXID) - } - if user == nil || puppet.CustomMXID != user.MXID { - userIDs = append(userIDs, puppet.MXID) - } - - if invite { - if user != nil { - portal.ensureUserInvited(user) - } - - if user == nil || !puppet.IntentFor(portal).IsCustomPuppet { - if err := puppet.IntentFor(portal).EnsureJoined(portal.MXID); err != nil { - portal.log.Warnfln("Failed to make puppet of %s join %s: %v", participant, portal.MXID, err) - } - } - } +func (portal *Portal) MainIntent() *appservice.IntentAPI { + dmPuppet := portal.GetDMPuppet() + if dmPuppet != nil { + return dmPuppet.IntentFor(portal) } - return userIDs + return portal.bridge.Bot } func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventContent) { @@ -297,36 +284,33 @@ func (portal *Portal) GetEncryptionEventContent() (evt *event.EncryptionEventCon return } -func (portal *Portal) CreateMatrixRoom(user *User, userTeam *database.UserTeam, channel *slack.Channel, fill bool) error { +func (portal *Portal) CreateMatrixRoom(ctx context.Context, source *UserTeam, channel *slack.Channel) error { portal.roomCreateLock.Lock() defer portal.roomCreateLock.Unlock() - - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() - - // If we have a matrix id the room should exist so we have nothing to do. if portal.MXID != "" { return nil } - channel = portal.UpdateInfo(user, userTeam, channel, false) + var invite []id.UserID + channel, invite = portal.UpdateInfo(ctx, source, channel, true) if channel == nil { return fmt.Errorf("didn't find channel metadata") + } else if portal.Type == database.ChannelTypeUnknown { + return fmt.Errorf("unknown channel type") + } else if portal.Type == database.ChannelTypeGroupDM && len(channel.Members) == 0 { + return fmt.Errorf("group DM has no members") + } else if portal.Type == database.ChannelTypeDM && portal.DMUserID == "" { + return fmt.Errorf("other user in DM not known") } - typeFound := portal.setChannelType(channel) - if !typeFound { - portal.log.Warnln("No appropriate type found for channel") - return nil - } - if portal.Type == database.ChannelTypeGroupDM && len(channel.Members) == 0 { - portal.log.Warnln("Group DM with no members, not bridging") - return nil + // Clear invite list for private chats, we don't want to invite the room creator + if portal.IsPrivateChat() { + invite = []id.UserID{} } - portal.log.Infoln("Creating Matrix room for channel:", portal.Portal.Key.ChannelID) + portal.zlog.Info().Msg("Creating Matrix room for channel") intent := portal.MainIntent() - if err := intent.EnsureRegistered(); err != nil { + if err := intent.EnsureRegistered(ctx); err != nil { return err } @@ -340,12 +324,27 @@ func (portal *Portal) CreateMatrixRoom(user *User, userTeam *database.UserTeam, Type: event.StateHalfShotBridge, Content: event.Content{Parsed: bridgeInfo}, StateKey: &bridgeInfoStateKey, + }, { + Type: event.StateSpaceParent, + Content: event.Content{Parsed: &event.SpaceParentEventContent{ + Via: []string{portal.bridge.Config.Homeserver.Domain}, + Canonical: true, + }}, + StateKey: (*string)(&portal.Team.MXID), }} - creationContent := make(map[string]interface{}) - creationContent["m.federate"] = portal.bridge.Config.Bridge.FederateRooms - - var invite []id.UserID + creationContent := make(map[string]any) + if !portal.bridge.Config.Bridge.FederateRooms { + creationContent["m.federate"] = false + } + avatarSet := false + if !portal.AvatarMXC.IsEmpty() && portal.shouldSetDMRoomMetadata() { + avatarSet = true + initialState = append(initialState, &event.Event{ + Type: event.StateRoomAvatar, + Content: event.Content{Parsed: &event.RoomAvatarEventContent{URL: portal.AvatarMXC}}, + }) + } if portal.bridge.Config.Bridge.Encryption.Default { initialState = append(initialState, &event.Event{ @@ -358,28 +357,14 @@ func (portal *Portal) CreateMatrixRoom(user *User, userTeam *database.UserTeam, if portal.IsPrivateChat() { invite = append(invite, portal.bridge.Bot.UserID) - portal.log.Infoln("added the bot because this portal is encrypted") } } - var members []string - // no members are included in channels, only in group DMs - switch portal.Type { - case database.ChannelTypeChannel: - members = portal.getChannelMembers(userTeam, 3) // TODO: this just fetches 3 members so channels don't have to look like DMs - case database.ChannelTypeDM: - members = []string{channel.User, userTeam.Key.SlackID} - case database.ChannelTypeGroupDM: - members = channel.Members - } - - autoJoinInvites := portal.bridge.Config.Homeserver.Software == bridgeconfig.SoftwareHungry + autoJoinInvites := portal.bridge.SpecVersions.Supports(mautrix.BeeperFeatureAutojoinInvites) if autoJoinInvites { - portal.log.Debugfln("Hungryserv mode: adding all group members in create request") - participants := portal.syncParticipants(user, userTeam, members, false) - invite = append(invite, participants...) - if !slices.Contains(invite, user.MXID) { - invite = append(invite, user.MXID) + portal.zlog.Debug().Msg("Hungryserv mode: adding all group members in create request") + if !slices.Contains(invite, source.UserMXID) { + invite = append(invite, source.UserMXID) } } req := &mautrix.ReqCreateRoom{ @@ -396,28 +381,31 @@ func (portal *Portal) CreateMatrixRoom(user *User, userTeam *database.UserTeam, if !portal.shouldSetDMRoomMetadata() { req.Name = "" } - resp, err := intent.CreateRoom(req) + resp, err := intent.CreateRoom(ctx, req) if err != nil { portal.log.Warnln("Failed to create room:", err) return err } portal.NameSet = req.Name != "" + portal.AvatarSet = avatarSet portal.TopicSet = true portal.MXID = resp.RoomID portal.bridge.portalsLock.Lock() portal.bridge.portalsByMXID[portal.MXID] = portal portal.bridge.portalsLock.Unlock() - portal.Update(nil) - - portal.InsertUser(userTeam.Key) + portal.updateLogger() + portal.zlog.Info().Msg("Matrix room created") - portal.log.Infoln("Matrix room created:", portal.MXID) + err = portal.Update(ctx) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after creating Matrix room") + } if portal.Encrypted && portal.IsPrivateChat() { - err = portal.bridge.Bot.EnsureJoined(portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client}) + err = portal.bridge.Bot.EnsureJoined(ctx, portal.MXID, appservice.EnsureJoinedParams{BotOverride: portal.MainIntent().Client}) if err != nil { - portal.log.Errorfln("Failed to ensure bridge bot is joined to private chat portal: %v", err) + portal.zlog.Err(err).Msg("Failed to ensure bridge bot is joined to private chat portal") } } @@ -427,46 +415,39 @@ func (portal *Portal) CreateMatrixRoom(user *User, userTeam *database.UserTeam, inviteMembership = event.MembershipJoin } for _, userID := range invite { - portal.bridge.StateStore.SetMembership(portal.MXID, userID, inviteMembership) + portal.bridge.StateStore.SetMembership(ctx, portal.MXID, userID, inviteMembership) } if !autoJoinInvites { - portal.ensureUserInvited(user) - user.syncChatDoublePuppetDetails(portal, true) - portal.syncParticipants(user, userTeam, members, true) + portal.ensureUserInvited(ctx, source.User) + for _, mxid := range invite { + puppet := portal.bridge.GetPuppetByMXID(mxid) + if puppet != nil { + err = puppet.IntentFor(portal).EnsureJoined(ctx, portal.MXID) + if err != nil { + portal.zlog.Err(err).Stringer("puppet_mxid", mxid).Msg("Failed to ensure puppet is joined to portal") + } + } + } } if portal.Type == database.ChannelTypeChannel { - user.updateChatMute(portal, true) - } - - // if portal.IsPrivateChat() { - // puppet := user.bridge.GetPuppetByID(userTeam.Key.TeamID, portal.Key.UserID) - - // chats := map[id.UserID][]id.RoomID{puppet.MXID: {portal.MXID}} - // user.updateDirectChats(chats) - // } - - firstEventResp, err := portal.MainIntent().SendMessageEvent(portal.MXID, portalCreationDummyEvent, struct{}{}) - if err != nil { - portal.log.Errorln("Failed to send dummy event to mark portal creation:", err) - } else { - portal.FirstEventID = firstEventResp.EventID - portal.Update(nil) + source.User.updateChatMute(ctx, portal, true) } - if portal.bridge.Config.Bridge.Backfill.Enable { + /*if portal.bridge.Config.Bridge.Backfill.Enable { portal.log.Debugln("Performing initial backfill batch") - initialMessages, err := userTeam.Client.GetConversationHistory(&slack.GetConversationHistoryParameters{ - ChannelID: portal.Key.ChannelID, + initialMessages, err := source.Client.GetConversationHistory(&slack.GetConversationHistoryParameters{ + ChannelID: portal.ChannelID, Inclusive: true, Limit: portal.bridge.Config.Bridge.Backfill.ImmediateMessages, }) - backfillState := portal.bridge.DB.Backfill.NewBackfillState(&portal.Key) + backfillState := portal.bridge.DB.Backfill.New() + backfillState.Portal = portal.PortalKey if err != nil { portal.log.Errorfln("Error fetching initial backfill messages: %v", err) backfillState.BackfillComplete = true } else { - resp, err := portal.backfill(userTeam, initialMessages.Messages, true) + resp, err := portal.backfill(source, initialMessages.Messages, true) if err != nil { portal.log.Errorfln("Error sending initial backfill batch: %v", err) } @@ -478,62 +459,51 @@ func (portal *Portal) CreateMatrixRoom(user *User, userTeam *database.UserTeam, } } portal.log.Debugln("Enqueueing backfill") - backfillState.Upsert() + backfillState.Upsert(ctx) portal.bridge.BackfillQueue.ReCheck() - } + }*/ return nil } -func (portal *Portal) getChannelMembers(userTeam *database.UserTeam, limit int) []string { - members, _, err := userTeam.Client.GetUsersInConversation(&slack.GetUsersInConversationParameters{ - ChannelID: portal.Key.ChannelID, - Limit: limit, - }) - if err != nil { - portal.log.Errorfln("Error fetching channel members for %v: %v", portal.Key, err) - return nil +func (portal *Portal) getChannelMembers(source *UserTeam, limit int) (output []string) { + var cursor string + for limit > 0 { + chunkLimit := limit + if chunkLimit > 200 { + chunkLimit = 100 + } + membersChunk, nextCursor, err := source.Client.GetUsersInConversation(&slack.GetUsersInConversationParameters{ + ChannelID: portal.ChannelID, + Limit: limit, + Cursor: cursor, + }) + if err != nil { + portal.zlog.Err(err).Msg("Failed to get channel members") + break + } + output = append(output, membersChunk...) + cursor = nextCursor + limit -= len(membersChunk) + if nextCursor == "" || len(membersChunk) < chunkLimit { + break + } } - return members - -} - -func (portal *Portal) ensureUserInvited(user *User) bool { - return user.ensureInvited(portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) + return } -func (portal *Portal) markMessageHandled(txn dbutil.Transaction, slackID string, slackThreadID string, mxid id.EventID, authorID string) *database.Message { - msg := portal.bridge.DB.Message.New() - msg.Channel = portal.Key - msg.SlackID = slackID - msg.MatrixID = mxid - msg.AuthorID = authorID - msg.SlackThreadID = slackThreadID - msg.Insert(txn) - - return msg +func (portal *Portal) ensureUserInvited(ctx context.Context, user *User) bool { + return user.ensureInvited(ctx, portal.MainIntent(), portal.MXID, portal.IsPrivateChat()) } -// func (portal *Portal) sendMediaFailedMessage(intent *appservice.IntentAPI, bridgeErr error) { -// content := &event.MessageEventContent{ -// Body: fmt.Sprintf("Failed to bridge media: %v", bridgeErr), -// MsgType: event.MsgNotice, -// } - -// _, err := portal.sendMatrixMessage(intent, event.EventMessage, content, nil, time.Now().UTC().UnixMilli()) -// if err != nil { -// portal.log.Warnfln("failed to send error message to matrix: %v", err) -// } -// } - -func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { +func (portal *Portal) encrypt(ctx context.Context, intent *appservice.IntentAPI, content *event.Content, eventType event.Type) (event.Type, error) { if !portal.Encrypted || portal.bridge.Crypto == nil { return eventType, nil } intent.AddDoublePuppetValue(content) // TODO maybe the locking should be inside mautrix-go? portal.encryptLock.Lock() - err := portal.bridge.Crypto.Encrypt(portal.MXID, eventType, content) + err := portal.bridge.Crypto.Encrypt(ctx, portal.MXID, eventType, content) portal.encryptLock.Unlock() if err != nil { return eventType, fmt.Errorf("failed to encrypt event: %w", err) @@ -541,44 +511,19 @@ func (portal *Portal) encrypt(intent *appservice.IntentAPI, content *event.Conte return event.EventEncrypted, nil } -const doublePuppetKey = "fi.mau.double_puppet_source" -const doublePuppetValue = "mautrix-slack" - -func (portal *Portal) sendMatrixMessage(intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { +func (portal *Portal) sendMatrixMessage(ctx context.Context, intent *appservice.IntentAPI, eventType event.Type, content *event.MessageEventContent, extraContent map[string]interface{}, timestamp int64) (*mautrix.RespSendEvent, error) { wrappedContent := event.Content{Parsed: content, Raw: extraContent} - if timestamp != 0 && intent.IsCustomPuppet { - if wrappedContent.Raw == nil { - wrappedContent.Raw = map[string]interface{}{} - } - if intent.IsCustomPuppet { - wrappedContent.Raw[doublePuppetKey] = doublePuppetValue - } - } var err error - eventType, err = portal.encrypt(intent, &wrappedContent, eventType) + eventType, err = portal.encrypt(ctx, intent, &wrappedContent, eventType) if err != nil { return nil, err } - if eventType == event.EventEncrypted { - // Clear other custom keys if the event was encrypted, but keep the double puppet identifier - if intent.IsCustomPuppet { - wrappedContent.Raw = map[string]interface{}{doublePuppetKey: doublePuppetValue} - } else { - wrappedContent.Raw = nil - } - } - - _, _ = intent.UserTyping(portal.MXID, false, 0) - if timestamp == 0 { - return intent.SendMessageEvent(portal.MXID, eventType, &wrappedContent) - } else { - return intent.SendMassagedMessageEvent(portal.MXID, eventType, &wrappedContent, timestamp) - } + _, _ = intent.UserTyping(ctx, portal.MXID, false, 0) + return intent.SendMassagedMessageEvent(ctx, portal.MXID, eventType, &wrappedContent, timestamp) } func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { - evtTS := time.UnixMilli(msg.evt.Timestamp) timings := messageTimings{ initReceive: msg.evt.Mautrix.ReceivedAt.Sub(evtTS), @@ -587,262 +532,99 @@ func (portal *Portal) handleMatrixMessages(msg portalMatrixMessage) { totalReceive: time.Since(evtTS), } ms := metricSender{portal: portal, timings: &timings} + log := portal.zlog.With(). + Str("action", "handle matrix event"). + Stringer("sender", msg.evt.Sender). + Str("event_type", msg.evt.Type.Type). + Logger() + ctx := log.WithContext(context.TODO()) + ut := msg.user.GetTeam(portal.TeamID) + if ut == nil || ut.Token == "" || ut.Client == nil { + portal.log.Warnfln("User %s not logged into team %s", msg.user.MXID, portal.TeamID) + go ms.sendMessageMetrics(ctx, msg.evt, errUserNotLoggedIn, "Ignoring", true) + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("sender_slack_id", ut.UserID) + }) switch msg.evt.Type { case event.EventMessage: - portal.handleMatrixMessage(msg.user, msg.evt, &ms) + portal.handleMatrixMessage(ctx, ut, msg.evt, &ms) case event.EventRedaction: - portal.handleMatrixRedaction(msg.user, msg.evt) + portal.handleMatrixRedaction(ctx, ut, msg.evt) case event.EventReaction: - portal.handleMatrixReaction(msg.user, msg.evt, &ms) + portal.handleMatrixReaction(ctx, ut, msg.evt, &ms) default: portal.log.Debugln("unknown event type", msg.evt.Type) } } -func (portal *Portal) handleMatrixMessage(sender *User, evt *event.Event, ms *metricSender) { - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() - +func (portal *Portal) handleMatrixMessage(ctx context.Context, sender *UserTeam, evt *event.Event, ms *metricSender) { + log := zerolog.Ctx(ctx) + ctx = context.WithValue(ctx, convertContextKeySource, sender) start := time.Now() - - userTeam := sender.GetUserTeam(portal.Key.TeamID) - if userTeam == nil { - portal.log.Warnfln("User %s not logged into team %s", sender.MXID, portal.Key.TeamID) - go ms.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring", true) - return - } - if userTeam.Client == nil { - portal.log.Errorfln("Client for userteam %s is nil!", userTeam.Key) - return - } - - existing := portal.bridge.DB.Message.GetByMatrixID(portal.Key, evt.ID) - if existing != nil { - portal.log.Debugln("not handling duplicate message", evt.ID) - go ms.sendMessageMetrics(evt, nil, "", true) - return - } - - messageAge := ms.timings.totalReceive - errorAfter := portal.bridge.Config.Bridge.MessageHandlingTimeout.ErrorAfter - deadline := portal.bridge.Config.Bridge.MessageHandlingTimeout.Deadline - isScheduled, _ := evt.Content.Raw["com.beeper.scheduled"].(bool) - if isScheduled { - portal.log.Debugfln("%s is a scheduled message, extending handling timeouts", evt.ID) - errorAfter *= 10 - deadline *= 10 - } - - if errorAfter > 0 { - remainingTime := errorAfter - messageAge - if remainingTime < 0 { - go ms.sendMessageMetrics(evt, errTimeoutBeforeHandling, "Timeout handling", true) - return - } else if remainingTime < 1*time.Second { - portal.log.Warnfln("Message %s was delayed before reaching the bridge, only have %s (of %s timeout) until delay warning", evt.ID, remainingTime, errorAfter) - } - go func() { - time.Sleep(remainingTime) - ms.sendMessageMetrics(evt, errMessageTakingLong, "Timeout handling", false) - }() - } - - ctx := context.Background() - if deadline > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, deadline) - defer cancel() - } - ms.timings.preproc = time.Since(start) - - start = time.Now() - options, fileUpload, threadTs, err := portal.convertMatrixMessage(ctx, sender, userTeam, evt) + sendOpts, fileUpload, threadID, err := portal.MsgConv.ToSlack(ctx, evt) ms.timings.convert = time.Since(start) start = time.Now() var timestamp string - if options == nil && fileUpload == nil { - go ms.sendMessageMetrics(evt, err, "Error converting", true) + if err != nil { + go ms.sendMessageMetrics(ctx, evt, err, "Error converting", true) return - } else if options != nil { - portal.log.Debugfln("Sending message %s to Slack %s %s", evt.ID, portal.Key.TeamID, portal.Key.ChannelID) - _, timestamp, err = userTeam.Client.PostMessage( - portal.Key.ChannelID, + } else if sendOpts != nil { + log.Debug().Msg("Sending message to Slack") + _, timestamp, err = sender.Client.PostMessageContext( + ctx, + portal.ChannelID, slack.MsgOptionAsUser(true), - slack.MsgOptionCompose(options...)) + sendOpts) if err != nil { - go ms.sendMessageMetrics(evt, err, "Error sending", true) + go ms.sendMessageMetrics(ctx, evt, err, "Error sending", true) return } } else if fileUpload != nil { - portal.log.Debugfln("Uploading file from message %s to Slack %s %s", evt.ID, portal.Key.TeamID, portal.Key.ChannelID) - file, err := userTeam.Client.UploadFile(*fileUpload) + log.Debug().Msg("Uploading attachment to Slack") + file, err := sender.Client.UploadFileContext(ctx, *fileUpload) if err != nil { - portal.log.Errorfln("Failed to upload slack attachment: %v", err) - go ms.sendMessageMetrics(evt, errMediaSlackUploadFailed, "Error uploading", true) + log.Err(err).Msg("Failed to upload attachment to Slack") + go ms.sendMessageMetrics(ctx, evt, errMediaSlackUploadFailed, "Error uploading", true) return } var shareInfo slack.ShareFileInfo // Slack puts the channel message info after uploading a file in either file.shares.private or file.shares.public - if info, found := file.Shares.Private[portal.Key.ChannelID]; found && len(info) > 0 { + if info, found := file.Shares.Private[portal.ChannelID]; found && len(info) > 0 { shareInfo = info[0] - } else if info, found := file.Shares.Public[portal.Key.ChannelID]; found && len(info) > 0 { + } else if info, found = file.Shares.Public[portal.ChannelID]; found && len(info) > 0 { shareInfo = info[0] } else { - go ms.sendMessageMetrics(evt, errMediaSlackUploadFailed, "Error uploading", true) + go ms.sendMessageMetrics(ctx, evt, errMediaSlackUploadFailed, "Error uploading", true) return } timestamp = shareInfo.Ts } ms.timings.totalSend = time.Since(start) - go ms.sendMessageMetrics(evt, err, "Error sending", true) - // TODO: store these timings in some way - + go ms.sendMessageMetrics(ctx, evt, err, "Error sending", true) if timestamp != "" { dbMsg := portal.bridge.DB.Message.New() - dbMsg.Channel = portal.Key - dbMsg.SlackID = timestamp - dbMsg.MatrixID = evt.ID - dbMsg.AuthorID = userTeam.Key.SlackID - dbMsg.SlackThreadID = threadTs - dbMsg.Insert(nil) - } -} - -func (portal *Portal) convertMatrixMessage(ctx context.Context, sender *User, userTeam *database.UserTeam, evt *event.Event) (options []slack.MsgOption, fileUpload *slack.FileUploadParameters, threadTs string, err error) { - content, ok := evt.Content.Parsed.(*event.MessageEventContent) - if !ok { - return nil, nil, "", errUnexpectedParsedContentType - } - - var existingTs string - if content.RelatesTo != nil && content.RelatesTo.Type == event.RelReplace { // fetch the slack original TS for editing purposes - existing := portal.bridge.DB.Message.GetByMatrixID(portal.Key, content.RelatesTo.EventID) - if existing != nil && existing.SlackID != "" { - existingTs = existing.SlackID - content = content.NewContent - } else { - portal.log.Errorfln("Matrix message %s is an edit, but can't find the original Slack message ID", evt.ID) - return nil, nil, "", errTargetNotFound - } - } else if content.RelatesTo != nil && content.RelatesTo.Type == event.RelThread { // fetch the thread root ID via Matrix thread - rootMessage := portal.bridge.DB.Message.GetByMatrixID(portal.Key, content.RelatesTo.GetThreadParent()) - if rootMessage != nil { - threadTs = rootMessage.SlackID - } - } else if threadTs == "" && content.RelatesTo != nil && content.RelatesTo.InReplyTo != nil { // if the first method failed, try via Matrix reply - var slackMessageID string - var slackThreadID string - parentMessage := portal.bridge.DB.Message.GetByMatrixID(portal.Key, content.RelatesTo.GetReplyTo()) - if parentMessage != nil { - slackMessageID = parentMessage.SlackID - slackThreadID = parentMessage.SlackThreadID - } else { - parentAttachment := portal.bridge.DB.Attachment.GetByMatrixID(portal.Key, content.RelatesTo.GetReplyTo()) - if parentAttachment != nil { - slackMessageID = parentAttachment.SlackMessageID - slackThreadID = parentAttachment.SlackThreadID - } - } - if slackThreadID != "" { - threadTs = slackThreadID - } else { - threadTs = slackMessageID - } - } - - switch content.MsgType { - case event.MsgText, event.MsgEmote, event.MsgNotice: - if content.Format == event.FormatHTML { - options = []slack.MsgOption{slack.MsgOptionText(portal.bridge.ParseMatrix(content.FormattedBody), false)} - } else { - options = []slack.MsgOption{slack.MsgOptionText(content.Body, false)} - } - if threadTs != "" { - options = append(options, slack.MsgOptionTS(threadTs)) - } - if existingTs != "" { - options = append(options, slack.MsgOptionUpdate(existingTs)) - } - if content.MsgType == event.MsgEmote { - options = append(options, slack.MsgOptionMeMessage()) - } - return options, nil, threadTs, nil - case event.MsgAudio, event.MsgFile, event.MsgImage, event.MsgVideo: - data, err := portal.downloadMatrixAttachment(content) + dbMsg.PortalKey = portal.PortalKey + dbMsg.MessageID = timestamp + dbMsg.MXID = evt.ID + dbMsg.AuthorID = sender.UserID + dbMsg.ThreadID = threadID + err = dbMsg.Insert(ctx) if err != nil { - portal.log.Errorfln("Failed to download matrix attachment: %v", err) - return nil, nil, "", errMediaDownloadFailed - } - - var filename, caption string - if content.FileName == "" || content.FileName == content.Body { - filename = content.Body - } else { - filename = content.FileName - caption = content.Body - } - fileUpload = &slack.FileUploadParameters{ - Filename: filename, - Filetype: content.Info.MimeType, - Reader: bytes.NewReader(data), - Channels: []string{portal.Key.ChannelID}, - ThreadTimestamp: threadTs, - } - if caption != "" { - fileUpload.InitialComment = caption + log.Err(err).Msg("Failed to insert message to database") } - return nil, fileUpload, threadTs, nil - default: - return nil, nil, "", errUnknownMsgType } } -func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event, ms *metricSender) { - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() - - userTeam := sender.GetUserTeam(portal.Key.TeamID) - if userTeam == nil { - go ms.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring", true) - return - } - +func (portal *Portal) handleMatrixReaction(ctx context.Context, sender *UserTeam, evt *event.Event, ms *metricSender) { + log := zerolog.Ctx(ctx) reaction := evt.Content.AsReaction() if reaction.RelatesTo.Type != event.RelAnnotation { - portal.log.Errorfln("Ignoring reaction %s due to unknown m.relates_to data", evt.ID) - ms.sendMessageMetrics(evt, errUnexpectedRelatesTo, "Error sending", true) - return - } - - var slackID string - - msg := portal.bridge.DB.Message.GetByMatrixID(portal.Key, reaction.RelatesTo.EventID) - - // Due to the differences in attachments between Slack and Matrix, if a - // user reacts to a media message on discord our lookup above will fail - // because the relation of matrix media messages to attachments in handled - // in the attachments table instead of messages so we need to check that - // before continuing. - // - // This also leads to interesting problems when a Slack message comes in - // with multiple attachments. A user can react to each one individually on - // Matrix, which will cause us to send it twice. Slack tends to ignore - // this, but if the user removes one of them, discord removes it and now - // they're out of sync. Perhaps we should add a counter to the reactions - // table to keep them in sync and to avoid sending duplicates to Slack. - if msg == nil { - attachment := portal.bridge.DB.Attachment.GetByMatrixID(portal.Key, reaction.RelatesTo.EventID) - if attachment != nil { - slackID = attachment.SlackMessageID - } - } else { - slackID = msg.SlackID - } - if slackID == "" { - portal.log.Debugf("Message %s has not yet been sent to slack", reaction.RelatesTo.EventID) - ms.sendMessageMetrics(evt, errReactionTargetNotFound, "Error sending", true) + log.Warn().Msg("Ignoring reaction due to unknown m.relates_to data") + ms.sendMessageMetrics(ctx, evt, errUnexpectedRelatesTo, "Error sending", true) return } @@ -850,110 +632,145 @@ func (portal *Portal) handleMatrixReaction(sender *User, evt *event.Event, ms *m if strings.HasPrefix(reaction.RelatesTo.Key, "mxc://") { uri, err := id.ParseContentURI(reaction.RelatesTo.Key) if err == nil { - customEmoji := portal.bridge.DB.Emoji.GetByMXC(uri) - if customEmoji != nil { - emojiID = customEmoji.SlackID + customEmoji, err := portal.bridge.DB.Emoji.GetByMXC(ctx, uri) + if err != nil { + log.Err(err).Msg("Failed to get custom emoji from database") + } else if customEmoji != nil { + emojiID = customEmoji.EmojiID } } } else { - emojiID = emojiToShortcode(reaction.RelatesTo.Key) + emojiID = emoji.UnicodeToShortcodeMap[reaction.RelatesTo.Key] } if emojiID == "" { - portal.log.Errorfln("Couldn't find shortcode for emoji %s", reaction.RelatesTo.Key) - ms.sendMessageMetrics(evt, errEmojiShortcodeNotFound, "Error sending", true) + log.Warn().Str("reaction_key", reaction.RelatesTo.Key).Msg("Couldn't find shortcode for reaction emoji") + ms.sendMessageMetrics(ctx, evt, errEmojiShortcodeNotFound, "Error sending", true) return } - // TODO: Figure out if this is a custom emoji or not. - // emojiID := reaction.RelatesTo.Key - // if strings.HasPrefix(emojiID, "mxc://") { - // uri, _ := id.ParseContentURI(emojiID) - // emoji := portal.bridge.DB.Emoji.GetByMatrixURL(uri) - // if emoji == nil { - // portal.log.Errorfln("failed to find emoji for %s", emojiID) - - // return - // } + msg, err := portal.bridge.DB.Message.GetByMXID(ctx, reaction.RelatesTo.EventID) + if err != nil { + log.Err(err).Msg("Failed to get reaction target message from database") + // TODO log and metrics + return + } else if msg == nil || msg.PortalKey != portal.PortalKey { + log.Warn().Msg("Reaction target message not found") + ms.sendMessageMetrics(ctx, evt, errReactionTargetNotFound, "Error sending", true) + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("target_message_id", msg.MessageID) + }) - // emojiID = emoji.APIName() - // } + existingReaction, err := portal.bridge.DB.Reaction.GetBySlackID(ctx, portal.PortalKey, msg.MessageID, sender.UserID, emojiID) + if err != nil { + log.Err(err).Msg("Failed to check if reaction already exists") + } else if existingReaction != nil { + log.Debug().Msg("Ignoring duplicate reaction") + ms.sendMessageMetrics(ctx, evt, errDuplicateReaction, "Ignoring", true) + return + } - err := userTeam.Client.AddReaction(emojiID, slack.ItemRef{ - Channel: portal.Key.ChannelID, - Timestamp: slackID, + err = sender.Client.AddReactionContext(ctx, emojiID, slack.ItemRef{ + Channel: msg.ChannelID, + Timestamp: msg.MessageID, }) - ms.sendMessageMetrics(evt, err, "Error sending", true) + ms.sendMessageMetrics(ctx, evt, err, "Error sending", true) if err != nil { - portal.log.Debugfln("Failed to send reaction %s id:%s: %v", portal.Key, slackID, err) + log.Err(err).Msg("Failed to send reaction") return } dbReaction := portal.bridge.DB.Reaction.New() - dbReaction.Channel = portal.Key - dbReaction.MatrixEventID = evt.ID - dbReaction.SlackMessageID = slackID - dbReaction.AuthorID = userTeam.Key.SlackID - dbReaction.MatrixName = reaction.RelatesTo.Key - dbReaction.SlackName = emojiID - dbReaction.Insert(nil) - portal.log.Debugfln("Inserted reaction %v %s %s %s %s into database", dbReaction.Channel, dbReaction.MatrixEventID, dbReaction.SlackMessageID, dbReaction.AuthorID, dbReaction.SlackName) -} - -func (portal *Portal) handleMatrixRedaction(user *User, evt *event.Event) { - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() - - userTeam := user.GetUserTeam(portal.Key.TeamID) - if userTeam == nil { - go portal.sendMessageMetrics(evt, errUserNotLoggedIn, "Ignoring", nil) - return + dbReaction.PortalKey = portal.PortalKey + dbReaction.MXID = evt.ID + dbReaction.MessageID = msg.MessageID + dbReaction.MessageFirstPart = msg.Part + dbReaction.AuthorID = sender.UserID + dbReaction.EmojiID = emojiID + err = dbReaction.Insert(ctx) + if err != nil { + log.Err(err).Msg("Failed to insert reaction into database") } +} + +func (portal *Portal) handleMatrixRedaction(ctx context.Context, sender *UserTeam, evt *event.Event) { portal.log.Debugfln("Received redaction %s from %s", evt.ID, evt.Sender) // First look if we're redacting a message - message := portal.bridge.DB.Message.GetByMatrixID(portal.Key, evt.Redacts) - if message != nil { - if message.SlackID != "" { - _, _, err := userTeam.Client.DeleteMessage(portal.Key.ChannelID, message.SlackID) - if err != nil { - portal.log.Debugfln("Failed to delete slack message %s: %v", message.SlackID, err) - } else { - message.Delete() - } - go portal.sendMessageMetrics(evt, err, "Error sending", nil) + message, err := portal.bridge.DB.Message.GetByMXID(ctx, evt.Redacts) + if err != nil { + // TODO log and metrics + return + } else if message != nil { + if message.PortalKey != portal.PortalKey { + // TODO log and metrics + return + } + _, _, err := sender.Client.DeleteMessageContext(ctx, message.ChannelID, message.MessageID) + if err != nil { + portal.log.Debugfln("Failed to delete slack message %s: %v", message.ChannelID, err) } else { - go portal.sendMessageMetrics(evt, errTargetNotFound, "Error sending", nil) + message.Delete(ctx) } + go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil) return } // Now check if it's a reaction. - reaction := portal.bridge.DB.Reaction.GetByMatrixID(portal.Key, evt.Redacts) - if reaction != nil { - if reaction.SlackName != "" { - err := userTeam.Client.RemoveReaction(reaction.SlackName, slack.ItemRef{ - Channel: portal.Key.ChannelID, - Timestamp: reaction.SlackMessageID, - }) - if err != nil && err.Error() != "no_reaction" { - portal.log.Debugfln("Failed to delete reaction %s for message %s: %v", reaction.SlackName, reaction.SlackMessageID, err) - } else if err != nil && err.Error() == "no_reaction" { - portal.log.Warnfln("Didn't delete Slack reaction %s for message %s: reaction doesn't exist on Slack", reaction.SlackName, reaction.SlackMessageID) - reaction.Delete() - err = nil // not reporting an error for this - } else { - reaction.Delete() - } - go portal.sendMessageMetrics(evt, err, "Error sending", nil) + reaction, err := portal.bridge.DB.Reaction.GetByMXID(ctx, evt.Redacts) + if err != nil { + // TODO log and metrics + return + } else if reaction != nil { + if reaction.PortalKey != portal.PortalKey { + // TODO log and metrics + return + } + err = sender.Client.RemoveReactionContext(ctx, reaction.EmojiID, slack.ItemRef{ + Channel: portal.ChannelID, + Timestamp: reaction.MessageID, + }) + if err != nil && err.Error() != "no_reaction" { + portal.log.Debugfln("Failed to delete reaction %s for message %s: %v", reaction.EmojiID, reaction.MessageID, err) + } else if err != nil && err.Error() == "no_reaction" { + portal.log.Warnfln("Didn't delete Slack reaction %s for message %s: reaction doesn't exist on Slack", reaction.EmojiID, reaction.MessageID) + reaction.Delete(ctx) + err = nil // not reporting an error for this } else { - go portal.sendMessageMetrics(evt, errUnknownEmoji, "Error sending", nil) + reaction.Delete(ctx) } + go portal.sendMessageMetrics(ctx, evt, err, "Error sending", nil) + return + } + + portal.log.Warnfln("Failed to redact %s@%s: no event found", portal.PortalKey, evt.Redacts) + go portal.sendMessageMetrics(ctx, evt, errReactionTargetNotFound, "Error sending", nil) +} + +func (portal *Portal) HandleMatrixReadReceipt(sender bridge.User, eventID id.EventID, receipt event.ReadReceipt) { + portal.handleMatrixReadReceipt(sender.(*User).GetTeam(portal.TeamID), eventID) +} + +func (portal *Portal) handleMatrixReadReceipt(user *UserTeam, eventID id.EventID) { + if user == nil || user.Client == nil { + // TODO log + return + } + ctx := context.TODO() + + message, err := portal.bridge.DB.Message.GetByMXID(ctx, eventID) + if err != nil { + // TODO log + return + } else if message == nil { + // TODO log return } - portal.log.Warnfln("Failed to redact %s@%s: no event found", portal.Key, evt.Redacts) - go portal.sendMessageMetrics(evt, errReactionTargetNotFound, "Error sending", nil) + err = user.Client.MarkConversationContext(ctx, portal.ChannelID, message.MessageID) + // TODO log errors and successes } func typingDiff(prev, new []id.UserID) (started []id.UserID) { @@ -975,25 +792,13 @@ func (portal *Portal) HandleMatrixTyping(newTyping []id.UserID) { startedTyping := typingDiff(portal.currentlyTyping, newTyping) portal.currentlyTyping = newTyping for _, userID := range startedTyping { - user := portal.bridge.GetUserByMXID(userID) - if user != nil { - userTeam := user.GetUserTeam(portal.Key.TeamID) - if userTeam != nil && userTeam.IsLoggedIn() { - portal.sendSlackTyping(userTeam) - } + userTeam := portal.Team.GetCachedUserByMXID(userID) + if userTeam != nil && userTeam.RTM != nil { + userTeam.RTM.SendMessage(userTeam.RTM.NewTypingMessage(portal.ChannelID)) } } } -func (portal *Portal) sendSlackTyping(userTeam *database.UserTeam) { - if userTeam.RTM != nil { - typing := userTeam.RTM.NewTypingMessage(portal.Key.ChannelID) - userTeam.RTM.SendMessage(typing) - } else { - portal.log.Debugfln("RTM for userteam %s not connected!", userTeam.Key) - } -} - func (portal *Portal) slackRepeatTypingUpdater() { for { time.Sleep(3 * time.Second) @@ -1006,76 +811,104 @@ func (portal *Portal) sendSlackRepeatTyping() { defer portal.currentlyTypingLock.Unlock() for _, userID := range portal.currentlyTyping { - user := portal.bridge.GetUserByMXID(userID) - if user != nil { - userTeam := user.GetUserTeam(portal.Key.TeamID) - if userTeam != nil && userTeam.IsConnected() { - portal.sendSlackTyping(userTeam) - } + userTeam := portal.Team.GetCachedUserByMXID(userID) + if userTeam != nil && userTeam.RTM != nil { + userTeam.RTM.SendMessage(userTeam.RTM.NewTypingMessage(portal.ChannelID)) } } } func (portal *Portal) HandleMatrixLeave(brSender bridge.User) { portal.log.Debugln("User left private chat portal, cleaning up and deleting...") - portal.delete() - portal.bridge.cleanupRoom(portal.MainIntent(), portal.MXID, false, portal.log) - - // TODO: figure out how to close a dm from the API. - - portal.cleanupIfEmpty() + portal.CleanupIfEmpty(portal.zlog.WithContext(context.TODO())) } -func (portal *Portal) leave(userTeam *database.UserTeam) { +func (portal *Portal) DeleteUser(ctx context.Context, userTeam *UserTeam) { + err := portal.Portal.DeleteUser(ctx, userTeam.UserTeamMXIDKey) + if err != nil { + portal.zlog.Err(err).Object("user_team_key", userTeam.UserTeamMXIDKey). + Msg("Failed to delete user portal row from database") + } + if portal.MXID == "" { return } - intent := portal.bridge.GetPuppetByID(portal.Key.TeamID, userTeam.Key.SlackID).IntentFor(portal) - intent.LeaveRoom(portal.MXID) + puppet := portal.bridge.GetPuppetByID(userTeam.UserTeamKey) + if userTeam.User.DoublePuppetIntent != nil { + _, err = userTeam.User.DoublePuppetIntent.LeaveRoom(ctx, portal.MXID, &mautrix.ReqLeave{ + Reason: "User left channel", + }) + if err != nil { + portal.zlog.Err(err).Stringer("user_mxid", userTeam.UserMXID). + Msg("Failed to leave room with double puppet") + } + } else { + _, err = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{ + Reason: "User left channel", + UserID: userTeam.UserMXID, + }) + if err != nil { + portal.zlog.Err(err).Stringer("user_mxid", userTeam.UserMXID). + Msg("Failed to kick user") + } - portal.cleanupIfEmpty() + _, err = puppet.DefaultIntent().LeaveRoom(ctx, portal.MXID, &mautrix.ReqLeave{ + Reason: "User left channel", + }) + if err != nil { + portal.zlog.Err(err).Stringer("ghost_mxid", puppet.MXID). + Msg("Failed to leave room with ghost") + } + } + + portal.CleanupIfEmpty(ctx) } -func (portal *Portal) delete() { - portal.Portal.Delete() +func (portal *Portal) Delete(ctx context.Context) { + err := portal.Portal.Delete(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to delete portal from database") + } portal.bridge.portalsLock.Lock() - delete(portal.bridge.portalsByID, portal.Key) - + delete(portal.bridge.portalsByID, portal.PortalKey) if portal.MXID != "" { delete(portal.bridge.portalsByMXID, portal.MXID) } - portal.bridge.portalsLock.Unlock() } -func (portal *Portal) cleanupIfEmpty() { - users, err := portal.getMatrixUsers() +func (portal *Portal) CleanupIfEmpty(ctx context.Context) { + users, err := portal.getMatrixUsers(ctx) if err != nil && !errors.Is(err, mautrix.MForbidden) { portal.log.Errorfln("Failed to get Matrix user list to determine if portal needs to be cleaned up: %v", err) - return } if len(users) == 0 { portal.log.Infoln("Room seems to be empty, cleaning up...") - portal.delete() - if portal.bridge.SpecVersions.UnstableFeatures["com.beeper.room_yeeting"] { - intent := portal.MainIntent() - err := intent.BeeperDeleteRoom(portal.MXID) - if err == nil || errors.Is(err, mautrix.MNotFound) { - return - } - portal.log.Warnfln("Failed to delete %s using hungryserv yeet endpoint, falling back to normal behavior: %v", portal.MXID, err) - } - portal.bridge.cleanupRoom(portal.MainIntent(), portal.MXID, false, portal.log) + portal.Delete(ctx) + portal.Cleanup(ctx) } } -func (br *SlackBridge) cleanupRoom(intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool, log log.Logger) { - members, err := intent.JoinedMembers(mxid) +func (portal *Portal) Cleanup(ctx context.Context) { + portal.bridge.cleanupRoom(ctx, portal.MainIntent(), portal.MXID, false) +} + +func (br *SlackBridge) cleanupRoom(ctx context.Context, intent *appservice.IntentAPI, mxid id.RoomID, puppetsOnly bool) { + log := zerolog.Ctx(ctx) + if br.SpecVersions.Supports(mautrix.BeeperFeatureRoomYeeting) { + err := intent.BeeperDeleteRoom(ctx, mxid) + if err == nil || errors.Is(err, mautrix.MNotFound) { + return + } + log.Err(err).Msg("Failed to delete room using hungryserv yeet endpoint, falling back to normal behavior") + } + + members, err := intent.JoinedMembers(ctx, mxid) if err != nil { - log.Errorln("Failed to get portal members for cleanup:", err) + log.Err(err).Msg("Failed to get portal members for cleanup") return } @@ -1086,33 +919,33 @@ func (br *SlackBridge) cleanupRoom(intent *appservice.IntentAPI, mxid id.RoomID, puppet := br.GetPuppetByMXID(member) if puppet != nil { - _, err = puppet.DefaultIntent().LeaveRoom(mxid) + _, err = puppet.DefaultIntent().LeaveRoom(ctx, mxid) if err != nil { - log.Errorln("Error leaving as puppet while cleaning up portal:", err) + log.Err(err).Stringer("ghost_mxid", mxid).Msg("Failed to leave room with ghost while cleaning up portal") } } else if !puppetsOnly { - _, err = intent.KickUser(mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) + _, err = intent.KickUser(ctx, mxid, &mautrix.ReqKickUser{UserID: member, Reason: "Deleting portal"}) if err != nil { - log.Errorln("Error kicking user while cleaning up portal:", err) + log.Err(err).Stringer("user_mxid", mxid).Msg("Failed to kick user while cleaning up portal") } } } - _, err = intent.LeaveRoom(mxid) + _, err = intent.LeaveRoom(ctx, mxid) if err != nil { - log.Errorln("Error leaving with main intent while cleaning up portal:", err) + log.Err(err).Msg("Failed to leave room with main intent while cleaning up portal") } } -func (portal *Portal) getMatrixUsers() ([]id.UserID, error) { - members, err := portal.MainIntent().JoinedMembers(portal.MXID) +func (portal *Portal) getMatrixUsers(ctx context.Context) ([]id.UserID, error) { + members, err := portal.MainIntent().JoinedMembers(ctx, portal.MXID) if err != nil { return nil, fmt.Errorf("failed to get member list: %w", err) } var users []id.UserID for userID := range members.Joined { - _, _, isPuppet := portal.bridge.ParsePuppetMXID(userID) + _, isPuppet := portal.bridge.ParsePuppetMXID(userID) if !isPuppet && userID != portal.bridge.Bot.UserID { users = append(users, userID) } @@ -1143,7 +976,17 @@ func parseSlackTimestamp(timestamp string) time.Time { } func (portal *Portal) getBridgeInfoStateKey() string { - return fmt.Sprintf("fi.mau.slack://slackgo/%s/%s", portal.Key.TeamID, portal.Key.ChannelID) + return fmt.Sprintf("fi.mau.slack://slackgo/%s/%s", portal.TeamID, portal.ChannelID) +} + +type CustomBridgeInfoContent struct { + event.BridgeEventContent + RoomType string `json:"com.beeper.room_type,omitempty"` +} + +func init() { + event.TypeMap[event.StateBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) + event.TypeMap[event.StateHalfShotBridge] = reflect.TypeOf(CustomBridgeInfoContent{}) } func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { @@ -1157,89 +1000,94 @@ func (portal *Portal) getBridgeInfo() (string, CustomBridgeInfoContent) { AvatarURL: portal.bridge.Config.AppService.Bot.ParsedAvatar.CUString(), ExternalURL: "https://slack.com/", }, + Network: &event.BridgeInfoSection{ + ID: portal.TeamID, + DisplayName: portal.Team.Name, + ExternalURL: portal.Team.URL, + AvatarURL: portal.Team.AvatarMXC.CUString(), + }, Channel: event.BridgeInfoSection{ - ID: portal.Key.ChannelID, + ID: portal.ChannelID, DisplayName: portal.Name, + ExternalURL: fmt.Sprintf("https://app.slack.com/client/%s/%s", portal.TeamID, portal.ChannelID), }, }, } - if portal.Type == database.ChannelTypeDM || portal.Type == database.ChannelTypeGroupDM { bridgeInfo.RoomType = "dm" } - - teamInfo := portal.bridge.DB.TeamInfo.GetBySlackTeam(portal.Key.TeamID) - if teamInfo != nil { - bridgeInfo.Network = &event.BridgeInfoSection{ - ID: portal.Key.TeamID, - DisplayName: teamInfo.TeamName, - ExternalURL: teamInfo.TeamUrl, - AvatarURL: teamInfo.AvatarUrl.CUString(), - } - } - var bridgeInfoStateKey = portal.getBridgeInfoStateKey() - - return bridgeInfoStateKey, bridgeInfo + return portal.getBridgeInfoStateKey(), bridgeInfo } -func (portal *Portal) UpdateBridgeInfo() { +func (portal *Portal) UpdateBridgeInfo(ctx context.Context) { if len(portal.MXID) == 0 { portal.log.Debugln("Not updating bridge info: no Matrix room created") return } portal.log.Debugln("Updating bridge info...") stateKey, content := portal.getBridgeInfo() - _, err := portal.MainIntent().SendStateEvent(portal.MXID, event.StateBridge, stateKey, content) + _, err := portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateBridge, stateKey, content) if err != nil { portal.log.Warnln("Failed to update m.bridge:", err) } // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - _, err = portal.MainIntent().SendStateEvent(portal.MXID, event.StateHalfShotBridge, stateKey, content) + _, err = portal.MainIntent().SendStateEvent(ctx, portal.MXID, event.StateHalfShotBridge, stateKey, content) if err != nil { portal.log.Warnln("Failed to update uk.half-shot.bridge:", err) } } -func (portal *Portal) setChannelType(channel *slack.Channel) bool { - if channel == nil { - portal.log.Errorln("can't get type from nil channel metadata") - return false - } - +func (portal *Portal) updateChannelType(channel *slack.Channel) bool { + var newType database.ChannelType if channel.IsMpIM { - portal.Type = database.ChannelTypeGroupDM - return true + newType = database.ChannelTypeGroupDM } else if channel.IsIM { - portal.Type = database.ChannelTypeDM - return true + newType = database.ChannelTypeDM } else if channel.Name != "" { - portal.Type = database.ChannelTypeChannel - return true + newType = database.ChannelTypeChannel + } else { + portal.zlog.Warn().Msg("Channel type couldn't be determined") + return false } - - portal.log.Errorfln("unknown channel type, metadata %v", channel) - return false + if portal.Type == database.ChannelTypeUnknown { + portal.zlog.Debug().Stringer("channel_type", newType).Msg("Found channel type") + portal.Type = newType + } else if portal.Type != newType { + portal.zlog.Warn().Stringer("channel_type", newType).Msg("Channel type changed") + portal.Type = newType + } else { + return false + } + return true } func (portal *Portal) GetPlainName(meta *slack.Channel) string { - if portal.Type == database.ChannelTypeDM || portal.Type == database.ChannelTypeGroupDM { - return "" - } else { + switch portal.Type { + case database.ChannelTypeDM: + return portal.GetDMPuppet().Name + case database.ChannelTypeGroupDM: + puppetNames := make([]string, len(meta.Members)) + for i, member := range meta.Members { + puppet := portal.Team.GetPuppetByID(member) + puppetNames[i] = puppet.Name + } + return strings.Join(puppetNames, ", ") + default: return meta.Name } } -func (portal *Portal) UpdateNameDirect(name string) bool { - if name == "#" || portal.Name == name && (portal.NameSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) { +func (portal *Portal) UpdateNameDirect(ctx context.Context, name string) bool { + if portal.Name == name && (portal.NameSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) { return false } - portal.log.Debugfln("Updating name %q -> %q", portal.Name, name) + portal.zlog.Debug().Str("old_name", portal.Name).Str("new_name", name).Msg("Updating room name") portal.Name = name portal.NameSet = false - if portal.MXID != "" && portal.Name != "" && portal.shouldSetDMRoomMetadata() { - _, err := portal.MainIntent().SetRoomName(portal.MXID, portal.Name) + if portal.MXID != "" && portal.shouldSetDMRoomMetadata() { + _, err := portal.MainIntent().SetRoomName(ctx, portal.MXID, portal.Name) if err != nil { - portal.log.Warnln("Failed to update room name:", err) + portal.zlog.Err(err).Msg("Failed to update room name") } else { portal.NameSet = true } @@ -1247,132 +1095,293 @@ func (portal *Portal) UpdateNameDirect(name string) bool { return true } -func (portal *Portal) UpdateName(meta *slack.Channel, sourceTeam *database.UserTeam) bool { - plainName := portal.GetPlainName(meta) - - plainNameChanged := portal.PlainName != plainName - portal.PlainName = plainName - +func (portal *Portal) UpdateName(ctx context.Context, meta *slack.Channel) bool { + if (portal.Type == database.ChannelTypeChannel && meta.Name == "") || !portal.shouldSetDMRoomMetadata() { + return false + } + meta.Name = portal.GetPlainName(meta) + plainNameChanged := portal.PlainName != meta.Name + portal.PlainName = meta.Name formattedName := portal.bridge.Config.Bridge.FormatChannelName(config.ChannelNameParams{ - Name: plainName, - Type: portal.Type, - TeamName: sourceTeam.TeamName, + Channel: meta, + Type: portal.Type, + TeamName: portal.Team.Name, + TeamDomain: portal.Team.Domain, }) - return portal.UpdateNameDirect(formattedName) || plainNameChanged + return portal.UpdateNameDirect(ctx, formattedName) || plainNameChanged } -func (portal *Portal) updateRoomAvatar() { +func (portal *Portal) updateRoomAvatar(ctx context.Context) { if portal.MXID == "" || !portal.shouldSetDMRoomMetadata() { return } - _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, portal.AvatarURL) + _, err := portal.MainIntent().SetRoomAvatar(ctx, portal.MXID, portal.AvatarMXC) if err != nil { - portal.log.Warnln("Failed to update room avatar:", err) + portal.zlog.Err(err).Msg("Failed to update room avatar") } else { portal.AvatarSet = true } } -func (portal *Portal) UpdateAvatarFromPuppet(puppet *Puppet) bool { - if portal.Avatar == puppet.Avatar && portal.AvatarURL == puppet.AvatarURL && (portal.AvatarSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) { - return false +func (portal *Portal) UpdateNameFromPuppet(ctx context.Context, puppet *Puppet) { + if portal.UpdateNameDirect(ctx, puppet.Name) { + err := portal.Update(ctx) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after updating name") + } + portal.UpdateBridgeInfo(ctx) + } +} + +func (portal *Portal) UpdateAvatarFromPuppet(ctx context.Context, puppet *Puppet) { + if portal.Avatar == puppet.Avatar && portal.AvatarMXC == puppet.AvatarMXC && (portal.AvatarSet || portal.MXID == "" || !portal.shouldSetDMRoomMetadata()) { + return } - portal.log.Debugfln("Updating avatar from puppet %q -> %q", portal.Avatar, puppet.Avatar) + portal.zlog.Debug().Msg("Updating avatar from puppet") portal.Avatar = puppet.Avatar - portal.AvatarURL = puppet.AvatarURL + portal.AvatarMXC = puppet.AvatarMXC portal.AvatarSet = false - portal.updateRoomAvatar() + portal.updateRoomAvatar(ctx) + err := portal.Update(ctx) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after updating avatar") + } + portal.UpdateBridgeInfo(ctx) +} - return true +func (portal *Portal) getTopic(meta *slack.Channel) string { + switch portal.Type { + case database.ChannelTypeDM, database.ChannelTypeGroupDM: + return "" + case database.ChannelTypeChannel: + topicParts := make([]string, 0, 2) + if meta.Topic.Value != "" { + topicParts = append(topicParts, meta.Topic.Value) + } + if meta.Purpose.Value != "" { + topicParts = append(topicParts, meta.Purpose.Value) + } + return strings.Join(topicParts, "\n---\n") + default: + return "" + } } -func (portal *Portal) UpdateTopicDirect(topic string) bool { - if portal.Topic == topic && (portal.TopicSet || portal.MXID == "") { +func (portal *Portal) UpdateTopic(ctx context.Context, meta *slack.Channel) bool { + newTopic := portal.getTopic(meta) + if portal.Topic == newTopic && (portal.TopicSet || portal.MXID == "") { return false } - - portal.log.Debugfln("Updating topic for room %s", portal.Key.ChannelID) - portal.Topic = topic + portal.zlog.Debug().Str("old_topic", portal.Topic).Str("new_topic", newTopic).Msg("Updating room topic") + portal.Topic = newTopic portal.TopicSet = false if portal.MXID != "" { - _, err := portal.MainIntent().SetRoomTopic(portal.MXID, portal.Topic) + _, err := portal.MainIntent().SetRoomTopic(ctx, portal.MXID, portal.Topic) if err != nil { - portal.log.Warnln("Failed to update room topic:", err) + portal.zlog.Err(err).Msg("Failed to update room topic") } else { portal.TopicSet = true } } - return true } -func (portal *Portal) getTopic(meta *slack.Channel) string { - switch portal.Type { - case database.ChannelTypeDM, database.ChannelTypeGroupDM: - return "" - case database.ChannelTypeChannel: - plainTopic := meta.Topic.Value - plainDescription := meta.Purpose.Value - - var topicParts []string +func (portal *Portal) syncParticipants(ctx context.Context, source *UserTeam, participants []string) []id.UserID { + infosMap := make(map[string]*slack.User) - if plainTopic != "" { - topicParts = append(topicParts, fmt.Sprintf("Topic: %s", plainTopic)) + for _, participantChunk := range exslices.Chunk(participants, 100) { + infos, err := source.Client.GetUsersInfoContext(ctx, participantChunk...) + if err != nil { + portal.zlog.Err(err).Msg("Failed to get info of participants") + return nil } - if plainDescription != "" { - topicParts = append(topicParts, fmt.Sprintf("Description: %s", plainDescription)) + for _, info := range *infos { + infoCopy := info + infosMap[info.ID] = &infoCopy } - - return strings.Join(topicParts, "\n") - default: - return "" } -} -func (portal *Portal) UpdateTopic(meta *slack.Channel, sourceTeam *database.UserTeam) bool { - matrixTopic := portal.getTopic(meta) + userIDs := make([]id.UserID, 0, len(participants)+1) + for _, participant := range participants { + puppet := portal.Team.GetPuppetByID(participant) + puppet.UpdateInfo(ctx, source, infosMap[participant], nil) + + user := portal.Team.GetCachedUserByID(participant) + inviteGhost := false + if user != nil { + userIDs = append(userIDs, user.UserMXID) + } + if user == nil || user.User.DoublePuppetIntent == nil { + inviteGhost = true + userIDs = append(userIDs, puppet.MXID) + } - changed := portal.Topic != matrixTopic - return portal.UpdateTopicDirect(matrixTopic) || changed + if portal.MXID != "" { + if user != nil { + portal.ensureUserInvited(ctx, user.User) + } + if inviteGhost { + if err := puppet.DefaultIntent().EnsureJoined(ctx, portal.MXID); err != nil { + portal.zlog.Err(err).Str("user_id", participant).Msg("Failed to make ghost of user join portal room") + } + } + } + } + return userIDs } -func (portal *Portal) UpdateInfo(source *User, sourceTeam *database.UserTeam, meta *slack.Channel, force bool) *slack.Channel { - portal.log.Debugfln("Updating info for portal %s", portal.Key) - changed := false - +func (portal *Portal) UpdateInfo(ctx context.Context, source *UserTeam, meta *slack.Channel, syncChannelParticipants bool) (*slack.Channel, []id.UserID) { if meta == nil { - portal.log.Debugfln("UpdateInfo called without metadata, fetching from server via %s", sourceTeam.Key.SlackID) + portal.zlog.Debug().Object("via_user_id", source.UserTeamMXIDKey).Msg("Fetching channel meta from server") var err error - meta, err = sourceTeam.Client.GetConversationInfo(&slack.GetConversationInfoInput{ - ChannelID: portal.Key.ChannelID, + meta, err = source.Client.GetConversationInfo(&slack.GetConversationInfoInput{ + ChannelID: portal.ChannelID, IncludeLocale: true, IncludeNumMembers: true, }) if err != nil { - portal.log.Errorfln("Failed to fetch meta via %s: %v", sourceTeam.Key.SlackID, err) - return nil + portal.zlog.Err(err).Msg("Failed to fetch channel meta") + return nil, nil } } - changed = portal.setChannelType(meta) || changed + changed := portal.updateChannelType(meta) if portal.DMUserID == "" && portal.IsPrivateChat() { portal.DMUserID = meta.User - portal.log.Infoln("Found other user ID:", portal.DMUserID) + portal.zlog.Info().Str("other_user_id", portal.DMUserID).Msg("Found other user ID") + changed = true + } + if portal.Receiver == "" && ((portal.Type == database.ChannelTypeDM && meta.User == portal.DMUserID) || portal.Type == database.ChannelTypeGroupDM) { + portal.Receiver = source.UserID changed = true } - changed = portal.UpdateName(meta, sourceTeam) || changed - changed = portal.UpdateTopic(meta, sourceTeam) || changed + var memberMXIDs []id.UserID + switch portal.Type { + case database.ChannelTypeDM: + memberMXIDs = portal.syncParticipants(ctx, source, []string{meta.User}) + case database.ChannelTypeGroupDM: + memberMXIDs = portal.syncParticipants(ctx, source, meta.Members) + case database.ChannelTypeChannel: + if syncChannelParticipants && (portal.MXID == "" || !portal.bridge.Config.Bridge.ParticipantSyncOnlyOnCreate) { + members := portal.getChannelMembers(source, portal.bridge.Config.Bridge.ParticipantSyncCount) + memberMXIDs = portal.syncParticipants(ctx, source, members) + } + default: + } + + changed = portal.UpdateName(ctx, meta) || changed + changed = portal.UpdateTopic(ctx, meta) || changed + + if changed { + portal.UpdateBridgeInfo(ctx) + err := portal.Update(ctx) + if err != nil { + portal.zlog.Err(err).Msg("Failed to save portal after updating info") + } + } - if changed || force { - portal.UpdateBridgeInfo() - portal.Update(nil) + err := portal.InsertUser(ctx, source.UserTeamMXIDKey) + if err != nil { + portal.zlog.Err(err).Object("user_team_key", source.UserTeamMXIDKey). + Msg("Failed to insert user portal row") + } + if portal.MXID != "" { + portal.ensureUserInvited(ctx, source.User) } - return meta + return meta, memberMXIDs +} + +func (portal *Portal) handleSlackEvent(source *UserTeam, rawEvt any) { + log := portal.zlog.With(). + Object("source_key", source.UserTeamMXIDKey). + Type("event_type", rawEvt). + Logger() + ctx := log.WithContext(context.TODO()) + switch evt := rawEvt.(type) { + case *slack.ChannelJoinedEvent, *slack.GroupJoinedEvent: + var ch *slack.Channel + if joinedEvt, ok := evt.(*slack.ChannelJoinedEvent); ok { + ch = &joinedEvt.Channel + } else { + ch = &evt.(*slack.GroupJoinedEvent).Channel + } + if portal.MXID == "" { + log.Debug().Msg("Creating Matrix room from joined channel") + if err := portal.CreateMatrixRoom(ctx, source, ch); err != nil { + log.Err(err).Msg("Failed to create portal room after join event") + } + } else { + portal.UpdateInfo(ctx, source, ch, true) + } + case *slack.ChannelLeftEvent, *slack.GroupLeftEvent: + portal.DeleteUser(ctx, source) + case *slack.ChannelUpdateEvent: + portal.UpdateInfo(ctx, source, nil, true) + case *slack.MemberLeftChannelEvent: + // TODO + case *slack.MemberJoinedChannelEvent: + // TODO + case *slack.UserTypingEvent: + if portal.MXID == "" { + log.Warn().Msg("Ignoring typing notification in channel with no portal room") + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("user_id", evt.User) + }) + portal.HandleSlackTyping(ctx, source, evt) + case *slack.ChannelMarkedEvent: + if portal.MXID == "" { + log.Warn().Msg("Ignoring read receipt in channel with no portal room") + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("message_id", evt.Timestamp).Str("sender_id", evt.User) + }) + portal.HandleSlackChannelMarked(ctx, source, evt) + case *slack.ReactionAddedEvent: + if portal.MXID == "" { + log.Warn().Msg("Ignoring reaction removal in channel with no portal room") + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("target_message_id", evt.Item.Timestamp).Str("sender_id", evt.User) + }) + portal.HandleSlackReaction(ctx, source, evt) + case *slack.ReactionRemovedEvent: + if portal.MXID == "" { + log.Warn().Msg("Ignoring reaction removal in channel with no portal room") + return + } + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("target_message_id", evt.Item.Timestamp).Str("sender_id", evt.User) + }) + portal.HandleSlackReactionRemoved(ctx, source, evt) + case *slack.MessageEvent: + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + if evt.ThreadTimestamp != "" { + c = c.Str("thread_id", evt.ThreadTimestamp) + } + return c. + Str("message_id", evt.Timestamp). + Str("sender_id", evt.User). + Str("subtype", evt.SubType) + }) + if portal.MXID == "" { + log.Warn().Msg("Received message in channel with no portal room creating portal") + err := portal.CreateMatrixRoom(ctx, source, nil) + if err != nil { + log.Err(err).Msg("Failed to create room for portal") + return + } + } + portal.HandleSlackMessage(ctx, source, evt) + } } type ConvertedSlackFile struct { @@ -1390,324 +1399,278 @@ type ConvertedSlackMessage struct { SlackThread []slack.Message } -func (portal *Portal) HandleSlackMessage(user *User, userTeam *database.UserTeam, msg *slack.MessageEvent) { - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() - - if msg.Msg.Type != "message" { - portal.log.Warnln("ignoring unknown message type:", msg.Msg.Type) +func (portal *Portal) HandleSlackMessage(ctx context.Context, source *UserTeam, msg *slack.MessageEvent) { + log := zerolog.Ctx(ctx) + if msg.Type != slack.TYPE_MESSAGE { + log.Warn().Str("message_type", msg.Type).Msg("Ignoring message with unexpected top-level type") return } - if msg.Msg.IsEphemeral { - portal.log.Debugfln("Ignoring ephemeral message") + if msg.IsEphemeral { + log.Debug().Msg("Ignoring ephemeral message") return } - existing := portal.bridge.DB.Message.GetBySlackID(portal.Key, msg.Msg.Timestamp) - if existing != nil && msg.Msg.SubType != "message_changed" { // Slack reuses the same message ID on message edits - portal.log.Debugln("Dropping duplicate message:", msg.Msg.Timestamp) + existing, err := portal.bridge.DB.Message.GetBySlackID(ctx, portal.PortalKey, msg.Timestamp) + if err != nil { + log.Err(err).Msg("Failed to check if message was already bridged") return - } - if msg.Msg.SubType == "message_changed" && existing == nil { - portal.log.Debugfln("Not sending edit for nonexistent message %s", msg.Msg.Timestamp) + } else if existing != nil && msg.SubType != slack.MsgSubTypeMessageChanged { + log.Debug().Msg("Dropping duplicate message") + return + } else if existing == nil && msg.SubType == slack.MsgSubTypeMessageChanged { + log.Debug().Msg("Dropping edit for unknown message") return } - - if msg.Msg.User == "" { - portal.log.Debugfln("Starting handling of %s (no sender), subtype %s", msg.Msg.Timestamp, msg.Msg.SubType) - } else { - portal.log.Debugfln("Starting handling of %s by %s, subtype %s", msg.Msg.Timestamp, msg.Msg.User, msg.Msg.SubType) + slackAuthor := msg.User + if slackAuthor == "" { + slackAuthor = msg.BotID + } + var sender *Puppet + if slackAuthor != "" { + sender = portal.Team.GetPuppetByID(slackAuthor) + sender.UpdateInfoIfNecessary(ctx, source) } - switch msg.Msg.SubType { - case "", "me_message", "bot_message", "thread_broadcast": // Regular messages and /me - portal.HandleSlackNormalMessage(user, userTeam, &msg.Msg, nil) - case "huddle_thread": - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: fmt.Sprintf("A huddle started. Go get it in https://app.slack.com/client/%s/%s", portal.Key.TeamID, portal.Key.ChannelID), - } - _, err := portal.sendMatrixMessage(portal.MainIntent(), event.EventMessage, content, nil, 0) - if err != nil { - portal.log.Warnfln("Failed to send message about the huddle:", err) - } - case "message_changed": - portal.HandleSlackNormalMessage(user, userTeam, msg.SubMessage, existing) - case "channel_topic", "channel_purpose", "channel_name", "group_topic", "group_purpose", "group_name": - portal.UpdateInfo(user, userTeam, nil, false) - portal.log.Debugfln("Received %s update, updating portal name and topic", msg.Msg.SubType) - case "message_deleted": - // Slack doesn't tell us who deleted a message, so there is no intent here - message := portal.bridge.DB.Message.GetBySlackID(portal.Key, msg.Msg.DeletedTimestamp) - if message == nil { - portal.log.Warnfln("Failed to redact %s: Matrix event not known", msg.Msg.DeletedTimestamp) - } else { - _, err := portal.MainIntent().RedactEvent(portal.MXID, message.MatrixID) - if err != nil { - portal.log.Errorfln("Failed to redact %s: %v", message.MatrixID, err) - } else { - message.Delete() - } - } + log.Debug().Msg("Starting handling of Slack message") - attachments := portal.bridge.DB.Attachment.GetAllBySlackMessageID(portal.Key, msg.Msg.DeletedTimestamp) - for _, attachment := range attachments { - _, err := portal.MainIntent().RedactEvent(portal.MXID, attachment.MatrixEventID) - if err != nil { - portal.log.Errorfln("Failed to redact %s: %v", attachment.MatrixEventID, err) - } else { - attachment.Delete() - } - } - case "message_replied", "group_join", "group_leave", "channel_join", "channel_leave": // Not yet an exhaustive list. - // These subtypes are simply ignored, because they're handled elsewhere/in other ways (Slack sends multiple info of these events) - portal.log.Debugfln("Received message subtype %s, which is ignored", msg.Msg.SubType) + switch msg.SubType { default: - portal.log.Warnfln("Received unknown message subtype %s", msg.Msg.SubType) - } -} - -func (portal *Portal) addThreadMetadata(content *event.MessageEventContent, threadTs string) (hasThread bool, hasReply bool) { - // fetch thread metadata and add to message - if threadTs != "" { - if content.RelatesTo == nil { - content.RelatesTo = &event.RelatesTo{} + if sender == nil { + log.Warn().Msg("Ignoring message from unknown sender") + return } - latestThreadMessage := portal.bridge.DB.Message.GetLastInThread(portal.Key, threadTs) - rootThreadMessage := portal.bridge.DB.Message.GetBySlackID(portal.Key, threadTs) - - var latestThreadMessageID id.EventID - if latestThreadMessage != nil { - latestThreadMessageID = latestThreadMessage.MatrixID - } else { - latestThreadMessageID = "" + switch msg.SubType { + case "", slack.MsgSubTypeMeMessage, slack.MsgSubTypeBotMessage, slack.MsgSubTypeThreadBroadcast, "huddle_thread": + // known types + default: + log.Warn().Msg("Received unknown message subtype") } - - if rootThreadMessage != nil { - content.RelatesTo.SetThread(rootThreadMessage.MatrixID, latestThreadMessageID) - return true, true - } else if latestThreadMessage != nil { - content.RelatesTo.SetReplyTo(latestThreadMessage.MatrixID) - return false, true + portal.HandleSlackNormalMessage(ctx, source, sender, &msg.Msg) + case slack.MsgSubTypeMessageChanged: + if sender == nil { + log.Warn().Msg("Ignoring edit from unknown sender") + return + } else if msg.SubMessage.SubType == "huddle_thread" { + log.Debug().Msg("Ignoring huddle thread edit") + return } + portal.HandleSlackEditMessage(ctx, source, sender, msg.SubMessage, msg.PreviousMessage, existing) + case slack.MsgSubTypeMessageDeleted: + portal.HandleSlackDelete(ctx, &msg.Msg) + case slack.MsgSubTypeChannelTopic, slack.MsgSubTypeChannelPurpose, slack.MsgSubTypeChannelName, + slack.MsgSubTypeGroupTopic, slack.MsgSubTypeGroupPurpose, slack.MsgSubTypeGroupName: + log.Debug().Msg("Resyncing channel info due to update message") + portal.UpdateInfo(ctx, source, nil, false) + case slack.MsgSubTypeMessageReplied, slack.MsgSubTypeGroupJoin, slack.MsgSubTypeGroupLeave, + slack.MsgSubTypeChannelJoin, slack.MsgSubTypeChannelLeave: + // These subtypes are simply ignored, because they're handled elsewhere/in other ways (Slack sends multiple info of these events) + log.Debug().Msg("Ignoring unnecessary message") } - return false, false } -func (portal *Portal) ConvertSlackMessage(userTeam *database.UserTeam, msg *slack.Msg) (converted ConvertedSlackMessage) { - if msg.User != "" { - converted.SlackAuthor = msg.User - } else if msg.BotID != "" { - converted.SlackAuthor = msg.BotID - } else { - portal.log.Errorfln("Couldn't convert text message %s: no user or bot ID in message", msg.Timestamp) +func (portal *Portal) getThreadMetadata(ctx context.Context, threadTs string) (threadRootID, lastMessageID id.EventID) { + // fetch thread metadata and add to message + if threadTs == "" { return } - converted.SlackTimestamp = msg.Timestamp - var text string - if msg.Text != "" { - text = msg.Text + rootThreadMessage, err := portal.bridge.DB.Message.GetFirstPartBySlackID(ctx, portal.PortalKey, threadTs) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get thread root message from database") } - for _, attachment := range msg.Attachments { - if text != "" { - text += "\n" - } - if attachment.Text != "" { - text += attachment.Text - } else if attachment.Fallback != "" { - text += attachment.Fallback - } + latestThreadMessage, err := portal.bridge.DB.Message.GetLastInThread(ctx, portal.PortalKey, threadTs) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to get last message in thread from database") } - if len(msg.Blocks.BlockSet) != 0 || len(msg.Attachments) != 0 { - var err error - converted.Event, err = portal.SlackBlocksToMatrix(msg.Blocks, msg.Attachments, userTeam) - if err != nil { - portal.log.Warnfln("Error rendering Slack blocks: %v", err) - converted.Event = nil - } - } else if text != "" { - converted.Event = portal.renderSlackMarkdown(text) + if latestThreadMessage != nil && rootThreadMessage != nil { + threadRootID = rootThreadMessage.MXID + lastMessageID = latestThreadMessage.MXID + } else if latestThreadMessage != nil { + threadRootID = latestThreadMessage.MXID + lastMessageID = latestThreadMessage.MXID + } else if rootThreadMessage != nil { + threadRootID = rootThreadMessage.MXID + lastMessageID = rootThreadMessage.MXID } + return +} - for _, file := range msg.Files { - fileInfo := file - if file.FileAccess == "check_file_info" { - connectFile, _, _, err := userTeam.Client.GetFileInfo(file.ID, 0, 0) - if err != nil || connectFile == nil { - portal.log.Errorln("Error fetching slack connect file info", err) - continue - } - fileInfo = *connectFile - } - if fileInfo.Size > int(portal.bridge.MediaConfig.UploadSize) { - portal.log.Errorfln("%d is too large to upload to Matrix, not bridging file %s", fileInfo.Size, fileInfo.ID) +func (portal *Portal) HandleSlackEditMessage(ctx context.Context, source *UserTeam, sender *Puppet, msg, oldMsg *slack.Msg, editTarget []*database.Message) { + log := zerolog.Ctx(ctx) + intent := sender.IntentFor(portal) + + ts := parseSlackTimestamp(msg.Timestamp) + ctx = context.WithValue(ctx, convertContextKeySource, source) + ctx = context.WithValue(ctx, convertContextKeyIntent, intent) + converted := portal.MsgConv.ToMatrix(ctx, msg) + // Don't merge caption if there's more than one part in the database + // (because it means the original didn't have a merged caption) + if len(editTarget) == 1 { + converted.MergeCaption() + } + + editTargetPartMap := make(map[database.PartID]*database.Message, len(editTarget)) + for _, editTargetPart := range editTarget { + editTargetPartMap[editTargetPart.Part] = editTargetPart + } + for _, part := range converted.Parts { + editTargetPart, ok := editTargetPartMap[part.PartID] + if !ok { + log.Warn().Stringer("part_id", &part.PartID).Msg("Failed to find part to edit") continue } - convertedFile := ConvertedSlackFile{ - SlackFileID: fileInfo.ID, - } - content := portal.renderSlackFile(fileInfo) - portal.addThreadMetadata(&content, msg.ThreadTimestamp) - var data bytes.Buffer - var err error - var url string - if fileInfo.URLPrivateDownload != "" { - url = fileInfo.URLPrivateDownload - } else if fileInfo.URLPrivate != "" { - url = fileInfo.URLPrivate - } - portal.log.Debugfln("File download URLs: urlPrivate=%s, urlPrivateDownload=%s", fileInfo.URLPrivate, fileInfo.URLPrivateDownload) - if url != "" { - portal.log.Debugfln("Downloading private file from Slack: %s", url) - err = userTeam.Client.GetFile(url, &data) - if bytes.HasPrefix(data.Bytes(), []byte("")) { - portal.log.Warnfln("Received HTML file from Slack (URL %s), trying again in 5 seconds", url) - time.Sleep(5 * time.Second) - data.Reset() - err = userTeam.Client.GetFile(fileInfo.URLPrivate, &data) - } else { - portal.log.Debugfln("Download success, expectedSize=%d, downloadedSize=%d", fileInfo.Size, data.Len()) - } - } else if fileInfo.PermalinkPublic != "" { - portal.log.Debugfln("Downloading public file from Slack: %s", fileInfo.PermalinkPublic) - client := http.Client{} - var resp *http.Response - resp, err = client.Get(fileInfo.PermalinkPublic) - if err != nil { - portal.log.Errorfln("Error downloading Slack file %s: %v", fileInfo.ID, err) - continue + delete(editTargetPartMap, part.PartID) + part.Content.SetEdit(editTargetPart.MXID) + // Never actually ping users in edits, only update the list in the edited content + part.Content.Mentions = nil + if part.Extra != nil { + part.Extra = map[string]any{ + "m.new_content": part.Extra, } - _, err = data.ReadFrom(resp.Body) - } else { - portal.log.Errorln("No usable URL found in file object, not bridging file") - continue } + resp, err := portal.sendMatrixMessage(ctx, intent, part.Type, part.Content, part.Extra, ts.UnixMilli()) if err != nil { - portal.log.Errorfln("Error downloading Slack file %s: %v", fileInfo.ID, err) - continue + log.Err(err). + Stringer("part_id", &part.PartID). + Stringer("part_mxid", editTargetPart.MXID). + Msg("Failed to edit message part") + } else { + log.Debug(). + Stringer("part_id", &part.PartID). + Stringer("part_mxid", editTargetPart.MXID). + Stringer("edit_mxid", resp.EventID). + Msg("Edited message part") } - err = portal.uploadMedia(portal.MainIntent(), data.Bytes(), &content) + } + for _, deletedPart := range editTargetPartMap { + resp, err := portal.MainIntent().RedactEvent(ctx, portal.MXID, deletedPart.MXID, mautrix.ReqRedact{Reason: "Part removed in edit"}) if err != nil { - if errors.Is(err, mautrix.MTooLarge) { - portal.log.Errorfln("File %s too large for Matrix server: %v", fileInfo.ID, err) - continue - } else if httpErr, ok := err.(mautrix.HTTPError); ok && httpErr.IsStatus(413) { - portal.log.Errorfln("Proxy rejected too large file %s: %v", fileInfo.ID, err) - continue - } else { - portal.log.Errorfln("Error uploading file %s to Matrix: %v", fileInfo.ID, err) - continue + log.Err(err). + Stringer("part_id", &deletedPart.Part). + Stringer("part_mxid", deletedPart.MXID). + Msg("Failed to redact message part deleted in edit") + } else if err = deletedPart.Delete(ctx); err != nil { + log.Err(err). + Stringer("part_id", &deletedPart.Part). + Msg("Failed to delete message part from database") + } else { + log.Debug(). + Stringer("part_id", &deletedPart.Part). + Stringer("part_mxid", deletedPart.MXID). + Stringer("redaction_mxid", resp.EventID). + Msg("Redacted message part that was deleted in edit") + } + } +} + +func (portal *Portal) HandleSlackDelete(ctx context.Context, msg *slack.Msg) { + log := zerolog.Ctx(ctx) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str("deleted_message_id", msg.DeletedTimestamp) + }) + messageParts, err := portal.bridge.DB.Message.GetBySlackID(ctx, portal.PortalKey, msg.DeletedTimestamp) + if err != nil { + log.Err(err).Msg("Failed to get delete target message") + } else if messageParts == nil { + log.Warn().Msg("Received message deletion event for unknown message") + } else { + for _, part := range messageParts { + if _, err = portal.MainIntent().RedactEvent(ctx, portal.MXID, part.MXID); err != nil { + log.Err(err). + Stringer("part_mxid", part.MXID). + Stringer("part_id", &part.Part). + Msg("Failed to redact deleted message part") + } else if err = part.Delete(ctx); err != nil { + log.Err(err). + Stringer("part_mxid", part.MXID). + Stringer("part_id", &part.Part). + Msg("Failed to delete deleted message part from database") } } - convertedFile.Event = &content - converted.FileAttachments = append(converted.FileAttachments, convertedFile) } +} - converted.SlackThreadTs = msg.ThreadTimestamp +func (portal *Portal) HandleSlackNormalMessage(ctx context.Context, source *UserTeam, sender *Puppet, msg *slack.Msg) { + intent := sender.IntentFor(portal) - return converted -} + hasThread := msg.ThreadTimestamp != "" + threadRootID, threadLastMessageID := portal.getThreadMetadata(ctx, msg.ThreadTimestamp) -func (portal *Portal) HandleSlackNormalMessage(user *User, userTeam *database.UserTeam, msg *slack.Msg, editExisting *database.Message) { ts := parseSlackTimestamp(msg.Timestamp) - e := portal.ConvertSlackMessage(userTeam, msg) + ctx = context.WithValue(ctx, convertContextKeySource, source) + ctx = context.WithValue(ctx, convertContextKeyIntent, intent) + converted := portal.MsgConv.ToMatrix(ctx, msg) - puppet := portal.bridge.GetPuppetByID(portal.Key.TeamID, e.SlackAuthor) - if puppet == nil { - portal.log.Errorfln("Can't find puppet for %s", e.SlackAuthor) - return - } - puppet.UpdateInfo(userTeam, true, nil) - intent := puppet.IntentFor(portal) - - for _, file := range e.FileAttachments { - if editExisting == nil { - portal.addThreadMetadata(file.Event, msg.ThreadTimestamp) + var lastEventID id.EventID + for _, part := range converted.Parts { + if threadRootID != "" { + part.Content.GetRelatesTo().SetThread(threadRootID, threadLastMessageID) } - - resp, err := portal.sendMatrixMessage(intent, event.EventMessage, file.Event, nil, ts.UnixMilli()) + resp, err := portal.sendMatrixMessage(ctx, intent, part.Type, part.Content, part.Extra, ts.UnixMilli()) if err != nil { portal.log.Warnfln("Failed to send media message %s to matrix: %v", ts, err) continue } - go portal.sendDeliveryReceipt(resp.EventID) - attachment := portal.bridge.DB.Attachment.New() - attachment.Channel = portal.Key - attachment.SlackFileID = file.SlackFileID - attachment.SlackMessageID = msg.Timestamp - attachment.MatrixEventID = resp.EventID - attachment.SlackThreadID = msg.ThreadTimestamp - attachment.Insert(nil) - } - - if e.Event != nil { - // set m.emote if it's a /me message - if msg.SubType == "me_message" { - e.Event.MsgType = event.MsgEmote - } - - if editExisting != nil { - e.Event.SetEdit(editExisting.MatrixID) - } else { - portal.addThreadMetadata(e.Event, msg.ThreadTimestamp) - } - - resp, err := portal.sendMatrixMessage(intent, event.EventMessage, e.Event, nil, ts.UnixMilli()) + dbMessage := portal.bridge.DB.Message.New() + dbMessage.PortalKey = portal.PortalKey + dbMessage.MessageID = msg.Timestamp + dbMessage.Part = part.PartID + dbMessage.ThreadID = msg.ThreadTimestamp + dbMessage.AuthorID = sender.UserID + dbMessage.MXID = resp.EventID + err = dbMessage.Insert(ctx) if err != nil { - portal.log.Warnfln("Failed to send message %s to matrix: %v", msg.Timestamp, err) - return + zerolog.Ctx(ctx).Err(err). + Stringer("part_id", &part.PartID). + Msg("Failed to insert message part to database") } - portal.markMessageHandled(nil, msg.Timestamp, msg.ThreadTimestamp, resp.EventID, e.SlackAuthor) - go portal.sendDeliveryReceipt(resp.EventID) - return + if hasThread { + threadLastMessageID = resp.EventID + if threadRootID == "" { + threadRootID = resp.EventID + } + } + lastEventID = resp.EventID } + go portal.sendDeliveryReceipt(ctx, lastEventID) } -func (portal *Portal) HandleSlackReaction(user *User, userTeam *database.UserTeam, msg *slack.ReactionAddedEvent) { - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() - - if msg.Type != "reaction_added" { - portal.log.Warnln("ignoring unknown message type:", msg.Type) +func (portal *Portal) HandleSlackReaction(ctx context.Context, source *UserTeam, msg *slack.ReactionAddedEvent) { + puppet := portal.Team.GetPuppetByID(msg.User) + if puppet == nil { return } + puppet.UpdateInfoIfNecessary(ctx, source) - portal.log.Debugfln("Handling Slack reaction: %v %s %s", portal.Key, msg.Item.Timestamp, msg.Reaction) - - if portal.MXID == "" { - portal.log.Warnfln("No Matrix portal created for room %s %s, not bridging reaction") - } - - existing := portal.bridge.DB.Reaction.GetBySlackID(portal.Key, msg.User, msg.Item.Timestamp, msg.Reaction) - if existing != nil { - portal.log.Warnfln("Dropping duplicate reaction: %s %s %s", portal.Key, msg.Item.Timestamp, msg.Reaction) + log := zerolog.Ctx(ctx) + existing, err := portal.bridge.DB.Reaction.GetBySlackID(ctx, portal.PortalKey, msg.Item.Timestamp, msg.User, msg.Reaction) + if err != nil { + log.Err(err).Msg("Failed to check if reaction is duplicate") return - } - - puppet := portal.bridge.GetPuppetByID(portal.Key.TeamID, msg.User) - if puppet == nil { - portal.log.Errorfln("Not sending reaction: can't find puppet for Slack user %s", msg.User) + } else if existing != nil { + log.Debug().Msg("Dropping duplicate reaction") return } - puppet.UpdateInfo(userTeam, true, nil) intent := puppet.IntentFor(portal) - targetMessage := portal.bridge.DB.Message.GetBySlackID(portal.Key, msg.Item.Timestamp) - if targetMessage == nil { - portal.log.Errorfln("Not sending reaction: can't find Matrix message for %s %s %s", portal.Key.TeamID, portal.Key.ChannelID, msg.Item.Timestamp) + targetMessage, err := portal.bridge.DB.Message.GetFirstPartBySlackID(ctx, portal.PortalKey, msg.Item.Timestamp) + if err != nil { + log.Err(err).Msg("Failed to get reaction target from database") + return + } else if targetMessage == nil { + log.Warn().Msg("Dropping reaction to unknown message") return } slackReaction := strings.Trim(msg.Reaction, ":") - key := portal.bridge.GetEmoji(slackReaction, userTeam) + key := source.GetEmoji(ctx, slackReaction) var content event.ReactionEventContent content.RelatesTo = event.RelatesTo{ Type: event.RelAnnotation, - EventID: targetMessage.MatrixID, + EventID: targetMessage.MXID, Key: key, } extraContent := map[string]any{} @@ -1716,104 +1679,99 @@ func (portal *Portal) HandleSlackReaction(user *User, userTeam *database.UserTea "name": slackReaction, "mxc": key, } + extraContent["com.beeper.reaction.shortcode"] = msg.Reaction if !portal.bridge.Config.Bridge.CustomEmojiReactions { content.RelatesTo.Key = slackReaction } } - resp, err := intent.SendMassagedMessageEvent(portal.MXID, event.EventReaction, &event.Content{ + resp, err := intent.SendMassagedMessageEvent(ctx, portal.MXID, event.EventReaction, &event.Content{ Parsed: &content, Raw: extraContent, }, parseSlackTimestamp(msg.EventTimestamp).UnixMilli()) if err != nil { - portal.log.Errorfln("Failed to bridge reaction: %v", err) + log.Err(err).Msg("Failed to send Slack reaction to Matrix") return } dbReaction := portal.bridge.DB.Reaction.New() - dbReaction.Channel = portal.Key - dbReaction.SlackMessageID = msg.Item.Timestamp - dbReaction.MatrixEventID = resp.EventID + dbReaction.PortalKey = portal.PortalKey + dbReaction.MessageID = msg.Item.Timestamp + dbReaction.MessageFirstPart = targetMessage.Part + dbReaction.MXID = resp.EventID dbReaction.AuthorID = msg.User - dbReaction.MatrixName = key - dbReaction.SlackName = msg.Reaction - dbReaction.Insert(nil) + dbReaction.EmojiID = msg.Reaction + err = dbReaction.Insert(ctx) + if err != nil { + log.Err(err).Msg("Failed to save reaction to database") + } } -func (portal *Portal) HandleSlackReactionRemoved(user *User, userTeam *database.UserTeam, msg *slack.ReactionRemovedEvent) { - if portal.MXID == "" { +func (portal *Portal) HandleSlackReactionRemoved(ctx context.Context, source *UserTeam, msg *slack.ReactionRemovedEvent) { + puppet := portal.Team.GetPuppetByID(msg.User) + if puppet == nil { return } - portal.slackMessageLock.Lock() - defer portal.slackMessageLock.Unlock() + puppet.UpdateInfoIfNecessary(ctx, source) - if msg.Type != "reaction_removed" { - portal.log.Warnln("ignoring unknown message type:", msg.Type) + log := zerolog.Ctx(ctx) + dbReaction, err := portal.bridge.DB.Reaction.GetBySlackID(ctx, portal.PortalKey, msg.Item.Timestamp, msg.User, msg.Reaction) + if err != nil { + log.Err(err).Msg("Failed to get removed reaction info from database") return - } - - dbReaction := portal.bridge.DB.Reaction.GetBySlackID(portal.Key, msg.User, msg.Item.Timestamp, msg.Reaction) - if dbReaction == nil { - portal.log.Errorfln("Failed to redact reaction %v %s %s %s: reaction not found in database", portal.Key, msg.User, msg.Item.Timestamp, msg.Reaction) + } else if dbReaction == nil { + log.Warn().Msg("Ignoring removal of unknown reaction") return } - puppet := portal.bridge.GetPuppetByID(portal.Key.TeamID, msg.User) - if puppet == nil { - portal.log.Errorfln("Not redacting reaction: can't find puppet for Slack user %s %s", portal.Key.TeamID, msg.User) - return + _, err = puppet.IntentFor(portal).RedactEvent(ctx, portal.MXID, dbReaction.MXID) + if err != nil { + log.Err(err).Msg("Failed to redact reaction") } - puppet.UpdateInfo(userTeam, true, nil) - intent := puppet.IntentFor(portal) - - _, err := intent.RedactEvent(portal.MXID, dbReaction.MatrixEventID) + err = dbReaction.Delete(ctx) if err != nil { - portal.log.Errorfln("Failed to redact reaction %v %s %s %s: %v", portal.Key, msg.User, msg.Item.Timestamp, msg.Reaction, err) - return + log.Err(err).Msg("Failed to remove reaction from database") } - - dbReaction.Delete() } -func (portal *Portal) HandleSlackTyping(user *User, userTeam *database.UserTeam, msg *slack.UserTypingEvent) { - if portal.MXID == "" { - return - } - puppet := portal.bridge.GetPuppetByID(portal.Key.TeamID, msg.User) +func (portal *Portal) HandleSlackTyping(ctx context.Context, source *UserTeam, msg *slack.UserTypingEvent) { + puppet := portal.Team.GetPuppetByID(msg.User) if puppet == nil { - portal.log.Errorfln("Not sending typing status: can't find puppet for Slack user %s", msg.User) return } - puppet.UpdateInfo(userTeam, true, nil) + puppet.UpdateInfoIfNecessary(ctx, source) + log := zerolog.Ctx(ctx) intent := puppet.IntentFor(portal) - - _, err := intent.UserTyping(portal.MXID, true, time.Duration(time.Second*5)) + err := intent.EnsureJoined(ctx, portal.MXID) + if err != nil { + log.Err(err).Msg("Failed to ensure ghost is joined to room to bridge typing notification") + } + _, err = intent.UserTyping(ctx, portal.MXID, true, 5*time.Second) if err != nil { - portal.log.Warnfln("Error sending typing status to Matrix: %v", err) + log.Err(err).Msg("Failed to bridge typing notification to Matrix") } } -func (portal *Portal) HandleSlackChannelMarked(user *User, userTeam *database.UserTeam, msg *slack.ChannelMarkedEvent) { - if portal.MXID == "" { +func (portal *Portal) HandleSlackChannelMarked(ctx context.Context, source *UserTeam, msg *slack.ChannelMarkedEvent) { + if source.User.DoublePuppetIntent == nil { return } - puppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) - if puppet == nil { - portal.log.Errorfln("Not marking room as read: can't find puppet for Slack user %s", msg.User) + log := zerolog.Ctx(ctx) + message, err := portal.bridge.DB.Message.GetLastPartBySlackID(ctx, portal.PortalKey, msg.Timestamp) + if err != nil { + log.Err(err).Msg("Failed to get read receipt target message") return - } - puppet.UpdateInfo(userTeam, true, nil) - intent := puppet.IntentFor(portal) - - message := portal.bridge.DB.Message.GetBySlackID(portal.Key, msg.Timestamp) - - if message == nil { - portal.log.Debugfln("Couldn't mark portal %s as read: no Matrix room", portal.Key) + } else if message == nil { + log.Debug().Msg("Ignoring read receipt for unknown message") return } - - err := intent.MarkRead(portal.MXID, message.MatrixID) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("message_mxid", message.MXID) + }) + err = source.User.DoublePuppetIntent.MarkRead(ctx, portal.MXID, message.MXID) if err != nil { - portal.log.Warnfln("Error marking Matrix room %s as read by %s: %v", portal.MXID, intent.UserID, err) + log.Err(err).Msg("Failed to mark message as read on Matrix after Slack read receipt") + } else { + log.Debug().Msg("Marked message as read on Matrix after Slack read receipt") } } diff --git a/provisioning-legacy.go b/provisioning-legacy.go new file mode 100644 index 0000000..d533302 --- /dev/null +++ b/provisioning-legacy.go @@ -0,0 +1,146 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/rs/zerolog/hlog" + "maunium.net/go/mautrix/bridge/status" + "maunium.net/go/mautrix/id" +) + +type legacyResponse struct { + Success bool `json:"success"` + Status string `json:"status"` +} + +type legacyError struct { + Success bool `json:"success"` + Error string `json:"error"` + ErrCode string `json:"errcode"` +} + +func (p *ProvisioningAPI) legacyPing(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) + + puppets := []any{} + p.bridge.userAndTeamLock.Lock() + for _, ut := range user.teams { + puppets = append(puppets, map[string]interface{}{ + "puppetId": fmt.Sprintf("%s-%s", ut.UserTeamKey.TeamID, ut.UserTeamKey.UserID), + "puppetMxid": user.MXID, + "userId": ut.UserID, + "data": map[string]interface{}{ + "team": map[string]string{ + "id": ut.TeamID, + "name": ut.Team.Name, + }, + "self": map[string]string{ + "id": ut.UserID, + "name": ut.Email, + }, + }, + }) + } + p.bridge.userAndTeamLock.Unlock() + + resp := map[string]any{ + "puppets": puppets, + "management_room": user.ManagementRoom, + "mxid": user.MXID, + } + jsonResponse(w, http.StatusOK, resp) +} + +func (p *ProvisioningAPI) legacyLogout(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("user_id") + user := p.bridge.GetUserByMXID(id.UserID(userID)) + + slackTeamID := strings.Split(r.URL.Query().Get("slack_team_id"), "-")[0] // in case some client sends userTeam instead of team ID + + userTeam := user.GetTeam(slackTeamID) + if userTeam == nil || userTeam.Token == "" { + jsonResponse(w, http.StatusNotFound, legacyError{ + Error: "Not logged in", + ErrCode: "Not logged in", + }) + + return + } + + userTeam.Logout(r.Context(), status.BridgeState{StateEvent: status.StateLoggedOut}) + jsonResponse(w, http.StatusOK, legacyResponse{true, "Logged out successfully."}) +} + +func (p *ProvisioningAPI) legacyLogin(w http.ResponseWriter, r *http.Request) { + userID := r.URL.Query().Get("user_id") + user := p.bridge.GetUserByMXID(id.UserID(userID)) + + var data struct { + Token string + Cookietoken string + } + + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + jsonResponse(w, http.StatusBadRequest, legacyError{ + Error: "Invalid JSON", + ErrCode: "Invalid JSON", + }) + return + } + + if data.Token == "" { + jsonResponse(w, http.StatusBadRequest, legacyError{ + Error: "Missing field token", + ErrCode: "Missing field token", + }) + return + } + + if data.Cookietoken == "" { + jsonResponse(w, http.StatusBadRequest, legacyError{ + Error: "Missing field cookietoken", + ErrCode: "Missing field cookietoken", + }) + return + } + + cookieToken, _ := url.PathUnescape(data.Cookietoken) + info, err := user.TokenLogin(r.Context(), data.Token, cookieToken) + if err != nil { + hlog.FromRequest(r).Err(err).Msg("Failed to do token login") + jsonResponse(w, http.StatusNotAcceptable, legacyError{ + Error: fmt.Sprintf("Slack login error: %s", err), + ErrCode: err.Error(), + }) + + return + } + + jsonResponse(w, http.StatusCreated, + map[string]interface{}{ + "success": true, + "teamid": info.TeamID, + "userid": info.UserID, + }) +} diff --git a/provisioning.go b/provisioning.go index 7619f94..5397c46 100644 --- a/provisioning.go +++ b/provisioning.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,103 +17,59 @@ package main import ( - "bufio" "context" "encoding/json" - "errors" - "fmt" - "net" "net/http" - "net/url" "strings" - "time" - - "github.com/gorilla/websocket" - log "maunium.net/go/maulogger/v2" + "github.com/beeper/libserv/pkg/requestlog" + "github.com/gorilla/mux" + "github.com/rs/zerolog" + "github.com/rs/zerolog/hlog" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/bridge/status" "maunium.net/go/mautrix/id" ) -const ( - SecWebSocketProtocol = "com.gitlab.beeper.slack" -) - type ProvisioningAPI struct { bridge *SlackBridge - log log.Logger + log zerolog.Logger } func newProvisioningAPI(br *SlackBridge) *ProvisioningAPI { - p := &ProvisioningAPI{ + prov := &ProvisioningAPI{ bridge: br, - log: br.Log.Sub("Provisioning"), + log: br.ZLog.With().Str("component", "provisioning").Logger(), } - prefix := br.Config.Bridge.Provisioning.Prefix + prov.log.Debug().Str("base_path", prov.bridge.Config.Bridge.Provisioning.Prefix).Msg("Enabling provisioning API") + r := br.AS.Router.PathPrefix(br.Config.Bridge.Provisioning.Prefix).Subrouter() - p.log.Debugln("Enabling provisioning API at", prefix) + r.Use(hlog.NewHandler(prov.log)) + r.Use(requestlog.AccessLogger(true)) + r.Use(prov.authMiddleware) - r := br.AS.Router.PathPrefix(prefix).Subrouter() + r.HandleFunc("/v1/ping", prov.legacyPing).Methods(http.MethodGet) + r.HandleFunc("/v1/login", prov.legacyLogin).Methods(http.MethodPost) + r.HandleFunc("/v1/logout", prov.legacyLogout).Methods(http.MethodPost) - r.Use(p.authMiddleware) + r.HandleFunc("/v2/ping", prov.ping).Methods(http.MethodGet) + r.HandleFunc("/v2/login", prov.login).Methods(http.MethodPost) + r.HandleFunc("/v2/logout/{teamID}", prov.logout).Methods(http.MethodPost) - r.HandleFunc("/v1/ping", p.ping).Methods(http.MethodGet) - r.HandleFunc("/v1/login", p.login).Methods(http.MethodPost) - r.HandleFunc("/v1/logout", p.logout).Methods(http.MethodPost) - p.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.asmux/ping", p.BridgeStatePing).Methods(http.MethodPost) - p.bridge.AS.Router.HandleFunc("/_matrix/app/com.beeper.bridge_state", p.BridgeStatePing).Methods(http.MethodPost) - - return p + return prov } -func jsonResponse(w http.ResponseWriter, status int, response interface{}) { +func jsonResponse(w http.ResponseWriter, status int, response any) { w.Header().Add("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(response) } -// Response structs -type Response struct { - Success bool `json:"success"` - Status string `json:"status"` -} - -type Error struct { - Success bool `json:"success"` - Error string `json:"error"` - ErrCode string `json:"errcode"` -} - -// Wrapped http.ResponseWriter to capture the status code -type responseWrap struct { - http.ResponseWriter - statusCode int -} - -var _ http.Hijacker = (*responseWrap)(nil) - -func (rw *responseWrap) WriteHeader(statusCode int) { - rw.ResponseWriter.WriteHeader(statusCode) - rw.statusCode = statusCode -} - -func (rw *responseWrap) Hijack() (net.Conn, *bufio.ReadWriter, error) { - hijacker, ok := rw.ResponseWriter.(http.Hijacker) - if !ok { - return nil, nil, errors.New("response does not implement http.Hijacker") - } - return hijacker.Hijack() -} - -// Middleware func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - auth := r.Header.Get("Authorization") - - // Special case the login endpoint - auth = strings.TrimPrefix(auth, "Bearer ") - + auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if auth != p.bridge.Config.Bridge.Provisioning.SharedSecret { jsonResponse(w, http.StatusForbidden, map[string]interface{}{ "error": "Invalid auth token", @@ -125,172 +81,146 @@ func (p *ProvisioningAPI) authMiddleware(h http.Handler) http.Handler { userID := r.URL.Query().Get("user_id") user := p.bridge.GetUserByMXID(id.UserID(userID)) + if user != nil { + hlog.FromRequest(r).UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Stringer("user_id", user.MXID) + }) + } - start := time.Now() - wWrap := &responseWrap{w, 200} - h.ServeHTTP(wWrap, r.WithContext(context.WithValue(r.Context(), "user", user))) - duration := time.Since(start).Seconds() - - p.log.Infofln("%s %s from %s took %.2f seconds and returned status %d", r.Method, r.URL.Path, user.MXID, duration, wWrap.statusCode) + h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", user))) }) } -// websocket upgrader -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true - }, - Subprotocols: []string{SecWebSocketProtocol}, +type RespPingSlackTeam struct { + ID string `json:"id"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + SpaceMXID id.RoomID `json:"space_mxid"` + AvatarURL id.ContentURI `json:"avatar_url"` } -// Handlers -func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user").(*User) - - puppets := []interface{}{} - for _, team := range user.GetLoggedInTeams() { - puppets = append(puppets, map[string]interface{}{ - "puppetId": team.Key.String(), - "puppetMxid": user.MXID, - "userId": team.Key.SlackID, - "data": map[string]interface{}{ - "team": map[string]string{ - "id": team.Key.TeamID, - "name": team.TeamName, - }, - "self": map[string]string{ - "id": team.Key.String(), - "name": team.SlackEmail, - }, - }, - }) - } - - user.Lock() - - resp := map[string]interface{}{ - "puppets": puppets, - "management_room": user.ManagementRoom, - "mxid": user.MXID, - } +type RespPingSlackUser struct { + ID string `json:"id"` + Email string `json:"email"` +} - user.Unlock() +type RespPingSlackEntry struct { + Team RespPingSlackTeam `json:"team"` + User RespPingSlackUser `json:"user"` - jsonResponse(w, http.StatusOK, resp) + Connected bool `json:"connected"` + LoggedIn bool `json:"logged_in"` } -func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) { - userID := r.URL.Query().Get("user_id") - user := p.bridge.GetUserByMXID(id.UserID(userID)) +type RespPing struct { + MXID id.UserID `json:"mxid"` + ManagementRoom id.RoomID `json:"management_room"` + SpaceRoom id.RoomID `json:"space_room"` + Admin bool `json:"admin"` + Whitelisted bool `json:"whitelisted"` + SlackTeams []RespPingSlackEntry `json:"slack_teams"` +} - slackTeamID := strings.Split(r.URL.Query().Get("slack_team_id"), "-")[0] // in case some client sends userTeam instead of team ID +func (p *ProvisioningAPI) ping(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) - userTeam := user.GetUserTeam(slackTeamID) - if userTeam == nil || !userTeam.IsLoggedIn() { - jsonResponse(w, http.StatusNotFound, Error{ - Error: "Not logged in", - ErrCode: "Not logged in", + p.bridge.userAndTeamLock.Lock() + teams := make([]RespPingSlackEntry, 0, len(user.teams)) + for _, ut := range user.teams { + if ut.Token == "" { + return + } + teams = append(teams, RespPingSlackEntry{ + Team: RespPingSlackTeam{ + ID: ut.Team.ID, + Name: ut.Team.Name, + Subdomain: ut.Team.Domain, + SpaceMXID: ut.Team.MXID, + AvatarURL: ut.Team.AvatarMXC, + }, + User: RespPingSlackUser{ + ID: ut.UserID, + Email: ut.Email, + }, + Connected: ut.RTM != nil, + LoggedIn: ut.Token != "", }) - - return } + p.bridge.userAndTeamLock.Unlock() + + jsonResponse(w, http.StatusOK, &RespPing{ + MXID: user.MXID, + ManagementRoom: user.ManagementRoom, + SpaceRoom: user.SpaceRoom, + Admin: user.PermissionLevel >= bridgeconfig.PermissionLevelAdmin, + Whitelisted: user.PermissionLevel >= bridgeconfig.PermissionLevelUser, + SlackTeams: teams, + }) +} - err := user.LogoutUserTeam(userTeam) - - if err != nil { - user.log.Warnln("Error while logging out:", err) - - jsonResponse(w, http.StatusInternalServerError, Error{ - Error: fmt.Sprintf("Unknown error while logging out: %v", err), - ErrCode: err.Error(), - }) - - return - } +type ReqLogin struct { + Token string `json:"token"` + CookieToken string `json:"cookie_token"` +} - jsonResponse(w, http.StatusOK, Response{true, "Logged out successfully."}) +type RespLogin struct { + TeamID string `json:"team_id"` + TeamName string `json:"team_name"` + UserID string `json:"user_id"` + UserEmail string `json:"user_email"` } func (p *ProvisioningAPI) login(w http.ResponseWriter, r *http.Request) { - userID := r.URL.Query().Get("user_id") - user := p.bridge.GetUserByMXID(id.UserID(userID)) - - var data struct { - Token string - Cookietoken string - } + user := r.Context().Value("user").(*User) - err := json.NewDecoder(r.Body).Decode(&data) + var req ReqLogin + err := json.NewDecoder(r.Body).Decode(&req) if err != nil { - jsonResponse(w, http.StatusBadRequest, Error{ - Error: "Invalid JSON", - ErrCode: "Invalid JSON", + jsonResponse(w, http.StatusBadRequest, mautrix.RespError{ + Err: "Invalid request body", + ErrCode: "M_NOT_JSON", }) return - } - - if data.Token == "" { - jsonResponse(w, http.StatusBadRequest, Error{ - Error: "Missing field token", - ErrCode: "Missing field token", + } else if req.Token == "" { + jsonResponse(w, http.StatusBadRequest, mautrix.RespError{ + Err: "Missing `token` field in request body", + ErrCode: "M_BAD_JSON", }) return } - if data.Cookietoken == "" { - jsonResponse(w, http.StatusBadRequest, Error{ - Error: "Missing field cookietoken", - ErrCode: "Missing field cookietoken", - }) - return - } - - cookieToken, _ := url.PathUnescape(data.Cookietoken) - info, err := user.TokenLogin(data.Token, cookieToken) + authInfo, err := user.TokenLogin(r.Context(), req.Token, req.CookieToken) if err != nil { - jsonResponse(w, http.StatusNotAcceptable, Error{ - Error: fmt.Sprintf("Slack login error: %s", err), - ErrCode: err.Error(), + hlog.FromRequest(r).Err(err).Msg("Failed to do token login") + // TODO proper error messages for known types + jsonResponse(w, http.StatusInternalServerError, &mautrix.RespError{ + Err: "Failed to log in", + ErrCode: "M_UNKNOWN", }) - return } - - jsonResponse(w, http.StatusCreated, - map[string]interface{}{ - "success": true, - "teamid": info.TeamID, - "userid": info.UserID, - }) -} - -func (p *ProvisioningAPI) BridgeStatePing(w http.ResponseWriter, r *http.Request) { - if !p.bridge.AS.CheckServerToken(w, r) { - return + resp := &RespLogin{ + TeamID: authInfo.TeamID, + TeamName: authInfo.TeamName, + UserID: authInfo.UserID, + UserEmail: authInfo.UserEmail, } - userID := r.URL.Query().Get("user_id") - user := p.bridge.GetUserByMXID(id.UserID(userID)) - var global status.BridgeState - global.StateEvent = status.StateRunning - global = global.Fill(nil) + hlog.FromRequest(r).Info().Any("auth_info", resp).Msg("Token login successful") + jsonResponse(w, http.StatusOK, resp) +} - resp := status.GlobalBridgeState{ - BridgeState: global, - RemoteStates: map[string]status.BridgeState{}, - } +func (p *ProvisioningAPI) logout(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user").(*User) - userTeams := user.GetLoggedInTeams() - for _, userTeam := range userTeams { - var remote status.BridgeState - if userTeam.IsLoggedIn() { - remote.StateEvent = status.StateConnected - } else { - remote.StateEvent = status.StateLoggedOut - } - remote = remote.Fill(userTeam) - resp.RemoteStates[remote.RemoteID] = remote + userTeam := user.GetTeam(strings.ToUpper(mux.Vars(r)["team_id"])) + if userTeam == nil || userTeam.Token == "" { + jsonResponse(w, http.StatusNotFound, &mautrix.RespError{ + Err: "Not logged into that team", + ErrCode: "M_NOT_FOUND", + }) + return } - user.log.Debugfln("Responding bridge state in bridge status endpoint: %+v", resp) - jsonResponse(w, http.StatusOK, &resp) + userTeam.Logout(r.Context(), status.BridgeState{StateEvent: status.StateLoggedOut}) + jsonResponse(w, http.StatusOK, struct{}{}) } diff --git a/provisioning.md b/provisioning.md index c5a1d26..c99777c 100644 --- a/provisioning.md +++ b/provisioning.md @@ -2,59 +2,61 @@ All endpoints below require the provisioning shared secret in the `Authorization` HTTP header, and the Matrix user ID of the bridge user in the `user_id` query parameter. -## GET `/_matrix/provision/v1/ping` +## GET `/_matrix/provision/v2/ping` ### Success response format -``` +```json { - "management_room": "Management room ID for this user", - "mxid": "Matrix ID of this user", - "puppets": [ + "mxid": "@user:example.com", + "management_room": "!foomanagement:example.com", + "space_room": "!foospace:example.com", + "admin": false, + "whitelisted": true, + "slack_teams": [ { - "puppetId": "TEAMID-USERID", - "puppetMxid": "Matrix ID of user", - "data": { - "team": { - "id": "TEAMID", - "name": "Name of Slack team" - }, - "self": { - "id": "USERID", - "name": "Name of Slack user" - } + "team": { + "id": "T0G9PQBBK", + "name": "Example Team", + "subdomain": "example", + "space_mxid": "!fooworkspace:example.com", + "avatar_url": "mxc://example.com/foovatar" + }, + "user": { + "id": "U0G9QF9C6", + "email": "user@example.com" }, - "userId": "USERID" + "connected": true, + "logged_in": true } ] } ``` -## POST `/_matrix/provision/v1/login` +## POST `/_matrix/provision/v2/login` ### Body format -``` +```json { "token": "xoxc-client token", - "cookietoken": "xoxd-cookie token" + "cookie_token": "xoxd-cookie token" } ``` ### Success response format -``` +```json { - "success": true, - "teamid": "Slack team ID", - "userid": "ID of this user on this team" + "team_id": "T0G9PQBBK", + "team_name": "Example Team", + "user_id": "U0G9QF9C6", + "user_email": "user@example.com" } ``` -## POST `/_matrix/provision/v1/logout` - -### Body format +## POST `/_matrix/provision/v2/logout/{teamID}` -Requires URL query parameter `slack_team_id` - team ID of the Slack team to be logged out +Requires path parameter with the team ID of the Slack team to be logged out (e.g. `T0G9PQBBK`). -Returns 200 on successful logout. +Returns 200 with an empty JSON object on successful logout. diff --git a/puppet.go b/puppet.go index 852dc7a..a49800f 100644 --- a/puppet.go +++ b/puppet.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,16 +17,18 @@ package main import ( + "context" "fmt" "regexp" "strings" "sync" + "time" - log "maunium.net/go/maulogger/v2" + "github.com/rs/zerolog" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" "maunium.net/go/mautrix/bridge" - "maunium.net/go/mautrix/bridge/bridgeconfig" "maunium.net/go/mautrix/id" "github.com/slack-go/slack" @@ -38,437 +40,292 @@ type Puppet struct { *database.Puppet bridge *SlackBridge - log log.Logger + zlog zerolog.Logger MXID id.UserID - customIntent *appservice.IntentAPI - customUser *User - + lastSync time.Time syncLock sync.Mutex } var _ bridge.Ghost = (*Puppet)(nil) +func (puppet *Puppet) SwitchCustomMXID(accessToken string, userID id.UserID) error { + return fmt.Errorf("puppets don't support custom MXIDs here") +} + +func (puppet *Puppet) ClearCustomMXID() {} + +func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { + return nil +} + func (puppet *Puppet) GetMXID() id.UserID { return puppet.MXID } -func (puppet *Puppet) GetCustomOrGhostMXID() id.UserID { - if puppet.CustomMXID != "" { - return puppet.CustomMXID - } else { - return puppet.MXID +func (br *SlackBridge) loadPuppet(ctx context.Context, dbPuppet *database.Puppet, key *database.UserTeamKey) *Puppet { + if dbPuppet == nil { + if key == nil { + return nil + } + dbPuppet = br.DB.Puppet.New() + dbPuppet.UserTeamKey = *key + err := dbPuppet.Insert(ctx) + if err != nil { + br.ZLog.Err(err).Object("puppet_id", key).Msg("Failed to insert new puppet") + return nil + } } -} -var userIDRegex *regexp.Regexp + puppet := br.newPuppet(dbPuppet) + br.puppets[puppet.UserTeamKey] = puppet + return puppet +} -func (br *SlackBridge) NewPuppet(dbPuppet *database.Puppet) *Puppet { +func (br *SlackBridge) newPuppet(dbPuppet *database.Puppet) *Puppet { + log := br.ZLog.With().Object("puppet_id", dbPuppet.UserTeamKey).Logger() return &Puppet{ Puppet: dbPuppet, bridge: br, - log: br.Log.Sub(fmt.Sprintf("Puppet/%s-%s", dbPuppet.TeamID, dbPuppet.UserID)), - - MXID: br.FormatPuppetMXID(dbPuppet.TeamID + "-" + dbPuppet.UserID), + zlog: log, + MXID: br.FormatPuppetMXID(dbPuppet.UserTeamKey), } } -func (br *SlackBridge) ParsePuppetMXID(mxid id.UserID) (string, string, bool) { - if userIDRegex == nil { - pattern := fmt.Sprintf( - "^@%s:%s$", - br.Config.Bridge.FormatUsername("([A-Za-z0-9]+)-([A-Za-z0-9]+)"), - br.Config.Homeserver.Domain, - ) +func (br *SlackBridge) FormatPuppetMXID(utk database.UserTeamKey) id.UserID { + return id.NewUserID( + br.Config.Bridge.FormatUsername(fmt.Sprintf("%s-%s", strings.ToLower(utk.TeamID), strings.ToLower(utk.UserID))), + br.Config.Homeserver.Domain, + ) +} - userIDRegex = regexp.MustCompile(pattern) +var userIDRegex *regexp.Regexp + +func (br *SlackBridge) ParsePuppetMXID(mxid id.UserID) (database.UserTeamKey, bool) { + if userIDRegex == nil { + userIDRegex = br.Config.MakeUserIDRegex("([a-z0-9]+)-([a-z0-9]+)") } match := userIDRegex.FindStringSubmatch(string(mxid)) if len(match) == 3 { - return match[1], match[2], true + return database.UserTeamKey{TeamID: strings.ToUpper(match[1]), UserID: strings.ToUpper(match[2])}, true } - return "", "", false + return database.UserTeamKey{}, false } func (br *SlackBridge) GetPuppetByMXID(mxid id.UserID) *Puppet { - team, id, ok := br.ParsePuppetMXID(mxid) + key, ok := br.ParsePuppetMXID(mxid) if !ok { return nil } - return br.GetPuppetByID(team, id) + return br.GetPuppetByID(key) } -func (br *SlackBridge) GetPuppetByID(teamID, userID string) *Puppet { - br.puppetsLock.Lock() - defer br.puppetsLock.Unlock() - - puppet, ok := br.puppets[teamID+"-"+userID] - if !ok { - dbPuppet := br.DB.Puppet.Get(teamID, userID) - if dbPuppet == nil { - dbPuppet = br.DB.Puppet.New() - dbPuppet.TeamID = teamID - dbPuppet.UserID = userID - dbPuppet.Insert() - } - - puppet = br.NewPuppet(dbPuppet) - br.puppets[puppet.Key()] = puppet +func (br *SlackBridge) GetPuppetByID(key database.UserTeamKey) *Puppet { + if key.TeamID == "" || key.UserID == "" { + return nil } - - return puppet -} - -func (br *SlackBridge) GetPuppetByCustomMXID(mxid id.UserID) *Puppet { br.puppetsLock.Lock() defer br.puppetsLock.Unlock() - puppet, ok := br.puppetsByCustomMXID[mxid] + puppet, ok := br.puppets[key] if !ok { - dbPuppet := br.DB.Puppet.GetByCustomMXID(mxid) - if dbPuppet == nil { + ctx := context.TODO() + dbPuppet, err := br.DB.Puppet.Get(ctx, key) + if err != nil { + br.ZLog.Err(err).Object("puppet_id", key).Msg("Failed to get puppet from database") return nil } - - puppet = br.NewPuppet(dbPuppet) - br.puppets[puppet.Key()] = puppet - br.puppetsByCustomMXID[puppet.CustomMXID] = puppet + return br.loadPuppet(ctx, dbPuppet, &key) } return puppet } -func (br *SlackBridge) GetAllPuppetsWithCustomMXID() []*Puppet { - return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllWithCustomMXID()) -} - -func (br *SlackBridge) GetAllPuppets() []*Puppet { - return br.dbPuppetsToPuppets(br.DB.Puppet.GetAll()) -} - func (br *SlackBridge) GetAllPuppetsForTeam(teamID string) []*Puppet { - return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllForTeam(teamID)) + return br.dbPuppetsToPuppets(br.DB.Puppet.GetAllForTeam(context.TODO(), teamID)) } -func (br *SlackBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet) []*Puppet { +func (br *SlackBridge) dbPuppetsToPuppets(dbPuppets []*database.Puppet, err error) []*Puppet { + if err != nil { + br.ZLog.Err(err).Msg("Failed to load puppets") + return nil + } br.puppetsLock.Lock() defer br.puppetsLock.Unlock() output := make([]*Puppet, len(dbPuppets)) - for index, dbPuppet := range dbPuppets { - if dbPuppet == nil { - continue - } - - puppet, ok := br.puppets[dbPuppet.TeamID+"-"+dbPuppet.UserID] - if !ok { - puppet = br.NewPuppet(dbPuppet) - br.puppets[puppet.Key()] = puppet - - if dbPuppet.CustomMXID != "" { - br.puppetsByCustomMXID[dbPuppet.CustomMXID] = puppet - } + ctx := context.TODO() + for i, dbPuppet := range dbPuppets { + puppet, ok := br.puppets[dbPuppet.UserTeamKey] + if ok { + output[i] = puppet + } else { + output[i] = br.loadPuppet(ctx, dbPuppet, nil) } - - output[index] = puppet } return output } -func (br *SlackBridge) FormatPuppetMXID(did string) id.UserID { - return id.NewUserID( - br.Config.Bridge.FormatUsername(strings.ToLower(did)), - br.Config.Homeserver.Domain, - ) -} - func (puppet *Puppet) DefaultIntent() *appservice.IntentAPI { return puppet.bridge.AS.Intent(puppet.MXID) } func (puppet *Puppet) IntentFor(portal *Portal) *appservice.IntentAPI { - if puppet.customIntent == nil { - return puppet.DefaultIntent() + if puppet.UserID != portal.DMUserID { + userTeam := puppet.bridge.GetCachedUserTeamByID(puppet.UserTeamKey) + if userTeam != nil && userTeam.User.DoublePuppetIntent != nil { + return userTeam.User.DoublePuppetIntent + } } - - return puppet.customIntent + return puppet.DefaultIntent() } -func (puppet *Puppet) CustomIntent() *appservice.IntentAPI { - return puppet.customIntent -} +const minPuppetSyncInterval = 4 * time.Hour -func (puppet *Puppet) Key() string { - return puppet.TeamID + "-" + puppet.UserID +func (puppet *Puppet) UpdateInfoIfNecessary(ctx context.Context, source *UserTeam) { + puppet.syncLock.Lock() + defer puppet.syncLock.Unlock() + if puppet.Name != "" && time.Since(puppet.lastSync) > minPuppetSyncInterval { + return + } + puppet.unlockedUpdateInfo(ctx, source, nil, nil) } -func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) { - for _, portal := range puppet.bridge.GetDMPortalsWith(puppet.UserID) { - // Get room create lock to prevent races between receiving contact info and room creation. - portal.roomCreateLock.Lock() - meta(portal) - portal.roomCreateLock.Unlock() - } +func (puppet *Puppet) UpdateInfo(ctx context.Context, userTeam *UserTeam, info *slack.User, botInfo *slack.Bot) { + puppet.syncLock.Lock() + defer puppet.syncLock.Unlock() + puppet.unlockedUpdateInfo(ctx, userTeam, info, botInfo) } -func (puppet *Puppet) updateName(source *User) bool { - userTeam := source.GetUserTeam(puppet.TeamID) - user, err := userTeam.Client.GetUserInfo(puppet.UserID) +func (puppet *Puppet) unlockedUpdateInfo(ctx context.Context, userTeam *UserTeam, info *slack.User, botInfo *slack.Bot) { + puppet.lastSync = time.Now() + + err := puppet.DefaultIntent().EnsureRegistered(ctx) if err != nil { - puppet.log.Warnln("failed to get user from id:", err) - return false + puppet.zlog.Err(err).Msg("Failed to ensure registered") } - newName := puppet.bridge.Config.Bridge.FormatDisplayname(user) - - if puppet.Name != newName { - err := puppet.DefaultIntent().SetDisplayName(newName) - if err == nil { - puppet.Name = newName - puppet.Update() + if info == nil && botInfo == nil { + if strings.ToLower(puppet.UserID[0:1]) == "b" { + botInfo, err = userTeam.Client.GetBotInfo(puppet.UserID) } else { - puppet.log.Warnln("failed to set display name:", err) + info, err = userTeam.Client.GetUserInfo(puppet.UserID) } - - return true - } - - return false -} - -func (puppet *Puppet) updateAvatar(source *User) bool { - // TODO - return false - // user, err := source.Client.GetUserInfo(puppet.ID) - // if err != nil { - // puppet.log.Warnln("Failed to get user:", err) - - // return false - // } - - // if puppet.Avatar == user.Avatar { - // return false - // } - - // if user.Avatar == "" { - // puppet.log.Warnln("User does not have an avatar") - - // return false - // } - - // url, err := uploadAvatar(puppet.DefaultIntent(), user.AvatarURL("")) - // if err != nil { - // puppet.log.Warnln("Failed to upload user avatar:", err) - - // return false - // } - - // puppet.AvatarURL = url - - // err = puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) - // if err != nil { - // puppet.log.Warnln("Failed to set avatar:", err) - // } - - // puppet.log.Debugln("Updated avatar", puppet.Avatar, "->", user.Avatar) - // puppet.Avatar = user.Avatar - // go puppet.updatePortalAvatar() - - // return true -} - -func (puppet *Puppet) updatePortalAvatar() { - puppet.updatePortalMeta(func(portal *Portal) { - if portal.MXID != "" { - _, err := portal.MainIntent().SetRoomAvatar(portal.MXID, puppet.AvatarURL) - if err != nil { - portal.log.Warnln("Failed to set avatar:", err) - } + if err != nil { + puppet.zlog.Err(err).Object("fetch_via_id", userTeam.UserTeamMXIDKey). + Msg("Failed to fetch info to update ghost") + return } - - portal.AvatarURL = puppet.AvatarURL - portal.Avatar = puppet.Avatar - portal.Update(nil) - }) - -} - -func (puppet *Puppet) SyncContact(source *User) { - puppet.syncLock.Lock() - defer puppet.syncLock.Unlock() - - puppet.log.Debugln("syncing contact", puppet.Name) - - err := puppet.DefaultIntent().EnsureRegistered() - if err != nil { - puppet.log.Errorln("Failed to ensure registered:", err) } - update := false + changed := false - update = puppet.updateName(source) || update + if !puppet.IsBot && (strings.ToLower(puppet.UserID) == "uslackbot" || botInfo != nil) { + puppet.IsBot = true + changed = true + } + if info != nil { + newName := puppet.bridge.Config.Bridge.FormatDisplayname(info) + changed = puppet.UpdateName(ctx, newName) || changed + changed = puppet.UpdateAvatar(ctx, info.Profile.ImageOriginal) || changed - if puppet.Avatar == "" { - update = puppet.updateAvatar(source) || update - puppet.log.Debugln("update avatar returned", update) + if (info.IsBot || info.IsAppUser) && !puppet.IsBot { + puppet.IsBot = true + changed = true + } + } else if botInfo != nil { + newName := puppet.bridge.Config.Bridge.FormatBotDisplayname(botInfo) + changed = puppet.UpdateName(ctx, newName) || changed + changed = puppet.UpdateAvatar(ctx, botInfo.Icons.Image72) || changed } + changed = puppet.UpdateContactInfo(ctx, puppet.IsBot) || changed - if update { - puppet.Update() + if changed { + err = puppet.Update(ctx) + if err != nil { + puppet.zlog.Err(err).Msg("Failed to save info to database") + } } } -func (puppet *Puppet) UpdateName(newName string) bool { +func (puppet *Puppet) UpdateName(ctx context.Context, newName string) bool { if puppet.Name == newName && puppet.NameSet { return false } + puppet.zlog.Debug().Str("old_name", puppet.Name).Str("new_name", newName).Msg("Updating displayname") puppet.Name = newName puppet.NameSet = false - err := puppet.DefaultIntent().SetDisplayName(newName) + err := puppet.DefaultIntent().SetDisplayName(ctx, newName) if err != nil { - puppet.log.Warnln("Failed to update displayname:", err) + puppet.zlog.Err(err).Msg("Failed to update displayname") } else { go puppet.updatePortalMeta(func(portal *Portal) { - if portal.UpdateNameDirect(puppet.Name) { - portal.Update(nil) - portal.UpdateBridgeInfo() - } + portal.UpdateNameFromPuppet(ctx, puppet) }) puppet.NameSet = true } return true } -func (puppet *Puppet) UpdateAvatar(url string) bool { +func (puppet *Puppet) updatePortalMeta(meta func(portal *Portal)) { + for _, portal := range puppet.bridge.GetDMPortalsWith(puppet.UserTeamKey) { + // Get room create lock to prevent races between receiving contact info and room creation. + portal.roomCreateLock.Lock() + meta(portal) + portal.roomCreateLock.Unlock() + } +} + +func (puppet *Puppet) UpdateAvatar(ctx context.Context, url string) bool { if puppet.Avatar == url && puppet.AvatarSet { return false } avatarChanged := url != puppet.Avatar puppet.Avatar = url puppet.AvatarSet = false - puppet.AvatarURL = id.ContentURI{} + puppet.AvatarMXC = id.ContentURI{} - // TODO should we just use slack's default avatars for users with no avatar? - if puppet.Avatar != "" && (puppet.AvatarURL.IsEmpty() || avatarChanged) { - url, err := uploadPlainFile(puppet.DefaultIntent(), url) + if puppet.Avatar != "" && (puppet.AvatarMXC.IsEmpty() || avatarChanged) { + url, err := uploadPlainFile(ctx, puppet.DefaultIntent(), url) if err != nil { - puppet.log.Warnfln("Failed to reupload user avatar %s: %v", puppet.Avatar, err) + puppet.zlog.Err(err).Msg("Failed to reupload new avatar") return true } - puppet.AvatarURL = url + puppet.AvatarMXC = url } - err := puppet.DefaultIntent().SetAvatarURL(puppet.AvatarURL) + err := puppet.DefaultIntent().SetAvatarURL(ctx, puppet.AvatarMXC) if err != nil { - puppet.log.Warnln("Failed to update avatar:", err) + puppet.zlog.Err(err).Msg("Failed to update avatar") } else { go puppet.updatePortalMeta(func(portal *Portal) { - if portal.UpdateAvatarFromPuppet(puppet) { - portal.Update(nil) - portal.UpdateBridgeInfo() - } + portal.UpdateAvatarFromPuppet(ctx, puppet) }) puppet.AvatarSet = true } return true } -func (puppet *Puppet) UpdateInfo(userTeam *database.UserTeam, fetch bool, info *slack.User) { - if strings.HasPrefix(strings.ToLower(puppet.UserID), "b") { - puppet.UpdateInfoBot(userTeam) - return - } - - puppet.syncLock.Lock() - defer puppet.syncLock.Unlock() - - if info == nil && fetch { - var err error - puppet.log.Debugfln("Fetching info through team %s to update", userTeam.Key.TeamID) - - info, err = userTeam.Client.GetUserInfo(puppet.UserID) - if err != nil { - puppet.log.Errorfln("Failed to fetch info through %s: %v", userTeam.Key.TeamID, err) - return - } - } - - err := puppet.DefaultIntent().EnsureRegistered() - if err != nil { - puppet.log.Errorln("Failed to ensure registered:", err) - } - - changed := false - - if info != nil { - newName := puppet.bridge.Config.Bridge.FormatDisplayname(info) - changed = puppet.UpdateName(newName) || changed - changed = puppet.UpdateAvatar(info.Profile.ImageOriginal) || changed - - if (info.IsBot || info.IsAppUser) && !puppet.IsBot { - puppet.IsBot = true - changed = true - } - } - changed = puppet.UpdateContactInfo(puppet.IsBot || strings.ToLower(puppet.UserID) == "uslackbot") || changed - - if changed { - puppet.Update() - } -} - -func (puppet *Puppet) UpdateContactInfo(isBot bool) bool { - if puppet.bridge.Config.Homeserver.Software != bridgeconfig.SoftwareHungry { +func (puppet *Puppet) UpdateContactInfo(ctx context.Context, isBot bool) bool { + if puppet.bridge.SpecVersions.Supports(mautrix.BeeperFeatureArbitraryProfileMeta) || puppet.ContactInfoSet { return false } - if !puppet.ContactInfoSet { - contactInfo := map[string]any{ - "com.beeper.bridge.remote_id": puppet.UserID, - "com.beeper.bridge.service": "slackgo", - "com.beeper.bridge.network": "slack", - "com.beeper.bridge.is_network_bot": isBot, - } - err := puppet.DefaultIntent().BeeperUpdateProfile(contactInfo) - if err != nil { - puppet.log.Warnln("Failed to store custom contact info in profile:", err) - return false - } else { - puppet.ContactInfoSet = true - return true - } - } - return false -} - -func (puppet *Puppet) UpdateInfoBot(userTeam *database.UserTeam) { - puppet.syncLock.Lock() - defer puppet.syncLock.Unlock() - - puppet.log.Debugfln("Fetching bot info through team %s to update", userTeam.Key.TeamID) - info, err := userTeam.Client.GetBotInfo(puppet.UserID) - if err != nil { - puppet.log.Errorfln("Failed to fetch bot info through %s: %v", userTeam.Key.TeamID, err) - return - } - - err = puppet.DefaultIntent().EnsureRegistered() + err := puppet.DefaultIntent().BeeperUpdateProfile(ctx, map[string]any{ + "com.beeper.bridge.remote_id": puppet.UserID, + "com.beeper.bridge.service": "slackgo", + "com.beeper.bridge.network": "slack", + "com.beeper.bridge.is_network_bot": isBot, + }) if err != nil { - puppet.log.Errorfln("Failed to ensure bot %s registered: %v", puppet.UserID, err) - } - - changed := false - - newName := puppet.bridge.Config.Bridge.FormatBotDisplayname(info) - changed = puppet.UpdateName(newName) || changed - changed = puppet.UpdateAvatar(info.Icons.Image72) || changed - changed = puppet.UpdateContactInfo(true) || changed - - if changed { - puppet.Update() + puppet.zlog.Err(err).Msg("Failed to store custom contact info in profile") + return false + } else { + puppet.ContactInfoSet = true + return true } } diff --git a/resources/emoji.json b/resources/emoji.json deleted file mode 100644 index df5d6b6..0000000 --- a/resources/emoji.json +++ /dev/null @@ -1 +0,0 @@ -{"hash": "#️⃣", "keycap_star": "*️⃣", "zero": "0️⃣", "one": "1️⃣", "two": "2️⃣", "three": "3️⃣", "four": "4️⃣", "five": "5️⃣", "six": "6️⃣", "seven": "7️⃣", "eight": "8️⃣", "nine": "9️⃣", "copyright": "©️", "registered": "®️", "mahjong": "🀄", "black_joker": "🃏", "a": "🅰️", "b": "🅱️", "o2": "🅾️", "parking": "🅿️", "ab": "🆎", "cl": "🆑", "cool": "🆒", "free": "🆓", "id": "🆔", "new": "🆕", "ng": "🆖", "ok": "🆗", "sos": "🆘", "up": "🆙", "vs": "🆚", "flag-ac": "🇦🇨", "flag-ad": "🇦🇩", "flag-ae": "🇦🇪", "flag-af": "🇦🇫", "flag-ag": "🇦🇬", "flag-ai": "🇦🇮", "flag-al": "🇦🇱", "flag-am": "🇦🇲", "flag-ao": "🇦🇴", "flag-aq": "🇦🇶", "flag-ar": "🇦🇷", "flag-as": "🇦🇸", "flag-at": "🇦🇹", "flag-au": "🇦🇺", "flag-aw": "🇦🇼", "flag-ax": "🇦🇽", "flag-az": "🇦🇿", "flag-ba": "🇧🇦", "flag-bb": "🇧🇧", "flag-bd": "🇧🇩", "flag-be": "🇧🇪", "flag-bf": "🇧🇫", "flag-bg": "🇧🇬", "flag-bh": "🇧🇭", "flag-bi": "🇧🇮", "flag-bj": "🇧🇯", "flag-bl": "🇧🇱", "flag-bm": "🇧🇲", "flag-bn": "🇧🇳", "flag-bo": "🇧🇴", "flag-bq": "🇧🇶", "flag-br": "🇧🇷", "flag-bs": "🇧🇸", "flag-bt": "🇧🇹", "flag-bv": "🇧🇻", "flag-bw": "🇧🇼", "flag-by": "🇧🇾", "flag-bz": "🇧🇿", "flag-ca": "🇨🇦", "flag-cc": "🇨🇨", "flag-cd": "🇨🇩", "flag-cf": "🇨🇫", "flag-cg": "🇨🇬", "flag-ch": "🇨🇭", "flag-ci": "🇨🇮", "flag-ck": "🇨🇰", "flag-cl": "🇨🇱", "flag-cm": "🇨🇲", "cn": "🇨🇳", "flag-co": "🇨🇴", "flag-cp": "🇨🇵", "flag-cr": "🇨🇷", "flag-cu": "🇨🇺", "flag-cv": "🇨🇻", "flag-cw": "🇨🇼", "flag-cx": "🇨🇽", "flag-cy": "🇨🇾", "flag-cz": "🇨🇿", "de": "🇩🇪", "flag-dg": "🇩🇬", "flag-dj": "🇩🇯", "flag-dk": "🇩🇰", "flag-dm": "🇩🇲", "flag-do": "🇩🇴", "flag-dz": "🇩🇿", "flag-ea": "🇪🇦", "flag-ec": "🇪🇨", "flag-ee": "🇪🇪", "flag-eg": "🇪🇬", "flag-eh": "🇪🇭", "flag-er": "🇪🇷", "es": "🇪🇸", "flag-et": "🇪🇹", "flag-eu": "🇪🇺", "flag-fi": "🇫🇮", "flag-fj": "🇫🇯", "flag-fk": "🇫🇰", "flag-fm": "🇫🇲", "flag-fo": "🇫🇴", "fr": "🇫🇷", "flag-ga": "🇬🇦", "gb": "🇬🇧", "flag-gd": "🇬🇩", "flag-ge": "🇬🇪", "flag-gf": "🇬🇫", "flag-gg": "🇬🇬", "flag-gh": "🇬🇭", "flag-gi": "🇬🇮", "flag-gl": "🇬🇱", "flag-gm": "🇬🇲", "flag-gn": "🇬🇳", "flag-gp": "🇬🇵", "flag-gq": "🇬🇶", "flag-gr": "🇬🇷", "flag-gs": "🇬🇸", "flag-gt": "🇬🇹", "flag-gu": "🇬🇺", "flag-gw": "🇬🇼", "flag-gy": "🇬🇾", "flag-hk": "🇭🇰", "flag-hm": "🇭🇲", "flag-hn": "🇭🇳", "flag-hr": "🇭🇷", "flag-ht": "🇭🇹", "flag-hu": "🇭🇺", "flag-ic": "🇮🇨", "flag-id": "🇮🇩", "flag-ie": "🇮🇪", "flag-il": "🇮🇱", "flag-im": "🇮🇲", "flag-in": "🇮🇳", "flag-io": "🇮🇴", "flag-iq": "🇮🇶", "flag-ir": "🇮🇷", "flag-is": "🇮🇸", "it": "🇮🇹", "flag-je": "🇯🇪", "flag-jm": "🇯🇲", "flag-jo": "🇯🇴", "jp": "🇯🇵", "flag-ke": "🇰🇪", "flag-kg": "🇰🇬", "flag-kh": "🇰🇭", "flag-ki": "🇰🇮", "flag-km": "🇰🇲", "flag-kn": "🇰🇳", "flag-kp": "🇰🇵", "kr": "🇰🇷", "flag-kw": "🇰🇼", "flag-ky": "🇰🇾", "flag-kz": "🇰🇿", "flag-la": "🇱🇦", "flag-lb": "🇱🇧", "flag-lc": "🇱🇨", "flag-li": "🇱🇮", "flag-lk": "🇱🇰", "flag-lr": "🇱🇷", "flag-ls": "🇱🇸", "flag-lt": "🇱🇹", "flag-lu": "🇱🇺", "flag-lv": "🇱🇻", "flag-ly": "🇱🇾", "flag-ma": "🇲🇦", "flag-mc": "🇲🇨", "flag-md": "🇲🇩", "flag-me": "🇲🇪", "flag-mf": "🇲🇫", "flag-mg": "🇲🇬", "flag-mh": "🇲🇭", "flag-mk": "🇲🇰", "flag-ml": "🇲🇱", "flag-mm": "🇲🇲", "flag-mn": "🇲🇳", "flag-mo": "🇲🇴", "flag-mp": "🇲🇵", "flag-mq": "🇲🇶", "flag-mr": "🇲🇷", "flag-ms": "🇲🇸", "flag-mt": "🇲🇹", "flag-mu": "🇲🇺", "flag-mv": "🇲🇻", "flag-mw": "🇲🇼", "flag-mx": "🇲🇽", "flag-my": "🇲🇾", "flag-mz": "🇲🇿", "flag-na": "🇳🇦", "flag-nc": "🇳🇨", "flag-ne": "🇳🇪", "flag-nf": "🇳🇫", "flag-ng": "🇳🇬", "flag-ni": "🇳🇮", "flag-nl": "🇳🇱", "flag-no": "🇳🇴", "flag-np": "🇳🇵", "flag-nr": "🇳🇷", "flag-nu": "🇳🇺", "flag-nz": "🇳🇿", "flag-om": "🇴🇲", "flag-pa": "🇵🇦", "flag-pe": "🇵🇪", "flag-pf": "🇵🇫", "flag-pg": "🇵🇬", "flag-ph": "🇵🇭", "flag-pk": "🇵🇰", "flag-pl": "🇵🇱", "flag-pm": "🇵🇲", "flag-pn": "🇵🇳", "flag-pr": "🇵🇷", "flag-ps": "🇵🇸", "flag-pt": "🇵🇹", "flag-pw": "🇵🇼", "flag-py": "🇵🇾", "flag-qa": "🇶🇦", "flag-re": "🇷🇪", "flag-ro": "🇷🇴", "flag-rs": "🇷🇸", "ru": "🇷🇺", "flag-rw": "🇷🇼", "flag-sa": "🇸🇦", "flag-sb": "🇸🇧", "flag-sc": "🇸🇨", "flag-sd": "🇸🇩", "flag-se": "🇸🇪", "flag-sg": "🇸🇬", "flag-sh": "🇸🇭", "flag-si": "🇸🇮", "flag-sj": "🇸🇯", "flag-sk": "🇸🇰", "flag-sl": "🇸🇱", "flag-sm": "🇸🇲", "flag-sn": "🇸🇳", "flag-so": "🇸🇴", "flag-sr": "🇸🇷", "flag-ss": "🇸🇸", "flag-st": "🇸🇹", "flag-sv": "🇸🇻", "flag-sx": "🇸🇽", "flag-sy": "🇸🇾", "flag-sz": "🇸🇿", "flag-ta": "🇹🇦", "flag-tc": "🇹🇨", "flag-td": "🇹🇩", "flag-tf": "🇹🇫", "flag-tg": "🇹🇬", "flag-th": "🇹🇭", "flag-tj": "🇹🇯", "flag-tk": "🇹🇰", "flag-tl": "🇹🇱", "flag-tm": "🇹🇲", "flag-tn": "🇹🇳", "flag-to": "🇹🇴", "flag-tr": "🇹🇷", "flag-tt": "🇹🇹", "flag-tv": "🇹🇻", "flag-tw": "🇹🇼", "flag-tz": "🇹🇿", "flag-ua": "🇺🇦", "flag-ug": "🇺🇬", "flag-um": "🇺🇲", "flag-un": "🇺🇳", "us": "🇺🇸", "flag-uy": "🇺🇾", "flag-uz": "🇺🇿", "flag-va": "🇻🇦", "flag-vc": "🇻🇨", "flag-ve": "🇻🇪", "flag-vg": "🇻🇬", "flag-vi": "🇻🇮", "flag-vn": "🇻🇳", "flag-vu": "🇻🇺", "flag-wf": "🇼🇫", "flag-ws": "🇼🇸", "flag-xk": "🇽🇰", "flag-ye": "🇾🇪", "flag-yt": "🇾🇹", "flag-za": "🇿🇦", "flag-zm": "🇿🇲", "flag-zw": "🇿🇼", "koko": "🈁", "sa": "🈂️", "u7121": "🈚", "u6307": "🈯", "u7981": "🈲", "u7a7a": "🈳", "u5408": "🈴", "u6e80": "🈵", "u6709": "🈶", "u6708": "🈷️", "u7533": "🈸", "u5272": "🈹", "u55b6": "🈺", "ideograph_advantage": "🉐", "accept": "🉑", "cyclone": "🌀", "foggy": "🌁", "closed_umbrella": "🌂", "night_with_stars": "🌃", "sunrise_over_mountains": "🌄", "sunrise": "🌅", "city_sunset": "🌆", "city_sunrise": "🌇", "rainbow": "🌈", "bridge_at_night": "🌉", "ocean": "🌊", "volcano": "🌋", "milky_way": "🌌", "earth_africa": "🌍", "earth_americas": "🌎", "earth_asia": "🌏", "globe_with_meridians": "🌐", "new_moon": "🌑", "waxing_crescent_moon": "🌒", "first_quarter_moon": "🌓", "moon": "🌔", "full_moon": "🌕", "waning_gibbous_moon": "🌖", "last_quarter_moon": "🌗", "waning_crescent_moon": "🌘", "crescent_moon": "🌙", "new_moon_with_face": "🌚", "first_quarter_moon_with_face": "🌛", "last_quarter_moon_with_face": "🌜", "full_moon_with_face": "🌝", "sun_with_face": "🌞", "star2": "🌟", "stars": "🌠", "thermometer": "🌡️", "mostly_sunny": "🌤️", "barely_sunny": "🌥️", "partly_sunny_rain": "🌦️", "rain_cloud": "🌧️", "snow_cloud": "🌨️", "lightning": "🌩️", "tornado": "🌪️", "fog": "🌫️", "wind_blowing_face": "🌬️", "hotdog": "🌭", "taco": "🌮", "burrito": "🌯", "chestnut": "🌰", "seedling": "🌱", "evergreen_tree": "🌲", "deciduous_tree": "🌳", "palm_tree": "🌴", "cactus": "🌵", "hot_pepper": "🌶️", "tulip": "🌷", "cherry_blossom": "🌸", "rose": "🌹", "hibiscus": "🌺", "sunflower": "🌻", "blossom": "🌼", "corn": "🌽", "ear_of_rice": "🌾", "herb": "🌿", "four_leaf_clover": "🍀", "maple_leaf": "🍁", "fallen_leaf": "🍂", "leaves": "🍃", "mushroom": "🍄", "tomato": "🍅", "eggplant": "🍆", "grapes": "🍇", "melon": "🍈", "watermelon": "🍉", "tangerine": "🍊", "lemon": "🍋", "banana": "🍌", "pineapple": "🍍", "apple": "🍎", "green_apple": "🍏", "pear": "🍐", "peach": "🍑", "cherries": "🍒", "strawberry": "🍓", "hamburger": "🍔", "pizza": "🍕", "meat_on_bone": "🍖", "poultry_leg": "🍗", "rice_cracker": "🍘", "rice_ball": "🍙", "rice": "🍚", "curry": "🍛", "ramen": "🍜", "spaghetti": "🍝", "bread": "🍞", "fries": "🍟", "sweet_potato": "🍠", "dango": "🍡", "oden": "🍢", "sushi": "🍣", "fried_shrimp": "🍤", "fish_cake": "🍥", "icecream": "🍦", "shaved_ice": "🍧", "ice_cream": "🍨", "doughnut": "🍩", "cookie": "🍪", "chocolate_bar": "🍫", "candy": "🍬", "lollipop": "🍭", "custard": "🍮", "honey_pot": "🍯", "cake": "🍰", "bento": "🍱", "stew": "🍲", "fried_egg": "🍳", "fork_and_knife": "🍴", "tea": "🍵", "sake": "🍶", "wine_glass": "🍷", "cocktail": "🍸", "tropical_drink": "🍹", "beer": "🍺", "beers": "🍻", "baby_bottle": "🍼", "knife_fork_plate": "🍽️", "champagne": "🍾", "popcorn": "🍿", "ribbon": "🎀", "gift": "🎁", "birthday": "🎂", "jack_o_lantern": "🎃", "christmas_tree": "🎄", "santa": "🎅", "fireworks": "🎆", "sparkler": "🎇", "balloon": "🎈", "tada": "🎉", "confetti_ball": "🎊", "tanabata_tree": "🎋", "crossed_flags": "🎌", "bamboo": "🎍", "dolls": "🎎", "flags": "🎏", "wind_chime": "🎐", "rice_scene": "🎑", "school_satchel": "🎒", "mortar_board": "🎓", "medal": "🎖️", "reminder_ribbon": "🎗️", "studio_microphone": "🎙️", "level_slider": "🎚️", "control_knobs": "🎛️", "film_frames": "🎞️", "admission_tickets": "🎟️", "carousel_horse": "🎠", "ferris_wheel": "🎡", "roller_coaster": "🎢", "fishing_pole_and_fish": "🎣", "microphone": "🎤", "movie_camera": "🎥", "cinema": "🎦", "headphones": "🎧", "art": "🎨", "tophat": "🎩", "circus_tent": "🎪", "ticket": "🎫", "clapper": "🎬", "performing_arts": "🎭", "video_game": "🎮", "dart": "🎯", "slot_machine": "🎰", "8ball": "🎱", "game_die": "🎲", "bowling": "🎳", "flower_playing_cards": "🎴", "musical_note": "🎵", "notes": "🎶", "saxophone": "🎷", "guitar": "🎸", "musical_keyboard": "🎹", "trumpet": "🎺", "violin": "🎻", "musical_score": "🎼", "running_shirt_with_sash": "🎽", "tennis": "🎾", "ski": "🎿", "basketball": "🏀", "checkered_flag": "🏁", "snowboarder": "🏂", "woman-running": "🏃‍♀️", "man-running": "🏃‍♂️", "runner": "🏃", "woman-surfing": "🏄‍♀️", "man-surfing": "🏄‍♂️", "surfer": "🏄", "sports_medal": "🏅", "trophy": "🏆", "horse_racing": "🏇", "football": "🏈", "rugby_football": "🏉", "woman-swimming": "🏊‍♀️", "man-swimming": "🏊‍♂️", "swimmer": "🏊", "woman-lifting-weights": "🏋️‍♀️", "man-lifting-weights": "🏋️‍♂️", "weight_lifter": "🏋️", "woman-golfing": "🏌️‍♀️", "man-golfing": "🏌️‍♂️", "golfer": "🏌️", "racing_motorcycle": "🏍️", "racing_car": "🏎️", "cricket_bat_and_ball": "🏏", "volleyball": "🏐", "field_hockey_stick_and_ball": "🏑", "ice_hockey_stick_and_puck": "🏒", "table_tennis_paddle_and_ball": "🏓", "snow_capped_mountain": "🏔️", "camping": "🏕️", "beach_with_umbrella": "🏖️", "building_construction": "🏗️", "house_buildings": "🏘️", "cityscape": "🏙️", "derelict_house_building": "🏚️", "classical_building": "🏛️", "desert": "🏜️", "desert_island": "🏝️", "national_park": "🏞️", "stadium": "🏟️", "house": "🏠", "house_with_garden": "🏡", "office": "🏢", "post_office": "🏣", "european_post_office": "🏤", "hospital": "🏥", "bank": "🏦", "atm": "🏧", "hotel": "🏨", "love_hotel": "🏩", "convenience_store": "🏪", "school": "🏫", "department_store": "🏬", "factory": "🏭", "izakaya_lantern": "🏮", "japanese_castle": "🏯", "european_castle": "🏰", "rainbow-flag": "🏳️‍🌈", "transgender_flag": "🏳️‍⚧️", "waving_white_flag": "🏳️", "pirate_flag": "🏴‍☠️", "flag-england": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", "flag-scotland": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "flag-wales": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", "waving_black_flag": "🏴", "rosette": "🏵️", "label": "🏷️", "badminton_racquet_and_shuttlecock": "🏸", "bow_and_arrow": "🏹", "amphora": "🏺", "skin-tone-2": "🏻", "skin-tone-3": "🏼", "skin-tone-4": "🏽", "skin-tone-5": "🏾", "skin-tone-6": "🏿", "rat": "🐀", "mouse2": "🐁", "ox": "🐂", "water_buffalo": "🐃", "cow2": "🐄", "tiger2": "🐅", "leopard": "🐆", "rabbit2": "🐇", "black_cat": "🐈‍⬛", "cat2": "🐈", "dragon": "🐉", "crocodile": "🐊", "whale2": "🐋", "snail": "🐌", "snake": "🐍", "racehorse": "🐎", "ram": "🐏", "goat": "🐐", "sheep": "🐑", "monkey": "🐒", "rooster": "🐓", "chicken": "🐔", "service_dog": "🐕‍🦺", "dog2": "🐕", "pig2": "🐖", "boar": "🐗", "elephant": "🐘", "octopus": "🐙", "shell": "🐚", "bug": "🐛", "ant": "🐜", "bee": "🐝", "ladybug": "🐞", "fish": "🐟", "tropical_fish": "🐠", "blowfish": "🐡", "turtle": "🐢", "hatching_chick": "🐣", "baby_chick": "🐤", "hatched_chick": "🐥", "bird": "🐦", "penguin": "🐧", "koala": "🐨", "poodle": "🐩", "dromedary_camel": "🐪", "camel": "🐫", "dolphin": "🐬", "mouse": "🐭", "cow": "🐮", "tiger": "🐯", "rabbit": "🐰", "cat": "🐱", "dragon_face": "🐲", "whale": "🐳", "horse": "🐴", "monkey_face": "🐵", "dog": "🐶", "pig": "🐷", "frog": "🐸", "hamster": "🐹", "wolf": "🐺", "polar_bear": "🐻‍❄️", "bear": "🐻", "panda_face": "🐼", "pig_nose": "🐽", "feet": "🐾", "chipmunk": "🐿️", "eyes": "👀", "eye-in-speech-bubble": "👁️‍🗨️", "eye": "👁️", "ear": "👂", "nose": "👃", "lips": "👄", "tongue": "👅", "point_up_2": "👆", "point_down": "👇", "point_left": "👈", "point_right": "👉", "facepunch": "👊", "wave": "👋", "ok_hand": "👌", "+1": "👍", "-1": "👎", "clap": "👏", "open_hands": "👐", "crown": "👑", "womans_hat": "👒", "eyeglasses": "👓", "necktie": "👔", "shirt": "👕", "jeans": "👖", "dress": "👗", "kimono": "👘", "bikini": "👙", "womans_clothes": "👚", "purse": "👛", "handbag": "👜", "pouch": "👝", "mans_shoe": "👞", "athletic_shoe": "👟", "high_heel": "👠", "sandal": "👡", "boot": "👢", "footprints": "👣", "bust_in_silhouette": "👤", "busts_in_silhouette": "👥", "boy": "👦", "girl": "👧", "male-farmer": "👨‍🌾", "male-cook": "👨‍🍳", "man_feeding_baby": "👨‍🍼", "male-student": "👨‍🎓", "male-singer": "👨‍🎤", "male-artist": "👨‍🎨", "male-teacher": "👨‍🏫", "male-factory-worker": "👨‍🏭", "man-boy-boy": "👨‍👦‍👦", "man-boy": "👨‍👦", "man-girl-boy": "👨‍👧‍👦", "man-girl-girl": "👨‍👧‍👧", "man-girl": "👨‍👧", "man-man-boy": "👨‍👨‍👦", "man-man-boy-boy": "👨‍👨‍👦‍👦", "man-man-girl": "👨‍👨‍👧", "man-man-girl-boy": "👨‍👨‍👧‍👦", "man-man-girl-girl": "👨‍👨‍👧‍👧", "man-woman-boy": "👨‍👩‍👦", "man-woman-boy-boy": "👨‍👩‍👦‍👦", "man-woman-girl": "👨‍👩‍👧", "man-woman-girl-boy": "👨‍👩‍👧‍👦", "man-woman-girl-girl": "👨‍👩‍👧‍👧", "male-technologist": "👨‍💻", "male-office-worker": "👨‍💼", "male-mechanic": "👨‍🔧", "male-scientist": "👨‍🔬", "male-astronaut": "👨‍🚀", "male-firefighter": "👨‍🚒", "man_with_probing_cane": "👨‍🦯", "red_haired_man": "👨‍🦰", "curly_haired_man": "👨‍🦱", "bald_man": "👨‍🦲", "white_haired_man": "👨‍🦳", "man_in_motorized_wheelchair": "👨‍🦼", "man_in_manual_wheelchair": "👨‍🦽", "male-doctor": "👨‍⚕️", "male-judge": "👨‍⚖️", "male-pilot": "👨‍✈️", "man-heart-man": "👨‍❤️‍👨", "man-kiss-man": "👨‍❤️‍💋‍👨", "man": "👨", "female-farmer": "👩‍🌾", "female-cook": "👩‍🍳", "woman_feeding_baby": "👩‍🍼", "female-student": "👩‍🎓", "female-singer": "👩‍🎤", "female-artist": "👩‍🎨", "female-teacher": "👩‍🏫", "female-factory-worker": "👩‍🏭", "woman-boy-boy": "👩‍👦‍👦", "woman-boy": "👩‍👦", "woman-girl-boy": "👩‍👧‍👦", "woman-girl-girl": "👩‍👧‍👧", "woman-girl": "👩‍👧", "woman-woman-boy": "👩‍👩‍👦", "woman-woman-boy-boy": "👩‍👩‍👦‍👦", "woman-woman-girl": "👩‍👩‍👧", "woman-woman-girl-boy": "👩‍👩‍👧‍👦", "woman-woman-girl-girl": "👩‍👩‍👧‍👧", "female-technologist": "👩‍💻", "female-office-worker": "👩‍💼", "female-mechanic": "👩‍🔧", "female-scientist": "👩‍🔬", "female-astronaut": "👩‍🚀", "female-firefighter": "👩‍🚒", "woman_with_probing_cane": "👩‍🦯", "red_haired_woman": "👩‍🦰", "curly_haired_woman": "👩‍🦱", "bald_woman": "👩‍🦲", "white_haired_woman": "👩‍🦳", "woman_in_motorized_wheelchair": "👩‍🦼", "woman_in_manual_wheelchair": "👩‍🦽", "female-doctor": "👩‍⚕️", "female-judge": "👩‍⚖️", "female-pilot": "👩‍✈️", "woman-heart-man": "👩‍❤️‍👨", "woman-heart-woman": "👩‍❤️‍👩", "woman-kiss-man": "👩‍❤️‍💋‍👨", "woman-kiss-woman": "👩‍❤️‍💋‍👩", "woman": "👩", "family": "👪", "man_and_woman_holding_hands": "👫", "two_men_holding_hands": "👬", "two_women_holding_hands": "👭", "female-police-officer": "👮‍♀️", "male-police-officer": "👮‍♂️", "cop": "👮", "women-with-bunny-ears-partying": "👯‍♀️", "men-with-bunny-ears-partying": "👯‍♂️", "dancers": "👯", "woman_with_veil": "👰‍♀️", "man_with_veil": "👰‍♂️", "bride_with_veil": "👰", "blond-haired-woman": "👱‍♀️", "blond-haired-man": "👱‍♂️", "person_with_blond_hair": "👱", "man_with_gua_pi_mao": "👲", "woman-wearing-turban": "👳‍♀️", "man-wearing-turban": "👳‍♂️", "man_with_turban": "👳", "older_man": "👴", "older_woman": "👵", "baby": "👶", "female-construction-worker": "👷‍♀️", "male-construction-worker": "👷‍♂️", "construction_worker": "👷", "princess": "👸", "japanese_ogre": "👹", "japanese_goblin": "👺", "ghost": "👻", "angel": "👼", "alien": "👽", "space_invader": "👾", "imp": "👿", "skull": "💀", "woman-tipping-hand": "💁‍♀️", "man-tipping-hand": "💁‍♂️", "information_desk_person": "💁", "female-guard": "💂‍♀️", "male-guard": "💂‍♂️", "guardsman": "💂", "dancer": "💃", "lipstick": "💄", "nail_care": "💅", "woman-getting-massage": "💆‍♀️", "man-getting-massage": "💆‍♂️", "massage": "💆", "woman-getting-haircut": "💇‍♀️", "man-getting-haircut": "💇‍♂️", "haircut": "💇", "barber": "💈", "syringe": "💉", "pill": "💊", "kiss": "💋", "love_letter": "💌", "ring": "💍", "gem": "💎", "couplekiss": "💏", "bouquet": "💐", "couple_with_heart": "💑", "wedding": "💒", "heartbeat": "💓", "broken_heart": "💔", "two_hearts": "💕", "sparkling_heart": "💖", "heartpulse": "💗", "cupid": "💘", "blue_heart": "💙", "green_heart": "💚", "yellow_heart": "💛", "purple_heart": "💜", "gift_heart": "💝", "revolving_hearts": "💞", "heart_decoration": "💟", "diamond_shape_with_a_dot_inside": "💠", "bulb": "💡", "anger": "💢", "bomb": "💣", "zzz": "💤", "boom": "💥", "sweat_drops": "💦", "droplet": "💧", "dash": "💨", "hankey": "💩", "muscle": "💪", "dizzy": "💫", "speech_balloon": "💬", "thought_balloon": "💭", "white_flower": "💮", "100": "💯", "moneybag": "💰", "currency_exchange": "💱", "heavy_dollar_sign": "💲", "credit_card": "💳", "yen": "💴", "dollar": "💵", "euro": "💶", "pound": "💷", "money_with_wings": "💸", "chart": "💹", "seat": "💺", "computer": "💻", "briefcase": "💼", "minidisc": "💽", "floppy_disk": "💾", "cd": "💿", "dvd": "📀", "file_folder": "📁", "open_file_folder": "📂", "page_with_curl": "📃", "page_facing_up": "📄", "date": "📅", "calendar": "📆", "card_index": "📇", "chart_with_upwards_trend": "📈", "chart_with_downwards_trend": "📉", "bar_chart": "📊", "clipboard": "📋", "pushpin": "📌", "round_pushpin": "📍", "paperclip": "📎", "straight_ruler": "📏", "triangular_ruler": "📐", "bookmark_tabs": "📑", "ledger": "📒", "notebook": "📓", "notebook_with_decorative_cover": "📔", "closed_book": "📕", "book": "📖", "green_book": "📗", "blue_book": "📘", "orange_book": "📙", "books": "📚", "name_badge": "📛", "scroll": "📜", "memo": "📝", "telephone_receiver": "📞", "pager": "📟", "fax": "📠", "satellite_antenna": "📡", "loudspeaker": "📢", "mega": "📣", "outbox_tray": "📤", "inbox_tray": "📥", "package": "📦", "e-mail": "📧", "incoming_envelope": "📨", "envelope_with_arrow": "📩", "mailbox_closed": "📪", "mailbox": "📫", "mailbox_with_mail": "📬", "mailbox_with_no_mail": "📭", "postbox": "📮", "postal_horn": "📯", "newspaper": "📰", "iphone": "📱", "calling": "📲", "vibration_mode": "📳", "mobile_phone_off": "📴", "no_mobile_phones": "📵", "signal_strength": "📶", "camera": "📷", "camera_with_flash": "📸", "video_camera": "📹", "tv": "📺", "radio": "📻", "vhs": "📼", "film_projector": "📽️", "prayer_beads": "📿", "twisted_rightwards_arrows": "🔀", "repeat": "🔁", "repeat_one": "🔂", "arrows_clockwise": "🔃", "arrows_counterclockwise": "🔄", "low_brightness": "🔅", "high_brightness": "🔆", "mute": "🔇", "speaker": "🔈", "sound": "🔉", "loud_sound": "🔊", "battery": "🔋", "electric_plug": "🔌", "mag": "🔍", "mag_right": "🔎", "lock_with_ink_pen": "🔏", "closed_lock_with_key": "🔐", "key": "🔑", "lock": "🔒", "unlock": "🔓", "bell": "🔔", "no_bell": "🔕", "bookmark": "🔖", "link": "🔗", "radio_button": "🔘", "back": "🔙", "end": "🔚", "on": "🔛", "soon": "🔜", "top": "🔝", "underage": "🔞", "keycap_ten": "🔟", "capital_abcd": "🔠", "abcd": "🔡", "1234": "🔢", "symbols": "🔣", "abc": "🔤", "fire": "🔥", "flashlight": "🔦", "wrench": "🔧", "hammer": "🔨", "nut_and_bolt": "🔩", "hocho": "🔪", "gun": "🔫", "microscope": "🔬", "telescope": "🔭", "crystal_ball": "🔮", "six_pointed_star": "🔯", "beginner": "🔰", "trident": "🔱", "black_square_button": "🔲", "white_square_button": "🔳", "red_circle": "🔴", "large_blue_circle": "🔵", "large_orange_diamond": "🔶", "large_blue_diamond": "🔷", "small_orange_diamond": "🔸", "small_blue_diamond": "🔹", "small_red_triangle": "🔺", "small_red_triangle_down": "🔻", "arrow_up_small": "🔼", "arrow_down_small": "🔽", "om_symbol": "🕉️", "dove_of_peace": "🕊️", "kaaba": "🕋", "mosque": "🕌", "synagogue": "🕍", "menorah_with_nine_branches": "🕎", "clock1": "🕐", "clock2": "🕑", "clock3": "🕒", "clock4": "🕓", "clock5": "🕔", "clock6": "🕕", "clock7": "🕖", "clock8": "🕗", "clock9": "🕘", "clock10": "🕙", "clock11": "🕚", "clock12": "🕛", "clock130": "🕜", "clock230": "🕝", "clock330": "🕞", "clock430": "🕟", "clock530": "🕠", "clock630": "🕡", "clock730": "🕢", "clock830": "🕣", "clock930": "🕤", "clock1030": "🕥", "clock1130": "🕦", "clock1230": "🕧", "candle": "🕯️", "mantelpiece_clock": "🕰️", "hole": "🕳️", "man_in_business_suit_levitating": "🕴️", "female-detective": "🕵️‍♀️", "male-detective": "🕵️‍♂️", "sleuth_or_spy": "🕵️", "dark_sunglasses": "🕶️", "spider": "🕷️", "spider_web": "🕸️", "joystick": "🕹️", "man_dancing": "🕺", "linked_paperclips": "🖇️", "lower_left_ballpoint_pen": "🖊️", "lower_left_fountain_pen": "🖋️", "lower_left_paintbrush": "🖌️", "lower_left_crayon": "🖍️", "raised_hand_with_fingers_splayed": "🖐️", "middle_finger": "🖕", "spock-hand": "🖖", "black_heart": "🖤", "desktop_computer": "🖥️", "printer": "🖨️", "three_button_mouse": "🖱️", "trackball": "🖲️", "frame_with_picture": "🖼️", "card_index_dividers": "🗂️", "card_file_box": "🗃️", "file_cabinet": "🗄️", "wastebasket": "🗑️", "spiral_note_pad": "🗒️", "spiral_calendar_pad": "🗓️", "compression": "🗜️", "old_key": "🗝️", "rolled_up_newspaper": "🗞️", "dagger_knife": "🗡️", "speaking_head_in_silhouette": "🗣️", "left_speech_bubble": "🗨️", "right_anger_bubble": "🗯️", "ballot_box_with_ballot": "🗳️", "world_map": "🗺️", "mount_fuji": "🗻", "tokyo_tower": "🗼", "statue_of_liberty": "🗽", "japan": "🗾", "moyai": "🗿", "grinning": "😀", "grin": "😁", "joy": "😂", "smiley": "😃", "smile": "😄", "sweat_smile": "😅", "laughing": "😆", "innocent": "😇", "smiling_imp": "😈", "wink": "😉", "blush": "😊", "yum": "😋", "relieved": "😌", "heart_eyes": "😍", "sunglasses": "😎", "smirk": "😏", "neutral_face": "😐", "expressionless": "😑", "unamused": "😒", "sweat": "😓", "pensive": "😔", "confused": "😕", "confounded": "😖", "kissing": "😗", "kissing_heart": "😘", "kissing_smiling_eyes": "😙", "kissing_closed_eyes": "😚", "stuck_out_tongue": "😛", "stuck_out_tongue_winking_eye": "😜", "stuck_out_tongue_closed_eyes": "😝", "disappointed": "😞", "worried": "😟", "angry": "😠", "rage": "😡", "cry": "😢", "persevere": "😣", "triumph": "😤", "disappointed_relieved": "😥", "frowning": "😦", "anguished": "😧", "fearful": "😨", "weary": "😩", "sleepy": "😪", "tired_face": "😫", "grimacing": "😬", "sob": "😭", "face_exhaling": "😮‍💨", "open_mouth": "😮", "hushed": "😯", "cold_sweat": "😰", "scream": "😱", "astonished": "😲", "flushed": "😳", "sleeping": "😴", "face_with_spiral_eyes": "😵‍💫", "dizzy_face": "😵", "face_in_clouds": "😶‍🌫️", "no_mouth": "😶", "mask": "😷", "smile_cat": "😸", "joy_cat": "😹", "smiley_cat": "😺", "heart_eyes_cat": "😻", "smirk_cat": "😼", "kissing_cat": "😽", "pouting_cat": "😾", "crying_cat_face": "😿", "scream_cat": "🙀", "slightly_frowning_face": "🙁", "slightly_smiling_face": "🙂", "upside_down_face": "🙃", "face_with_rolling_eyes": "🙄", "woman-gesturing-no": "🙅‍♀️", "man-gesturing-no": "🙅‍♂️", "no_good": "🙅", "woman-gesturing-ok": "🙆‍♀️", "man-gesturing-ok": "🙆‍♂️", "ok_woman": "🙆", "woman-bowing": "🙇‍♀️", "man-bowing": "🙇‍♂️", "bow": "🙇", "see_no_evil": "🙈", "hear_no_evil": "🙉", "speak_no_evil": "🙊", "woman-raising-hand": "🙋‍♀️", "man-raising-hand": "🙋‍♂️", "raising_hand": "🙋", "raised_hands": "🙌", "woman-frowning": "🙍‍♀️", "man-frowning": "🙍‍♂️", "person_frowning": "🙍", "woman-pouting": "🙎‍♀️", "man-pouting": "🙎‍♂️", "person_with_pouting_face": "🙎", "pray": "🙏", "rocket": "🚀", "helicopter": "🚁", "steam_locomotive": "🚂", "railway_car": "🚃", "bullettrain_side": "🚄", "bullettrain_front": "🚅", "train2": "🚆", "metro": "🚇", "light_rail": "🚈", "station": "🚉", "tram": "🚊", "train": "🚋", "bus": "🚌", "oncoming_bus": "🚍", "trolleybus": "🚎", "busstop": "🚏", "minibus": "🚐", "ambulance": "🚑", "fire_engine": "🚒", "police_car": "🚓", "oncoming_police_car": "🚔", "taxi": "🚕", "oncoming_taxi": "🚖", "car": "🚗", "oncoming_automobile": "🚘", "blue_car": "🚙", "truck": "🚚", "articulated_lorry": "🚛", "tractor": "🚜", "monorail": "🚝", "mountain_railway": "🚞", "suspension_railway": "🚟", "mountain_cableway": "🚠", "aerial_tramway": "🚡", "ship": "🚢", "woman-rowing-boat": "🚣‍♀️", "man-rowing-boat": "🚣‍♂️", "rowboat": "🚣", "speedboat": "🚤", "traffic_light": "🚥", "vertical_traffic_light": "🚦", "construction": "🚧", "rotating_light": "🚨", "triangular_flag_on_post": "🚩", "door": "🚪", "no_entry_sign": "🚫", "smoking": "🚬", "no_smoking": "🚭", "put_litter_in_its_place": "🚮", "do_not_litter": "🚯", "potable_water": "🚰", "non-potable_water": "🚱", "bike": "🚲", "no_bicycles": "🚳", "woman-biking": "🚴‍♀️", "man-biking": "🚴‍♂️", "bicyclist": "🚴", "woman-mountain-biking": "🚵‍♀️", "man-mountain-biking": "🚵‍♂️", "mountain_bicyclist": "🚵", "woman-walking": "🚶‍♀️", "man-walking": "🚶‍♂️", "walking": "🚶", "no_pedestrians": "🚷", "children_crossing": "🚸", "mens": "🚹", "womens": "🚺", "restroom": "🚻", "baby_symbol": "🚼", "toilet": "🚽", "wc": "🚾", "shower": "🚿", "bath": "🛀", "bathtub": "🛁", "passport_control": "🛂", "customs": "🛃", "baggage_claim": "🛄", "left_luggage": "🛅", "couch_and_lamp": "🛋️", "sleeping_accommodation": "🛌", "shopping_bags": "🛍️", "bellhop_bell": "🛎️", "bed": "🛏️", "place_of_worship": "🛐", "octagonal_sign": "🛑", "shopping_trolley": "🛒", "hindu_temple": "🛕", "hut": "🛖", "elevator": "🛗", "playground_slide": "🛝", "wheel": "🛞", "ring_buoy": "🛟", "hammer_and_wrench": "🛠️", "shield": "🛡️", "oil_drum": "🛢️", "motorway": "🛣️", "railway_track": "🛤️", "motor_boat": "🛥️", "small_airplane": "🛩️", "airplane_departure": "🛫", "airplane_arriving": "🛬", "satellite": "🛰️", "passenger_ship": "🛳️", "scooter": "🛴", "motor_scooter": "🛵", "canoe": "🛶", "sled": "🛷", "flying_saucer": "🛸", "skateboard": "🛹", "auto_rickshaw": "🛺", "pickup_truck": "🛻", "roller_skate": "🛼", "large_orange_circle": "🟠", "large_yellow_circle": "🟡", "large_green_circle": "🟢", "large_purple_circle": "🟣", "large_brown_circle": "🟤", "large_red_square": "🟥", "large_blue_square": "🟦", "large_orange_square": "🟧", "large_yellow_square": "🟨", "large_green_square": "🟩", "large_purple_square": "🟪", "large_brown_square": "🟫", "heavy_equals_sign": "🟰", "pinched_fingers": "🤌", "white_heart": "🤍", "brown_heart": "🤎", "pinching_hand": "🤏", "zipper_mouth_face": "🤐", "money_mouth_face": "🤑", "face_with_thermometer": "🤒", "nerd_face": "🤓", "thinking_face": "🤔", "face_with_head_bandage": "🤕", "robot_face": "🤖", "hugging_face": "🤗", "the_horns": "🤘", "call_me_hand": "🤙", "raised_back_of_hand": "🤚", "left-facing_fist": "🤛", "right-facing_fist": "🤜", "handshake": "🤝", "crossed_fingers": "🤞", "i_love_you_hand_sign": "🤟", "face_with_cowboy_hat": "🤠", "clown_face": "🤡", "nauseated_face": "🤢", "rolling_on_the_floor_laughing": "🤣", "drooling_face": "🤤", "lying_face": "🤥", "woman-facepalming": "🤦‍♀️", "man-facepalming": "🤦‍♂️", "face_palm": "🤦", "sneezing_face": "🤧", "face_with_raised_eyebrow": "🤨", "star-struck": "🤩", "zany_face": "🤪", "shushing_face": "🤫", "face_with_symbols_on_mouth": "🤬", "face_with_hand_over_mouth": "🤭", "face_vomiting": "🤮", "exploding_head": "🤯", "pregnant_woman": "🤰", "breast-feeding": "🤱", "palms_up_together": "🤲", "selfie": "🤳", "prince": "🤴", "woman_in_tuxedo": "🤵‍♀️", "man_in_tuxedo": "🤵‍♂️", "person_in_tuxedo": "🤵", "mrs_claus": "🤶", "woman-shrugging": "🤷‍♀️", "man-shrugging": "🤷‍♂️", "shrug": "🤷", "woman-cartwheeling": "🤸‍♀️", "man-cartwheeling": "🤸‍♂️", "person_doing_cartwheel": "🤸", "woman-juggling": "🤹‍♀️", "man-juggling": "🤹‍♂️", "juggling": "🤹", "fencer": "🤺", "woman-wrestling": "🤼‍♀️", "man-wrestling": "🤼‍♂️", "wrestlers": "🤼", "woman-playing-water-polo": "🤽‍♀️", "man-playing-water-polo": "🤽‍♂️", "water_polo": "🤽", "woman-playing-handball": "🤾‍♀️", "man-playing-handball": "🤾‍♂️", "handball": "🤾", "diving_mask": "🤿", "wilted_flower": "🥀", "drum_with_drumsticks": "🥁", "clinking_glasses": "🥂", "tumbler_glass": "🥃", "spoon": "🥄", "goal_net": "🥅", "first_place_medal": "🥇", "second_place_medal": "🥈", "third_place_medal": "🥉", "boxing_glove": "🥊", "martial_arts_uniform": "🥋", "curling_stone": "🥌", "lacrosse": "🥍", "softball": "🥎", "flying_disc": "🥏", "croissant": "🥐", "avocado": "🥑", "cucumber": "🥒", "bacon": "🥓", "potato": "🥔", "carrot": "🥕", "baguette_bread": "🥖", "green_salad": "🥗", "shallow_pan_of_food": "🥘", "stuffed_flatbread": "🥙", "egg": "🥚", "glass_of_milk": "🥛", "peanuts": "🥜", "kiwifruit": "🥝", "pancakes": "🥞", "dumpling": "🥟", "fortune_cookie": "🥠", "takeout_box": "🥡", "chopsticks": "🥢", "bowl_with_spoon": "🥣", "cup_with_straw": "🥤", "coconut": "🥥", "broccoli": "🥦", "pie": "🥧", "pretzel": "🥨", "cut_of_meat": "🥩", "sandwich": "🥪", "canned_food": "🥫", "leafy_green": "🥬", "mango": "🥭", "moon_cake": "🥮", "bagel": "🥯", "smiling_face_with_3_hearts": "🥰", "yawning_face": "🥱", "smiling_face_with_tear": "🥲", "partying_face": "🥳", "woozy_face": "🥴", "hot_face": "🥵", "cold_face": "🥶", "ninja": "🥷", "disguised_face": "🥸", "face_holding_back_tears": "🥹", "pleading_face": "🥺", "sari": "🥻", "lab_coat": "🥼", "goggles": "🥽", "hiking_boot": "🥾", "womans_flat_shoe": "🥿", "crab": "🦀", "lion_face": "🦁", "scorpion": "🦂", "turkey": "🦃", "unicorn_face": "🦄", "eagle": "🦅", "duck": "🦆", "bat": "🦇", "shark": "🦈", "owl": "🦉", "fox_face": "🦊", "butterfly": "🦋", "deer": "🦌", "gorilla": "🦍", "lizard": "🦎", "rhinoceros": "🦏", "shrimp": "🦐", "squid": "🦑", "giraffe_face": "🦒", "zebra_face": "🦓", "hedgehog": "🦔", "sauropod": "🦕", "t-rex": "🦖", "cricket": "🦗", "kangaroo": "🦘", "llama": "🦙", "peacock": "🦚", "hippopotamus": "🦛", "parrot": "🦜", "raccoon": "🦝", "lobster": "🦞", "mosquito": "🦟", "microbe": "🦠", "badger": "🦡", "swan": "🦢", "mammoth": "🦣", "dodo": "🦤", "sloth": "🦥", "otter": "🦦", "orangutan": "🦧", "skunk": "🦨", "flamingo": "🦩", "oyster": "🦪", "beaver": "🦫", "bison": "🦬", "seal": "🦭", "guide_dog": "🦮", "probing_cane": "🦯", "bone": "🦴", "leg": "🦵", "foot": "🦶", "tooth": "🦷", "female_superhero": "🦸‍♀️", "male_superhero": "🦸‍♂️", "superhero": "🦸", "female_supervillain": "🦹‍♀️", "male_supervillain": "🦹‍♂️", "supervillain": "🦹", "safety_vest": "🦺", "ear_with_hearing_aid": "🦻", "motorized_wheelchair": "🦼", "manual_wheelchair": "🦽", "mechanical_arm": "🦾", "mechanical_leg": "🦿", "cheese_wedge": "🧀", "cupcake": "🧁", "salt": "🧂", "beverage_box": "🧃", "garlic": "🧄", "onion": "🧅", "falafel": "🧆", "waffle": "🧇", "butter": "🧈", "mate_drink": "🧉", "ice_cube": "🧊", "bubble_tea": "🧋", "troll": "🧌", "woman_standing": "🧍‍♀️", "man_standing": "🧍‍♂️", "standing_person": "🧍", "woman_kneeling": "🧎‍♀️", "man_kneeling": "🧎‍♂️", "kneeling_person": "🧎", "deaf_woman": "🧏‍♀️", "deaf_man": "🧏‍♂️", "deaf_person": "🧏", "face_with_monocle": "🧐", "farmer": "🧑‍🌾", "cook": "🧑‍🍳", "person_feeding_baby": "🧑‍🍼", "mx_claus": "🧑‍🎄", "student": "🧑‍🎓", "singer": "🧑‍🎤", "artist": "🧑‍🎨", "teacher": "🧑‍🏫", "factory_worker": "🧑‍🏭", "technologist": "🧑‍💻", "office_worker": "🧑‍💼", "mechanic": "🧑‍🔧", "scientist": "🧑‍🔬", "astronaut": "🧑‍🚀", "firefighter": "🧑‍🚒", "people_holding_hands": "🧑‍🤝‍🧑", "person_with_probing_cane": "🧑‍🦯", "red_haired_person": "🧑‍🦰", "curly_haired_person": "🧑‍🦱", "bald_person": "🧑‍🦲", "white_haired_person": "🧑‍🦳", "person_in_motorized_wheelchair": "🧑‍🦼", "person_in_manual_wheelchair": "🧑‍🦽", "health_worker": "🧑‍⚕️", "judge": "🧑‍⚖️", "pilot": "🧑‍✈️", "adult": "🧑", "child": "🧒", "older_adult": "🧓", "woman_with_beard": "🧔‍♀️", "man_with_beard": "🧔‍♂️", "bearded_person": "🧔", "person_with_headscarf": "🧕", "woman_in_steamy_room": "🧖‍♀️", "man_in_steamy_room": "🧖‍♂️", "person_in_steamy_room": "🧖", "woman_climbing": "🧗‍♀️", "man_climbing": "🧗‍♂️", "person_climbing": "🧗", "woman_in_lotus_position": "🧘‍♀️", "man_in_lotus_position": "🧘‍♂️", "person_in_lotus_position": "🧘", "female_mage": "🧙‍♀️", "male_mage": "🧙‍♂️", "mage": "🧙", "female_fairy": "🧚‍♀️", "male_fairy": "🧚‍♂️", "fairy": "🧚", "female_vampire": "🧛‍♀️", "male_vampire": "🧛‍♂️", "vampire": "🧛", "mermaid": "🧜‍♀️", "merman": "🧜‍♂️", "merperson": "🧜", "female_elf": "🧝‍♀️", "male_elf": "🧝‍♂️", "elf": "🧝", "female_genie": "🧞‍♀️", "male_genie": "🧞‍♂️", "genie": "🧞", "female_zombie": "🧟‍♀️", "male_zombie": "🧟‍♂️", "zombie": "🧟", "brain": "🧠", "orange_heart": "🧡", "billed_cap": "🧢", "scarf": "🧣", "gloves": "🧤", "coat": "🧥", "socks": "🧦", "red_envelope": "🧧", "firecracker": "🧨", "jigsaw": "🧩", "test_tube": "🧪", "petri_dish": "🧫", "dna": "🧬", "compass": "🧭", "abacus": "🧮", "fire_extinguisher": "🧯", "toolbox": "🧰", "bricks": "🧱", "magnet": "🧲", "luggage": "🧳", "lotion_bottle": "🧴", "thread": "🧵", "yarn": "🧶", "safety_pin": "🧷", "teddy_bear": "🧸", "broom": "🧹", "basket": "🧺", "roll_of_paper": "🧻", "soap": "🧼", "sponge": "🧽", "receipt": "🧾", "nazar_amulet": "🧿", "ballet_shoes": "🩰", "one-piece_swimsuit": "🩱", "briefs": "🩲", "shorts": "🩳", "thong_sandal": "🩴", "drop_of_blood": "🩸", "adhesive_bandage": "🩹", "stethoscope": "🩺", "x-ray": "🩻", "crutch": "🩼", "yo-yo": "🪀", "kite": "🪁", "parachute": "🪂", "boomerang": "🪃", "magic_wand": "🪄", "pinata": "🪅", "nesting_dolls": "🪆", "ringed_planet": "🪐", "chair": "🪑", "razor": "🪒", "axe": "🪓", "diya_lamp": "🪔", "banjo": "🪕", "military_helmet": "🪖", "accordion": "🪗", "long_drum": "🪘", "coin": "🪙", "carpentry_saw": "🪚", "screwdriver": "🪛", "ladder": "🪜", "hook": "🪝", "mirror": "🪞", "window": "🪟", "plunger": "🪠", "sewing_needle": "🪡", "knot": "🪢", "bucket": "🪣", "mouse_trap": "🪤", "toothbrush": "🪥", "headstone": "🪦", "placard": "🪧", "rock": "🪨", "mirror_ball": "🪩", "identification_card": "🪪", "low_battery": "🪫", "hamsa": "🪬", "fly": "🪰", "worm": "🪱", "beetle": "🪲", "cockroach": "🪳", "potted_plant": "🪴", "wood": "🪵", "feather": "🪶", "lotus": "🪷", "coral": "🪸", "empty_nest": "🪹", "nest_with_eggs": "🪺", "anatomical_heart": "🫀", "lungs": "🫁", "people_hugging": "🫂", "pregnant_man": "🫃", "pregnant_person": "🫄", "person_with_crown": "🫅", "blueberries": "🫐", "bell_pepper": "🫑", "olive": "🫒", "flatbread": "🫓", "tamale": "🫔", "fondue": "🫕", "teapot": "🫖", "pouring_liquid": "🫗", "beans": "🫘", "jar": "🫙", "melting_face": "🫠", "saluting_face": "🫡", "face_with_open_eyes_and_hand_over_mouth": "🫢", "face_with_peeking_eye": "🫣", "face_with_diagonal_mouth": "🫤", "dotted_line_face": "🫥", "biting_lip": "🫦", "bubbles": "🫧", "hand_with_index_finger_and_thumb_crossed": "🫰", "rightwards_hand": "🫱", "leftwards_hand": "🫲", "palm_down_hand": "🫳", "palm_up_hand": "🫴", "index_pointing_at_the_viewer": "🫵", "heart_hands": "🫶", "bangbang": "‼️", "interrobang": "⁉️", "tm": "™️", "information_source": "ℹ️", "left_right_arrow": "↔️", "arrow_up_down": "↕️", "arrow_upper_left": "↖️", "arrow_upper_right": "↗️", "arrow_lower_right": "↘️", "arrow_lower_left": "↙️", "leftwards_arrow_with_hook": "↩️", "arrow_right_hook": "↪️", "watch": "⌚", "hourglass": "⌛", "keyboard": "⌨️", "eject": "⏏️", "fast_forward": "⏩", "rewind": "⏪", "arrow_double_up": "⏫", "arrow_double_down": "⏬", "black_right_pointing_double_triangle_with_vertical_bar": "⏭️", "black_left_pointing_double_triangle_with_vertical_bar": "⏮️", "black_right_pointing_triangle_with_double_vertical_bar": "⏯️", "alarm_clock": "⏰", "stopwatch": "⏱️", "timer_clock": "⏲️", "hourglass_flowing_sand": "⏳", "double_vertical_bar": "⏸️", "black_square_for_stop": "⏹️", "black_circle_for_record": "⏺️", "m": "Ⓜ️", "black_small_square": "▪️", "white_small_square": "▫️", "arrow_forward": "▶️", "arrow_backward": "◀️", "white_medium_square": "◻️", "black_medium_square": "◼️", "white_medium_small_square": "◽", "black_medium_small_square": "◾", "sunny": "☀️", "cloud": "☁️", "umbrella": "☂️", "snowman": "☃️", "comet": "☄️", "phone": "☎️", "ballot_box_with_check": "☑️", "umbrella_with_rain_drops": "☔", "coffee": "☕", "shamrock": "☘️", "point_up": "☝️", "skull_and_crossbones": "☠️", "radioactive_sign": "☢️", "biohazard_sign": "☣️", "orthodox_cross": "☦️", "star_and_crescent": "☪️", "peace_symbol": "☮️", "yin_yang": "☯️", "wheel_of_dharma": "☸️", "white_frowning_face": "☹️", "relaxed": "☺️", "female_sign": "♀️", "male_sign": "♂️", "aries": "♈", "taurus": "♉", "gemini": "♊", "cancer": "♋", "leo": "♌", "virgo": "♍", "libra": "♎", "scorpius": "♏", "sagittarius": "♐", "capricorn": "♑", "aquarius": "♒", "pisces": "♓", "chess_pawn": "♟️", "spades": "♠️", "clubs": "♣️", "hearts": "♥️", "diamonds": "♦️", "hotsprings": "♨️", "recycle": "♻️", "infinity": "♾️", "wheelchair": "♿", "hammer_and_pick": "⚒️", "anchor": "⚓", "crossed_swords": "⚔️", "medical_symbol": "⚕️", "scales": "⚖️", "alembic": "⚗️", "gear": "⚙️", "atom_symbol": "⚛️", "fleur_de_lis": "⚜️", "warning": "⚠️", "zap": "⚡", "transgender_symbol": "⚧️", "white_circle": "⚪", "black_circle": "⚫", "coffin": "⚰️", "funeral_urn": "⚱️", "soccer": "⚽", "baseball": "⚾", "snowman_without_snow": "⛄", "partly_sunny": "⛅", "thunder_cloud_and_rain": "⛈️", "ophiuchus": "⛎", "pick": "⛏️", "helmet_with_white_cross": "⛑️", "chains": "⛓️", "no_entry": "⛔", "shinto_shrine": "⛩️", "church": "⛪", "mountain": "⛰️", "umbrella_on_ground": "⛱️", "fountain": "⛲", "golf": "⛳", "ferry": "⛴️", "boat": "⛵", "skier": "⛷️", "ice_skate": "⛸️", "woman-bouncing-ball": "⛹️‍♀️", "man-bouncing-ball": "⛹️‍♂️", "person_with_ball": "⛹️", "tent": "⛺", "fuelpump": "⛽", "scissors": "✂️", "white_check_mark": "✅", "airplane": "✈️", "email": "✉️", "fist": "✊", "hand": "✋", "v": "✌️", "writing_hand": "✍️", "pencil2": "✏️", "black_nib": "✒️", "heavy_check_mark": "✔️", "heavy_multiplication_x": "✖️", "latin_cross": "✝️", "star_of_david": "✡️", "sparkles": "✨", "eight_spoked_asterisk": "✳️", "eight_pointed_black_star": "✴️", "snowflake": "❄️", "sparkle": "❇️", "x": "❌", "negative_squared_cross_mark": "❎", "question": "❓", "grey_question": "❔", "grey_exclamation": "❕", "exclamation": "❗", "heavy_heart_exclamation_mark_ornament": "❣️", "heart_on_fire": "❤️‍🔥", "mending_heart": "❤️‍🩹", "heart": "❤️", "heavy_plus_sign": "➕", "heavy_minus_sign": "➖", "heavy_division_sign": "➗", "arrow_right": "➡️", "curly_loop": "➰", "loop": "➿", "arrow_heading_up": "⤴️", "arrow_heading_down": "⤵️", "arrow_left": "⬅️", "arrow_up": "⬆️", "arrow_down": "⬇️", "black_large_square": "⬛", "white_large_square": "⬜", "star": "⭐", "o": "⭕", "wavy_dash": "〰️", "part_alternation_mark": "〽️", "congratulations": "㊗️", "secret": "㊙️"} diff --git a/resources/update-emoji.json.py b/resources/update-emoji.json.py deleted file mode 100644 index 50af904..0000000 --- a/resources/update-emoji.json.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/python3 -import requests -import json - - -def unified_to_unicode(unified: str) -> str: - return ( - "".join(rf"\U{chunk:0>8}" for chunk in unified.split("-")) - .encode("ascii") - .decode("unicode_escape") - ) - - -data = requests.get( - "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json" -).json() -emojis = {emoji["short_name"]: unified_to_unicode(emoji["unified"]) for emoji in data} -with open("emoji.json", "w") as file: - json.dump(emojis, file, ensure_ascii=False) diff --git a/teamportal.go b/teamportal.go index 06db70d..94d1907 100644 --- a/teamportal.go +++ b/teamportal.go @@ -1,5 +1,6 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. // Copyright (C) 2022 Max Sandholm +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,13 +18,14 @@ package main import ( - "errors" + "context" "fmt" "sync" + "time" - "github.com/slack-go/slack" + "github.com/rs/zerolog" - log "maunium.net/go/maulogger/v2" + "github.com/slack-go/slack" "go.mau.fi/mautrix-slack/database" @@ -33,89 +35,96 @@ import ( ) type Team struct { - *database.TeamInfo + *database.TeamPortal bridge *SlackBridge - log log.Logger + log zerolog.Logger - roomCreateLock sync.Mutex + roomCreateLock sync.Mutex + emojiLock sync.Mutex + lastEmojiResync time.Time } -func (br *SlackBridge) loadTeam(dbTeam *database.TeamInfo, id string, createIfNotExist bool) *Team { +func (br *SlackBridge) loadTeam(ctx context.Context, dbTeam *database.TeamPortal, teamID *string) *Team { if dbTeam == nil { - if id == "" || !createIfNotExist { + if teamID == nil { return nil } - dbTeam = br.DB.TeamInfo.New() - dbTeam.TeamID = id - dbTeam.Upsert() + dbTeam = br.DB.TeamPortal.New() + dbTeam.ID = *teamID + err := dbTeam.Insert(ctx) + if err != nil { + br.ZLog.Err(err).Str("team_id", *teamID).Msg("Failed to insert new team") + return nil + } } - team := br.NewTeam(dbTeam) + team := br.newTeam(dbTeam) - br.teamsByID[team.TeamID] = team - if team.SpaceRoom != "" { - br.teamsByMXID[team.SpaceRoom] = team + br.teamsByID[team.ID] = team + if team.MXID != "" { + br.teamsByMXID[team.MXID] = team } return team } +func (br *SlackBridge) newTeam(dbTeam *database.TeamPortal) *Team { + team := &Team{ + TeamPortal: dbTeam, + bridge: br, + } + team.updateLogger() + return team +} + +func (team *Team) updateLogger() { + withLog := team.bridge.ZLog.With().Str("team_id", team.ID) + if team.MXID != "" { + withLog = withLog.Stringer("space_room_id", team.MXID) + } + team.log = withLog.Logger() +} + func (br *SlackBridge) GetTeamByMXID(mxid id.RoomID) *Team { - br.teamsLock.Lock() - defer br.teamsLock.Unlock() + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() portal, ok := br.teamsByMXID[mxid] if !ok { - return br.loadTeam(br.DB.TeamInfo.GetByMXID(mxid), "", false) + ctx := context.TODO() + dbTeam, err := br.DB.TeamPortal.GetByMXID(ctx, mxid) + if err != nil { + br.ZLog.Err(err).Str("mxid", mxid.String()).Msg("Failed to get team by mxid") + return nil + } + return br.loadTeam(ctx, dbTeam, nil) } return portal } -func (br *SlackBridge) GetTeamByID(id string, createIfNotExist bool) *Team { - br.teamsLock.Lock() - defer br.teamsLock.Unlock() +func (br *SlackBridge) GetTeamByID(id string) *Team { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.unlockedGetTeamByID(id, false) +} +func (br *SlackBridge) unlockedGetTeamByID(id string, onlyIfExists bool) *Team { team, ok := br.teamsByID[id] if !ok { - return br.loadTeam(br.DB.TeamInfo.GetBySlackTeam(id), id, createIfNotExist) - } - - return team -} - -// func (br *SlackBridge) GetAllTeams() []*Team { -// return br.dbTeamsToTeams(br.DB.TeamInfo.GetAll()) -// } - -// func (br *SlackBridge) dbTeamsToTeams(dbTeams []*database.TeamInfo) []*Team { -// br.teamsLock.Lock() -// defer br.teamsLock.Unlock() - -// output := make([]*Team, len(dbTeams)) -// for index, dbTeam := range dbTeams { -// if dbTeam == nil { -// continue -// } - -// team, ok := br.teamsByID[dbTeam.TeamID] -// if !ok { -// team = br.loadTeam(dbTeam, "", false) -// } - -// output[index] = team -// } - -// return output -// } - -func (br *SlackBridge) NewTeam(dbTeam *database.TeamInfo) *Team { - team := &Team{ - TeamInfo: dbTeam, - bridge: br, - log: br.Log.Sub(fmt.Sprintf("Team/%s", dbTeam.TeamID)), + ctx := context.TODO() + dbTeam, err := br.DB.TeamPortal.GetBySlackID(ctx, id) + if err != nil { + br.ZLog.Err(err).Str("team_id", id).Msg("Failed to get team by ID") + return nil + } + idPtr := &id + if onlyIfExists { + idPtr = nil + } + return br.loadTeam(ctx, dbTeam, idPtr) } return team @@ -132,41 +141,71 @@ func (team *Team) getBridgeInfo() (string, event.BridgeEventContent) { ExternalURL: "https://slack.com/", }, Channel: event.BridgeInfoSection{ - ID: team.TeamID, - DisplayName: team.TeamName, - AvatarURL: team.AvatarUrl.CUString(), + ID: team.ID, + DisplayName: team.Name, + AvatarURL: team.AvatarMXC.CUString(), }, } - bridgeInfoStateKey := fmt.Sprintf("fi.mau.slack://slackgo/%s", team.TeamID) + bridgeInfoStateKey := fmt.Sprintf("fi.mau.slack://slackgo/%s", team.ID) return bridgeInfoStateKey, bridgeInfo } -func (team *Team) UpdateBridgeInfo() { - if len(team.SpaceRoom) == 0 { - team.log.Debugln("Not updating bridge info: no Matrix room created") +func (team *Team) UpdateBridgeInfo(ctx context.Context) { + if len(team.MXID) == 0 { + team.log.Debug().Msg("Not updating bridge info: no Matrix room created") return } - team.log.Debugln("Updating bridge info...") + team.log.Debug().Msg("Updating bridge info") stateKey, content := team.getBridgeInfo() - _, err := team.bridge.Bot.SendStateEvent(team.SpaceRoom, event.StateBridge, stateKey, content) + _, err := team.bridge.Bot.SendStateEvent(ctx, team.MXID, event.StateBridge, stateKey, content) if err != nil { - team.log.Warnln("Failed to update m.bridge:", err) + team.log.Err(err).Msg("Failed to update m.bridge event") } // TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec - _, err = team.bridge.Bot.SendStateEvent(team.SpaceRoom, event.StateHalfShotBridge, stateKey, content) + _, err = team.bridge.Bot.SendStateEvent(ctx, team.MXID, event.StateHalfShotBridge, stateKey, content) if err != nil { - team.log.Warnln("Failed to update uk.half-shot.bridge:", err) + team.log.Err(err).Msg("Failed to update uk.half-shot.bridge event") } } -func (team *Team) CreateMatrixRoom(user *User, meta *slack.TeamInfo) error { +func (team *Team) GetCachedUserByID(userID string) *UserTeam { + return team.bridge.GetCachedUserTeamByID(database.UserTeamKey{ + TeamID: team.ID, + UserID: userID, + }) +} + +func (team *Team) GetCachedUserByMXID(userID id.UserID) *UserTeam { + team.bridge.userAndTeamLock.Lock() + defer team.bridge.userAndTeamLock.Unlock() + user, ok := team.bridge.usersByMXID[userID] + if !ok { + return nil + } + return user.teams[team.ID] +} + +func (team *Team) GetPuppetByID(userID string) *Puppet { + return team.bridge.GetPuppetByID(database.UserTeamKey{ + TeamID: team.ID, + UserID: userID, + }) +} + +func (team *Team) GetPortalByID(channelID string) *Portal { + return team.bridge.GetPortalByID(database.PortalKey{ + TeamID: team.ID, + ChannelID: channelID, + }) +} + +func (team *Team) CreateMatrixRoom(ctx context.Context) error { team.roomCreateLock.Lock() defer team.roomCreateLock.Unlock() - if team.SpaceRoom != "" { + if team.MXID != "" { return nil } - team.log.Infoln("Creating Matrix room for team") - team.UpdateInfo(user, meta) + team.log.Info().Msg("Creating Matrix space for team") bridgeInfoStateKey, bridgeInfo := team.getBridgeInfo() @@ -181,102 +220,112 @@ func (team *Team) CreateMatrixRoom(user *User, meta *slack.TeamInfo) error { StateKey: &bridgeInfoStateKey, }} - if !team.AvatarUrl.IsEmpty() { + if !team.AvatarMXC.IsEmpty() { initialState = append(initialState, &event.Event{ Type: event.StateRoomAvatar, Content: event.Content{Parsed: &event.RoomAvatarEventContent{ - URL: team.AvatarUrl, + URL: team.AvatarMXC, }}, }) } - creationContent := map[string]interface{}{ + creationContent := map[string]any{ "type": event.RoomTypeSpace, } if !team.bridge.Config.Bridge.FederateRooms { creationContent["m.federate"] = false } - resp, err := team.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ + resp, err := team.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ Visibility: "private", - Name: team.TeamName, + Name: team.Name, Preset: "private_chat", InitialState: initialState, CreationContent: creationContent, }) if err != nil { - team.log.Warnln("Failed to create room:", err) + team.log.Err(err).Msg("Failed to create Matrix space") return err } - team.SpaceRoom = resp.RoomID + team.bridge.userAndTeamLock.Lock() + team.MXID = resp.RoomID team.NameSet = true - team.AvatarSet = !team.AvatarUrl.IsEmpty() - team.Upsert() - team.bridge.teamsLock.Lock() - team.bridge.teamsByMXID[team.SpaceRoom] = team - team.bridge.teamsLock.Unlock() - team.log.Infoln("Matrix room created:", team.SpaceRoom) + team.AvatarSet = !team.AvatarMXC.IsEmpty() + team.bridge.teamsByMXID[team.MXID] = team + team.bridge.userAndTeamLock.Unlock() + team.updateLogger() + team.log.Info().Msg("Matrix space created") - user.ensureInvited(nil, team.SpaceRoom, false) + err = team.Update(ctx) + if err != nil { + team.log.Err(err).Msg("Failed to save team after creating Matrix space") + } return nil } -func (team *Team) UpdateInfo(source *User, meta *slack.TeamInfo) (changed bool) { - changed = team.UpdateName(meta) || changed - changed = team.UpdateAvatar(meta) || changed - if team.TeamDomain != meta.Domain { - team.TeamDomain = meta.Domain +func (team *Team) UpdateInfo(ctx context.Context, meta *slack.TeamInfo) (changed bool) { + changed = team.UpdateName(ctx, meta) || changed + changed = team.UpdateAvatar(ctx, meta) || changed + if team.Domain != meta.Domain { + team.Domain = meta.Domain changed = true } - if team.TeamUrl != meta.URL { - team.TeamUrl = meta.URL + if team.URL != meta.URL { + team.URL = meta.URL changed = true } if changed { - team.UpdateBridgeInfo() - team.Upsert() + team.UpdateBridgeInfo(ctx) + err := team.Update(ctx) + if err != nil { + zerolog.Ctx(ctx).Err(err).Msg("Failed to save team after updating info") + } } return } -func (team *Team) UpdateName(meta *slack.TeamInfo) (changed bool) { - if team.TeamName != meta.Name { - team.log.Debugfln("Updating name %q -> %q", team.TeamName, meta.Name) - team.TeamName = meta.Name - changed = true +func (team *Team) UpdateName(ctx context.Context, meta *slack.TeamInfo) bool { + newName := team.bridge.Config.Bridge.FormatTeamName(meta) + if team.Name == newName && team.NameSet { + return false } - if team.SpaceRoom != "" { - _, err := team.bridge.Bot.SetRoomName(team.SpaceRoom, team.TeamName) + team.log.Debug().Str("old_name", team.Name).Str("new_name", newName).Msg("Updating name") + team.Name = newName + team.NameSet = false + if team.MXID != "" { + _, err := team.bridge.Bot.SetRoomName(ctx, team.MXID, team.Name) if err != nil { - team.log.Warnln("Failed to update room name: %s", err) + team.log.Err(err).Msg("Failed to update room name") } else { team.NameSet = true } } - return + return true } -func (team *Team) UpdateAvatar(meta *slack.TeamInfo) (changed bool) { +func (team *Team) UpdateAvatar(ctx context.Context, meta *slack.TeamInfo) (changed bool) { if meta.Icon["image_default"] != nil && meta.Icon["image_default"] == true && team.Avatar != "" { + team.AvatarSet = false team.Avatar = "" - team.AvatarUrl = id.MustParseContentURI("") + team.AvatarMXC = id.MustParseContentURI("") changed = true } else if meta.Icon["image_default"] != nil && meta.Icon["image_default"] == false && meta.Icon["image_230"] != nil && team.Avatar != meta.Icon["image_230"] { - avatar, err := uploadPlainFile(team.bridge.AS.BotIntent(), meta.Icon["image_230"].(string)) + avatar, err := uploadPlainFile(ctx, team.bridge.AS.BotIntent(), meta.Icon["image_230"].(string)) if err != nil { - team.log.Warnfln("Error uploading new team avatar for team %s: %v", team.TeamID, err) + team.log.Err(err).Msg("Failed to reupload team avatar") } else { team.Avatar = meta.Icon["image_230"].(string) - team.AvatarUrl = avatar + team.AvatarMXC = avatar + team.AvatarSet = false changed = true } } - if team.SpaceRoom != "" { - _, err := team.bridge.Bot.SetRoomAvatar(team.SpaceRoom, team.AvatarUrl) + if team.MXID != "" && (changed || !team.AvatarSet) { + _, err := team.bridge.Bot.SetRoomAvatar(ctx, team.MXID, team.AvatarMXC) if err != nil { - team.log.Warnln("Failed to update room avatar:", err) + team.log.Err(err).Msg("Failed to update room avatar") } else { team.AvatarSet = true } @@ -284,61 +333,61 @@ func (team *Team) UpdateAvatar(meta *slack.TeamInfo) (changed bool) { return } -func (team *Team) cleanup() { - if team.SpaceRoom == "" { +func (team *Team) Cleanup(ctx context.Context) { + if team.MXID == "" { return } intent := team.bridge.Bot - if team.bridge.SpecVersions.UnstableFeatures["com.beeper.room_yeeting"] { - err := intent.BeeperDeleteRoom(team.SpaceRoom) - if err == nil || errors.Is(err, mautrix.MNotFound) { - return - } - team.log.Warnfln("Failed to delete %s using hungryserv yeet endpoint, falling back to normal behavior: %v", team.SpaceRoom, err) - } - team.bridge.cleanupRoom(intent, team.SpaceRoom, false, team.log) + team.bridge.cleanupRoom(ctx, intent, team.MXID, false) } -func (team *Team) RemoveMXID() { - team.bridge.teamsLock.Lock() - defer team.bridge.teamsLock.Unlock() - if team.SpaceRoom == "" { +func (team *Team) RemoveMXID(ctx context.Context) { + team.bridge.userAndTeamLock.Lock() + defer team.bridge.userAndTeamLock.Unlock() + if team.MXID == "" { return } - delete(team.bridge.teamsByMXID, team.SpaceRoom) - team.SpaceRoom = "" + delete(team.bridge.teamsByMXID, team.MXID) + team.MXID = "" team.AvatarSet = false team.NameSet = false - team.Upsert() + err := team.Update(ctx) + if err != nil { + team.log.Err(err).Msg("Failed to save team after removing mxid") + } } // func (team *Team) Delete() { -// team.TeamInfo.Delete() +// team.TeamPortal.Delete() // team.bridge.teamsLock.Lock() -// delete(team.bridge.teamsByID, team.TeamID) -// if team.SpaceRoom != "" { -// delete(team.bridge.teamsByMXID, team.SpaceRoom) +// delete(team.bridge.teamsByID, team.ID) +// if team.MXID != "" { +// delete(team.bridge.teamsByMXID, team.MXID) // } // team.bridge.teamsLock.Unlock() // } -func (team *Team) addPortalToTeam(portal *database.Portal, isInSpace bool) bool { - if len(team.SpaceRoom) == 0 { - team.log.Errorln("Tried to add portal to space that has no matrix ID") +func (team *Team) AddPortal(ctx context.Context, portal *Portal) bool { + if len(team.MXID) == 0 { + team.log.Error().Msg("Tried to add portal to team that has no matrix ID") + if portal.InSpace { + portal.InSpace = false + return true + } + return false + } else if portal.InSpace { return false } - if len(portal.MXID) > 0 && !isInSpace { - _, err := team.bridge.Bot.SendStateEvent(team.SpaceRoom, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ - Via: []string{team.bridge.AS.HomeserverDomain}, - }) - if err != nil { - team.log.Errorfln("Failed to add portal %s to team space", portal.MXID) - } else { - isInSpace = true - } + _, err := team.bridge.Bot.SendStateEvent(ctx, team.MXID, event.StateSpaceChild, portal.MXID.String(), &event.SpaceChildEventContent{ + Via: []string{team.bridge.AS.HomeserverDomain}, + }) + if err != nil { + team.log.Err(err).Stringer("room_mxid", portal.MXID).Msg("Failed to add portal to team space") + portal.InSpace = false + } else { + portal.InSpace = true } - - return isInSpace + return true } diff --git a/user.go b/user.go index 57018b1..8db96a8 100644 --- a/user.go +++ b/user.go @@ -1,5 +1,5 @@ // mautrix-slack - A Matrix-Slack puppeting bridge. -// Copyright (C) 2022 Tulir Asokan +// Copyright (C) 2024 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by @@ -17,15 +17,14 @@ package main import ( + "context" "errors" "fmt" - "sort" "strings" "sync" - log "maunium.net/go/maulogger/v2" - - "github.com/slack-go/slack" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridge/commands" "maunium.net/go/mautrix" "maunium.net/go/mautrix/appservice" @@ -40,24 +39,18 @@ import ( "go.mau.fi/mautrix-slack/database" ) -var ( - ErrNotConnected = errors.New("not connected") - ErrNotLoggedIn = errors.New("not logged in") -) - type User struct { *database.User - sync.Mutex - bridge *SlackBridge - log log.Logger + zlog zerolog.Logger - BridgeStates map[string]*bridge.BridgeStateQueue + teams map[string]*UserTeam - PermissionLevel bridgeconfig.PermissionLevel - - spaceCreateLock sync.Mutex + spaceCreateLock sync.Mutex + PermissionLevel bridgeconfig.PermissionLevel + DoublePuppetIntent *appservice.IntentAPI + CommandState *commands.CommandState } func (user *User) GetPermissionLevel() bridgeconfig.PermissionLevel { @@ -72,35 +65,28 @@ func (user *User) GetMXID() id.UserID { return user.MXID } -func (user *User) GetCommandState() map[string]interface{} { - return nil -} - func (user *User) GetIDoublePuppet() bridge.DoublePuppet { - p := user.bridge.GetPuppetByCustomMXID(user.MXID) - if p == nil || p.CustomIntent() == nil { - return nil - } - return p + return user } func (user *User) GetIGhost() bridge.Ghost { - // if user.ID == "" { - // return nil - // } - // p := user.bridge.GetPuppetByID(user.ID) - // if p == nil { - // return nil - // } - // return p return nil } -var _ bridge.User = (*User)(nil) +func (user *User) GetCommandState() *commands.CommandState { + return user.CommandState +} + +func (user *User) SetCommandState(state *commands.CommandState) { + user.CommandState = state +} + +var ( + _ bridge.User = (*User)(nil) + _ commands.CommandingUser = (*User)(nil) +) -func (br *SlackBridge) loadUser(dbUser *database.User, mxid *id.UserID) *User { - // If we weren't passed in a user we attempt to create one if we were given - // a matrix id. +func (br *SlackBridge) loadUser(ctx context.Context, dbUser *database.User, mxid *id.UserID) *User { if dbUser == nil { if mxid == nil { return nil @@ -108,859 +94,295 @@ func (br *SlackBridge) loadUser(dbUser *database.User, mxid *id.UserID) *User { dbUser = br.DB.User.New() dbUser.MXID = *mxid - dbUser.Insert() + err := dbUser.Insert(ctx) + if err != nil { + br.ZLog.Err(err).Stringer("user_id", dbUser.MXID).Msg("Failed to insert new user to database") + return nil + } } - user := br.NewUser(dbUser) + user := br.newUser(dbUser) - // We assume the usersLock was acquired by our caller. br.usersByMXID[user.MXID] = user - if user.ManagementRoom != "" { - // Lock the management rooms for our update - br.managementRoomsLock.Lock() br.managementRooms[user.ManagementRoom] = user - br.managementRoomsLock.Unlock() } + user.bridge.unlockedGetAllUserTeamsForUser(user.MXID) return user } -func (br *SlackBridge) GetUserByMXID(userID id.UserID) *User { - _, _, isPuppet := br.ParsePuppetMXID(userID) - if isPuppet || userID == br.Bot.UserID { - return nil - } - - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByMXID[userID] - if !ok { - br.Log.Debugfln("User %s not present in usersByMXID map!", userID) - return br.loadUser(br.DB.User.GetByMXID(userID), &userID) +func (br *SlackBridge) newUser(dbUser *database.User) *User { + user := &User{ + User: dbUser, + bridge: br, + zlog: br.ZLog.With().Stringer("user_id", dbUser.MXID).Logger(), + teams: make(map[string]*UserTeam), } - + user.PermissionLevel = br.Config.Bridge.Permissions.Get(user.MXID) return user } -func (br *SlackBridge) GetUserByID(teamID, userID string) *User { - br.usersLock.Lock() - defer br.usersLock.Unlock() - - user, ok := br.usersByID[teamID+"-"+userID] - if !ok { - return br.loadUser(br.DB.User.GetBySlackID(teamID, userID), nil) - } +func (br *SlackBridge) GetUserByMXID(userID id.UserID) *User { + return br.getUserByMXID(userID, false) +} - return user +func (br *SlackBridge) GetCachedUserByMXID(userID id.UserID) *User { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.usersByMXID[userID] } -func (br *SlackBridge) NewUser(dbUser *database.User) *User { - user := &User{ - User: dbUser, - bridge: br, - log: br.Log.Sub("User").Sub(string(dbUser.MXID)), +func (br *SlackBridge) getUserByMXID(userID id.UserID, onlyIfExists bool) *User { + _, isPuppet := br.ParsePuppetMXID(userID) + if isPuppet || userID == br.Bot.UserID { + return nil } + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.unlockedGetUserByMXID(userID, onlyIfExists) +} - user.PermissionLevel = br.Config.Bridge.Permissions.Get(user.MXID) - user.BridgeStates = make(map[string]*bridge.BridgeStateQueue) +func (br *SlackBridge) unlockedGetUserByMXID(userID id.UserID, onlyIfExists bool) *User { + user, ok := br.usersByMXID[userID] + if !ok { + ctx := context.TODO() + dbUser, err := br.DB.User.GetByMXID(ctx, userID) + if err != nil { + br.ZLog.Err(err).Stringer("user_id", userID).Msg("Failed to get user by MXID from database") + return nil + } + mxidPtr := &userID + if onlyIfExists { + mxidPtr = nil + } + return br.loadUser(ctx, dbUser, mxidPtr) + } return user } -func (br *SlackBridge) getAllUsers() []*User { - br.usersLock.Lock() - defer br.usersLock.Unlock() +func (br *SlackBridge) GetAllUsersWithAccessToken() []*User { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() - dbUsers := br.DB.User.GetAll() + dbUsers, err := br.DB.User.GetAllWithAccessToken(context.TODO()) + if err != nil { + br.ZLog.Err(err).Msg("Failed to get all users from database") + return nil + } users := make([]*User, len(dbUsers)) - for idx, dbUser := range dbUsers { + for i, dbUser := range dbUsers { user, ok := br.usersByMXID[dbUser.MXID] if !ok { - user = br.loadUser(dbUser, nil) + users[i] = br.loadUser(context.TODO(), dbUser, nil) + } else { + users[i] = user } - users[idx] = user } return users } func (br *SlackBridge) startUsers() { - br.Log.Debugln("Starting users") - - if !br.historySyncLoopStarted { - br.Log.Debugln("Starting backfill loop") - go br.handleHistorySyncsLoop() - } - - users := br.getAllUsers() - - for _, user := range users { - go user.Connect() - } - if sort.Search(len(users), func(i int) bool { return len(users[i].Teams) > 0 }) == len(users) { // if there are no users with any configured userTeams - br.Log.Debugln("No users with userTeams found, sending UNCONFIGURED") + //if !br.historySyncLoopStarted { + // br.ZLog.Debug().Msg("Starting backfill loop") + // go br.handleHistorySyncsLoop() + //} + + br.ZLog.Debug().Msg("Starting user teams") + userTeams := br.GetAllUserTeamsWithToken() + for _, ut := range userTeams { + go ut.Connect() + } + if len(userTeams) == 0 { + br.ZLog.Debug().Msg("No users to start, sending unconfigured state") br.SendGlobalBridgeState(status.BridgeState{StateEvent: status.StateUnconfigured}.Fill(nil)) } - br.Log.Debugln("Starting custom puppets") - for _, customPuppet := range br.GetAllPuppetsWithCustomMXID() { - go func(puppet *Puppet) { - br.Log.Debugln("Starting custom puppet", puppet.CustomMXID) - - if err := puppet.StartCustomMXID(true); err != nil { - puppet.log.Errorln("Failed to start custom puppet:", err) + br.ZLog.Debug().Msg("Starting custom puppets") + for _, user := range br.GetAllUsersWithAccessToken() { + go func(user *User) { + user.zlog.Debug().Msg("Starting double puppet") + if err := user.StartCustomMXID(true); err != nil { + user.zlog.Err(err).Msg("Failed to start double puppet") } - }(customPuppet) + }(user) } } func (user *User) SetManagementRoom(roomID id.RoomID) { - user.bridge.managementRoomsLock.Lock() - defer user.bridge.managementRoomsLock.Unlock() + user.bridge.userAndTeamLock.Lock() + defer user.bridge.userAndTeamLock.Unlock() + ctx := context.TODO() existing, ok := user.bridge.managementRooms[roomID] if ok { - // If there's a user already assigned to this management room, clear it - // out. - // I think this is due a name change or something? I dunno, leaving it - // for now. existing.ManagementRoom = "" - existing.Update() + err := existing.Update(ctx) + if err != nil { + user.zlog.Err(err).Stringer("previous_user_mxid", existing.MXID). + Msg("Failed to update previous user's management room") + } } user.ManagementRoom = roomID user.bridge.managementRooms[user.ManagementRoom] = user - user.Update() -} - -func (user *User) tryAutomaticDoublePuppeting(userTeam *database.UserTeam) { - user.Lock() - defer user.Unlock() - - if !user.bridge.Config.CanAutoDoublePuppet(user.MXID) { - return - } - - user.log.Debugln("Checking if double puppeting needs to be enabled") - - puppet := user.bridge.GetPuppetByID(userTeam.Key.TeamID, userTeam.Key.SlackID) - if puppet.CustomMXID != "" { - user.log.Debugln("User already has double-puppeting enabled") - - return - } - - accessToken, err := puppet.loginWithSharedSecret(user.MXID, userTeam.Key.TeamID) + err := user.Update(ctx) if err != nil { - user.log.Warnln("Failed to login with shared secret:", err) - - return + user.zlog.Err(err).Stringer("management_room_mxid", roomID). + Msg("Failed to save user after updating management room") } - - err = puppet.SwitchCustomMXID(accessToken, user.MXID) - if err != nil { - puppet.log.Warnln("Failed to switch to auto-logined custom puppet:", err) - - return - } - - user.log.Infoln("Successfully automatically enabled custom puppet") } -func (user *User) syncChatDoublePuppetDetails(portal *Portal, justCreated bool) { - doublePuppet := portal.bridge.GetPuppetByCustomMXID(user.MXID) - if doublePuppet == nil { - return +func (user *User) login(ctx context.Context, info *auth.Info) error { + user.bridge.userAndTeamLock.Lock() + existingTeam, ok := user.teams[info.TeamID] + user.bridge.userAndTeamLock.Unlock() + if ok && existingTeam.UserID != info.UserID { + if existingTeam.Token == "" { + existingTeam.Delete(ctx) + } else { + return fmt.Errorf("already logged into that team as %s/%s", existingTeam.Email, existingTeam.UserID) + } } - if doublePuppet == nil || doublePuppet.CustomIntent() == nil || portal.MXID == "" { - return + userTeam := user.bridge.GetUserTeamByID(database.UserTeamKey{ + TeamID: info.TeamID, + UserID: info.UserID, + }, user.MXID) + if userTeam == nil { + return fmt.Errorf("failed to get user team from database") + } else if userTeam.UserMXID != user.MXID { + return fmt.Errorf("%s is already logged into that account", userTeam.UserMXID) } - - // TODO sync mute status -} - -func (user *User) login(info *auth.Info, force bool) { - userTeam := user.bridge.DB.UserTeam.New() - - userTeam.Key.MXID = user.MXID - userTeam.Key.SlackID = info.UserID - userTeam.Key.TeamID = info.TeamID - userTeam.SlackEmail = info.UserEmail - userTeam.TeamName = info.TeamName + userTeam.Email = info.UserEmail userTeam.Token = info.Token userTeam.CookieToken = info.CookieToken - - // We minimize the time we hold the lock because SyncTeams also needs the - // lock. - user.TeamsLock.Lock() - user.Teams[userTeam.Key.TeamID] = userTeam - user.TeamsLock.Unlock() - - user.User.SyncTeams() - - user.log.Debugfln("logged into %s successfully", info.TeamName) - - user.BridgeStates[info.TeamID] = user.bridge.NewBridgeStateQueue(userTeam) - user.bridge.usersByID[fmt.Sprintf("%s-%s", userTeam.Key.TeamID, userTeam.Key.SlackID)] = user - user.connectTeam(userTeam) + err := userTeam.Update(ctx) + if err != nil { + return fmt.Errorf("failed to save token: %w", err) + } + user.zlog.Info(). + Str("team_name", info.TeamName). + Str("team_id", info.TeamID). + Str("user_id", info.UserID). + Str("user_email", info.UserEmail). + Msg("Successfully logged in") + go userTeam.Connect() + return nil } -func (user *User) LoginTeam(email, team, password string) error { - info, err := auth.LoginPassword(user.log, email, team, password) +func (user *User) LoginTeam(ctx context.Context, email, team, password string) error { + info, err := auth.LoginPassword(email, team, password) if err != nil { return err } - go user.login(info, false) - return nil + return user.login(ctx, info) } -func (user *User) TokenLogin(token string, cookieToken string) (*auth.Info, error) { +func (user *User) TokenLogin(ctx context.Context, token string, cookieToken string) (*auth.Info, error) { info, err := auth.LoginToken(token, cookieToken) if err != nil { return nil, err } - go user.login(info, true) - return info, nil + return info, user.login(ctx, info) } func (user *User) IsLoggedIn() bool { - return len(user.GetLoggedInTeams()) > 0 -} - -func (user *User) IsLoggedInTeam(email, team string) bool { - if user.TeamLoggedIn(email, team) { - user.log.Errorf("%s is already logged into team %s with %s", user.MXID, team, email) - - return true - } - - return false -} - -func (user *User) LogoutUserTeam(userTeam *database.UserTeam) error { - if userTeam == nil || !userTeam.IsLoggedIn() { - return ErrNotLoggedIn - } - - user.leavePortals(userTeam) - - puppet := user.bridge.GetPuppetByID(userTeam.Key.TeamID, userTeam.Key.SlackID) - if puppet.CustomMXID != "" { - err := puppet.SwitchCustomMXID("", "") - if err != nil { - user.log.Warnln("Failed to logout-matrix while logging out of Slack:", err) - } - } - - if userTeam.RTM != nil { - if err := userTeam.RTM.Disconnect(); err != nil && !errors.Is(err, slack.ErrAlreadyDisconnected) { - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: err.Error()}) - return err - } - } - - if _, err := userTeam.Client.SendAuthSignout(); err != nil { - user.log.Errorfln("Failed to send auth.signout request to Slack! %v", err) - } - - userTeam.Client = nil - - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateLoggedOut}) - - user.TeamsLock.Lock() - delete(user.Teams, userTeam.Key.TeamID) - user.TeamsLock.Unlock() - - userTeam.Token = "" - userTeam.CookieToken = "" - userTeam.Upsert() - - user.Update() - - return nil -} - -func (user *User) leavePortals(userTeam *database.UserTeam) { - for _, portal := range user.bridge.GetAllPortalsForUserTeam(userTeam.Key) { - portal.leave(userTeam) - } -} - -func (user *User) slackMessageHandler(userTeam *database.UserTeam) { - user.log.Debugfln("Start receiving Slack events for %s", userTeam.Key) - for msg := range userTeam.RTM.IncomingEvents { - switch event := msg.Data.(type) { - case *slack.ConnectingEvent: - user.log.Debugfln("connecting: attempt %d", event.Attempt) - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateConnecting}) - case *slack.ConnectedEvent: - // Update all of our values according to what the server has for us. - userTeam.Key.SlackID = event.Info.User.ID - userTeam.Key.TeamID = event.Info.Team.ID - userTeam.TeamName = event.Info.Team.Name - - userTeam.Upsert() - - user.tryAutomaticDoublePuppeting(userTeam) - user.log.Infofln("connected to team %s as %s", userTeam.TeamName, userTeam.SlackEmail) - - if user.bridge.Config.Bridge.Backfill.Enable { - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateBackfilling}) - - portals := user.bridge.dbPortalsToPortals(user.bridge.DB.Portal.GetAllForUserTeam(userTeam.Key)) - for _, portal := range portals { - err := portal.ForwardBackfill() - if err != nil { - user.log.Warnfln("Forward backfill for portal %s failed: %v", portal.Key, err) - } - } - } - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateConnected}) - case *slack.HelloEvent: - // Ignored for now - case *slack.InvalidAuthEvent: - user.log.Errorln("invalid authentication token") - - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateBadCredentials}) - - // TODO: Should drop a message in the management room - - return - case *slack.LatencyReport: - user.log.Debugln("latency report:", event.Value) - case *slack.MessageEvent: - user.bridge.ZLog.Trace().Any("event_content", event).Msg("Raw slack message event") - key := database.NewPortalKey(userTeam.Key.TeamID, event.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - if portal.MXID == "" { - channel, err := userTeam.Client.GetConversationInfo(&slack.GetConversationInfoInput{ - ChannelID: event.Channel, - IncludeLocale: true, - IncludeNumMembers: true, - }) - if err != nil { - portal.log.Errorln("failed to lookup channel info:", err) - continue - } - - portal.log.Debugln("Creating Matrix room from incoming message") - if err := portal.CreateMatrixRoom(user, userTeam, channel, false); err != nil { - portal.log.Errorln("Failed to create portal room:", err) - continue - } - } - portal.HandleSlackMessage(user, userTeam, event) - } - case *slack.ReactionAddedEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Item.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - portal.HandleSlackReaction(user, userTeam, event) - } - case *slack.ReactionRemovedEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Item.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - portal.HandleSlackReactionRemoved(user, userTeam, event) - } - case *slack.UserTypingEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - portal.HandleSlackTyping(user, userTeam, event) - } - case *slack.ChannelMarkedEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - portal.HandleSlackChannelMarked(user, userTeam, event) - } - case *slack.ChannelJoinedEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Channel.ID) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - if portal.MXID == "" { - portal.log.Debugln("Creating Matrix room from joined channel") - if err := portal.CreateMatrixRoom(user, userTeam, &event.Channel, false); err != nil { - portal.log.Errorln("Failed to create portal room:", err) - continue - } - } - } else { - portal.ensureUserInvited(user) - } - case *slack.ChannelLeftEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - portal.leave(userTeam) - } - case *slack.ChannelUpdateEvent: - key := database.NewPortalKey(userTeam.Key.TeamID, event.Channel) - portal := user.bridge.GetPortalByID(key) - if portal != nil { - portal.UpdateInfo(user, userTeam, nil, true) - } - case *slack.RTMError: - user.log.Errorln("rtm error:", event.Error()) - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: event.Error()}) - case *slack.FileSharedEvent, *slack.FilePublicEvent, *slack.FilePrivateEvent, *slack.FileCreatedEvent, *slack.FileChangeEvent, *slack.FileDeletedEvent, *slack.DesktopNotificationEvent: - // ignored intentionally, these are duplicates or do not contain useful information - default: - user.log.Warnln("unknown message", msg) - } - } - user.log.Errorfln("Slack RTM for %s unexpectedly disconnected!", userTeam.Key) - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: "Disconnected for unknown reason"}) -} - -func (user *User) connectTeam(userTeam *database.UserTeam) error { - user.log.Infofln("Connecting %s to Slack userteam %s (%s)", user.MXID, userTeam.Key, userTeam.TeamName) - slackOptions := []slack.Option{ - slack.OptionLog(SlackgoLogger{user.log.Sub(fmt.Sprintf("SlackGo/%s", userTeam.Key))}), - //slack.OptionDebug(user.bridge.Config.Logging.PrintLevel <= 0), - } - if userTeam.CookieToken != "" { - slackOptions = append(slackOptions, slack.OptionCookie("d", userTeam.CookieToken)) - } - userTeam.Client = slack.New(userTeam.Token, slackOptions...) - - // test Slack connection before trying to go further - _, err := userTeam.Client.GetUserProfile(&slack.GetUserProfileParameters{}) - if err != nil { - user.log.Errorln("Error connecting to Slack team", err) - return err - } - - userTeam.RTM = userTeam.Client.NewRTM() - - go userTeam.RTM.ManageConnection() - - go user.slackMessageHandler(userTeam) - - go user.UpdateTeam(userTeam, false) - - return nil -} - -func (user *User) isChannelOrOpenIM(channel *slack.Channel) bool { - if !channel.IsIM { - return true - } else { - return channel.Latest != nil && channel.Latest.SubType == "" - } -} - -func (user *User) SyncPortals(team *Team, userTeam *database.UserTeam, force bool) error { - channelInfo := map[string]slack.Channel{} - - if !strings.HasPrefix(userTeam.Token, "xoxs") { - // TODO: use pagination to make sure we get everything! - channels, _, err := userTeam.Client.GetConversationsForUser(&slack.GetConversationsForUserParameters{ - Types: []string{"public_channel", "private_channel", "mpim", "im"}, - Limit: user.bridge.Config.Bridge.Backfill.ConversationsCount, - }) - if err != nil { - user.log.Warnfln("Error fetching channels: %v", err) - } - for i, channel := range channels { - // replace channel entry in list with one that has more metadata - c, err := userTeam.Client.GetConversationInfo(&slack.GetConversationInfoInput{ - ChannelID: channel.ID, - IncludeLocale: true, - IncludeNumMembers: true, - }) - if err != nil { - user.log.Errorfln("Error getting information about IM: %v", err) - return err - } - channels[i] = *c - } - sort.Slice(channels, func(i, j int) bool { - if channels[i].LastRead == "" { - return false - } - if channels[j].LastRead == "" { - return true - } - return parseSlackTimestamp(channels[i].LastRead).After(parseSlackTimestamp(channels[j].LastRead)) - }) - for _, channel := range channels { - if user.isChannelOrOpenIM(&channel) { - channelInfo[channel.ID] = channel - } - } - } else { - user.log.Warnfln("Not fetching channels for userteam %s: xoxs token type can't fetch user's joined channels", userTeam.Key) - } - - portals := user.bridge.DB.Portal.GetAllForUserTeam(userTeam.Key) - for _, dbPortal := range portals { - // First, go through all pre-existing portals and update their info - portal := user.bridge.GetPortalByID(dbPortal.Key) - channel := channelInfo[dbPortal.Key.ChannelID] - if portal.MXID != "" { - if channel.ID == "" { - portal.UpdateInfo(user, userTeam, nil, force) - } else { - portal.UpdateInfo(user, userTeam, &channel, force) - } - portal.ensureUserInvited(user) - portal.InsertUser(userTeam.Key) - } else { - portal.CreateMatrixRoom(user, userTeam, &channel, true) - } - portal.InSpace = team.addPortalToTeam(portal.Portal, portal.InSpace) - portal.Update(nil) - // Delete already handled ones from the map - delete(channelInfo, dbPortal.Key.ChannelID) - } - - for _, channel := range channelInfo { - // Remaining ones in the map are new channels that weren't handled yet - key := database.NewPortalKey(userTeam.Key.TeamID, channel.ID) - portal := user.bridge.GetPortalByID(key) - if portal.MXID != "" { - portal.UpdateInfo(user, userTeam, &channel, force) - portal.InsertUser(userTeam.Key) - } else { - portal.CreateMatrixRoom(user, userTeam, &channel, true) - } - portal.InSpace = team.addPortalToTeam(portal.Portal, portal.InSpace) - portal.Update(nil) - } - - return nil -} - -func (user *User) UpdateTeam(userTeam *database.UserTeam, force bool) error { - user.log.Debugfln("Updating team info for team %s", userTeam.Key.TeamID) - team := user.bridge.GetTeamByID(userTeam.Key.TeamID, true) - - teamInfo, err := userTeam.Client.GetTeamInfo() - if err != nil { - user.log.Errorln("Failed to fetch team info ", userTeam.Key.TeamID) - } - - var changed bool - if team.SpaceRoom == "" { - err = team.CreateMatrixRoom(user, teamInfo) - } - - if team.TeamName != teamInfo.Name { - team.TeamName = teamInfo.Name - changed = true - } - if team.TeamDomain != teamInfo.Domain { - team.TeamDomain = teamInfo.Domain - changed = true - } - if team.TeamUrl != teamInfo.URL { - team.TeamUrl = teamInfo.URL - changed = true - } - if teamInfo.Icon["image_default"] != nil && teamInfo.Icon["image_default"] == true && team.Avatar != "" { - team.Avatar = "" - team.AvatarUrl = id.MustParseContentURI("") - changed = true - } else if teamInfo.Icon["image_default"] != nil && teamInfo.Icon["image_default"] == false && teamInfo.Icon["image_230"] != nil && team.Avatar != teamInfo.Icon["image_230"] { - avatar, err := uploadPlainFile(user.bridge.AS.BotIntent(), teamInfo.Icon["image_230"].(string)) - if err != nil { - user.log.Warnfln("Error uploading new team avatar for team %s: %v", userTeam.Key.TeamID, err) - } else { - team.Avatar = teamInfo.Icon["image_230"].(string) - team.AvatarUrl = avatar - changed = true - } - } else { - changed = team.UpdateInfo(user, teamInfo) - } - team.Upsert() - - emojis, err := userTeam.Client.GetEmoji() - if err != nil { - user.log.Error("Fetching emojis for team failed", err) - } else { - emojiCount, err := user.bridge.DB.Emoji.GetEmojiCount(userTeam.Key.TeamID) - if err != nil { - user.log.Error("Getting emoji count from database failed", err) - } else { - if emojiCount != len(emojis) { - user.log.Info("Importing emojis for team") - go user.bridge.ImportEmojis(userTeam, &emojis, false) - } - } - } - - puppets := user.bridge.GetAllPuppetsForTeam(userTeam.Key.TeamID) - for _, puppet := range puppets { - puppet.UpdateInfo(userTeam, false, nil) - } - - inSpace := user.addTeamToSpace(team.TeamInfo, userTeam.InSpace) - userTeam.InSpace = inSpace - userTeam.Upsert() - - return user.SyncPortals(team, userTeam, changed || force) -} - -func (user *User) Connect() error { - user.Lock() - defer user.Unlock() - - user.log.Infofln("Connecting Slack teams for user %s", user.MXID) - for key, userTeam := range user.Teams { - user.bridge.usersByID[fmt.Sprintf("%s-%s", userTeam.Key.TeamID, userTeam.Key.SlackID)] = user - user.BridgeStates[key] = user.bridge.NewBridgeStateQueue(userTeam) - err := user.connectTeam(userTeam) - if err != nil && (err.Error() == "user_removed_from_team" || err.Error() == "invalid_auth") { - user.LogoutUserTeam(userTeam) - user.log.Infoln("User not logged in to Slack team, deleting") - } else if err != nil { - user.log.Errorln("Error connecting to Slack team", err) + user.bridge.userAndTeamLock.Lock() + defer user.bridge.userAndTeamLock.Unlock() + for _, ut := range user.teams { + if ut.Client != nil { + return true } - // if err != nil { - // user.log.Errorfln("Error connecting to Slack userteam %s: %v", userTeam.Key, err) - // // TODO: more detailed error state - // user.BridgeStates[key].Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: err.Error()}) - // } } - - return nil -} - -func (user *User) disconnectTeam(userTeam *database.UserTeam) error { - user.log.Infofln("Disconnecting Slack userteam %s", userTeam.Key) - if userTeam.RTM != nil { - if err := userTeam.RTM.Disconnect(); err != nil { - user.log.Errorfln("Error disconnecting RTM for %s: %v", userTeam.Key, err) - user.BridgeStates[userTeam.Key.TeamID].Send(status.BridgeState{StateEvent: status.StateUnknownError, Message: err.Error()}) - return err - } - } - - userTeam.Client = nil - user.log.Debugfln("Slack client for %s set to nil!", userTeam.Key) - - return nil + return false } -func (user *User) Disconnect() error { - user.Lock() - defer user.Unlock() - - user.log.Infofln("Disconnecting Slack teams for user %s", user.MXID) - for _, userTeam := range user.Teams { - if err := user.disconnectTeam(userTeam); err != nil { - return err - } - } - - return nil +func (user *User) GetTeam(teamID string) *UserTeam { + user.bridge.userAndTeamLock.Lock() + defer user.bridge.userAndTeamLock.Unlock() + return user.teams[teamID] } -func (user *User) GetUserTeam(teamID string) *database.UserTeam { - user.TeamsLock.Lock() - defer user.TeamsLock.Unlock() - - if userTeam, found := user.Teams[teamID]; found { - return userTeam +func (user *User) ensureInvited(ctx context.Context, intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) (ok bool) { + extraContent := make(map[string]interface{}) + if isDirect { + extraContent["is_direct"] = true } - - return nil -} - -// func (user *User) getDirectChats() map[id.UserID][]id.RoomID { -// chats := map[id.UserID][]id.RoomID{} - -// privateChats := user.bridge.DB.Portal.FindPrivateChatsOf(user.DiscordID) -// for _, portal := range privateChats { -// if portal.MXID != "" { -// puppetMXID := user.bridge.FormatPuppetMXID(portal.Key.Receiver) - -// chats[puppetMXID] = []id.RoomID{portal.MXID} -// } -// } - -// return chats -// } - -// func (user *User) updateDirectChats(chats map[id.UserID][]id.RoomID) { -// if !user.bridge.Config.Bridge.SyncDirectChatList { -// return -// } - -// puppet := user.bridge.GetPuppetByMXID(user.MXID) -// if puppet == nil { -// return -// } - -// intent := puppet.CustomIntent() -// if intent == nil { -// return -// } - -// method := http.MethodPatch -// if chats == nil { -// chats = user.getDirectChats() -// method = http.MethodPut -// } - -// user.log.Debugln("Updating m.direct list on homeserver") - -// var err error -// if user.bridge.Config.Homeserver.Software { -// urlPath := intent.BuildURL(mautrix.ClientURLPath{"unstable", "com.beeper.asmux", "dms"}) -// _, err = intent.MakeFullRequest(mautrix.FullRequest{ -// Method: method, -// URL: urlPath, -// Headers: http.Header{"X-Asmux-Auth": {user.bridge.AS.Registration.AppToken}}, -// RequestJSON: chats, -// }) -// } else { -// existingChats := map[id.UserID][]id.RoomID{} - -// err = intent.GetAccountData(event.AccountDataDirectChats.Type, &existingChats) -// if err != nil { -// user.log.Warnln("Failed to get m.direct list to update it:", err) - -// return -// } - -// for userID, rooms := range existingChats { -// if _, ok := user.bridge.ParsePuppetMXID(userID); !ok { -// // This is not a ghost user, include it in the new list -// chats[userID] = rooms -// } else if _, ok := chats[userID]; !ok && method == http.MethodPatch { -// // This is a ghost user, but we're not replacing the whole list, so include it too -// chats[userID] = rooms -// } -// } - -// err = intent.SetAccountData(event.AccountDataDirectChats.Type, &chats) -// } - -// if err != nil { -// user.log.Warnln("Failed to update m.direct list:", err) -// } -// } - -func (user *User) ensureInvited(intent *appservice.IntentAPI, roomID id.RoomID, isDirect bool) bool { - if intent == nil { - intent = user.bridge.Bot - } - ret := false - - inviteContent := event.Content{ - Parsed: &event.MemberEventContent{ - Membership: event.MembershipInvite, - IsDirect: isDirect, - }, - Raw: map[string]interface{}{}, - } - - customPuppet := user.bridge.GetPuppetByCustomMXID(user.MXID) - if customPuppet != nil && customPuppet.CustomIntent() != nil { - inviteContent.Raw["fi.mau.will_auto_accept"] = true + customPuppet := user.DoublePuppetIntent + if customPuppet != nil { + extraContent["fi.mau.will_auto_accept"] = true } - - _, err := intent.SendStateEvent(roomID, event.StateMember, user.MXID.String(), &inviteContent) - + _, err := intent.InviteUser(ctx, roomID, &mautrix.ReqInviteUser{UserID: user.MXID}, extraContent) var httpErr mautrix.HTTPError if err != nil && errors.As(err, &httpErr) && httpErr.RespError != nil && strings.Contains(httpErr.RespError.Err, "is already in the room") { - user.bridge.StateStore.SetMembership(roomID, user.MXID, event.MembershipJoin) - ret = true + err = user.bridge.StateStore.SetMembership(ctx, roomID, user.MXID, event.MembershipJoin) + if err != nil { + user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to update membership to join in state store after invite failed") + } + ok = true + return } else if err != nil { - user.log.Warnfln("Failed to invite user to %s: %v", roomID, err) + user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to invite user to room") } else { - ret = true + ok = true } - if customPuppet != nil && customPuppet.CustomIntent() != nil { - err = customPuppet.CustomIntent().EnsureJoined(roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) + if customPuppet != nil { + err = customPuppet.EnsureJoined(ctx, roomID, appservice.EnsureJoinedParams{IgnoreCache: true}) if err != nil { - user.log.Warnfln("Failed to auto-join %s: %v", roomID, err) - ret = false + user.zlog.Err(err).Stringer("room_id", roomID).Msg("Failed to auto-join room") + ok = false } else { - ret = true + ok = true } } - - return ret + return } -func (user *User) updateChatMute(portal *Portal, muted bool) { - if len(portal.MXID) == 0 { - return - } - puppet := user.GetIDoublePuppet() - if puppet == nil { - return - } - intent := puppet.CustomIntent() - if intent == nil { +func (user *User) updateChatMute(ctx context.Context, portal *Portal, muted bool) { + if len(portal.MXID) == 0 || user.DoublePuppetIntent == nil { return } var err error if muted { - user.log.Debugfln("Muting portal %s...", portal.MXID) - err = intent.PutPushRule("global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ + user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Msg("Muting portal") + err = user.DoublePuppetIntent.PutPushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID), &mautrix.ReqPutPushRule{ Actions: []pushrules.PushActionType{pushrules.ActionDontNotify}, }) } else { - user.log.Debugfln("Unmuting portal %s...", portal.MXID) - err = intent.DeletePushRule("global", pushrules.RoomRule, string(portal.MXID)) + user.zlog.Debug().Stringer("portal_mxid", portal.MXID).Msg("Unmuting portal") + err = user.DoublePuppetIntent.DeletePushRule(ctx, "global", pushrules.RoomRule, string(portal.MXID)) } if err != nil && !errors.Is(err, mautrix.MNotFound) { - user.log.Warnfln("Failed to update push rule for %s through double puppet: %v", portal.MXID, err) + user.zlog.Err(err).Stringer("portal_mxid", portal.MXID).Msg("Failed to update push rule for portal") } } -func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.RoomID) id.RoomID { - if len(*ptr) > 0 { - return *ptr - } +func (user *User) GetSpaceRoom(ctx context.Context) (id.RoomID, error) { user.spaceCreateLock.Lock() defer user.spaceCreateLock.Unlock() - if len(*ptr) > 0 { - return *ptr + if len(user.SpaceRoom) > 0 { + return user.SpaceRoom, nil } - initialState := []*event.Event{{ - Type: event.StateRoomAvatar, - Content: event.Content{ - Parsed: &event.RoomAvatarEventContent{ - URL: user.bridge.Config.AppService.Bot.ParsedAvatar, - }, - }, - }} - - if parent != "" { - parentIDStr := parent.String() - initialState = append(initialState, &event.Event{ - Type: event.StateSpaceParent, - StateKey: &parentIDStr, + resp, err := user.bridge.Bot.CreateRoom(ctx, &mautrix.ReqCreateRoom{ + Visibility: "private", + Name: "Slack", + Topic: "Your Slack bridged chats", + InitialState: []*event.Event{{ + Type: event.StateRoomAvatar, Content: event.Content{ - Parsed: &event.SpaceParentEventContent{ - Canonical: true, - Via: []string{user.bridge.AS.HomeserverDomain}, + Parsed: &event.RoomAvatarEventContent{ + URL: user.bridge.Config.AppService.Bot.ParsedAvatar, }, }, - }) - } - - resp, err := user.bridge.Bot.CreateRoom(&mautrix.ReqCreateRoom{ - Visibility: "private", - Name: name, - Topic: topic, - InitialState: initialState, + }}, CreationContent: map[string]interface{}{ "type": event.RoomTypeSpace, }, @@ -971,42 +393,28 @@ func (user *User) getSpaceRoom(ptr *id.RoomID, name, topic string, parent id.Roo }, }, }) - if err != nil { - user.log.Error("Failed to auto-create space room") - } else { - *ptr = resp.RoomID - user.Update() - user.ensureInvited(nil, *ptr, false) - - if parent != "" { - _, err = user.bridge.Bot.SendStateEvent(parent, event.StateSpaceChild, resp.RoomID.String(), &event.SpaceChildEventContent{ - Via: []string{user.bridge.AS.HomeserverDomain}, - Order: " 0000", - }) - if err != nil { - user.log.Error("Failed to add created space room to parent space") - } - } + user.zlog.Err(err).Msg("Failed to auto-create space room") + return "", fmt.Errorf("failed to create space: %w", err) } - return *ptr -} - -func (user *User) GetSpaceRoom() id.RoomID { - return user.getSpaceRoom(&user.SpaceRoom, "Slack", "Your Slack bridged chats", "") + user.SpaceRoom = resp.RoomID + err = user.Update(ctx) + if err != nil { + user.zlog.Err(err).Msg("Failed to save user after creating space room") + } + user.ensureInvited(ctx, nil, user.SpaceRoom, false) + return user.SpaceRoom, nil } -func (user *User) addTeamToSpace(teamInfo *database.TeamInfo, isInSpace bool) bool { - if len(teamInfo.SpaceRoom) > 0 && !isInSpace { - _, err := user.bridge.Bot.SendStateEvent(user.GetSpaceRoom(), event.StateSpaceChild, teamInfo.SpaceRoom.String(), &event.SpaceChildEventContent{ - Via: []string{user.bridge.AS.HomeserverDomain}, - }) - if err != nil { - user.log.Errorfln("Failed to add team space %s to user space", teamInfo.SpaceRoom) - } else { - isInSpace = true - } +func (user *User) SendBridgeAlert(message string, args ...any) { + if user.ManagementRoom == "" { + return + } + if len(args) > 0 { + message = fmt.Sprintf(message, args...) + } + _, err := user.bridge.Bot.SendText(context.TODO(), user.ManagementRoom, message) + if err != nil { + user.zlog.Err(err).Msg("Failed to send bridge alert") } - - return isInSpace } diff --git a/userteam.go b/userteam.go new file mode 100644 index 0000000..44481af --- /dev/null +++ b/userteam.go @@ -0,0 +1,559 @@ +// mautrix-slack - A Matrix-Slack puppeting bridge. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "cmp" + "context" + "fmt" + "strings" + "sync/atomic" + + "github.com/rs/zerolog" + "golang.org/x/exp/slices" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridge" + "maunium.net/go/mautrix/bridge/status" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/slack-go/slack" + + "go.mau.fi/mautrix-slack/database" +) + +type UserTeam struct { + *database.UserTeam + bridge *SlackBridge + User *User + Team *Team + Log zerolog.Logger + + BridgeState *bridge.BridgeStateQueue + + RTM *slack.RTM + Client *slack.Client + + stopEventLoop atomic.Pointer[context.CancelFunc] +} + +func (ut *UserTeam) GetMXID() id.UserID { + return ut.UserMXID +} + +func (ut *UserTeam) GetRemoteID() string { + return ut.TeamID +} + +func (ut *UserTeam) GetRemoteName() string { + // TODO why is this the team name? + return ut.Team.Name +} + +func (br *SlackBridge) loadUserTeam(ctx context.Context, dbUserTeam *database.UserTeam, key *database.UserTeamMXIDKey) *UserTeam { + if dbUserTeam == nil { + if key == nil { + return nil + } + dbUserTeam = br.DB.UserTeam.New() + dbUserTeam.UserTeamMXIDKey = *key + err := dbUserTeam.Insert(ctx) + if err != nil { + br.ZLog.Err(err).Object("key", key).Msg("Failed to insert new user team") + return nil + } + } + userTeam := &UserTeam{ + UserTeam: dbUserTeam, + bridge: br, + Log: br.ZLog.With().Object("user_team_key", dbUserTeam.UserTeamMXIDKey).Logger(), + } + userTeam.BridgeState = br.NewBridgeStateQueue(userTeam) + + existingUT, alreadyExists := br.userTeamsByID[userTeam.UserTeamKey] + if alreadyExists { + panic(fmt.Errorf("%v (%s/%s) already exists in bridge map", userTeam.UserTeamKey, userTeam.UserMXID, existingUT.UserMXID)) + } + br.userTeamsByID[userTeam.UserTeamKey] = userTeam + + userTeam.Team = br.unlockedGetTeamByID(dbUserTeam.TeamID, true) + userTeam.User = br.unlockedGetUserByMXID(dbUserTeam.UserMXID, true) + + existingUT, alreadyExists = userTeam.User.teams[userTeam.TeamID] + if alreadyExists { + panic(fmt.Errorf("%s (%s/%s) already exists in %s's user team map", userTeam.TeamID, userTeam.UserID, existingUT.UserID, userTeam.UserMXID)) + } + userTeam.User.teams[userTeam.TeamID] = userTeam + + return userTeam +} + +func (br *SlackBridge) loadUserTeams(dbUserTeams []*database.UserTeam, err error) []*UserTeam { + if err != nil { + br.ZLog.Err(err).Msg("Failed to load user teams") + return nil + } + userTeams := make([]*UserTeam, len(dbUserTeams)) + for i, dbUserTeam := range dbUserTeams { + cached, ok := br.userTeamsByID[dbUserTeam.UserTeamKey] + if ok { + userTeams[i] = cached + } else { + userTeams[i] = br.loadUserTeam(context.TODO(), dbUserTeam, nil) + } + } + return userTeams +} + +func (br *SlackBridge) GetUserTeamByID(key database.UserTeamKey, userMXID id.UserID) *UserTeam { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.unlockedGetUserTeamByID(key, userMXID) +} + +func (br *SlackBridge) GetExistingUserTeamByID(key database.UserTeamKey) *UserTeam { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.unlockedGetUserTeamByID(key, "") +} + +func (br *SlackBridge) GetCachedUserTeamByID(key database.UserTeamKey) *UserTeam { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.userTeamsByID[key] +} + +func (br *SlackBridge) unlockedGetUserTeamByID(key database.UserTeamKey, userMXID id.UserID) *UserTeam { + userTeam, ok := br.userTeamsByID[key] + if !ok { + ctx := context.TODO() + dbUserTeam, err := br.DB.UserTeam.GetByID(ctx, key) + if err != nil { + br.ZLog.Err(err).Any("key", key).Msg("Failed to get user team by ID") + return nil + } + var newKey *database.UserTeamMXIDKey + if userMXID != "" { + newKey = &database.UserTeamMXIDKey{UserTeamKey: key, UserMXID: userMXID} + } + return br.loadUserTeam(ctx, dbUserTeam, newKey) + } + return userTeam +} + +func (br *SlackBridge) GetAllUserTeamsWithToken() []*UserTeam { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.loadUserTeams(br.DB.UserTeam.GetAllWithToken(context.TODO())) +} + +func (br *SlackBridge) GetAllUserTeamsForUser(userID id.UserID) []*UserTeam { + br.userAndTeamLock.Lock() + defer br.userAndTeamLock.Unlock() + return br.unlockedGetAllUserTeamsForUser(userID) +} + +func (br *SlackBridge) unlockedGetAllUserTeamsForUser(userID id.UserID) []*UserTeam { + return br.loadUserTeams(br.DB.UserTeam.GetAllForUser(context.TODO(), userID)) +} + +type slackgoZerolog struct { + zerolog.Logger +} + +func (l slackgoZerolog) Output(i int, s string) error { + l.Debug().Msg(s) + return nil +} + +func (ut *UserTeam) Connect() { + evt := ut.Log.Trace() + hasTraceLog := evt.Enabled() + evt.Discard() + slackOptions := []slack.Option{ + slack.OptionLog(slackgoZerolog{ut.Log.With().Str("component", "slackgo").Logger()}), + slack.OptionDebug(hasTraceLog), + } + if ut.CookieToken != "" { + slackOptions = append(slackOptions, slack.OptionCookie("d", ut.CookieToken)) + } + ut.Client = slack.New(ut.Token, slackOptions...) + + ctx := context.TODO() + teamInfo, err := ut.Client.GetTeamInfoContext(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to connect to Slack team") + // TODO use proper error comparisons + if err.Error() == "user_removed_from_team" { + go ut.Logout(ctx, status.BridgeState{StateEvent: status.StateBadCredentials, Error: "slack-user-removed-from-team"}) + } else if err.Error() == "invalid_auth" { + go ut.Logout(ctx, status.BridgeState{StateEvent: status.StateBadCredentials, Error: "slack-invalid-auth"}) + } else { + ut.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "slack-get-info-failed"}) + } + return + } + + ut.RTM = ut.Client.NewRTM() + + go ut.slackEventLoop() + go ut.RTM.ManageConnection() + go ut.Sync(ctx, teamInfo) + return +} + +func (ut *UserTeam) Sync(ctx context.Context, meta *slack.TeamInfo) { + if meta == nil { + var err error + meta, err = ut.Client.GetTeamInfoContext(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to get team info from Slack for sync") + return + } + } + ut.Team.UpdateInfo(ctx, meta) + if ut.Team.MXID == "" { + err := ut.Team.CreateMatrixRoom(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to create Matrix space for team") + return + } + } + ut.AddToSpace(ctx) + ut.User.ensureInvited(ctx, nil, ut.Team.MXID, false) + ut.syncPortals(ctx) + ut.SyncEmojis(ctx) +} + +func (ut *UserTeam) syncPortals(ctx context.Context) { + serverInfo := make(map[string]*slack.Channel) + if !strings.HasPrefix(ut.Token, "xoxs") { + totalLimit := ut.bridge.Config.Bridge.Backfill.ConversationsCount + var cursor string + for totalLimit > 0 { + reqLimit := totalLimit + if totalLimit > 200 { + reqLimit = 100 + } + channelsChunk, nextCursor, err := ut.Client.GetConversationsForUserContext(ctx, &slack.GetConversationsForUserParameters{ + Types: []string{"public_channel", "private_channel", "mpim", "im"}, + Limit: reqLimit, + Cursor: cursor, + }) + if err != nil { + ut.Log.Err(err).Msg("Failed to fetch conversations for sync") + return + } + for _, channel := range channelsChunk { + // Skip non-"open" DMs + if channel.IsIM && (channel.Latest == nil || channel.Latest.SubType == "") { + continue + } + serverInfo[channel.ID] = &channel + } + if nextCursor == "" || len(channelsChunk) == 0 { + break + } + totalLimit -= len(channelsChunk) + cursor = nextCursor + } + } + existingPortals := ut.bridge.GetAllPortalsForUserTeam(ut.UserTeamMXIDKey) + for _, portal := range existingPortals { + if portal.MXID != "" { + portal.UpdateInfo(ctx, ut, serverInfo[portal.ChannelID], true) + delete(serverInfo, portal.ChannelID) + } + } + remainingChannels := make([]*slack.Channel, len(serverInfo)) + i := 0 + for _, channel := range serverInfo { + remainingChannels[i] = channel + i++ + } + slices.SortFunc(remainingChannels, func(a, b *slack.Channel) int { + return cmp.Compare(a.LastRead, b.LastRead) + }) + for _, ch := range remainingChannels { + portal := ut.Team.GetPortalByID(ch.ID) + if portal == nil { + continue + } + err := portal.CreateMatrixRoom(ctx, ut, ch) + if err != nil { + ut.Log.Err(err).Str("channel_id", ch.ID).Msg("Failed to create Matrix room for channel") + } + } +} + +func (ut *UserTeam) AddToSpace(ctx context.Context) { + if ut.Team.MXID == "" { + if ut.InSpace { + ut.InSpace = false + err := ut.Update(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to save user team info after marking not in space") + } + } + return + } else if ut.InSpace { + return + } + spaceRoom, err := ut.User.GetSpaceRoom(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to get user's space room to add team space") + return + } + _, err = ut.bridge.Bot.SendStateEvent(ctx, spaceRoom, event.StateSpaceChild, ut.Team.MXID.String(), &event.SpaceChildEventContent{ + Via: []string{ut.bridge.AS.HomeserverDomain}, + }) + if err != nil { + ut.InSpace = false + ut.Log.Err(err).Msg("Failed to add team space to user's personal space") + } else { + ut.InSpace = true + ut.Log.Info().Msg("Added team space to user's personal space") + } + err = ut.Update(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to save user team info after adding to space") + } +} + +func (ut *UserTeam) slackEventLoop() { + log := ut.Log.With().Str("action", "slack event loop").Logger() + ctx := log.WithContext(context.TODO()) + ctx, cancel := context.WithCancel(log.WithContext(context.TODO())) + defer cancel() + if prevCancel := ut.stopEventLoop.Swap(&cancel); prevCancel != nil { + (*prevCancel)() + } + ctxDone := ctx.Done() + log.Info().Msg("Event loop starting") + for { + select { + case evt := <-ut.RTM.IncomingEvents: + log.Trace().Type("event_type", evt).Any("event_data", evt).Msg("Received raw Slack event") + if evt.Type == "" && evt.Data == nil { + log.Warn().Msg("Event channel closed") + ut.BridgeState.Send(status.BridgeState{StateEvent: status.StateUnknownError, Error: "slack-rtm-channel-closed"}) + return + } + ut.handleSlackEvent(ctx, evt.Data) + case <-ctxDone: + log.Info().Msg("Event loop stopping") + return + } + } +} + +func init() { + status.BridgeStateHumanErrors.Update(map[status.BridgeStateErrorCode]string{ + "slack-invalid-auth": "Invalid authentication token", + "slack-user-removed-from-team": "Removed from Slack workspace", + }) +} + +func (ut *UserTeam) handleSlackEvent(ctx context.Context, rawEvt any) { + switch evt := rawEvt.(type) { + case *slack.ConnectingEvent: + ut.Log.Debug(). + Int("attempt_num", evt.Attempt). + Int("connection_count", evt.ConnectionCount). + Msg("Connecting to RTM") + ut.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) + case *slack.ConnectedEvent: + if evt.Info.User.ID != ut.UserID || evt.Info.Team.ID != ut.TeamID { + ut.Log.Error(). + Str("evt_user_id", evt.Info.User.ID). + Str("evt_team_id", evt.Info.Team.ID). + Msg("User/team ID mismatch in connected event") + ut.Logout(context.WithoutCancel(ctx), status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: "slack-id-mismatch", + }) + return + } + ut.Log.Info().Msg("Connected to RTM") + + //if ut.bridge.Config.Bridge.Backfill.Enable { + // ut.BridgeState.Send(status.BridgeState{StateEvent: status.StateBackfilling}) + // + // portals := ut.bridge.GetAllPortalsForUserTeam(ut.UserTeamMXIDKey) + // for _, portal := range portals { + // err := portal.ForwardBackfill() + // if err != nil { + // ut.Log.Err(err). + // Str("channel_id", portal.ChannelID). + // Stringer("channel_mxid", portal.MXID). + // Msg("Failed to forward backfill channel") + // } + // } + //} + ut.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) + case *slack.HelloEvent: + // Ignored for now + case *slack.InvalidAuthEvent: + ut.Logout(context.TODO(), status.BridgeState{StateEvent: status.StateBadCredentials, Error: "slack-invalid-auth"}) + return + case *slack.LatencyReport: + ut.Log.Trace().Dur("latency", evt.Value).Msg("Latency report") + case *slack.MessageEvent: + ut.Log.Trace().Any("event_content", evt).Msg("Received Slack message event") + ut.pushPortalEvent(evt.Channel, evt) + case *slack.ReactionAddedEvent: + ut.pushPortalEvent(evt.Item.Channel, evt) + case *slack.ReactionRemovedEvent: + ut.pushPortalEvent(evt.Item.Channel, evt) + case *slack.UserTypingEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.ChannelMarkedEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.IMMarkedEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.GroupMarkedEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.ChannelJoinedEvent: + ut.pushPortalEvent(evt.Channel.ID, evt) + case *slack.ChannelLeftEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.GroupJoinedEvent: + ut.pushPortalEvent(evt.Channel.ID, evt) + case *slack.GroupLeftEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.MemberJoinedChannelEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.MemberLeftChannelEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.ChannelUpdateEvent: + ut.pushPortalEvent(evt.Channel, evt) + case *slack.EmojiChangedEvent: + go ut.handleEmojiChange(ctx, evt) + case *slack.RTMError: + ut.Log.Err(evt).Msg("Got RTM error") + ut.BridgeState.Send(status.BridgeState{ + StateEvent: status.StateUnknownError, + Error: status.BridgeStateErrorCode(fmt.Sprintf("slack-rtm-error-%d", evt.Code)), + Message: fmt.Sprintf("%d: %s", evt.Code, evt.Msg), + }) + case *slack.FileSharedEvent, *slack.FilePublicEvent, *slack.FilePrivateEvent, *slack.FileCreatedEvent, *slack.FileChangeEvent, *slack.FileDeletedEvent, *slack.DesktopNotificationEvent: + // ignored intentionally, these are duplicates or do not contain useful information + default: + ut.Log.Warn().Any("event_data", evt).Msg("Unrecognized Slack event type") + } +} + +func (ut *UserTeam) pushPortalEvent(channelID string, evt any) { + portal := ut.Team.GetPortalByID(channelID) + if portal == nil { + ut.Log.Warn(). + Str("channel_id", channelID). + Type("event_type", evt). + Msg("Portal is nil for incoming event") + return + } + select { + case portal.slackMessages <- portalSlackMessage{evt: evt, userTeam: ut}: + default: + ut.Log.Warn(). + Str("channel_id", channelID). + Type("event_type", evt). + Msg("Portal message channel is full") + } +} + +func (ut *UserTeam) stopRTM() { + if stopFunc := ut.stopEventLoop.Swap(nil); stopFunc != nil { + (*stopFunc)() + } + + if ut.RTM != nil { + go ut.RTM.Disconnect() + } +} + +func (ut *UserTeam) Disconnect() { + ut.stopRTM() + ut.Client = nil + ut.RTM = nil +} + +func (ut *UserTeam) Logout(ctx context.Context, state status.BridgeState) { + ut.stopRTM() + + if state.StateEvent == status.StateLoggedOut { + if ut.Client != nil { + _, err := ut.Client.SendAuthSignoutContext(ctx) + if err != nil { + ut.Log.Warn().Err(err).Msg("Failed to send auth signout request to Slack") + } + } + } else { + go ut.User.SendBridgeAlert("Invalid credentials for %s (%s.slack.com / %s)", ut.Team.Name, ut.Team.Domain, ut.Team.ID) + } + + ut.CookieToken = "" + ut.Token = "" + ut.Client = nil + ut.RTM = nil + ut.BridgeState.Send(state) + err := ut.Update(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to save user team after deleting token") + } + if ut.bridge.Config.Bridge.KickOnLogout { + ut.Log.Debug().Msg("Kicking user from rooms and deleting user team from database") + ut.RemoveFromRooms(ctx) + ut.Delete(ctx) + } else { + ut.Log.Debug().Msg("Not kicking user from rooms") + } +} + +func (ut *UserTeam) RemoveFromRooms(ctx context.Context) { + portals := ut.bridge.GetAllPortalsForUserTeam(ut.UserTeamMXIDKey) + dpi := ut.User.DoublePuppetIntent + var err error + for _, portal := range portals { + if dpi != nil { + _, err = dpi.LeaveRoom(ctx, portal.MXID, &mautrix.ReqLeave{Reason: "Logged out from bridge"}) + } else { + _, err = portal.MainIntent().KickUser(ctx, portal.MXID, &mautrix.ReqKickUser{ + Reason: "Logged out from bridge", + UserID: ut.UserMXID, + }) + } + if err != nil { + ut.Log.Err(err). + Stringer("portal_mxid", portal.MXID). + Stringer("portal_key", portal.PortalKey). + Msg("Failed to remove user from room") + } + portal.CleanupIfEmpty(ctx) + } +} + +func (ut *UserTeam) Delete(ctx context.Context) { + ut.bridge.userAndTeamLock.Lock() + defer ut.bridge.userAndTeamLock.Unlock() + delete(ut.User.teams, ut.TeamID) + delete(ut.bridge.userTeamsByID, ut.UserTeamKey) + err := ut.UserTeam.Delete(ctx) + if err != nil { + ut.Log.Err(err).Msg("Failed to delete user team") + } +}