diff --git a/.gitignore b/.gitignore index d3beee5..b4c344a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ _testmain.go *.test *.prof +# The binary +sluice diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1c29f5b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 J.P.H. Bruins Slot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7ec960a..d9a1850 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,140 @@ # sluice -A terminal Slack client \ No newline at end of file +A [Slack](https://slack.com) client for your terminal. + +![Screenshot](/screenshot.png?raw=true) + +Installation +------------ + +#### Binary installation + +[Download](https://github.com/erroneousboat/slack-term/releases) a +compatible binary for your system. For convenience place `slack-term` in a +directory where you can access it from the command line. Usually this is +`/usr/local/bin`. + +```bash +$ mv slack-term /usr/local/bin +``` + +#### Via Go + +If you want you can also get `slack-term` via Go: + +```bash +$ go get -u github.com/erroneousboat/slack-term +``` + +Setup +----- + +1. Get a slack token, click [here](https://api.slack.com/docs/oauth-test-tokens) + +2. Create a `slack-term.json` file, place it in your home directory. Below is + an an example file, you can leave out the `OPTIONAL` parts, you are only + required to specify a `slack_token`. Remember that your file should be + a valid json file so don't forget to remove the comments. + +```javascript +{ + "slack_token": "yourslacktokenhere", + + // OPTIONAL: add the following to use light theme, default is dark + "theme": "light", + + // OPTIONAL: set the width of the sidebar (between 1 and 11), default is 1 + "sidebar_width": 3, + + // OPTIONAL: define custom key mappings, defaults are: + "key_map": { + "command": { + "i": "mode-insert", + "k": "channel-up", + "j": "channel-down", + "g": "channel-top", + "G": "channel-bottom", + "": "chat-up", + "C-b": "chat-up", + "C-u": "chat-up", + "": "chat-down", + "C-f": "chat-down", + "C-d": "chat-down", + "q": "quit", + "": "help" + }, + "insert": { + "": "cursor-left", + "": "cursor-right", + "": "send", + "": "mode-command", + "": "backspace", + "C-8": "backspace", + "": "delete", + "": "space" + }, + "search": { + "": "cursor-left", + "": "cursor-right", + "": "clear-input", + "": "clear-input", + "": "backspace", + "C-8": "backspace", + "": "delete", + "": "space" + } + } +} +``` + +Usage +----- + +When everything is setup correctly you can run `slack-term` with the following +command: + +```bash +$ slack-term +``` + +You can also specify the location of the config file, this will give you +the possibility to run several instances of `slack-term` with different +accounts. + +```bash +$ slack-term -config [path-to-config-file] +``` + +Default Key Mapping +------------------- + +Below are the default key-mapping for `slack-term`, you can change them +in your `slack-term.json` file. + +| mode | key | action | +|---------|-----------|----------------------------| +| command | `i` | insert mode | +| command | `/` | search mode | +| command | `k` | move channel cursor up | +| command | `j` | move channel cursor down | +| command | `g` | move channel cursor top | +| command | `G` | move channel cursor bottom | +| command | `pg-up` | scroll chat pane up | +| command | `ctrl-b` | scroll chat pane up | +| command | `ctrl-u` | scroll chat pane up | +| command | `pg-down` | scroll chat pane down | +| command | `ctrl-f` | scroll chat pane down | +| command | `ctrl-d` | scroll chat pane down | +| command | `q` | quit | +| command | `f1` | help | +| insert | `left` | move input cursor left | +| insert | `right` | move input cursor right | +| insert | `enter` | send message | +| insert | `esc` | command mode | +| search | `esc` | command mode | +| search | `enter` | command mode | + + + +Originally forked from erroneousboat's slack-term: +https://github.com/erroneousboat/slack-term diff --git a/channels.go b/channels.go new file mode 100644 index 0000000..3224127 --- /dev/null +++ b/channels.go @@ -0,0 +1,352 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/gizak/termui" +) + +const ( + IconOnline = "●" + IconOffline = "○" + IconChannel = "#" + IconGroup = "☰" + IconIM = "●" + IconNotification = "🞷" + + PresenceAway = "away" + PresenceActive = "active" +) + +// Channels is the definition of a Channels component +type Channels struct { + List *termui.List + SelectedChannel int // index of which channel is selected from the List + Offset int // from what offset are channels rendered + CursorPosition int // the y position of the 'cursor' +} + +// CreateChannels is the constructor for the Channels component +func CreateChannels(svc *SlackService, inputHeight int) *Channels { + channels := &Channels{ + List: termui.NewList(), + } + + channels.List.BorderLabel = "Channels" + channels.List.Height = termui.TermHeight() - inputHeight + + channels.SelectedChannel = 0 + channels.Offset = 0 + channels.CursorPosition = channels.List.InnerBounds().Min.Y + + channels.GetChannels(svc) + channels.SetPresenceForIMChannels(svc) + + return channels +} + +// Buffer implements interface termui.Bufferer +func (c *Channels) Buffer() termui.Buffer { + buf := c.List.Buffer() + + for i, item := range c.List.Items[c.Offset:] { + + y := c.List.InnerBounds().Min.Y + i + + if y > c.List.InnerBounds().Max.Y-1 { + break + } + + var cells []termui.Cell + if y == c.CursorPosition { + cells = termui.DefaultTxBuilder.Build( + item, c.List.ItemBgColor, c.List.ItemFgColor) + } else { + cells = termui.DefaultTxBuilder.Build( + item, c.List.ItemFgColor, c.List.ItemBgColor) + } + + cells = termui.DTrimTxCls(cells, c.List.InnerWidth()) + + x := 0 + for _, cell := range cells { + width := cell.Width() + buf.Set(c.List.InnerBounds().Min.X+x, y, cell) + x += width + } + + // When not at the end of the pane fill it up empty characters + for x < c.List.InnerBounds().Max.X-1 { + if y == c.CursorPosition { + buf.Set(x+1, y, + termui.Cell{ + Ch: ' ', + Fg: c.List.ItemBgColor, + Bg: c.List.ItemFgColor, + }, + ) + } else { + buf.Set( + x+1, y, + termui.Cell{ + Ch: ' ', + Fg: c.List.ItemFgColor, + Bg: c.List.ItemBgColor, + }, + ) + } + x++ + } + } + + return buf +} + +// GetHeight implements interface termui.GridBufferer +func (c *Channels) GetHeight() int { + return c.List.Block.GetHeight() +} + +// SetWidth implements interface termui.GridBufferer +func (c *Channels) SetWidth(w int) { + c.List.SetWidth(w) +} + +// SetX implements interface termui.GridBufferer +func (c *Channels) SetX(x int) { + c.List.SetX(x) +} + +// SetY implements interface termui.GridBufferer +func (c *Channels) SetY(y int) { + c.List.SetY(y) +} + +// GetChannels will get all available channels from the SlackService +func (c *Channels) GetChannels(svc *SlackService) { + for _, slackChan := range svc.GetChannels() { + label := setChannelLabel(slackChan, false) + c.List.Items = append(c.List.Items, label) + + } +} + +// SetPresenceForIMChannels this will set the correct icon for +// IM channels for when they're online of away +func (c *Channels) SetPresenceForIMChannels(svc *SlackService) { + for _, slackChan := range svc.GetChannels() { + if slackChan.Type == ChannelTypeIM { + presence, err := svc.GetUserPresence(slackChan.UserID) + if err != nil { + continue + } + c.SetPresence(svc, slackChan.UserID, presence) + } + } +} + +// SetSelectedChannel sets the SelectedChannel given the index +func (c *Channels) SetSelectedChannel(index int) { + c.SelectedChannel = index +} + +// MoveCursorUp will decrease the SelectedChannel by 1 +func (c *Channels) MoveCursorUp() { + if c.SelectedChannel > 0 { + c.SetSelectedChannel(c.SelectedChannel - 1) + c.ScrollUp() + c.ClearNewMessageIndicator() + } +} + +// MoveCursorDown will increase the SelectedChannel by 1 +func (c *Channels) MoveCursorDown() { + if c.SelectedChannel < len(c.List.Items)-1 { + c.SetSelectedChannel(c.SelectedChannel + 1) + c.ScrollDown() + c.ClearNewMessageIndicator() + } +} + +// MoveCursorTop will move the cursor to the top of the channels +func (c *Channels) MoveCursorTop() { + c.SetSelectedChannel(0) + c.CursorPosition = c.List.InnerBounds().Min.Y + c.Offset = 0 +} + +// MoveCursorBottom will move the cursor to the bottom of the channels +func (c *Channels) MoveCursorBottom() { + c.SetSelectedChannel(len(c.List.Items) - 1) + + offset := len(c.List.Items) - (c.List.InnerBounds().Max.Y - 1) + + if offset < 0 { + c.Offset = 0 + c.CursorPosition = c.SelectedChannel + 1 + } else { + c.Offset = offset + c.CursorPosition = c.List.InnerBounds().Max.Y - 1 + } +} + +// ScrollUp enables us to scroll through the channel list when it overflows +func (c *Channels) ScrollUp() { + // Is cursor at the top of the channel view? + if c.CursorPosition == c.List.InnerBounds().Min.Y { + if c.Offset > 0 { + c.Offset-- + } + } else { + c.CursorPosition-- + } +} + +// ScrollDown enables us to scroll through the channel list when it overflows +func (c *Channels) ScrollDown() { + // Is the cursor at the bottom of the channel view? + if c.CursorPosition == c.List.InnerBounds().Max.Y-1 { + if c.Offset < len(c.List.Items)-1 { + c.Offset++ + } + } else { + c.CursorPosition++ + } +} + +// Search will search through the channels to find a channel, +// when a match has been found the selected channel will then +// be the channel that has been found +func (c *Channels) Search(term string) { + for i, item := range c.List.Items { + if strings.Contains(item, term) { + + // The new position + newPos := i + + // Is the new position in range of the current view? + minRange := c.Offset + maxRange := c.Offset + (c.List.InnerBounds().Max.Y - 2) + + if newPos < minRange { + // newPos is above, we need to scroll up. + c.SetSelectedChannel(i) + + // How much do we need to scroll to get it into range? + c.Offset = c.Offset - (minRange - newPos) + } else if newPos > maxRange { + // newPos is below, we need to scroll down + c.SetSelectedChannel(i) + + // How much do we need to scroll to get it into range? + c.Offset = c.Offset + (newPos - maxRange) + } else { + // newPos is inside range + c.SetSelectedChannel(i) + } + + // Set cursor to correct position + c.CursorPosition = (newPos - c.Offset) + 1 + + break + } + } +} + +// SetNotification will be called when a new message arrives and will +// render an notification icon in front of the channel name +func (c *Channels) SetNotification(svc *SlackService, channelID string) { + // Get the correct Channel from svc.Channels + var index int + for i, channel := range svc.Channels { + if channelID == channel.ID { + index = i + break + } + } + + if !strings.Contains(c.List.Items[index], IconNotification) { + // The order of svc.Channels relates to the order of + // List.Items, index will be the index of the channel + c.List.Items[index] = fmt.Sprintf( + "%s %s", IconNotification, strings.TrimSpace(c.List.Items[index]), + ) + } + + // Play terminal bell sound + fmt.Print("\a") +} + +// ClearNewMessageIndicator will remove the notification icon in front of +// a channel that received a new message. This will happen as one will +// move up or down the cursor for Channels +func (c *Channels) ClearNewMessageIndicator() { + channelName := strings.Split( + c.List.Items[c.SelectedChannel], + fmt.Sprintf("%s ", IconNotification), + ) + + if len(channelName) > 1 { + c.List.Items[c.SelectedChannel] = fmt.Sprintf(" %s", channelName[1]) + } else { + c.List.Items[c.SelectedChannel] = channelName[0] + } +} + +// SetReadMark will send the ReadMark event on the service +func (c *Channels) SetReadMark(svc *SlackService) { + svc.SetChannelReadMark(svc.SlackChannels[c.SelectedChannel]) +} + +// SetPresence will set the correct icon for a IM Channel +func (c *Channels) SetPresence(svc *SlackService, userID string, presence string) { + // Get the correct Channel from svc.Channels + var index int + for i, channel := range svc.Channels { + if userID == channel.UserID { + index = i + break + } + } + + switch presence { + case PresenceActive: + c.List.Items[index] = strings.Replace( + c.List.Items[index], IconOffline, IconOnline, 1, + ) + case PresenceAway: + c.List.Items[index] = strings.Replace( + c.List.Items[index], IconOnline, IconOffline, 1, + ) + default: + c.List.Items[index] = strings.Replace( + c.List.Items[index], IconOnline, IconOffline, 1, + ) + } + +} + +// setChannelLabel will set the label of the channel, meaning, how it +// is displayed on screen. Based on the type, different icons are +// shown, as well as an optional notification icon. +func setChannelLabel(channel Channel, notification bool) string { + var prefix string + if notification { + prefix = IconNotification + } else { + prefix = " " + } + + var label string + switch channel.Type { + case ChannelTypeChannel: + label = fmt.Sprintf("%s %s %s", prefix, IconChannel, channel.Name) + case ChannelTypeGroup: + label = fmt.Sprintf("%s %s %s", prefix, IconGroup, channel.Name) + case ChannelTypeIM: + label = fmt.Sprintf("%s %s %s", prefix, IconIM, channel.Name) + } + + return label +} diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..4b67274 --- /dev/null +++ b/chat.go @@ -0,0 +1,250 @@ +package main + +import ( + "fmt" + "html" + "sort" + "strings" + + "github.com/gizak/termui" +) + +// Chat is the definition of a Chat component +type Chat struct { + List *termui.List + Offset int +} + +// CreateChat is the constructor for the Chat struct +func CreateChat(svc *SlackService, inputHeight int, selectedSlackChannel interface{}, selectedChannel Channel) *Chat { + chat := &Chat{ + List: termui.NewList(), + Offset: 0, + } + + chat.List.Height = termui.TermHeight() - inputHeight + chat.List.Overflow = "wrap" + + chat.GetMessages(svc, selectedSlackChannel) + chat.SetBorderLabel(selectedChannel) + + return chat +} + +// Buffer implements interface termui.Bufferer +func (c *Chat) Buffer() termui.Buffer { + // Build cells, after every item put a newline + cells := termui.DefaultTxBuilder.Build( + strings.Join(c.List.Items, "\n"), + c.List.ItemFgColor, c.List.ItemBgColor, + ) + + // We will create an array of Line structs, this allows us + // to more easily render the items in a list. We will range + // over the cells we've created and create a Line within + // the bounds of the Chat pane + type Line struct { + cells []termui.Cell + } + + lines := []Line{} + line := Line{} + + x := 0 + for _, cell := range cells { + + if cell.Ch == '\n' { + lines = append(lines, line) + line = Line{} + x = 0 + continue + } + + if x+cell.Width() > c.List.InnerBounds().Dx() { + lines = append(lines, line) + line = Line{} + x = 0 + } + + line.cells = append(line.cells, cell) + x++ + } + lines = append(lines, line) + + // We will print lines bottom up, it will loop over the lines + // backwards and for every line it'll set the cell in that line. + // Offset is the number which allows us to begin printing the + // line above the last line. + buf := c.List.Buffer() + linesHeight := len(lines) + paneMinY := c.List.InnerBounds().Min.Y + paneMaxY := c.List.InnerBounds().Max.Y + + currentY := paneMaxY - 1 + for i := (linesHeight - 1) - c.Offset; i >= 0; i-- { + if currentY < paneMinY { + break + } + + x := c.List.InnerBounds().Min.X + for _, cell := range lines[i].cells { + buf.Set(x, currentY, cell) + x += cell.Width() + } + + // When we're not at the end of the pane, fill it up + // with empty characters + for x < c.List.InnerBounds().Max.X { + buf.Set( + x, currentY, + termui.Cell{ + Ch: ' ', + Fg: c.List.ItemFgColor, + Bg: c.List.ItemBgColor, + }, + ) + x++ + } + currentY-- + } + + // If the space above currentY is empty we need to fill + // it up with blank lines, otherwise the List object will + // render the items top down, and the result will mix. + for currentY >= paneMinY { + x := c.List.InnerBounds().Min.X + for x < c.List.InnerBounds().Max.X { + buf.Set( + x, currentY, + termui.Cell{ + Ch: ' ', + Fg: c.List.ItemFgColor, + Bg: c.List.ItemBgColor, + }, + ) + x++ + } + currentY-- + } + + return buf +} + +// GetHeight implements interface termui.GridBufferer +func (c *Chat) GetHeight() int { + return c.List.Block.GetHeight() +} + +// SetWidth implements interface termui.GridBufferer +func (c *Chat) SetWidth(w int) { + c.List.SetWidth(w) +} + +// SetX implements interface termui.GridBufferer +func (c *Chat) SetX(x int) { + c.List.SetX(x) +} + +// SetY implements interface termui.GridBufferer +func (c *Chat) SetY(y int) { + c.List.SetY(y) +} + +// GetMessages will get an array of strings for a specific channel which will +// contain messages in turn all these messages will be added to List.Items +func (c *Chat) GetMessages(svc *SlackService, channel interface{}) { + // Get the count of message that fit in the pane + count := c.List.InnerBounds().Max.Y - c.List.InnerBounds().Min.Y + messages := svc.GetMessages(channel, count) + + for _, message := range messages { + c.AddMessage(message) + } +} + +// AddMessage adds a single message to List.Items +func (c *Chat) AddMessage(message string) { + c.List.Items = append(c.List.Items, html.UnescapeString(message)) +} + +// ClearMessages clear the List.Items +func (c *Chat) ClearMessages() { + c.List.Items = []string{} +} + +// ScrollUp will render the chat messages based on the Offset of the Chat +// pane. +// +// Offset is 0 when scrolled down. (we loop backwards over the array, so we +// start with rendering last item in the list at the maximum y of the Chat +// pane). Increasing the Offset will thus result in substracting the offset +// from the len(Chat.List.Items). +func (c *Chat) ScrollUp() { + c.Offset = c.Offset + 10 + + // Protect overscrolling + if c.Offset > len(c.List.Items)-1 { + c.Offset = len(c.List.Items) - 1 + } +} + +// ScrollDown will render the chat messages based on the Offset of the Chat +// pane. +// +// Offset is 0 when scrolled down. (we loop backwards over the array, so we +// start with rendering last item in the list at the maximum y of the Chat +// pane). Increasing the Offset will thus result in substracting the offset +// from the len(Chat.List.Items). +func (c *Chat) ScrollDown() { + c.Offset = c.Offset - 10 + + // Protect overscrolling + if c.Offset < 0 { + c.Offset = 0 + } +} + +// SetBorderLabel will set Label of the Chat pane to the specified string +func (c *Chat) SetBorderLabel(channel Channel) { + var channelName string + if channel.Topic != "" { + channelName = fmt.Sprintf("%s - %s", + channel.Name, + channel.Topic, + ) + } else { + channelName = channel.Name + } + c.List.BorderLabel = channelName +} + +// Help shows the usage and key bindings in the chat pane +func (c *Chat) Help(cfg *Config) { + help := []string{ + "slack-term - slack client for your terminal", + "", + "USAGE:", + " slack-term -config [path-to-config]", + "", + "KEY BINDINGS:", + "", + } + + for mode, mapping := range cfg.KeyMap { + help = append(help, fmt.Sprintf(" %s", strings.ToUpper(mode))) + help = append(help, "") + + var keys []string + for k := range mapping { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + help = append(help, fmt.Sprintf(" %-12s%-15s", k, mapping[k])) + } + help = append(help, "") + } + + c.List.Items = help +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..586954f --- /dev/null +++ b/config.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + + "github.com/gizak/termui" +) + +// Config is the definition of a Config struct +type Config struct { + SlackToken string `json:"slack_token"` + Theme string `json:"theme"` + SidebarWidth int `json:"sidebar_width"` + MainWidth int `json:"-"` + KeyMap map[string]keyMapping `json:"key_map"` +} + +type keyMapping map[string]string + +// NewConfig loads the config file and returns a Config struct +func NewConfig(filepath string) (*Config, error) { + cfg := Config{ + Theme: "dark", + SidebarWidth: 1, + MainWidth: 11, + KeyMap: map[string]keyMapping{ + "command": { + "i": "mode-insert", + "/": "mode-search", + "k": "channel-up", + "j": "channel-down", + "g": "channel-top", + "G": "channel-bottom", + "": "chat-up", + "C-b": "chat-up", + "C-u": "chat-up", + "": "chat-down", + "C-f": "chat-down", + "C-d": "chat-down", + "q": "quit", + "": "help", + }, + "insert": { + "": "cursor-left", + "": "cursor-right", + "": "send", + "": "mode-command", + "": "backspace", + "C-8": "backspace", + "": "delete", + "": "space", + }, + "search": { + "": "cursor-left", + "": "cursor-right", + "": "clear-input", + "": "clear-input", + "": "backspace", + "C-8": "backspace", + "": "delete", + "": "space", + }, + }, + } + + file, err := os.Open(filepath) + if err != nil { + return &cfg, err + } + + if err := json.NewDecoder(file).Decode(&cfg); err != nil { + return &cfg, err + } + + if cfg.SlackToken == "" { + return &cfg, errors.New("couldn't find 'slack_token' parameter") + } + + if cfg.SidebarWidth < 1 || cfg.SidebarWidth > 11 { + return &cfg, errors.New("please specify the 'sidebar_width' between 1 and 11") + } + + cfg.MainWidth = 12 - cfg.SidebarWidth + + if cfg.Theme == "light" { + termui.ColorMap = map[string]termui.Attribute{ + "fg": termui.ColorBlack, + "bg": termui.ColorWhite, + "border.fg": termui.ColorBlack, + "label.fg": termui.ColorBlue, + "par.fg": termui.ColorYellow, + "par.label.bg": termui.ColorWhite, + } + } + + return &cfg, nil +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..f1f0120 --- /dev/null +++ b/context.go @@ -0,0 +1,45 @@ +package main + +import ( + "github.com/gizak/termui" + termbox "github.com/nsf/termbox-go" +) + +const ( + CommandMode = "command" + InsertMode = "insert" + SearchMode = "search" +) + +type AppContext struct { + EventQueue chan termbox.Event + Service *SlackService + Body *termui.Grid + View *View + Config *Config + Mode string +} + +// CreateAppContext creates an application context which can be passed +// and referenced througout the application +func CreateAppContext(flgConfig string) (*AppContext, error) { + // Load config + config, err := NewConfig(flgConfig) + if err != nil { + return nil, err + } + + // Create Service + svc := NewSlackService(config.SlackToken) + + // Create ChatView + view := CreateChatView(svc) + + return &AppContext{ + EventQueue: make(chan termbox.Event, 20), + Service: svc, + View: view, + Config: config, + Mode: CommandMode, + }, nil +} diff --git a/emoji.go b/emoji.go new file mode 100644 index 0000000..8421577 --- /dev/null +++ b/emoji.go @@ -0,0 +1,1511 @@ +package main + +// Referenced from: https://github.com/kyokomi/emoji +var EmojiCodemap = map[string]string{ + ":+1:": "\U0001f44d", + ":-1:": "\U0001f44e", + ":100:": "\U0001f4af", + ":1234:": "\U0001f522", + ":1st_place_medal:": "\U0001f947", + ":2nd_place_medal:": "\U0001f948", + ":3rd_place_medal:": "\U0001f949", + ":8ball:": "\U0001f3b1", + ":a:": "\U0001f170\ufe0f", + ":ab:": "\U0001f18e", + ":abc:": "\U0001f524", + ":abcd:": "\U0001f521", + ":accept:": "\U0001f251", + ":aerial_tramway:": "\U0001f6a1", + ":afghanistan:": "\U0001f1e6\U0001f1eb", + ":airplane:": "\u2708\ufe0f", + ":aland_islands:": "\U0001f1e6\U0001f1fd", + ":alarm_clock:": "\u23f0", + ":albania:": "\U0001f1e6\U0001f1f1", + ":alembic:": "\u2697\ufe0f", + ":algeria:": "\U0001f1e9\U0001f1ff", + ":alien:": "\U0001f47d", + ":ambulance:": "\U0001f691", + ":american_samoa:": "\U0001f1e6\U0001f1f8", + ":amphora:": "\U0001f3fa", + ":anchor:": "\u2693\ufe0f", + ":andorra:": "\U0001f1e6\U0001f1e9", + ":angel:": "\U0001f47c", + ":anger:": "\U0001f4a2", + ":angola:": "\U0001f1e6\U0001f1f4", + ":angry:": "\U0001f620", + ":anguilla:": "\U0001f1e6\U0001f1ee", + ":anguished:": "\U0001f627", + ":ant:": "\U0001f41c", + ":antarctica:": "\U0001f1e6\U0001f1f6", + ":antigua_barbuda:": "\U0001f1e6\U0001f1ec", + ":apple:": "\U0001f34e", + ":aquarius:": "\u2652\ufe0f", + ":argentina:": "\U0001f1e6\U0001f1f7", + ":aries:": "\u2648\ufe0f", + ":armenia:": "\U0001f1e6\U0001f1f2", + ":arrow_backward:": "\u25c0\ufe0f", + ":arrow_double_down:": "\u23ec", + ":arrow_double_up:": "\u23eb", + ":arrow_down:": "\u2b07\ufe0f", + ":arrow_down_small:": "\U0001f53d", + ":arrow_forward:": "\u25b6\ufe0f", + ":arrow_heading_down:": "\u2935\ufe0f", + ":arrow_heading_up:": "\u2934\ufe0f", + ":arrow_left:": "\u2b05\ufe0f", + ":arrow_lower_left:": "\u2199\ufe0f", + ":arrow_lower_right:": "\u2198\ufe0f", + ":arrow_right:": "\u27a1\ufe0f", + ":arrow_right_hook:": "\u21aa\ufe0f", + ":arrow_up:": "\u2b06\ufe0f", + ":arrow_up_down:": "\u2195\ufe0f", + ":arrow_up_small:": "\U0001f53c", + ":arrow_upper_left:": "\u2196\ufe0f", + ":arrow_upper_right:": "\u2197\ufe0f", + ":arrows_clockwise:": "\U0001f503", + ":arrows_counterclockwise:": "\U0001f504", + ":art:": "\U0001f3a8", + ":articulated_lorry:": "\U0001f69b", + ":artificial_satellite:": "\U0001f6f0", + ":aruba:": "\U0001f1e6\U0001f1fc", + ":asterisk:": "*\ufe0f\u20e3", + ":astonished:": "\U0001f632", + ":athletic_shoe:": "\U0001f45f", + ":atm:": "\U0001f3e7", + ":atom_symbol:": "\u269b\ufe0f", + ":australia:": "\U0001f1e6\U0001f1fa", + ":austria:": "\U0001f1e6\U0001f1f9", + ":avocado:": "\U0001f951", + ":azerbaijan:": "\U0001f1e6\U0001f1ff", + ":b:": "\U0001f171\ufe0f", + ":baby:": "\U0001f476", + ":baby_bottle:": "\U0001f37c", + ":baby_chick:": "\U0001f424", + ":baby_symbol:": "\U0001f6bc", + ":back:": "\U0001f519", + ":bacon:": "\U0001f953", + ":badminton:": "\U0001f3f8", + ":baggage_claim:": "\U0001f6c4", + ":baguette_bread:": "\U0001f956", + ":bahamas:": "\U0001f1e7\U0001f1f8", + ":bahrain:": "\U0001f1e7\U0001f1ed", + ":balance_scale:": "\u2696\ufe0f", + ":balloon:": "\U0001f388", + ":ballot_box:": "\U0001f5f3", + ":ballot_box_with_check:": "\u2611\ufe0f", + ":bamboo:": "\U0001f38d", + ":banana:": "\U0001f34c", + ":bangbang:": "\u203c\ufe0f", + ":bangladesh:": "\U0001f1e7\U0001f1e9", + ":bank:": "\U0001f3e6", + ":bar_chart:": "\U0001f4ca", + ":barbados:": "\U0001f1e7\U0001f1e7", + ":barber:": "\U0001f488", + ":baseball:": "\u26be\ufe0f", + ":basecamp:": "", + ":basecampy:": "", + ":basketball:": "\U0001f3c0", + ":basketball_man:": "\u26f9", + ":basketball_woman:": "\u26f9\ufe0f\u200d\u2640\ufe0f", + ":bat:": "\U0001f987", + ":bath:": "\U0001f6c0", + ":bathtub:": "\U0001f6c1", + ":battery:": "\U0001f50b", + ":beach_umbrella:": "\U0001f3d6", + ":bear:": "\U0001f43b", + ":bed:": "\U0001f6cf", + ":bee:": "\U0001f41d", + ":beer:": "\U0001f37a", + ":beers:": "\U0001f37b", + ":beetle:": "\U0001f41e", + ":beginner:": "\U0001f530", + ":belarus:": "\U0001f1e7\U0001f1fe", + ":belgium:": "\U0001f1e7\U0001f1ea", + ":belize:": "\U0001f1e7\U0001f1ff", + ":bell:": "\U0001f514", + ":bellhop_bell:": "\U0001f6ce", + ":benin:": "\U0001f1e7\U0001f1ef", + ":bento:": "\U0001f371", + ":bermuda:": "\U0001f1e7\U0001f1f2", + ":bhutan:": "\U0001f1e7\U0001f1f9", + ":bicyclist:": "\U0001f6b4", + ":bike:": "\U0001f6b2", + ":biking_man:": "\U0001f6b4", + ":biking_woman:": "\U0001f6b4\u200d\u2640", + ":bikini:": "\U0001f459", + ":biohazard:": "\u2623\ufe0f", + ":bird:": "\U0001f426", + ":birthday:": "\U0001f382", + ":black_circle:": "\u26ab\ufe0f", + ":black_flag:": "\U0001f3f4", + ":black_heart:": "\U0001f5a4", + ":black_joker:": "\U0001f0cf", + ":black_large_square:": "\u2b1b\ufe0f", + ":black_medium_small_square:": "\u25fe\ufe0f", + ":black_medium_square:": "\u25fc\ufe0f", + ":black_nib:": "\u2712\ufe0f", + ":black_small_square:": "\u25aa\ufe0f", + ":black_square_button:": "\U0001f532", + ":blonde_man:": "\U0001f471", + ":blonde_woman:": "\U0001f471\u200d\u2640", + ":blossom:": "\U0001f33c", + ":blowfish:": "\U0001f421", + ":blue_book:": "\U0001f4d8", + ":blue_car:": "\U0001f699", + ":blue_heart:": "\U0001f499", + ":blush:": "\U0001f60a", + ":boar:": "\U0001f417", + ":boat:": "\u26f5\ufe0f", + ":bolivia:": "\U0001f1e7\U0001f1f4", + ":bomb:": "\U0001f4a3", + ":book:": "\U0001f4d6", + ":bookmark:": "\U0001f516", + ":bookmark_tabs:": "\U0001f4d1", + ":books:": "\U0001f4da", + ":boom:": "\U0001f4a5", + ":boot:": "\U0001f462", + ":bosnia_herzegovina:": "\U0001f1e7\U0001f1e6", + ":botswana:": "\U0001f1e7\U0001f1fc", + ":bouquet:": "\U0001f490", + ":bow:": "\U0001f647", + ":bow_and_arrow:": "\U0001f3f9", + ":bowing_man:": "\U0001f647", + ":bowing_woman:": "\U0001f647\u200d\u2640", + ":bowling:": "\U0001f3b3", + ":bowtie:": "", + ":boxing_glove:": "\U0001f94a", + ":boy:": "\U0001f466", + ":brazil:": "\U0001f1e7\U0001f1f7", + ":bread:": "\U0001f35e", + ":bride_with_veil:": "\U0001f470", + ":bridge_at_night:": "\U0001f309", + ":briefcase:": "\U0001f4bc", + ":british_indian_ocean_territory:": "\U0001f1ee\U0001f1f4", + ":british_virgin_islands:": "\U0001f1fb\U0001f1ec", + ":broken_heart:": "\U0001f494", + ":brunei:": "\U0001f1e7\U0001f1f3", + ":bug:": "\U0001f41b", + ":building_construction:": "\U0001f3d7", + ":bulb:": "\U0001f4a1", + ":bulgaria:": "\U0001f1e7\U0001f1ec", + ":bullettrain_front:": "\U0001f685", + ":bullettrain_side:": "\U0001f684", + ":burkina_faso:": "\U0001f1e7\U0001f1eb", + ":burrito:": "\U0001f32f", + ":burundi:": "\U0001f1e7\U0001f1ee", + ":bus:": "\U0001f68c", + ":business_suit_levitating:": "\U0001f574", + ":busstop:": "\U0001f68f", + ":bust_in_silhouette:": "\U0001f464", + ":busts_in_silhouette:": "\U0001f465", + ":butterfly:": "\U0001f98b", + ":cactus:": "\U0001f335", + ":cake:": "\U0001f370", + ":calendar:": "\U0001f4c6", + ":call_me_hand:": "\U0001f919", + ":calling:": "\U0001f4f2", + ":cambodia:": "\U0001f1f0\U0001f1ed", + ":camel:": "\U0001f42b", + ":camera:": "\U0001f4f7", + ":camera_flash:": "\U0001f4f8", + ":cameroon:": "\U0001f1e8\U0001f1f2", + ":camping:": "\U0001f3d5", + ":canada:": "\U0001f1e8\U0001f1e6", + ":canary_islands:": "\U0001f1ee\U0001f1e8", + ":cancer:": "\u264b\ufe0f", + ":candle:": "\U0001f56f", + ":candy:": "\U0001f36c", + ":canoe:": "\U0001f6f6", + ":cape_verde:": "\U0001f1e8\U0001f1fb", + ":capital_abcd:": "\U0001f520", + ":capricorn:": "\u2651\ufe0f", + ":car:": "\U0001f697", + ":card_file_box:": "\U0001f5c3", + ":card_index:": "\U0001f4c7", + ":card_index_dividers:": "\U0001f5c2", + ":caribbean_netherlands:": "\U0001f1e7\U0001f1f6", + ":carousel_horse:": "\U0001f3a0", + ":carrot:": "\U0001f955", + ":cat:": "\U0001f431", + ":cat2:": "\U0001f408", + ":cayman_islands:": "\U0001f1f0\U0001f1fe", + ":cd:": "\U0001f4bf", + ":central_african_republic:": "\U0001f1e8\U0001f1eb", + ":chad:": "\U0001f1f9\U0001f1e9", + ":chains:": "\u26d3", + ":champagne:": "\U0001f37e", + ":chart:": "\U0001f4b9", + ":chart_with_downwards_trend:": "\U0001f4c9", + ":chart_with_upwards_trend:": "\U0001f4c8", + ":checkered_flag:": "\U0001f3c1", + ":cheese:": "\U0001f9c0", + ":cherries:": "\U0001f352", + ":cherry_blossom:": "\U0001f338", + ":chestnut:": "\U0001f330", + ":chicken:": "\U0001f414", + ":children_crossing:": "\U0001f6b8", + ":chile:": "\U0001f1e8\U0001f1f1", + ":chipmunk:": "\U0001f43f", + ":chocolate_bar:": "\U0001f36b", + ":christmas_island:": "\U0001f1e8\U0001f1fd", + ":christmas_tree:": "\U0001f384", + ":church:": "\u26ea\ufe0f", + ":cinema:": "\U0001f3a6", + ":circus_tent:": "\U0001f3aa", + ":city_sunrise:": "\U0001f307", + ":city_sunset:": "\U0001f306", + ":cityscape:": "\U0001f3d9", + ":cl:": "\U0001f191", + ":clamp:": "\U0001f5dc", + ":clap:": "\U0001f44f", + ":clapper:": "\U0001f3ac", + ":classical_building:": "\U0001f3db", + ":clinking_glasses:": "\U0001f942", + ":clipboard:": "\U0001f4cb", + ":clock1:": "\U0001f550", + ":clock10:": "\U0001f559", + ":clock1030:": "\U0001f565", + ":clock11:": "\U0001f55a", + ":clock1130:": "\U0001f566", + ":clock12:": "\U0001f55b", + ":clock1230:": "\U0001f567", + ":clock130:": "\U0001f55c", + ":clock2:": "\U0001f551", + ":clock230:": "\U0001f55d", + ":clock3:": "\U0001f552", + ":clock330:": "\U0001f55e", + ":clock4:": "\U0001f553", + ":clock430:": "\U0001f55f", + ":clock5:": "\U0001f554", + ":clock530:": "\U0001f560", + ":clock6:": "\U0001f555", + ":clock630:": "\U0001f561", + ":clock7:": "\U0001f556", + ":clock730:": "\U0001f562", + ":clock8:": "\U0001f557", + ":clock830:": "\U0001f563", + ":clock9:": "\U0001f558", + ":clock930:": "\U0001f564", + ":closed_book:": "\U0001f4d5", + ":closed_lock_with_key:": "\U0001f510", + ":closed_umbrella:": "\U0001f302", + ":cloud:": "\u2601\ufe0f", + ":cloud_with_lightning:": "\U0001f329", + ":cloud_with_lightning_and_rain:": "\u26c8", + ":cloud_with_rain:": "\U0001f327", + ":cloud_with_snow:": "\U0001f328", + ":clown_face:": "\U0001f921", + ":clubs:": "\u2663\ufe0f", + ":cn:": "\U0001f1e8\U0001f1f3", + ":cocktail:": "\U0001f378", + ":cocos_islands:": "\U0001f1e8\U0001f1e8", + ":coffee:": "\u2615\ufe0f", + ":coffin:": "\u26b0\ufe0f", + ":cold_sweat:": "\U0001f630", + ":collision:": "\U0001f4a5", + ":colombia:": "\U0001f1e8\U0001f1f4", + ":comet:": "\u2604", + ":comoros:": "\U0001f1f0\U0001f1f2", + ":computer:": "\U0001f4bb", + ":computer_mouse:": "\U0001f5b1", + ":confetti_ball:": "\U0001f38a", + ":confounded:": "\U0001f616", + ":confused:": "\U0001f615", + ":congo_brazzaville:": "\U0001f1e8\U0001f1ec", + ":congo_kinshasa:": "\U0001f1e8\U0001f1e9", + ":congratulations:": "\u3297\ufe0f", + ":construction:": "\U0001f6a7", + ":construction_worker:": "\U0001f477", + ":construction_worker_man:": "\U0001f477", + ":construction_worker_woman:": "\U0001f477\u200d\u2640", + ":control_knobs:": "\U0001f39b", + ":convenience_store:": "\U0001f3ea", + ":cook_islands:": "\U0001f1e8\U0001f1f0", + ":cookie:": "\U0001f36a", + ":cool:": "\U0001f192", + ":cop:": "\U0001f46e", + ":copyright:": "\u00a9\ufe0f", + ":corn:": "\U0001f33d", + ":costa_rica:": "\U0001f1e8\U0001f1f7", + ":cote_divoire:": "\U0001f1e8\U0001f1ee", + ":couch_and_lamp:": "\U0001f6cb", + ":couple:": "\U0001f46b", + ":couple_with_heart:": "\U0001f491", + ":couple_with_heart_man_man:": "\U0001f468\u200d\u2764\ufe0f\u200d\U0001f468", + ":couple_with_heart_woman_man:": "\U0001f491", + ":couple_with_heart_woman_woman:": "\U0001f469\u200d\u2764\ufe0f\u200d\U0001f469", + ":couplekiss_man_man:": "\U0001f468\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f468", + ":couplekiss_man_woman:": "\U0001f48f", + ":couplekiss_woman_woman:": "\U0001f469\u200d\u2764\ufe0f\u200d\U0001f48b\u200d\U0001f469", + ":cow:": "\U0001f42e", + ":cow2:": "\U0001f404", + ":cowboy_hat_face:": "\U0001f920", + ":crab:": "\U0001f980", + ":crayon:": "\U0001f58d", + ":credit_card:": "\U0001f4b3", + ":crescent_moon:": "\U0001f319", + ":cricket:": "\U0001f3cf", + ":croatia:": "\U0001f1ed\U0001f1f7", + ":crocodile:": "\U0001f40a", + ":croissant:": "\U0001f950", + ":crossed_fingers:": "\U0001f91e", + ":crossed_flags:": "\U0001f38c", + ":crossed_swords:": "\u2694\ufe0f", + ":crown:": "\U0001f451", + ":cry:": "\U0001f622", + ":crying_cat_face:": "\U0001f63f", + ":crystal_ball:": "\U0001f52e", + ":cuba:": "\U0001f1e8\U0001f1fa", + ":cucumber:": "\U0001f952", + ":cupid:": "\U0001f498", + ":curacao:": "\U0001f1e8\U0001f1fc", + ":curly_loop:": "\u27b0", + ":currency_exchange:": "\U0001f4b1", + ":curry:": "\U0001f35b", + ":custard:": "\U0001f36e", + ":customs:": "\U0001f6c3", + ":cyclone:": "\U0001f300", + ":cyprus:": "\U0001f1e8\U0001f1fe", + ":czech_republic:": "\U0001f1e8\U0001f1ff", + ":dagger:": "\U0001f5e1", + ":dancer:": "\U0001f483", + ":dancers:": "\U0001f46f", + ":dancing_men:": "\U0001f46f\u200d\u2642", + ":dancing_women:": "\U0001f46f", + ":dango:": "\U0001f361", + ":dark_sunglasses:": "\U0001f576", + ":dart:": "\U0001f3af", + ":dash:": "\U0001f4a8", + ":date:": "\U0001f4c5", + ":de:": "\U0001f1e9\U0001f1ea", + ":deciduous_tree:": "\U0001f333", + ":deer:": "\U0001f98c", + ":denmark:": "\U0001f1e9\U0001f1f0", + ":department_store:": "\U0001f3ec", + ":derelict_house:": "\U0001f3da", + ":desert:": "\U0001f3dc", + ":desert_island:": "\U0001f3dd", + ":desktop_computer:": "\U0001f5a5", + ":detective:": "\U0001f575", + ":diamond_shape_with_a_dot_inside:": "\U0001f4a0", + ":diamonds:": "\u2666\ufe0f", + ":disappointed:": "\U0001f61e", + ":disappointed_relieved:": "\U0001f625", + ":dizzy:": "\U0001f4ab", + ":dizzy_face:": "\U0001f635", + ":djibouti:": "\U0001f1e9\U0001f1ef", + ":do_not_litter:": "\U0001f6af", + ":dog:": "\U0001f436", + ":dog2:": "\U0001f415", + ":dollar:": "\U0001f4b5", + ":dolls:": "\U0001f38e", + ":dolphin:": "\U0001f42c", + ":dominica:": "\U0001f1e9\U0001f1f2", + ":dominican_republic:": "\U0001f1e9\U0001f1f4", + ":door:": "\U0001f6aa", + ":doughnut:": "\U0001f369", + ":dove:": "\U0001f54a", + ":dragon:": "\U0001f409", + ":dragon_face:": "\U0001f432", + ":dress:": "\U0001f457", + ":dromedary_camel:": "\U0001f42a", + ":drooling_face:": "\U0001f924", + ":droplet:": "\U0001f4a7", + ":drum:": "\U0001f941", + ":duck:": "\U0001f986", + ":dvd:": "\U0001f4c0", + ":e-mail:": "\U0001f4e7", + ":eagle:": "\U0001f985", + ":ear:": "\U0001f442", + ":ear_of_rice:": "\U0001f33e", + ":earth_africa:": "\U0001f30d", + ":earth_americas:": "\U0001f30e", + ":earth_asia:": "\U0001f30f", + ":ecuador:": "\U0001f1ea\U0001f1e8", + ":egg:": "\U0001f95a", + ":eggplant:": "\U0001f346", + ":egypt:": "\U0001f1ea\U0001f1ec", + ":eight:": "8\ufe0f\u20e3", + ":eight_pointed_black_star:": "\u2734\ufe0f", + ":eight_spoked_asterisk:": "\u2733\ufe0f", + ":el_salvador:": "\U0001f1f8\U0001f1fb", + ":electric_plug:": "\U0001f50c", + ":elephant:": "\U0001f418", + ":email:": "\u2709\ufe0f", + ":end:": "\U0001f51a", + ":envelope:": "\u2709\ufe0f", + ":envelope_with_arrow:": "\U0001f4e9", + ":equatorial_guinea:": "\U0001f1ec\U0001f1f6", + ":eritrea:": "\U0001f1ea\U0001f1f7", + ":es:": "\U0001f1ea\U0001f1f8", + ":estonia:": "\U0001f1ea\U0001f1ea", + ":ethiopia:": "\U0001f1ea\U0001f1f9", + ":eu:": "\U0001f1ea\U0001f1fa", + ":euro:": "\U0001f4b6", + ":european_castle:": "\U0001f3f0", + ":european_post_office:": "\U0001f3e4", + ":european_union:": "\U0001f1ea\U0001f1fa", + ":evergreen_tree:": "\U0001f332", + ":exclamation:": "\u2757\ufe0f", + ":expressionless:": "\U0001f611", + ":eye:": "\U0001f441", + ":eye_speech_bubble:": "\U0001f441\u200d\U0001f5e8", + ":eyeglasses:": "\U0001f453", + ":eyes:": "\U0001f440", + ":face_with_head_bandage:": "\U0001f915", + ":face_with_thermometer:": "\U0001f912", + ":facepunch:": "\U0001f44a", + ":factory:": "\U0001f3ed", + ":falkland_islands:": "\U0001f1eb\U0001f1f0", + ":fallen_leaf:": "\U0001f342", + ":family:": "\U0001f46a", + ":family_man_boy:": "\U0001f468\u200d\U0001f466", + ":family_man_boy_boy:": "\U0001f468\u200d\U0001f466\u200d\U0001f466", + ":family_man_girl:": "\U0001f468\u200d\U0001f467", + ":family_man_girl_boy:": "\U0001f468\u200d\U0001f467\u200d\U0001f466", + ":family_man_girl_girl:": "\U0001f468\u200d\U0001f467\u200d\U0001f467", + ":family_man_man_boy:": "\U0001f468\u200d\U0001f468\u200d\U0001f466", + ":family_man_man_boy_boy:": "\U0001f468\u200d\U0001f468\u200d\U0001f466\u200d\U0001f466", + ":family_man_man_girl:": "\U0001f468\u200d\U0001f468\u200d\U0001f467", + ":family_man_man_girl_boy:": "\U0001f468\u200d\U0001f468\u200d\U0001f467\u200d\U0001f466", + ":family_man_man_girl_girl:": "\U0001f468\u200d\U0001f468\u200d\U0001f467\u200d\U0001f467", + ":family_man_woman_boy:": "\U0001f46a", + ":family_man_woman_boy_boy:": "\U0001f468\u200d\U0001f469\u200d\U0001f466\u200d\U0001f466", + ":family_man_woman_girl:": "\U0001f468\u200d\U0001f469\u200d\U0001f467", + ":family_man_woman_girl_boy:": "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466", + ":family_man_woman_girl_girl:": "\U0001f468\u200d\U0001f469\u200d\U0001f467\u200d\U0001f467", + ":family_woman_boy:": "\U0001f469\u200d\U0001f466", + ":family_woman_boy_boy:": "\U0001f469\u200d\U0001f466\u200d\U0001f466", + ":family_woman_girl:": "\U0001f469\u200d\U0001f467", + ":family_woman_girl_boy:": "\U0001f469\u200d\U0001f467\u200d\U0001f466", + ":family_woman_girl_girl:": "\U0001f469\u200d\U0001f467\u200d\U0001f467", + ":family_woman_woman_boy:": "\U0001f469\u200d\U0001f469\u200d\U0001f466", + ":family_woman_woman_boy_boy:": "\U0001f469\u200d\U0001f469\u200d\U0001f466\u200d\U0001f466", + ":family_woman_woman_girl:": "\U0001f469\u200d\U0001f469\u200d\U0001f467", + ":family_woman_woman_girl_boy:": "\U0001f469\u200d\U0001f469\u200d\U0001f467\u200d\U0001f466", + ":family_woman_woman_girl_girl:": "\U0001f469\u200d\U0001f469\u200d\U0001f467\u200d\U0001f467", + ":faroe_islands:": "\U0001f1eb\U0001f1f4", + ":fast_forward:": "\u23e9", + ":fax:": "\U0001f4e0", + ":fearful:": "\U0001f628", + ":feelsgood:": "", + ":feet:": "\U0001f43e", + ":female_detective:": "\U0001f575\ufe0f\u200d\u2640\ufe0f", + ":ferris_wheel:": "\U0001f3a1", + ":ferry:": "\u26f4", + ":field_hockey:": "\U0001f3d1", + ":fiji:": "\U0001f1eb\U0001f1ef", + ":file_cabinet:": "\U0001f5c4", + ":file_folder:": "\U0001f4c1", + ":film_projector:": "\U0001f4fd", + ":film_strip:": "\U0001f39e", + ":finland:": "\U0001f1eb\U0001f1ee", + ":finnadie:": "", + ":fire:": "\U0001f525", + ":fire_engine:": "\U0001f692", + ":fireworks:": "\U0001f386", + ":first_quarter_moon:": "\U0001f313", + ":first_quarter_moon_with_face:": "\U0001f31b", + ":fish:": "\U0001f41f", + ":fish_cake:": "\U0001f365", + ":fishing_pole_and_fish:": "\U0001f3a3", + ":fist:": "\u270a", + ":fist_left:": "\U0001f91b", + ":fist_oncoming:": "\U0001f44a", + ":fist_raised:": "\u270a", + ":fist_right:": "\U0001f91c", + ":five:": "5\ufe0f\u20e3", + ":flags:": "\U0001f38f", + ":flashlight:": "\U0001f526", + ":fleur_de_lis:": "\u269c\ufe0f", + ":flight_arrival:": "\U0001f6ec", + ":flight_departure:": "\U0001f6eb", + ":flipper:": "\U0001f42c", + ":floppy_disk:": "\U0001f4be", + ":flower_playing_cards:": "\U0001f3b4", + ":flushed:": "\U0001f633", + ":fog:": "\U0001f32b", + ":foggy:": "\U0001f301", + ":football:": "\U0001f3c8", + ":footprints:": "\U0001f463", + ":fork_and_knife:": "\U0001f374", + ":fountain:": "\u26f2\ufe0f", + ":fountain_pen:": "\U0001f58b", + ":four:": "4\ufe0f\u20e3", + ":four_leaf_clover:": "\U0001f340", + ":fox_face:": "\U0001f98a", + ":fr:": "\U0001f1eb\U0001f1f7", + ":framed_picture:": "\U0001f5bc", + ":free:": "\U0001f193", + ":french_guiana:": "\U0001f1ec\U0001f1eb", + ":french_polynesia:": "\U0001f1f5\U0001f1eb", + ":french_southern_territories:": "\U0001f1f9\U0001f1eb", + ":fried_egg:": "\U0001f373", + ":fried_shrimp:": "\U0001f364", + ":fries:": "\U0001f35f", + ":frog:": "\U0001f438", + ":frowning:": "\U0001f626", + ":frowning_face:": "\u2639\ufe0f", + ":frowning_man:": "\U0001f64d\u200d\u2642", + ":frowning_woman:": "\U0001f64d", + ":fu:": "\U0001f595", + ":fuelpump:": "\u26fd\ufe0f", + ":full_moon:": "\U0001f315", + ":full_moon_with_face:": "\U0001f31d", + ":funeral_urn:": "\u26b1\ufe0f", + ":gabon:": "\U0001f1ec\U0001f1e6", + ":gambia:": "\U0001f1ec\U0001f1f2", + ":game_die:": "\U0001f3b2", + ":gb:": "\U0001f1ec\U0001f1e7", + ":gear:": "\u2699\ufe0f", + ":gem:": "\U0001f48e", + ":gemini:": "\u264a\ufe0f", + ":georgia:": "\U0001f1ec\U0001f1ea", + ":ghana:": "\U0001f1ec\U0001f1ed", + ":ghost:": "\U0001f47b", + ":gibraltar:": "\U0001f1ec\U0001f1ee", + ":gift:": "\U0001f381", + ":gift_heart:": "\U0001f49d", + ":girl:": "\U0001f467", + ":globe_with_meridians:": "\U0001f310", + ":goal_net:": "\U0001f945", + ":goat:": "\U0001f410", + ":goberserk:": "", + ":godmode:": "", + ":golf:": "\u26f3\ufe0f", + ":golfing_man:": "\U0001f3cc", + ":golfing_woman:": "\U0001f3cc\ufe0f\u200d\u2640\ufe0f", + ":gorilla:": "\U0001f98d", + ":grapes:": "\U0001f347", + ":greece:": "\U0001f1ec\U0001f1f7", + ":green_apple:": "\U0001f34f", + ":green_book:": "\U0001f4d7", + ":green_heart:": "\U0001f49a", + ":green_salad:": "\U0001f957", + ":greenland:": "\U0001f1ec\U0001f1f1", + ":grenada:": "\U0001f1ec\U0001f1e9", + ":grey_exclamation:": "\u2755", + ":grey_question:": "\u2754", + ":grimacing:": "\U0001f62c", + ":grin:": "\U0001f601", + ":grinning:": "\U0001f600", + ":guadeloupe:": "\U0001f1ec\U0001f1f5", + ":guam:": "\U0001f1ec\U0001f1fa", + ":guardsman:": "\U0001f482", + ":guardswoman:": "\U0001f482\u200d\u2640", + ":guatemala:": "\U0001f1ec\U0001f1f9", + ":guernsey:": "\U0001f1ec\U0001f1ec", + ":guinea:": "\U0001f1ec\U0001f1f3", + ":guinea_bissau:": "\U0001f1ec\U0001f1fc", + ":guitar:": "\U0001f3b8", + ":gun:": "\U0001f52b", + ":guyana:": "\U0001f1ec\U0001f1fe", + ":haircut:": "\U0001f487", + ":haircut_man:": "\U0001f487\u200d\u2642", + ":haircut_woman:": "\U0001f487", + ":haiti:": "\U0001f1ed\U0001f1f9", + ":hamburger:": "\U0001f354", + ":hammer:": "\U0001f528", + ":hammer_and_pick:": "\u2692", + ":hammer_and_wrench:": "\U0001f6e0", + ":hamster:": "\U0001f439", + ":hand:": "\u270b", + ":handbag:": "\U0001f45c", + ":handshake:": "\U0001f91d", + ":hankey:": "\U0001f4a9", + ":hash:": "#\ufe0f\u20e3", + ":hatched_chick:": "\U0001f425", + ":hatching_chick:": "\U0001f423", + ":headphones:": "\U0001f3a7", + ":hear_no_evil:": "\U0001f649", + ":heart:": "\u2764\ufe0f", + ":heart_decoration:": "\U0001f49f", + ":heart_eyes:": "\U0001f60d", + ":heart_eyes_cat:": "\U0001f63b", + ":heartbeat:": "\U0001f493", + ":heartpulse:": "\U0001f497", + ":hearts:": "\u2665\ufe0f", + ":heavy_check_mark:": "\u2714\ufe0f", + ":heavy_division_sign:": "\u2797", + ":heavy_dollar_sign:": "\U0001f4b2", + ":heavy_exclamation_mark:": "\u2757\ufe0f", + ":heavy_heart_exclamation:": "\u2763\ufe0f", + ":heavy_minus_sign:": "\u2796", + ":heavy_multiplication_x:": "\u2716\ufe0f", + ":heavy_plus_sign:": "\u2795", + ":helicopter:": "\U0001f681", + ":herb:": "\U0001f33f", + ":hibiscus:": "\U0001f33a", + ":high_brightness:": "\U0001f506", + ":high_heel:": "\U0001f460", + ":hocho:": "\U0001f52a", + ":hole:": "\U0001f573", + ":honduras:": "\U0001f1ed\U0001f1f3", + ":honey_pot:": "\U0001f36f", + ":honeybee:": "\U0001f41d", + ":hong_kong:": "\U0001f1ed\U0001f1f0", + ":horse:": "\U0001f434", + ":horse_racing:": "\U0001f3c7", + ":hospital:": "\U0001f3e5", + ":hot_pepper:": "\U0001f336", + ":hotdog:": "\U0001f32d", + ":hotel:": "\U0001f3e8", + ":hotsprings:": "\u2668\ufe0f", + ":hourglass:": "\u231b\ufe0f", + ":hourglass_flowing_sand:": "\u23f3", + ":house:": "\U0001f3e0", + ":house_with_garden:": "\U0001f3e1", + ":houses:": "\U0001f3d8", + ":hugs:": "\U0001f917", + ":hungary:": "\U0001f1ed\U0001f1fa", + ":hurtrealbad:": "", + ":hushed:": "\U0001f62f", + ":ice_cream:": "\U0001f368", + ":ice_hockey:": "\U0001f3d2", + ":ice_skate:": "\u26f8", + ":icecream:": "\U0001f366", + ":iceland:": "\U0001f1ee\U0001f1f8", + ":id:": "\U0001f194", + ":ideograph_advantage:": "\U0001f250", + ":imp:": "\U0001f47f", + ":inbox_tray:": "\U0001f4e5", + ":incoming_envelope:": "\U0001f4e8", + ":india:": "\U0001f1ee\U0001f1f3", + ":indonesia:": "\U0001f1ee\U0001f1e9", + ":information_desk_person:": "\U0001f481", + ":information_source:": "\u2139\ufe0f", + ":innocent:": "\U0001f607", + ":interrobang:": "\u2049\ufe0f", + ":iphone:": "\U0001f4f1", + ":iran:": "\U0001f1ee\U0001f1f7", + ":iraq:": "\U0001f1ee\U0001f1f6", + ":ireland:": "\U0001f1ee\U0001f1ea", + ":isle_of_man:": "\U0001f1ee\U0001f1f2", + ":israel:": "\U0001f1ee\U0001f1f1", + ":it:": "\U0001f1ee\U0001f1f9", + ":izakaya_lantern:": "\U0001f3ee", + ":jack_o_lantern:": "\U0001f383", + ":jamaica:": "\U0001f1ef\U0001f1f2", + ":japan:": "\U0001f5fe", + ":japanese_castle:": "\U0001f3ef", + ":japanese_goblin:": "\U0001f47a", + ":japanese_ogre:": "\U0001f479", + ":jeans:": "\U0001f456", + ":jersey:": "\U0001f1ef\U0001f1ea", + ":jordan:": "\U0001f1ef\U0001f1f4", + ":joy:": "\U0001f602", + ":joy_cat:": "\U0001f639", + ":joystick:": "\U0001f579", + ":jp:": "\U0001f1ef\U0001f1f5", + ":kaaba:": "\U0001f54b", + ":kazakhstan:": "\U0001f1f0\U0001f1ff", + ":kenya:": "\U0001f1f0\U0001f1ea", + ":key:": "\U0001f511", + ":keyboard:": "\u2328\ufe0f", + ":keycap_ten:": "\U0001f51f", + ":kick_scooter:": "\U0001f6f4", + ":kimono:": "\U0001f458", + ":kiribati:": "\U0001f1f0\U0001f1ee", + ":kiss:": "\U0001f48b", + ":kissing:": "\U0001f617", + ":kissing_cat:": "\U0001f63d", + ":kissing_closed_eyes:": "\U0001f61a", + ":kissing_heart:": "\U0001f618", + ":kissing_smiling_eyes:": "\U0001f619", + ":kiwi_fruit:": "\U0001f95d", + ":knife:": "\U0001f52a", + ":koala:": "\U0001f428", + ":koko:": "\U0001f201", + ":kosovo:": "\U0001f1fd\U0001f1f0", + ":kr:": "\U0001f1f0\U0001f1f7", + ":kuwait:": "\U0001f1f0\U0001f1fc", + ":kyrgyzstan:": "\U0001f1f0\U0001f1ec", + ":label:": "\U0001f3f7", + ":lantern:": "\U0001f3ee", + ":laos:": "\U0001f1f1\U0001f1e6", + ":large_blue_circle:": "\U0001f535", + ":large_blue_diamond:": "\U0001f537", + ":large_orange_diamond:": "\U0001f536", + ":last_quarter_moon:": "\U0001f317", + ":last_quarter_moon_with_face:": "\U0001f31c", + ":latin_cross:": "\u271d\ufe0f", + ":latvia:": "\U0001f1f1\U0001f1fb", + ":laughing:": "\U0001f606", + ":leaves:": "\U0001f343", + ":lebanon:": "\U0001f1f1\U0001f1e7", + ":ledger:": "\U0001f4d2", + ":left_luggage:": "\U0001f6c5", + ":left_right_arrow:": "\u2194\ufe0f", + ":leftwards_arrow_with_hook:": "\u21a9\ufe0f", + ":lemon:": "\U0001f34b", + ":leo:": "\u264c\ufe0f", + ":leopard:": "\U0001f406", + ":lesotho:": "\U0001f1f1\U0001f1f8", + ":level_slider:": "\U0001f39a", + ":liberia:": "\U0001f1f1\U0001f1f7", + ":libra:": "\u264e\ufe0f", + ":libya:": "\U0001f1f1\U0001f1fe", + ":liechtenstein:": "\U0001f1f1\U0001f1ee", + ":light_rail:": "\U0001f688", + ":link:": "\U0001f517", + ":lion:": "\U0001f981", + ":lips:": "\U0001f444", + ":lipstick:": "\U0001f484", + ":lithuania:": "\U0001f1f1\U0001f1f9", + ":lizard:": "\U0001f98e", + ":lock:": "\U0001f512", + ":lock_with_ink_pen:": "\U0001f50f", + ":lollipop:": "\U0001f36d", + ":loop:": "\u27bf", + ":loud_sound:": "\U0001f50a", + ":loudspeaker:": "\U0001f4e2", + ":love_hotel:": "\U0001f3e9", + ":love_letter:": "\U0001f48c", + ":low_brightness:": "\U0001f505", + ":luxembourg:": "\U0001f1f1\U0001f1fa", + ":lying_face:": "\U0001f925", + ":m:": "\u24c2\ufe0f", + ":macau:": "\U0001f1f2\U0001f1f4", + ":macedonia:": "\U0001f1f2\U0001f1f0", + ":madagascar:": "\U0001f1f2\U0001f1ec", + ":mag:": "\U0001f50d", + ":mag_right:": "\U0001f50e", + ":mahjong:": "\U0001f004\ufe0f", + ":mailbox:": "\U0001f4eb", + ":mailbox_closed:": "\U0001f4ea", + ":mailbox_with_mail:": "\U0001f4ec", + ":mailbox_with_no_mail:": "\U0001f4ed", + ":malawi:": "\U0001f1f2\U0001f1fc", + ":malaysia:": "\U0001f1f2\U0001f1fe", + ":maldives:": "\U0001f1f2\U0001f1fb", + ":male_detective:": "\U0001f575", + ":mali:": "\U0001f1f2\U0001f1f1", + ":malta:": "\U0001f1f2\U0001f1f9", + ":man:": "\U0001f468", + ":man_artist:": "\U0001f468\u200d\U0001f3a8", + ":man_astronaut:": "\U0001f468\u200d\U0001f680", + ":man_cartwheeling:": "\U0001f938\u200d\u2642", + ":man_cook:": "\U0001f468\u200d\U0001f373", + ":man_dancing:": "\U0001f57a", + ":man_facepalming:": "\U0001f926\u200d\u2642", + ":man_factory_worker:": "\U0001f468\u200d\U0001f3ed", + ":man_farmer:": "\U0001f468\u200d\U0001f33e", + ":man_firefighter:": "\U0001f468\u200d\U0001f692", + ":man_health_worker:": "\U0001f468\u200d\u2695", + ":man_in_tuxedo:": "\U0001f935", + ":man_judge:": "\U0001f468\u200d\u2696", + ":man_juggling:": "\U0001f939\u200d\u2642", + ":man_mechanic:": "\U0001f468\u200d\U0001f527", + ":man_office_worker:": "\U0001f468\u200d\U0001f4bc", + ":man_pilot:": "\U0001f468\u200d\u2708", + ":man_playing_handball:": "\U0001f93e\u200d\u2642", + ":man_playing_water_polo:": "\U0001f93d\u200d\u2642", + ":man_scientist:": "\U0001f468\u200d\U0001f52c", + ":man_shrugging:": "\U0001f937\u200d\u2642", + ":man_singer:": "\U0001f468\u200d\U0001f3a4", + ":man_student:": "\U0001f468\u200d\U0001f393", + ":man_teacher:": "\U0001f468\u200d\U0001f3eb", + ":man_technologist:": "\U0001f468\u200d\U0001f4bb", + ":man_with_gua_pi_mao:": "\U0001f472", + ":man_with_turban:": "\U0001f473", + ":mandarin:": "\U0001f34a", + ":mans_shoe:": "\U0001f45e", + ":mantelpiece_clock:": "\U0001f570", + ":maple_leaf:": "\U0001f341", + ":marshall_islands:": "\U0001f1f2\U0001f1ed", + ":martial_arts_uniform:": "\U0001f94b", + ":martinique:": "\U0001f1f2\U0001f1f6", + ":mask:": "\U0001f637", + ":massage:": "\U0001f486", + ":massage_man:": "\U0001f486\u200d\u2642", + ":massage_woman:": "\U0001f486", + ":mauritania:": "\U0001f1f2\U0001f1f7", + ":mauritius:": "\U0001f1f2\U0001f1fa", + ":mayotte:": "\U0001f1fe\U0001f1f9", + ":meat_on_bone:": "\U0001f356", + ":medal_military:": "\U0001f396", + ":medal_sports:": "\U0001f3c5", + ":mega:": "\U0001f4e3", + ":melon:": "\U0001f348", + ":memo:": "\U0001f4dd", + ":men_wrestling:": "\U0001f93c\u200d\u2642", + ":menorah:": "\U0001f54e", + ":mens:": "\U0001f6b9", + ":metal:": "\U0001f918", + ":metro:": "\U0001f687", + ":mexico:": "\U0001f1f2\U0001f1fd", + ":micronesia:": "\U0001f1eb\U0001f1f2", + ":microphone:": "\U0001f3a4", + ":microscope:": "\U0001f52c", + ":middle_finger:": "\U0001f595", + ":milk_glass:": "\U0001f95b", + ":milky_way:": "\U0001f30c", + ":minibus:": "\U0001f690", + ":minidisc:": "\U0001f4bd", + ":mobile_phone_off:": "\U0001f4f4", + ":moldova:": "\U0001f1f2\U0001f1e9", + ":monaco:": "\U0001f1f2\U0001f1e8", + ":money_mouth_face:": "\U0001f911", + ":money_with_wings:": "\U0001f4b8", + ":moneybag:": "\U0001f4b0", + ":mongolia:": "\U0001f1f2\U0001f1f3", + ":monkey:": "\U0001f412", + ":monkey_face:": "\U0001f435", + ":monorail:": "\U0001f69d", + ":montenegro:": "\U0001f1f2\U0001f1ea", + ":montserrat:": "\U0001f1f2\U0001f1f8", + ":moon:": "\U0001f314", + ":morocco:": "\U0001f1f2\U0001f1e6", + ":mortar_board:": "\U0001f393", + ":mosque:": "\U0001f54c", + ":motor_boat:": "\U0001f6e5", + ":motor_scooter:": "\U0001f6f5", + ":motorcycle:": "\U0001f3cd", + ":motorway:": "\U0001f6e3", + ":mount_fuji:": "\U0001f5fb", + ":mountain:": "\u26f0", + ":mountain_bicyclist:": "\U0001f6b5", + ":mountain_biking_man:": "\U0001f6b5", + ":mountain_biking_woman:": "\U0001f6b5\u200d\u2640", + ":mountain_cableway:": "\U0001f6a0", + ":mountain_railway:": "\U0001f69e", + ":mountain_snow:": "\U0001f3d4", + ":mouse:": "\U0001f42d", + ":mouse2:": "\U0001f401", + ":movie_camera:": "\U0001f3a5", + ":moyai:": "\U0001f5ff", + ":mozambique:": "\U0001f1f2\U0001f1ff", + ":mrs_claus:": "\U0001f936", + ":muscle:": "\U0001f4aa", + ":mushroom:": "\U0001f344", + ":musical_keyboard:": "\U0001f3b9", + ":musical_note:": "\U0001f3b5", + ":musical_score:": "\U0001f3bc", + ":mute:": "\U0001f507", + ":myanmar:": "\U0001f1f2\U0001f1f2", + ":nail_care:": "\U0001f485", + ":name_badge:": "\U0001f4db", + ":namibia:": "\U0001f1f3\U0001f1e6", + ":national_park:": "\U0001f3de", + ":nauru:": "\U0001f1f3\U0001f1f7", + ":nauseated_face:": "\U0001f922", + ":neckbeard:": "", + ":necktie:": "\U0001f454", + ":negative_squared_cross_mark:": "\u274e", + ":nepal:": "\U0001f1f3\U0001f1f5", + ":nerd_face:": "\U0001f913", + ":netherlands:": "\U0001f1f3\U0001f1f1", + ":neutral_face:": "\U0001f610", + ":new:": "\U0001f195", + ":new_caledonia:": "\U0001f1f3\U0001f1e8", + ":new_moon:": "\U0001f311", + ":new_moon_with_face:": "\U0001f31a", + ":new_zealand:": "\U0001f1f3\U0001f1ff", + ":newspaper:": "\U0001f4f0", + ":newspaper_roll:": "\U0001f5de", + ":next_track_button:": "\u23ed", + ":ng:": "\U0001f196", + ":ng_man:": "\U0001f645\u200d\u2642", + ":ng_woman:": "\U0001f645", + ":nicaragua:": "\U0001f1f3\U0001f1ee", + ":niger:": "\U0001f1f3\U0001f1ea", + ":nigeria:": "\U0001f1f3\U0001f1ec", + ":night_with_stars:": "\U0001f303", + ":nine:": "9\ufe0f\u20e3", + ":niue:": "\U0001f1f3\U0001f1fa", + ":no_bell:": "\U0001f515", + ":no_bicycles:": "\U0001f6b3", + ":no_entry:": "\u26d4\ufe0f", + ":no_entry_sign:": "\U0001f6ab", + ":no_good:": "\U0001f645", + ":no_good_man:": "\U0001f645\u200d\u2642", + ":no_good_woman:": "\U0001f645", + ":no_mobile_phones:": "\U0001f4f5", + ":no_mouth:": "\U0001f636", + ":no_pedestrians:": "\U0001f6b7", + ":no_smoking:": "\U0001f6ad", + ":non-potable_water:": "\U0001f6b1", + ":norfolk_island:": "\U0001f1f3\U0001f1eb", + ":north_korea:": "\U0001f1f0\U0001f1f5", + ":northern_mariana_islands:": "\U0001f1f2\U0001f1f5", + ":norway:": "\U0001f1f3\U0001f1f4", + ":nose:": "\U0001f443", + ":notebook:": "\U0001f4d3", + ":notebook_with_decorative_cover:": "\U0001f4d4", + ":notes:": "\U0001f3b6", + ":nut_and_bolt:": "\U0001f529", + ":o:": "\u2b55\ufe0f", + ":o2:": "\U0001f17e\ufe0f", + ":ocean:": "\U0001f30a", + ":octocat:": "", + ":octopus:": "\U0001f419", + ":oden:": "\U0001f362", + ":office:": "\U0001f3e2", + ":oil_drum:": "\U0001f6e2", + ":ok:": "\U0001f197", + ":ok_hand:": "\U0001f44c", + ":ok_man:": "\U0001f646\u200d\u2642", + ":ok_woman:": "\U0001f646", + ":old_key:": "\U0001f5dd", + ":older_man:": "\U0001f474", + ":older_woman:": "\U0001f475", + ":om:": "\U0001f549", + ":oman:": "\U0001f1f4\U0001f1f2", + ":on:": "\U0001f51b", + ":oncoming_automobile:": "\U0001f698", + ":oncoming_bus:": "\U0001f68d", + ":oncoming_police_car:": "\U0001f694", + ":oncoming_taxi:": "\U0001f696", + ":one:": "1\ufe0f\u20e3", + ":open_book:": "\U0001f4d6", + ":open_file_folder:": "\U0001f4c2", + ":open_hands:": "\U0001f450", + ":open_mouth:": "\U0001f62e", + ":open_umbrella:": "\u2602\ufe0f", + ":ophiuchus:": "\u26ce", + ":orange:": "\U0001f34a", + ":orange_book:": "\U0001f4d9", + ":orthodox_cross:": "\u2626\ufe0f", + ":outbox_tray:": "\U0001f4e4", + ":owl:": "\U0001f989", + ":ox:": "\U0001f402", + ":package:": "\U0001f4e6", + ":page_facing_up:": "\U0001f4c4", + ":page_with_curl:": "\U0001f4c3", + ":pager:": "\U0001f4df", + ":paintbrush:": "\U0001f58c", + ":pakistan:": "\U0001f1f5\U0001f1f0", + ":palau:": "\U0001f1f5\U0001f1fc", + ":palestinian_territories:": "\U0001f1f5\U0001f1f8", + ":palm_tree:": "\U0001f334", + ":panama:": "\U0001f1f5\U0001f1e6", + ":pancakes:": "\U0001f95e", + ":panda_face:": "\U0001f43c", + ":paperclip:": "\U0001f4ce", + ":paperclips:": "\U0001f587", + ":papua_new_guinea:": "\U0001f1f5\U0001f1ec", + ":paraguay:": "\U0001f1f5\U0001f1fe", + ":parasol_on_ground:": "\u26f1", + ":parking:": "\U0001f17f\ufe0f", + ":part_alternation_mark:": "\u303d\ufe0f", + ":partly_sunny:": "\u26c5\ufe0f", + ":passenger_ship:": "\U0001f6f3", + ":passport_control:": "\U0001f6c2", + ":pause_button:": "\u23f8", + ":paw_prints:": "\U0001f43e", + ":peace_symbol:": "\u262e\ufe0f", + ":peach:": "\U0001f351", + ":peanuts:": "\U0001f95c", + ":pear:": "\U0001f350", + ":pen:": "\U0001f58a", + ":pencil:": "\U0001f4dd", + ":pencil2:": "\u270f\ufe0f", + ":penguin:": "\U0001f427", + ":pensive:": "\U0001f614", + ":performing_arts:": "\U0001f3ad", + ":persevere:": "\U0001f623", + ":person_fencing:": "\U0001f93a", + ":person_frowning:": "\U0001f64d", + ":person_with_blond_hair:": "\U0001f471", + ":person_with_pouting_face:": "\U0001f64e", + ":peru:": "\U0001f1f5\U0001f1ea", + ":philippines:": "\U0001f1f5\U0001f1ed", + ":phone:": "\u260e\ufe0f", + ":pick:": "\u26cf", + ":pig:": "\U0001f437", + ":pig2:": "\U0001f416", + ":pig_nose:": "\U0001f43d", + ":pill:": "\U0001f48a", + ":pineapple:": "\U0001f34d", + ":ping_pong:": "\U0001f3d3", + ":pisces:": "\u2653\ufe0f", + ":pitcairn_islands:": "\U0001f1f5\U0001f1f3", + ":pizza:": "\U0001f355", + ":place_of_worship:": "\U0001f6d0", + ":plate_with_cutlery:": "\U0001f37d", + ":play_or_pause_button:": "\u23ef", + ":point_down:": "\U0001f447", + ":point_left:": "\U0001f448", + ":point_right:": "\U0001f449", + ":point_up:": "\u261d\ufe0f", + ":point_up_2:": "\U0001f446", + ":poland:": "\U0001f1f5\U0001f1f1", + ":police_car:": "\U0001f693", + ":policeman:": "\U0001f46e", + ":policewoman:": "\U0001f46e\u200d\u2640", + ":poodle:": "\U0001f429", + ":poop:": "\U0001f4a9", + ":popcorn:": "\U0001f37f", + ":portugal:": "\U0001f1f5\U0001f1f9", + ":post_office:": "\U0001f3e3", + ":postal_horn:": "\U0001f4ef", + ":postbox:": "\U0001f4ee", + ":potable_water:": "\U0001f6b0", + ":potato:": "\U0001f954", + ":pouch:": "\U0001f45d", + ":poultry_leg:": "\U0001f357", + ":pound:": "\U0001f4b7", + ":pout:": "\U0001f621", + ":pouting_cat:": "\U0001f63e", + ":pouting_man:": "\U0001f64e\u200d\u2642", + ":pouting_woman:": "\U0001f64e", + ":pray:": "\U0001f64f", + ":prayer_beads:": "\U0001f4ff", + ":pregnant_woman:": "\U0001f930", + ":previous_track_button:": "\u23ee", + ":prince:": "\U0001f934", + ":princess:": "\U0001f478", + ":printer:": "\U0001f5a8", + ":puerto_rico:": "\U0001f1f5\U0001f1f7", + ":punch:": "\U0001f44a", + ":purple_heart:": "\U0001f49c", + ":purse:": "\U0001f45b", + ":pushpin:": "\U0001f4cc", + ":put_litter_in_its_place:": "\U0001f6ae", + ":qatar:": "\U0001f1f6\U0001f1e6", + ":question:": "\u2753", + ":rabbit:": "\U0001f430", + ":rabbit2:": "\U0001f407", + ":racehorse:": "\U0001f40e", + ":racing_car:": "\U0001f3ce", + ":radio:": "\U0001f4fb", + ":radio_button:": "\U0001f518", + ":radioactive:": "\u2622\ufe0f", + ":rage:": "\U0001f621", + ":rage1:": "", + ":rage2:": "", + ":rage3:": "", + ":rage4:": "", + ":railway_car:": "\U0001f683", + ":railway_track:": "\U0001f6e4", + ":rainbow:": "\U0001f308", + ":rainbow_flag:": "\U0001f3f3\ufe0f\u200d\U0001f308", + ":raised_back_of_hand:": "\U0001f91a", + ":raised_hand:": "\u270b", + ":raised_hand_with_fingers_splayed:": "\U0001f590", + ":raised_hands:": "\U0001f64c", + ":raising_hand:": "\U0001f64b", + ":raising_hand_man:": "\U0001f64b\u200d\u2642", + ":raising_hand_woman:": "\U0001f64b", + ":ram:": "\U0001f40f", + ":ramen:": "\U0001f35c", + ":rat:": "\U0001f400", + ":record_button:": "\u23fa", + ":recycle:": "\u267b\ufe0f", + ":red_car:": "\U0001f697", + ":red_circle:": "\U0001f534", + ":registered:": "\u00ae\ufe0f", + ":relaxed:": "\u263a\ufe0f", + ":relieved:": "\U0001f60c", + ":reminder_ribbon:": "\U0001f397", + ":repeat:": "\U0001f501", + ":repeat_one:": "\U0001f502", + ":rescue_worker_helmet:": "\u26d1", + ":restroom:": "\U0001f6bb", + ":reunion:": "\U0001f1f7\U0001f1ea", + ":revolving_hearts:": "\U0001f49e", + ":rewind:": "\u23ea", + ":rhinoceros:": "\U0001f98f", + ":ribbon:": "\U0001f380", + ":rice:": "\U0001f35a", + ":rice_ball:": "\U0001f359", + ":rice_cracker:": "\U0001f358", + ":rice_scene:": "\U0001f391", + ":right_anger_bubble:": "\U0001f5ef", + ":ring:": "\U0001f48d", + ":robot:": "\U0001f916", + ":rocket:": "\U0001f680", + ":rofl:": "\U0001f923", + ":roll_eyes:": "\U0001f644", + ":roller_coaster:": "\U0001f3a2", + ":romania:": "\U0001f1f7\U0001f1f4", + ":rooster:": "\U0001f413", + ":rose:": "\U0001f339", + ":rosette:": "\U0001f3f5", + ":rotating_light:": "\U0001f6a8", + ":round_pushpin:": "\U0001f4cd", + ":rowboat:": "\U0001f6a3", + ":rowing_man:": "\U0001f6a3", + ":rowing_woman:": "\U0001f6a3\u200d\u2640", + ":ru:": "\U0001f1f7\U0001f1fa", + ":rugby_football:": "\U0001f3c9", + ":runner:": "\U0001f3c3", + ":running:": "\U0001f3c3", + ":running_man:": "\U0001f3c3", + ":running_shirt_with_sash:": "\U0001f3bd", + ":running_woman:": "\U0001f3c3\u200d\u2640", + ":rwanda:": "\U0001f1f7\U0001f1fc", + ":sa:": "\U0001f202\ufe0f", + ":sagittarius:": "\u2650\ufe0f", + ":sailboat:": "\u26f5\ufe0f", + ":sake:": "\U0001f376", + ":samoa:": "\U0001f1fc\U0001f1f8", + ":san_marino:": "\U0001f1f8\U0001f1f2", + ":sandal:": "\U0001f461", + ":santa:": "\U0001f385", + ":sao_tome_principe:": "\U0001f1f8\U0001f1f9", + ":satellite:": "\U0001f4e1", + ":satisfied:": "\U0001f606", + ":saudi_arabia:": "\U0001f1f8\U0001f1e6", + ":saxophone:": "\U0001f3b7", + ":school:": "\U0001f3eb", + ":school_satchel:": "\U0001f392", + ":scissors:": "\u2702\ufe0f", + ":scorpion:": "\U0001f982", + ":scorpius:": "\u264f\ufe0f", + ":scream:": "\U0001f631", + ":scream_cat:": "\U0001f640", + ":scroll:": "\U0001f4dc", + ":seat:": "\U0001f4ba", + ":secret:": "\u3299\ufe0f", + ":see_no_evil:": "\U0001f648", + ":seedling:": "\U0001f331", + ":selfie:": "\U0001f933", + ":senegal:": "\U0001f1f8\U0001f1f3", + ":serbia:": "\U0001f1f7\U0001f1f8", + ":seven:": "7\ufe0f\u20e3", + ":seychelles:": "\U0001f1f8\U0001f1e8", + ":shallow_pan_of_food:": "\U0001f958", + ":shamrock:": "\u2618\ufe0f", + ":shark:": "\U0001f988", + ":shaved_ice:": "\U0001f367", + ":sheep:": "\U0001f411", + ":shell:": "\U0001f41a", + ":shield:": "\U0001f6e1", + ":shinto_shrine:": "\u26e9", + ":ship:": "\U0001f6a2", + ":shipit:": "", + ":shirt:": "\U0001f455", + ":shit:": "\U0001f4a9", + ":shoe:": "\U0001f45e", + ":shopping:": "\U0001f6cd", + ":shopping_cart:": "\U0001f6d2", + ":shower:": "\U0001f6bf", + ":shrimp:": "\U0001f990", + ":sierra_leone:": "\U0001f1f8\U0001f1f1", + ":signal_strength:": "\U0001f4f6", + ":singapore:": "\U0001f1f8\U0001f1ec", + ":sint_maarten:": "\U0001f1f8\U0001f1fd", + ":six:": "6\ufe0f\u20e3", + ":six_pointed_star:": "\U0001f52f", + ":ski:": "\U0001f3bf", + ":skier:": "\u26f7", + ":skull:": "\U0001f480", + ":skull_and_crossbones:": "\u2620\ufe0f", + ":sleeping:": "\U0001f634", + ":sleeping_bed:": "\U0001f6cc", + ":sleepy:": "\U0001f62a", + ":slightly_frowning_face:": "\U0001f641", + ":slightly_smiling_face:": "\U0001f642", + ":slot_machine:": "\U0001f3b0", + ":slovakia:": "\U0001f1f8\U0001f1f0", + ":slovenia:": "\U0001f1f8\U0001f1ee", + ":small_airplane:": "\U0001f6e9", + ":small_blue_diamond:": "\U0001f539", + ":small_orange_diamond:": "\U0001f538", + ":small_red_triangle:": "\U0001f53a", + ":small_red_triangle_down:": "\U0001f53b", + ":smile:": "\U0001f604", + ":smile_cat:": "\U0001f638", + ":smiley:": "\U0001f603", + ":smiley_cat:": "\U0001f63a", + ":smiling_imp:": "\U0001f608", + ":smirk:": "\U0001f60f", + ":smirk_cat:": "\U0001f63c", + ":smoking:": "\U0001f6ac", + ":snail:": "\U0001f40c", + ":snake:": "\U0001f40d", + ":sneezing_face:": "\U0001f927", + ":snowboarder:": "\U0001f3c2", + ":snowflake:": "\u2744\ufe0f", + ":snowman:": "\u26c4\ufe0f", + ":snowman_with_snow:": "\u2603\ufe0f", + ":sob:": "\U0001f62d", + ":soccer:": "\u26bd\ufe0f", + ":solomon_islands:": "\U0001f1f8\U0001f1e7", + ":somalia:": "\U0001f1f8\U0001f1f4", + ":soon:": "\U0001f51c", + ":sos:": "\U0001f198", + ":sound:": "\U0001f509", + ":south_africa:": "\U0001f1ff\U0001f1e6", + ":south_georgia_south_sandwich_islands:": "\U0001f1ec\U0001f1f8", + ":south_sudan:": "\U0001f1f8\U0001f1f8", + ":space_invader:": "\U0001f47e", + ":spades:": "\u2660\ufe0f", + ":spaghetti:": "\U0001f35d", + ":sparkle:": "\u2747\ufe0f", + ":sparkler:": "\U0001f387", + ":sparkles:": "\u2728", + ":sparkling_heart:": "\U0001f496", + ":speak_no_evil:": "\U0001f64a", + ":speaker:": "\U0001f508", + ":speaking_head:": "\U0001f5e3", + ":speech_balloon:": "\U0001f4ac", + ":speedboat:": "\U0001f6a4", + ":spider:": "\U0001f577", + ":spider_web:": "\U0001f578", + ":spiral_calendar:": "\U0001f5d3", + ":spiral_notepad:": "\U0001f5d2", + ":spoon:": "\U0001f944", + ":squid:": "\U0001f991", + ":squirrel:": "", + ":sri_lanka:": "\U0001f1f1\U0001f1f0", + ":st_barthelemy:": "\U0001f1e7\U0001f1f1", + ":st_helena:": "\U0001f1f8\U0001f1ed", + ":st_kitts_nevis:": "\U0001f1f0\U0001f1f3", + ":st_lucia:": "\U0001f1f1\U0001f1e8", + ":st_pierre_miquelon:": "\U0001f1f5\U0001f1f2", + ":st_vincent_grenadines:": "\U0001f1fb\U0001f1e8", + ":stadium:": "\U0001f3df", + ":star:": "\u2b50\ufe0f", + ":star2:": "\U0001f31f", + ":star_and_crescent:": "\u262a\ufe0f", + ":star_of_david:": "\u2721\ufe0f", + ":stars:": "\U0001f320", + ":station:": "\U0001f689", + ":statue_of_liberty:": "\U0001f5fd", + ":steam_locomotive:": "\U0001f682", + ":stew:": "\U0001f372", + ":stop_button:": "\u23f9", + ":stop_sign:": "\U0001f6d1", + ":stopwatch:": "\u23f1", + ":straight_ruler:": "\U0001f4cf", + ":strawberry:": "\U0001f353", + ":stuck_out_tongue:": "\U0001f61b", + ":stuck_out_tongue_closed_eyes:": "\U0001f61d", + ":stuck_out_tongue_winking_eye:": "\U0001f61c", + ":studio_microphone:": "\U0001f399", + ":stuffed_flatbread:": "\U0001f959", + ":sudan:": "\U0001f1f8\U0001f1e9", + ":sun_behind_large_cloud:": "\U0001f325", + ":sun_behind_rain_cloud:": "\U0001f326", + ":sun_behind_small_cloud:": "\U0001f324", + ":sun_with_face:": "\U0001f31e", + ":sunflower:": "\U0001f33b", + ":sunglasses:": "\U0001f60e", + ":sunny:": "\u2600\ufe0f", + ":sunrise:": "\U0001f305", + ":sunrise_over_mountains:": "\U0001f304", + ":surfer:": "\U0001f3c4", + ":surfing_man:": "\U0001f3c4", + ":surfing_woman:": "\U0001f3c4\u200d\u2640", + ":suriname:": "\U0001f1f8\U0001f1f7", + ":sushi:": "\U0001f363", + ":suspect:": "", + ":suspension_railway:": "\U0001f69f", + ":swaziland:": "\U0001f1f8\U0001f1ff", + ":sweat:": "\U0001f613", + ":sweat_drops:": "\U0001f4a6", + ":sweat_smile:": "\U0001f605", + ":sweden:": "\U0001f1f8\U0001f1ea", + ":sweet_potato:": "\U0001f360", + ":swimmer:": "\U0001f3ca", + ":swimming_man:": "\U0001f3ca", + ":swimming_woman:": "\U0001f3ca\u200d\u2640", + ":switzerland:": "\U0001f1e8\U0001f1ed", + ":symbols:": "\U0001f523", + ":synagogue:": "\U0001f54d", + ":syria:": "\U0001f1f8\U0001f1fe", + ":syringe:": "\U0001f489", + ":taco:": "\U0001f32e", + ":tada:": "\U0001f389", + ":taiwan:": "\U0001f1f9\U0001f1fc", + ":tajikistan:": "\U0001f1f9\U0001f1ef", + ":tanabata_tree:": "\U0001f38b", + ":tangerine:": "\U0001f34a", + ":tanzania:": "\U0001f1f9\U0001f1ff", + ":taurus:": "\u2649\ufe0f", + ":taxi:": "\U0001f695", + ":tea:": "\U0001f375", + ":telephone:": "\u260e\ufe0f", + ":telephone_receiver:": "\U0001f4de", + ":telescope:": "\U0001f52d", + ":tennis:": "\U0001f3be", + ":tent:": "\u26fa\ufe0f", + ":thailand:": "\U0001f1f9\U0001f1ed", + ":thermometer:": "\U0001f321", + ":thinking:": "\U0001f914", + ":thought_balloon:": "\U0001f4ad", + ":three:": "3\ufe0f\u20e3", + ":thumbsdown:": "\U0001f44e", + ":thumbsup:": "\U0001f44d", + ":ticket:": "\U0001f3ab", + ":tickets:": "\U0001f39f", + ":tiger:": "\U0001f42f", + ":tiger2:": "\U0001f405", + ":timer_clock:": "\u23f2", + ":timor_leste:": "\U0001f1f9\U0001f1f1", + ":tipping_hand_man:": "\U0001f481\u200d\u2642", + ":tipping_hand_woman:": "\U0001f481", + ":tired_face:": "\U0001f62b", + ":tm:": "\u2122\ufe0f", + ":togo:": "\U0001f1f9\U0001f1ec", + ":toilet:": "\U0001f6bd", + ":tokelau:": "\U0001f1f9\U0001f1f0", + ":tokyo_tower:": "\U0001f5fc", + ":tomato:": "\U0001f345", + ":tonga:": "\U0001f1f9\U0001f1f4", + ":tongue:": "\U0001f445", + ":top:": "\U0001f51d", + ":tophat:": "\U0001f3a9", + ":tornado:": "\U0001f32a", + ":tr:": "\U0001f1f9\U0001f1f7", + ":trackball:": "\U0001f5b2", + ":tractor:": "\U0001f69c", + ":traffic_light:": "\U0001f6a5", + ":train:": "\U0001f68b", + ":train2:": "\U0001f686", + ":tram:": "\U0001f68a", + ":triangular_flag_on_post:": "\U0001f6a9", + ":triangular_ruler:": "\U0001f4d0", + ":trident:": "\U0001f531", + ":trinidad_tobago:": "\U0001f1f9\U0001f1f9", + ":triumph:": "\U0001f624", + ":trolleybus:": "\U0001f68e", + ":trollface:": "", + ":trophy:": "\U0001f3c6", + ":tropical_drink:": "\U0001f379", + ":tropical_fish:": "\U0001f420", + ":truck:": "\U0001f69a", + ":trumpet:": "\U0001f3ba", + ":tshirt:": "\U0001f455", + ":tulip:": "\U0001f337", + ":tumbler_glass:": "\U0001f943", + ":tunisia:": "\U0001f1f9\U0001f1f3", + ":turkey:": "\U0001f983", + ":turkmenistan:": "\U0001f1f9\U0001f1f2", + ":turks_caicos_islands:": "\U0001f1f9\U0001f1e8", + ":turtle:": "\U0001f422", + ":tuvalu:": "\U0001f1f9\U0001f1fb", + ":tv:": "\U0001f4fa", + ":twisted_rightwards_arrows:": "\U0001f500", + ":two:": "2\ufe0f\u20e3", + ":two_hearts:": "\U0001f495", + ":two_men_holding_hands:": "\U0001f46c", + ":two_women_holding_hands:": "\U0001f46d", + ":u5272:": "\U0001f239", + ":u5408:": "\U0001f234", + ":u55b6:": "\U0001f23a", + ":u6307:": "\U0001f22f\ufe0f", + ":u6708:": "\U0001f237\ufe0f", + ":u6709:": "\U0001f236", + ":u6e80:": "\U0001f235", + ":u7121:": "\U0001f21a\ufe0f", + ":u7533:": "\U0001f238", + ":u7981:": "\U0001f232", + ":u7a7a:": "\U0001f233", + ":uganda:": "\U0001f1fa\U0001f1ec", + ":uk:": "\U0001f1ec\U0001f1e7", + ":ukraine:": "\U0001f1fa\U0001f1e6", + ":umbrella:": "\u2614\ufe0f", + ":unamused:": "\U0001f612", + ":underage:": "\U0001f51e", + ":unicorn:": "\U0001f984", + ":united_arab_emirates:": "\U0001f1e6\U0001f1ea", + ":unlock:": "\U0001f513", + ":up:": "\U0001f199", + ":upside_down_face:": "\U0001f643", + ":uruguay:": "\U0001f1fa\U0001f1fe", + ":us:": "\U0001f1fa\U0001f1f8", + ":us_virgin_islands:": "\U0001f1fb\U0001f1ee", + ":uzbekistan:": "\U0001f1fa\U0001f1ff", + ":v:": "\u270c\ufe0f", + ":vanuatu:": "\U0001f1fb\U0001f1fa", + ":vatican_city:": "\U0001f1fb\U0001f1e6", + ":venezuela:": "\U0001f1fb\U0001f1ea", + ":vertical_traffic_light:": "\U0001f6a6", + ":vhs:": "\U0001f4fc", + ":vibration_mode:": "\U0001f4f3", + ":video_camera:": "\U0001f4f9", + ":video_game:": "\U0001f3ae", + ":vietnam:": "\U0001f1fb\U0001f1f3", + ":violin:": "\U0001f3bb", + ":virgo:": "\u264d\ufe0f", + ":volcano:": "\U0001f30b", + ":volleyball:": "\U0001f3d0", + ":vs:": "\U0001f19a", + ":vulcan_salute:": "\U0001f596", + ":walking:": "\U0001f6b6", + ":walking_man:": "\U0001f6b6", + ":walking_woman:": "\U0001f6b6\u200d\u2640", + ":wallis_futuna:": "\U0001f1fc\U0001f1eb", + ":waning_crescent_moon:": "\U0001f318", + ":waning_gibbous_moon:": "\U0001f316", + ":warning:": "\u26a0\ufe0f", + ":wastebasket:": "\U0001f5d1", + ":watch:": "\u231a\ufe0f", + ":water_buffalo:": "\U0001f403", + ":watermelon:": "\U0001f349", + ":wave:": "\U0001f44b", + ":wavy_dash:": "\u3030\ufe0f", + ":waxing_crescent_moon:": "\U0001f312", + ":waxing_gibbous_moon:": "\U0001f314", + ":wc:": "\U0001f6be", + ":weary:": "\U0001f629", + ":wedding:": "\U0001f492", + ":weight_lifting_man:": "\U0001f3cb", + ":weight_lifting_woman:": "\U0001f3cb\ufe0f\u200d\u2640\ufe0f", + ":western_sahara:": "\U0001f1ea\U0001f1ed", + ":whale:": "\U0001f433", + ":whale2:": "\U0001f40b", + ":wheel_of_dharma:": "\u2638\ufe0f", + ":wheelchair:": "\u267f\ufe0f", + ":white_check_mark:": "\u2705", + ":white_circle:": "\u26aa\ufe0f", + ":white_flag:": "\U0001f3f3\ufe0f", + ":white_flower:": "\U0001f4ae", + ":white_large_square:": "\u2b1c\ufe0f", + ":white_medium_small_square:": "\u25fd\ufe0f", + ":white_medium_square:": "\u25fb\ufe0f", + ":white_small_square:": "\u25ab\ufe0f", + ":white_square_button:": "\U0001f533", + ":wilted_flower:": "\U0001f940", + ":wind_chime:": "\U0001f390", + ":wind_face:": "\U0001f32c", + ":wine_glass:": "\U0001f377", + ":wink:": "\U0001f609", + ":wolf:": "\U0001f43a", + ":woman:": "\U0001f469", + ":woman_artist:": "\U0001f469\u200d\U0001f3a8", + ":woman_astronaut:": "\U0001f469\u200d\U0001f680", + ":woman_cartwheeling:": "\U0001f938\u200d\u2640", + ":woman_cook:": "\U0001f469\u200d\U0001f373", + ":woman_facepalming:": "\U0001f926\u200d\u2640", + ":woman_factory_worker:": "\U0001f469\u200d\U0001f3ed", + ":woman_farmer:": "\U0001f469\u200d\U0001f33e", + ":woman_firefighter:": "\U0001f469\u200d\U0001f692", + ":woman_health_worker:": "\U0001f469\u200d\u2695", + ":woman_judge:": "\U0001f469\u200d\u2696", + ":woman_juggling:": "\U0001f939\u200d\u2640", + ":woman_mechanic:": "\U0001f469\u200d\U0001f527", + ":woman_office_worker:": "\U0001f469\u200d\U0001f4bc", + ":woman_pilot:": "\U0001f469\u200d\u2708", + ":woman_playing_handball:": "\U0001f93e\u200d\u2640", + ":woman_playing_water_polo:": "\U0001f93d\u200d\u2640", + ":woman_scientist:": "\U0001f469\u200d\U0001f52c", + ":woman_shrugging:": "\U0001f937\u200d\u2640", + ":woman_singer:": "\U0001f469\u200d\U0001f3a4", + ":woman_student:": "\U0001f469\u200d\U0001f393", + ":woman_teacher:": "\U0001f469\u200d\U0001f3eb", + ":woman_technologist:": "\U0001f469\u200d\U0001f4bb", + ":woman_with_turban:": "\U0001f473\u200d\u2640", + ":womans_clothes:": "\U0001f45a", + ":womans_hat:": "\U0001f452", + ":women_wrestling:": "\U0001f93c\u200d\u2640", + ":womens:": "\U0001f6ba", + ":world_map:": "\U0001f5fa", + ":worried:": "\U0001f61f", + ":wrench:": "\U0001f527", + ":writing_hand:": "\u270d\ufe0f", + ":x:": "\u274c", + ":yellow_heart:": "\U0001f49b", + ":yemen:": "\U0001f1fe\U0001f1ea", + ":yen:": "\U0001f4b4", + ":yin_yang:": "\u262f\ufe0f", + ":yum:": "\U0001f60b", + ":zambia:": "\U0001f1ff\U0001f1f2", + ":zap:": "\u26a1\ufe0f", + ":zero:": "0\ufe0f\u20e3", + ":zimbabwe:": "\U0001f1ff\U0001f1fc", + ":zipper_mouth_face:": "\U0001f910", + ":zzz:": "\U0001f4a4", +} diff --git a/event.go b/event.go new file mode 100644 index 0000000..d942c26 --- /dev/null +++ b/event.go @@ -0,0 +1,400 @@ +package main + +import ( + "os" + "strconv" + "time" + + "github.com/gizak/termui" + "github.com/nlopes/slack" + termbox "github.com/nsf/termbox-go" +) + +var timer *time.Timer + +// actionMap binds specific action names to the function counterparts, +// these action names can then be used to bind them to specific keys +// in the Config. +var actionMap = map[string]func(*AppContext){ + "space": actionSpace, + "backspace": actionBackSpace, + "delete": actionDelete, + "cursor-right": actionMoveCursorRight, + "cursor-left": actionMoveCursorLeft, + "send": actionSend, + "quit": actionQuit, + "mode-insert": actionInsertMode, + "mode-command": actionCommandMode, + "mode-search": actionSearchMode, + "clear-input": actionClearInput, + "channel-up": actionMoveCursorUpChannels, + "channel-down": actionMoveCursorDownChannels, + "channel-top": actionMoveCursorTopChannels, + "channel-bottom": actionMoveCursorBottomChannels, + "chat-up": actionScrollUpChat, + "chat-down": actionScrollDownChat, + "help": actionHelp, +} + +func RegisterEventHandlers(ctx *AppContext) { + eventHandler(ctx) + messageHandler(ctx) +} + +func eventHandler(ctx *AppContext) { + go func() { + for { + ctx.EventQueue <- termbox.PollEvent() + } + }() + + go func() { + for { + ev := <-ctx.EventQueue + handleTermboxEvents(ctx, ev) + handleMoreTermboxEvents(ctx, ev) + } + }() +} + +func handleTermboxEvents(ctx *AppContext, ev termbox.Event) bool { + switch ev.Type { + case termbox.EventKey: + actionKeyEvent(ctx, ev) + case termbox.EventResize: + actionResizeEvent(ctx, ev) + } + + return true +} + +func handleMoreTermboxEvents(ctx *AppContext, ev termbox.Event) bool { + for { + select { + case ev := <-ctx.EventQueue: + ok := handleTermboxEvents(ctx, ev) + if !ok { + return false + } + default: + return true + } + } +} + +func messageHandler(ctx *AppContext) { + go func() { + for { + select { + case msg := <-ctx.Service.RTM.IncomingEvents: + switch ev := msg.Data.(type) { + case *slack.MessageEvent: + // Construct message + msg := ctx.Service.CreateMessageFromMessageEvent(ev) + + // Add message to the selected channel + if ev.Channel == ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID { + + // reverse order of messages, mainly done + // when attachments are added to message + for i := len(msg) - 1; i >= 0; i-- { + ctx.View.Chat.AddMessage(msg[i]) + } + + termui.Render(ctx.View.Chat) + + // TODO: set Chat.Offset to 0, to automatically scroll + // down? + } + + // Set new message indicator for channel, I'm leaving + // this here because I also want to be notified when + // I'm currently in a channel but not in the terminal + // window (tmux). But only create a notification when + // it comes from someone else but the current user. + if ev.User != ctx.Service.CurrentUserID { + actionNewMessage(ctx, ev.Channel) + } + case *slack.PresenceChangeEvent: + actionSetPresence(ctx, ev.User, ev.Presence) + } + } + } + }() +} + +func actionKeyEvent(ctx *AppContext, ev termbox.Event) { + + keyStr := getKeyString(ev) + + // Get the action name (actionStr) from the key that + // has been pressed. If this is found try to uncover + // the associated function with this key and execute + // it. + actionStr, ok := ctx.Config.KeyMap[ctx.Mode][keyStr] + if ok { + action, ok := actionMap[actionStr] + if ok { + action(ctx) + } + } else { + if ctx.Mode == InsertMode && ev.Ch != 0 { + actionInput(ctx.View, ev.Ch) + } else if ctx.Mode == SearchMode && ev.Ch != 0 { + actionSearch(ctx, ev.Ch) + } + } +} + +func actionResizeEvent(ctx *AppContext, ev termbox.Event) { + termui.Body.Width = termui.TermWidth() + termui.Body.Align() + termui.Render(termui.Body) +} + +func actionInput(view *View, key rune) { + view.Input.Insert(key) + termui.Render(view.Input) +} + +func actionClearInput(ctx *AppContext) { + // Clear input + ctx.View.Input.Clear() + ctx.View.Refresh() + + // Set command mode + actionCommandMode(ctx) +} + +func actionSpace(ctx *AppContext) { + actionInput(ctx.View, ' ') +} + +func actionBackSpace(ctx *AppContext) { + ctx.View.Input.Backspace() + termui.Render(ctx.View.Input) +} + +func actionDelete(ctx *AppContext) { + ctx.View.Input.Delete() + termui.Render(ctx.View.Input) +} + +func actionMoveCursorRight(ctx *AppContext) { + ctx.View.Input.MoveCursorRight() + termui.Render(ctx.View.Input) +} + +func actionMoveCursorLeft(ctx *AppContext) { + ctx.View.Input.MoveCursorLeft() + termui.Render(ctx.View.Input) +} + +func actionSend(ctx *AppContext) { + if !ctx.View.Input.IsEmpty() { + + // Clear message before sending, to combat + // quick succession of actionSend + message := ctx.View.Input.GetText() + ctx.View.Input.Clear() + ctx.View.Refresh() + + ctx.View.Input.SendMessage( + ctx.Service, + ctx.Service.Channels[ctx.View.Channels.SelectedChannel].ID, + message, + ) + } +} + +func actionSearch(ctx *AppContext, key rune) { + go func() { + if timer != nil { + timer.Stop() + } + + actionInput(ctx.View, key) + + timer = time.NewTimer(time.Second / 4) + <-timer.C + + term := ctx.View.Input.GetText() + ctx.View.Channels.Search(term) + actionChangeChannel(ctx) + }() +} + +// actionQuit will exit the program by using os.Exit, this is +// done because we are using a custom termui EvtStream. Which +// we won't be able to call termui.StopLoop() on. See main.go +// for the customEvtStream and why this is done. +func actionQuit(ctx *AppContext) { + termbox.Close() + os.Exit(0) +} + +func actionInsertMode(ctx *AppContext) { + ctx.Mode = InsertMode + ctx.View.Mode.Par.Text = "INSERT" + termui.Render(ctx.View.Mode) +} + +func actionCommandMode(ctx *AppContext) { + ctx.Mode = CommandMode + ctx.View.Mode.Par.Text = "NORMAL" + termui.Render(ctx.View.Mode) +} + +func actionSearchMode(ctx *AppContext) { + ctx.Mode = SearchMode + ctx.View.Mode.Par.Text = "SEARCH" + termui.Render(ctx.View.Mode) +} + +func actionGetMessages(ctx *AppContext) { + ctx.View.Chat.GetMessages( + ctx.Service, + ctx.Service.Channels[ctx.View.Channels.SelectedChannel], + ) + + termui.Render(ctx.View.Chat) +} + +func actionMoveCursorUpChannels(ctx *AppContext) { + go func() { + if timer != nil { + timer.Stop() + } + + ctx.View.Channels.MoveCursorUp() + termui.Render(ctx.View.Channels) + + timer = time.NewTimer(time.Second / 4) + <-timer.C + + actionChangeChannel(ctx) + }() +} + +func actionMoveCursorDownChannels(ctx *AppContext) { + go func() { + if timer != nil { + timer.Stop() + } + + ctx.View.Channels.MoveCursorDown() + termui.Render(ctx.View.Channels) + + timer = time.NewTimer(time.Second / 4) + <-timer.C + + actionChangeChannel(ctx) + }() +} + +func actionMoveCursorTopChannels(ctx *AppContext) { + ctx.View.Channels.MoveCursorTop() + actionChangeChannel(ctx) +} + +func actionMoveCursorBottomChannels(ctx *AppContext) { + ctx.View.Channels.MoveCursorBottom() + actionChangeChannel(ctx) +} + +func actionChangeChannel(ctx *AppContext) { + // Clear messages from Chat pane + ctx.View.Chat.ClearMessages() + + // Get message for the new channel + ctx.View.Chat.GetMessages( + ctx.Service, + ctx.Service.SlackChannels[ctx.View.Channels.SelectedChannel], + ) + + // Set channel name for the Chat pane + ctx.View.Chat.SetBorderLabel( + ctx.Service.Channels[ctx.View.Channels.SelectedChannel], + ) + + // Set read mark + ctx.View.Channels.SetReadMark(ctx.Service) + + termui.Render(ctx.View.Channels) + termui.Render(ctx.View.Chat) +} + +func actionNewMessage(ctx *AppContext, channelID string) { + ctx.View.Channels.SetNotification(ctx.Service, channelID) + termui.Render(ctx.View.Channels) +} + +func actionSetPresence(ctx *AppContext, channelID string, presence string) { + ctx.View.Channels.SetPresence(ctx.Service, channelID, presence) + termui.Render(ctx.View.Channels) +} + +func actionScrollUpChat(ctx *AppContext) { + ctx.View.Chat.ScrollUp() + termui.Render(ctx.View.Chat) +} + +func actionScrollDownChat(ctx *AppContext) { + ctx.View.Chat.ScrollDown() + termui.Render(ctx.View.Chat) +} + +func actionHelp(ctx *AppContext) { + ctx.View.Chat.Help(ctx.Config) + termui.Render(ctx.View.Chat) +} + +// GetKeyString will return a string that resembles the key event from +// termbox. This is blatanly copied from termui because it is an unexported +// function. +// +// See: +// - https://github.com/gizak/termui/blob/a7e3aeef4cdf9fa2edb723b1541cb69b7bb089ea/events.go#L31-L72 +// - https://github.com/nsf/termbox-go/blob/master/api_common.go +func getKeyString(e termbox.Event) string { + var ek string + + k := string(e.Ch) + pre := "" + mod := "" + + if e.Mod == termbox.ModAlt { + mod = "M-" + } + if e.Ch == 0 { + if e.Key > 0xFFFF-12 { + k = "" + } else if e.Key > 0xFFFF-25 { + ks := []string{"", "", "", "", "", "", "", "", "", ""} + k = ks[0xFFFF-int(e.Key)-12] + } + + if e.Key <= 0x7F { + pre = "C-" + k = string('a' - 1 + int(e.Key)) + kmap := map[termbox.Key][2]string{ + termbox.KeyCtrlSpace: {"C-", ""}, + termbox.KeyBackspace: {"", ""}, + termbox.KeyTab: {"", ""}, + termbox.KeyEnter: {"", ""}, + termbox.KeyEsc: {"", ""}, + termbox.KeyCtrlBackslash: {"C-", "\\"}, + termbox.KeyCtrlSlash: {"C-", "/"}, + termbox.KeySpace: {"", ""}, + termbox.KeyCtrl8: {"C-", "8"}, + } + if sk, ok := kmap[e.Key]; ok { + pre = sk[0] + k = sk[1] + } + } + } + + ek = pre + mod + k + return ek +} diff --git a/input.go b/input.go new file mode 100644 index 0000000..98a0497 --- /dev/null +++ b/input.go @@ -0,0 +1,135 @@ +package main + +import ( + "github.com/gizak/termui" +) + +// Input is the definition of an Input component +type Input struct { + Par *termui.Par + Text []rune + CursorPosition int +} + +// CreateInput is the constructor of the Input struct +func CreateInput() *Input { + input := &Input{ + Par: termui.NewPar(""), + Text: make([]rune, 0), + CursorPosition: 0, + } + + input.Par.Height = 3 + + return input +} + +// Buffer implements interface termui.Bufferer +func (i *Input) Buffer() termui.Buffer { + buf := i.Par.Buffer() + + // Set visible cursor + char := buf.At(i.Par.InnerX()+i.CursorPosition, i.Par.Block.InnerY()) + buf.Set( + i.Par.InnerX()+i.CursorPosition, + i.Par.Block.InnerY(), + termui.Cell{ + Ch: char.Ch, + Fg: i.Par.TextBgColor, + Bg: i.Par.TextFgColor, + }, + ) + + return buf +} + +// GetHeight implements interface termui.GridBufferer +func (i *Input) GetHeight() int { + return i.Par.Block.GetHeight() +} + +// SetWidth implements interface termui.GridBufferer +func (i *Input) SetWidth(w int) { + i.Par.SetWidth(w) +} + +// SetX implements interface termui.GridBufferer +func (i *Input) SetX(x int) { + i.Par.SetX(x) +} + +// SetY implements interface termui.GridBufferer +func (i *Input) SetY(y int) { + i.Par.SetY(y) +} + +// SendMessage send the input text through the SlackService +func (i *Input) SendMessage(svc *SlackService, channel string, message string) { + svc.SendMessage(channel, message) +} + +// Insert will insert a given key at the place of the current CursorPosition +func (i *Input) Insert(key rune) { + if len(i.Text) < i.Par.InnerBounds().Dx()-1 { + + left := make([]rune, len(i.Text[0:i.CursorPosition])) + copy(left, i.Text[0:i.CursorPosition]) + left = append(left, key) + + i.Text = append(left, i.Text[i.CursorPosition:]...) + + i.Par.Text = string(i.Text) + i.MoveCursorRight() + } +} + +// Backspace will remove a character in front of the CursorPosition +func (i *Input) Backspace() { + if i.CursorPosition > 0 { + i.Text = append(i.Text[0:i.CursorPosition-1], i.Text[i.CursorPosition:]...) + i.Par.Text = string(i.Text) + i.MoveCursorLeft() + } +} + +// Delete will remove a character at the CursorPosition +func (i *Input) Delete() { + if i.CursorPosition < len(i.Text) { + i.Text = append(i.Text[0:i.CursorPosition], i.Text[i.CursorPosition+1:]...) + i.Par.Text = string(i.Text) + } +} + +// MoveCursorRight will increase the current CursorPosition with 1 +func (i *Input) MoveCursorRight() { + if i.CursorPosition < len(i.Text) { + i.CursorPosition++ + } +} + +// MoveCursorLeft will decrease the current CursorPosition with 1 +func (i *Input) MoveCursorLeft() { + if i.CursorPosition > 0 { + i.CursorPosition-- + } +} + +// IsEmpty will return true when the input is empty +func (i *Input) IsEmpty() bool { + if i.Par.Text == "" { + return true + } + return false +} + +// Clear will empty the input and move the cursor to the start position +func (i *Input) Clear() { + i.Text = make([]rune, 0) + i.Par.Text = "" + i.CursorPosition = 0 +} + +// GetText returns the text currently in the input +func (i *Input) GetText() string { + return i.Par.Text +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c9500bb --- /dev/null +++ b/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/user" + "path" + + "github.com/gizak/termui" + termbox "github.com/nsf/termbox-go" +) + +const ( + VERSION = "v0.2.3" + USAGE = `NAME: + slack-term - slack client for your terminal + +USAGE: + slack-term -config [path-to-config] + +VERSION: + %s + +GLOBAL OPTIONS: + --help, -h +` +) + +var ( + flgConfig string + flgUsage bool +) + +func init() { + // Get home dir for config file default + usr, err := user.Current() + if err != nil { + log.Fatal(err) + } + + // Parse flags + flag.StringVar( + &flgConfig, + "config", + path.Join(usr.HomeDir, "slack-term.json"), + "location of config file", + ) + + flag.Usage = func() { + fmt.Printf(USAGE, VERSION) + } + + flag.Parse() +} + +func main() { + // Start terminal user interface + err := termui.Init() + if err != nil { + log.Fatal(err) + } + defer termui.Close() + + // Create custom event stream for termui because + // termui's one has data race conditions with its + // event handling. We're circumventing it here until + // it has been fixed. + customEvtStream := &termui.EvtStream{ + Handlers: make(map[string]func(termui.Event)), + } + termui.DefaultEvtStream = customEvtStream + + // Create context + ctx, err := CreateAppContext(flgConfig) + if err != nil { + termbox.Close() + log.Println(err) + os.Exit(0) + } + + // Setup body + termui.Body.AddRows( + termui.NewRow( + termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Channels), + termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Chat), + ), + termui.NewRow( + termui.NewCol(ctx.Config.SidebarWidth, 0, ctx.View.Mode), + termui.NewCol(ctx.Config.MainWidth, 0, ctx.View.Input), + ), + ) + termui.Body.Align() + termui.Render(termui.Body) + + // Set body in context + ctx.Body = termui.Body + + // Register handlers + RegisterEventHandlers(ctx) + + termui.Loop() +} diff --git a/mode.go b/mode.go new file mode 100644 index 0000000..1db51e9 --- /dev/null +++ b/mode.go @@ -0,0 +1,82 @@ +package main + +import "github.com/gizak/termui" + +// Mode is the definition of Mode component +type Mode struct { + Par *termui.Par +} + +// CreateMode is the constructor of the Mode struct +func CreateMode() *Mode { + mode := &Mode{ + Par: termui.NewPar("NORMAL"), + } + + mode.Par.Height = 3 + + return mode +} + +// Buffer implements interface termui.Bufferer +func (m *Mode) Buffer() termui.Buffer { + buf := m.Par.Buffer() + + // Center text + space := m.Par.InnerWidth() + word := len(m.Par.Text) + + midSpace := space / 2 + midWord := word / 2 + + start := midSpace - midWord + + cells := termui.DefaultTxBuilder.Build( + m.Par.Text, m.Par.TextFgColor, m.Par.TextBgColor) + + i, j := 0, 0 + x := m.Par.InnerBounds().Min.X + for x < m.Par.InnerBounds().Max.X { + if i < start { + buf.Set( + x, m.Par.InnerY(), + termui.Cell{ + Ch: ' ', + Fg: m.Par.TextFgColor, + Bg: m.Par.TextBgColor, + }, + ) + x++ + i++ + } else { + if j < len(cells) { + buf.Set(x, m.Par.InnerY(), cells[j]) + i++ + j++ + } + x++ + } + } + + return buf +} + +// GetHeight implements interface termui.GridBufferer +func (m *Mode) GetHeight() int { + return m.Par.Block.GetHeight() +} + +// SetWidth implements interface termui.GridBufferer +func (m *Mode) SetWidth(w int) { + m.Par.SetWidth(w) +} + +// SetX implements interface termui.GridBufferer +func (m *Mode) SetX(x int) { + m.Par.SetX(x) +} + +// SetY implements interface termui.GridBufferer +func (m *Mode) SetY(y int) { + m.Par.SetY(y) +} diff --git a/slack.go b/slack.go new file mode 100644 index 0000000..7270372 --- /dev/null +++ b/slack.go @@ -0,0 +1,469 @@ +package main + +import ( + "fmt" + "log" + "regexp" + "strconv" + "strings" + "time" + + "github.com/nlopes/slack" +) + +const ( + ChannelTypeChannel = "channel" + ChannelTypeGroup = "group" + ChannelTypeIM = "im" +) + +type SlackService struct { + Client *slack.Client + RTM *slack.RTM + SlackChannels []interface{} + Channels []Channel + UserCache map[string]string + CurrentUserID string +} + +type Channel struct { + ID string + Name string + Topic string + Type string + UserID string +} + +// NewSlackService is the constructor for the SlackService and will initialize +// the RTM and a Client +func NewSlackService(token string) *SlackService { + svc := &SlackService{ + Client: slack.New(token), + UserCache: make(map[string]string), + } + + // Get user associated with token, mainly + // used to identify user when new messages + // arrives + authTest, err := svc.Client.AuthTest() + if err != nil { + log.Fatal("ERROR: not able to authorize client, check your connection and/or slack-token") + } + svc.CurrentUserID = authTest.UserID + + // Create RTM + svc.RTM = svc.Client.NewRTM() + go svc.RTM.ManageConnection() + + // Creation of user cache this speeds up + // the uncovering of usernames of messages + users, _ := svc.Client.GetUsers() + for _, user := range users { + // only add non-deleted users + if !user.Deleted { + svc.UserCache[user.ID] = user.Name + } + } + + return svc +} + +// GetChannels will retrieve all available channels, groups, and im channels. +// Because the channels are of different types, we will append them to +// an []interface as well as to a []Channel which will give us easy access +// to the id and name of the Channel. +func (s *SlackService) GetChannels() []Channel { + var chans []Channel + + // Channel + slackChans, err := s.Client.GetChannels(true) + if err != nil { + chans = append(chans, Channel{}) + } + for _, chn := range slackChans { + s.SlackChannels = append(s.SlackChannels, chn) + chans = append( + chans, Channel{ + ID: chn.ID, + Name: chn.Name, + Topic: chn.Topic.Value, + Type: ChannelTypeChannel, + UserID: "", + }, + ) + } + + // Groups + slackGroups, err := s.Client.GetGroups(true) + if err != nil { + chans = append(chans, Channel{}) + } + for _, grp := range slackGroups { + s.SlackChannels = append(s.SlackChannels, grp) + chans = append( + chans, Channel{ + ID: grp.ID, + Name: grp.Name, + Topic: grp.Topic.Value, + Type: ChannelTypeGroup, + UserID: "", + }, + ) + } + + // IM + slackIM, err := s.Client.GetIMChannels() + if err != nil { + chans = append(chans, Channel{}) + } + for _, im := range slackIM { + + // Uncover name, when we can't uncover name for + // IM channel this is then probably a deleted + // user, because we won't add deleted users + // to the UserCache, so we skip it + name, ok := s.UserCache[im.User] + + if ok { + chans = append( + chans, + Channel{ + ID: im.ID, + Name: name, + Topic: "", + Type: ChannelTypeIM, + UserID: im.User, + }, + ) + s.SlackChannels = append(s.SlackChannels, im) + } + } + + s.Channels = chans + + return chans +} + +// GetUserPresence will get the presence of a specific user +func (s *SlackService) GetUserPresence(userID string) (string, error) { + presence, err := s.Client.GetUserPresence(userID) + if err != nil { + return "", err + } + + return presence.Presence, nil +} + +// SetChannelReadMark will set the read mark for a channel, group, and im +// channel based on the current time. +func (s *SlackService) SetChannelReadMark(channel interface{}) { + switch channel := channel.(type) { + case slack.Channel: + s.Client.SetChannelReadMark( + channel.ID, fmt.Sprintf("%f", + float64(time.Now().Unix())), + ) + case slack.Group: + s.Client.SetGroupReadMark( + channel.ID, fmt.Sprintf("%f", + float64(time.Now().Unix())), + ) + case slack.IM: + s.Client.MarkIMChannel( + channel.ID, fmt.Sprintf("%f", + float64(time.Now().Unix())), + ) + } +} + +// SendMessage will send a message to a particular channel +func (s *SlackService) SendMessage(channel string, message string) { + // https://godoc.org/github.com/nlopes/slack#PostMessageParameters + postParams := slack.PostMessageParameters{ + AsUser: true, + } + + // https://godoc.org/github.com/nlopes/slack#Client.PostMessage + s.Client.PostMessage(channel, message, postParams) +} + +// GetMessages will get messages for a channel, group or im channel delimited +// by a count. +func (s *SlackService) GetMessages(channel interface{}, count int) []string { + // https://api.slack.com/methods/channels.history + historyParams := slack.HistoryParameters{ + Count: count, + Inclusive: false, + Unreads: false, + } + + // https://godoc.org/github.com/nlopes/slack#History + history := new(slack.History) + var err error + switch chnType := channel.(type) { + case slack.Channel: + history, err = s.Client.GetChannelHistory(chnType.ID, historyParams) + if err != nil { + log.Fatal(err) // FIXME + } + case slack.Group: + history, err = s.Client.GetGroupHistory(chnType.ID, historyParams) + if err != nil { + log.Fatal(err) // FIXME + } + case slack.IM: + history, err = s.Client.GetIMHistory(chnType.ID, historyParams) + if err != nil { + log.Fatal(err) // FIXME + } + } + + // Construct the messages + var messages []string + for _, message := range history.Messages { + msg := s.CreateMessage(message) + messages = append(messages, msg...) + } + + // Reverse the order of the messages, we want the newest in + // the last place + var messagesReversed []string + for i := len(messages) - 1; i >= 0; i-- { + messagesReversed = append(messagesReversed, messages[i]) + } + + return messagesReversed +} + +// CreateMessage will create a string formatted message that can be rendered +// in the Chat pane. +// +// [23:59] Hello world! +// +// This returns an array of string because we will try to uncover attachments +// associated with messages. +func (s *SlackService) CreateMessage(message slack.Message) []string { + var msgs []string + var name string + + // Get username from cache + name, ok := s.UserCache[message.User] + + // Name not in cache + if !ok { + if message.BotID != "" { + // Name not found, perhaps a bot, use Username + name, ok = s.UserCache[message.BotID] + if !ok { + // Not found in cache, add it + name = message.Username + s.UserCache[message.BotID] = message.Username + } + } else { + // Not a bot, not in cache, get user info + user, err := s.Client.GetUserInfo(message.User) + if err != nil { + name = "unknown" + s.UserCache[message.User] = name + } else { + name = user.Name + s.UserCache[message.User] = user.Name + } + } + } + + if name == "" { + name = "unknown" + } + + // When there are attachments append them + if len(message.Attachments) > 0 { + msgs = append(msgs, createMessageFromAttachments(message.Attachments)...) + } + + // Parse time + floatTime, err := strconv.ParseFloat(message.Timestamp, 64) + if err != nil { + floatTime = 0.0 + } + intTime := int64(floatTime) + + // Format message + msg := fmt.Sprintf( + "[%s] <%s> %s", + time.Unix(intTime, 0).Format("15:04"), + name, + parseMessage(s, message.Text), + ) + + msgs = append(msgs, msg) + + return msgs +} + +func (s *SlackService) CreateMessageFromMessageEvent(message *slack.MessageEvent) []string { + + var msgs []string + var name string + + // Append (edited) when an edited message is received + if message.SubType == "message_changed" { + message = &slack.MessageEvent{Msg: *message.SubMessage} + message.Text = fmt.Sprintf("%s (edited)", message.Text) + } + + // Get username from cache + name, ok := s.UserCache[message.User] + + // Name not in cache + if !ok { + if message.BotID != "" { + // Name not found, perhaps a bot, use Username + name, ok = s.UserCache[message.BotID] + if !ok { + // Not found in cache, add it + name = message.Username + s.UserCache[message.BotID] = message.Username + } + } else { + // Not a bot, not in cache, get user info + user, err := s.Client.GetUserInfo(message.User) + if err != nil { + name = "unknown" + s.UserCache[message.User] = name + } else { + name = user.Name + s.UserCache[message.User] = user.Name + } + } + } + + if name == "" { + name = "unknown" + } + + // When there are attachments append them + if len(message.Attachments) > 0 { + msgs = append(msgs, createMessageFromAttachments(message.Attachments)...) + } + + // Parse time + floatTime, err := strconv.ParseFloat(message.Timestamp, 64) + if err != nil { + floatTime = 0.0 + } + intTime := int64(floatTime) + + // Format message + msg := fmt.Sprintf( + "[%s] <%s> %s", + time.Unix(intTime, 0).Format("15:04"), + name, + parseMessage(s, message.Text), + ) + + msgs = append(msgs, msg) + + return msgs +} + +// parseMessage will parse a message string and find and replace: +// - emoji's +// - mentions +func parseMessage(s *SlackService, msg string) string { + // NOTE: Commented out because rendering of the emoji's + // creates artifacts from the last view because of + // double width emoji's + // msg = parseEmoji(msg) + + msg = parseMentions(s, msg) + + return msg +} + +// parseMentions will try to find mention placeholders in the message +// string and replace them with the correct username with and @ symbol +// +// Mentions have the following format: +// <@U12345|erroneousboat> +// <@U12345> +func parseMentions(s *SlackService, msg string) string { + r := regexp.MustCompile(`\<@(\w+\|*\w+)\>`) + rs := r.FindStringSubmatch(msg) + if len(rs) < 1 { + return msg + } + + return r.ReplaceAllStringFunc( + msg, func(str string) string { + var userID string + split := strings.Split(rs[1], "|") + if len(split) > 0 { + userID = split[0] + } else { + userID = rs[1] + } + + name, ok := s.UserCache[userID] + if !ok { + user, err := s.Client.GetUserInfo(userID) + if err != nil { + name = "unknown" + s.UserCache[userID] = name + } else { + name = user.Name + s.UserCache[userID] = user.Name + } + } + + if name == "" { + name = "unknown" + } + + return "@" + name + }, + ) +} + +// parseEmoji will try to find emoji placeholders in the message +// string and replace them with the correct unicode equivalent +func parseEmoji(msg string) string { + r := regexp.MustCompile("(:\\w+:)") + + return r.ReplaceAllStringFunc( + msg, func(str string) string { + code, ok := EmojiCodemap[str] + if !ok { + return str + } + return code + }, + ) +} + +// createMessageFromAttachments will construct a array of string of the Field +// values of Attachments from a Message. +func createMessageFromAttachments(atts []slack.Attachment) []string { + var msgs []string + for _, att := range atts { + for i := len(att.Fields) - 1; i >= 0; i-- { + msgs = append(msgs, + fmt.Sprintf( + "%s %s", + att.Fields[i].Title, + att.Fields[i].Value, + ), + ) + } + + if att.Text != "" { + msgs = append(msgs, att.Text) + } + + if att.Title != "" { + msgs = append(msgs, att.Title) + } + } + + return msgs +} diff --git a/view_chat.go b/view_chat.go new file mode 100644 index 0000000..0e1bc7e --- /dev/null +++ b/view_chat.go @@ -0,0 +1,45 @@ +package main + +import ( + "github.com/gizak/termui" +) + +type View struct { + Input *Input + Chat *Chat + Channels *Channels + Mode *Mode +} + +func CreateChatView(svc *SlackService) *View { + input := CreateInput() + + channels := CreateChannels(svc, input.Par.Height) + + chat := CreateChat( + svc, + input.Par.Height, + svc.SlackChannels[channels.SelectedChannel], + svc.Channels[channels.SelectedChannel], + ) + + mode := CreateMode() + + view := &View{ + Input: input, + Channels: channels, + Chat: chat, + Mode: mode, + } + + return view +} + +func (v *View) Refresh() { + termui.Render( + v.Input, + v.Chat, + v.Channels, + v.Mode, + ) +}