From d934b91188059d5fe7d521df542b39ef415b89cf Mon Sep 17 00:00:00 2001 From: Brian Buller Date: Tue, 26 Sep 2017 11:04:10 -0500 Subject: [PATCH] Initial Commit --- .gitignore | 2 + LICENSE | 21 + README.md | 139 ++++- channels.go | 352 ++++++++++++ chat.go | 250 +++++++++ config.go | 99 ++++ context.go | 45 ++ emoji.go | 1511 ++++++++++++++++++++++++++++++++++++++++++++++++++ event.go | 400 +++++++++++++ input.go | 135 +++++ main.go | 104 ++++ mode.go | 82 +++ slack.go | 469 ++++++++++++++++ view_chat.go | 45 ++ 14 files changed, 3653 insertions(+), 1 deletion(-) create mode 100644 LICENSE create mode 100644 channels.go create mode 100644 chat.go create mode 100644 config.go create mode 100644 context.go create mode 100644 emoji.go create mode 100644 event.go create mode 100644 input.go create mode 100644 main.go create mode 100644 mode.go create mode 100644 slack.go create mode 100644 view_chat.go 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, + ) +}