├── .gitignore ├── README.md ├── cmd └── main.go ├── go.mod ├── go.sum └── internal ├── badge └── badge.go ├── color ├── pretty.go └── red.go ├── config └── config.go ├── detector └── detector.go ├── image └── image.go ├── msg └── msg.go ├── pipe ├── log.go ├── stdout.go └── terminal.go ├── provider ├── provider.go ├── twitch.go └── youtube.go └── sticker ├── betterttv.go ├── downloader.go ├── emote.go ├── sticker.go └── twitch.go /.gitignore: -------------------------------------------------------------------------------- 1 | pic/stickers/ 2 | pic/badges/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About project 2 | It's a terminal client to see your online stream chat. 3 | 4 | ## Demo 5 | ![cli-chat-demo2](https://user-images.githubusercontent.com/16855504/163690117-bc468238-7306-49e8-960e-d752f17a6e15.gif) 6 | 7 | ## Supported features 8 | - YouTube chat 9 | - Twitch chat 10 | - Keep chat logs 11 | - Multi-tty mode 12 | - Support twitch stikers 13 | 14 | ## How to use 15 | Clone this repo and run: 16 | ``` 17 | go run cmd/main.go 18 | --twitch 19 | --youtube 20 | --devices /dev/tty1,/dev/tty2 21 | --log /path/to/your/log/directory 22 | ``` 23 | 24 | You must have golang compiler for running 25 | 26 | ## TODO features 27 | - Fetch an active youtube live stream link by channel name (We probably can use [that](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts)) 28 | - Write to twitch 29 | - Write to youtube ([API](https://developers.google.com/youtube/v3/live/docs/liveChatMessages/insert)) 30 | - Should we use YouTube Live Streaming API for everything? 31 | - terminal ui 32 | - how to distribute app 33 | 34 | -------------------------------------------------------------------------------- /cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "cli-stream-chat/internal/config" 8 | "cli-stream-chat/internal/pipe" 9 | "cli-stream-chat/internal/provider" 10 | ) 11 | 12 | func main() { 13 | cfg := config.New() 14 | 15 | err := cfg.Valid() 16 | if err != nil { 17 | log.Fatalln(err.Error()) 18 | } 19 | 20 | s := provider.New() 21 | if cfg.Twitch != "" { 22 | s.AddProvider( 23 | provider.NewTwitchProvider(cfg.Twitch), 24 | ) 25 | } 26 | 27 | if cfg.YoutubeLink != "" { 28 | s.AddProvider( 29 | provider.NewYoutubeProvider(cfg.YoutubeLink), 30 | ) 31 | } 32 | 33 | s.AddPipe( 34 | &pipe.Stdout{}, 35 | ) 36 | 37 | if cfg.LogPath != "" { 38 | // TODO: err says: handle me 39 | file, _ := pipe.GetFile(cfg.LogPath) 40 | defer file.Close() 41 | 42 | log := pipe.NewLog(file) 43 | s.AddPipe(log) 44 | } 45 | 46 | for i := 0; i < len(cfg.Devices); i++ { 47 | s.AddPipe( 48 | &pipe.Device{Path: cfg.Devices[i]}, 49 | ) 50 | } 51 | 52 | ctx := context.Background() 53 | if err := s.Run(ctx); err != nil { 54 | log.Fatal(err) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module cli-stream-chat 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/abhinavxd/youtube-live-chat-downloader/v2 v2.0.1 // indirect 7 | github.com/gempir/go-twitch-irc/v3 v3.0.0 // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/abhinavxd/youtube-live-chat-downloader/v2 v2.0.1 h1:C2//y1SiHxWb4yxrxvdf+wPqdO3Rne9OOAiz/iRia3M= 2 | github.com/abhinavxd/youtube-live-chat-downloader/v2 v2.0.1/go.mod h1:TrUogg8mrebgMD/JU094CmSXn3yKrt+CZjiDL3YtmMw= 3 | github.com/gempir/go-twitch-irc/v3 v3.0.0 h1:e34R+9BdKy+qrO/wN+FCt+BUtyn38gCnJuKWscIKbl4= 4 | github.com/gempir/go-twitch-irc/v3 v3.0.0/go.mod h1:/W9KZIiyizVecp4PEb7kc4AlIyXKiCmvlXrzlpPUytU= 5 | -------------------------------------------------------------------------------- /internal/badge/badge.go: -------------------------------------------------------------------------------- 1 | /* 2 | Fetch badges 3 | https://badges.twitch.tv/v1/badges/global/display 4 | */ 5 | package badge 6 | 7 | import ( 8 | "cli-stream-chat/internal/image" 9 | "encoding/json" 10 | "sort" 11 | 12 | "io" 13 | "net/http" 14 | "os" 15 | "path/filepath" 16 | ) 17 | 18 | type Badge struct { 19 | Name string 20 | url string 21 | } 22 | 23 | func (b Badge) path() string { 24 | return filepath.Join(BadgePath, b.Name) 25 | } 26 | 27 | func (b Badge) download() error { 28 | resp, err := http.Get(b.url) 29 | if err != nil { 30 | return err 31 | } 32 | defer resp.Body.Close() 33 | f, err := os.Create(b.path()) 34 | defer f.Close() 35 | _, err = io.Copy(f, resp.Body) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | type BadgeList []Badge 43 | 44 | func (b BadgeList) Len() int { 45 | return len(b) 46 | } 47 | 48 | func (b BadgeList) Swap(i, j int) { 49 | b[i], b[j] = b[j], b[i] 50 | } 51 | 52 | func (b BadgeList) Less(i, j int) bool { 53 | return b[i].Name[0] < b[j].Name[0] 54 | } 55 | 56 | type Badges struct { 57 | data BadgeList 58 | } 59 | 60 | func (b *Badges) get() BadgeList { 61 | if len(b.data) > 0 { 62 | return b.data 63 | } 64 | badges := b.fetch() 65 | b.data = badges 66 | return b.fetch() 67 | } 68 | 69 | type BadgeResponseItem struct { 70 | ClickAction string `json:"click_action"` 71 | ClickURL string `json:"click_url"` 72 | Description string `json:"description"` 73 | ImageURL1x string `json:"image_url_1x"` 74 | ImageURL2x string `json:"image_url_2x"` 75 | ImageURL4x string `json:"image_url_4x"` 76 | LastUpdated interface{} `json:"last_updated"` 77 | Title string `json:"title"` 78 | } 79 | 80 | func (b *Badges) fetch() BadgeList { 81 | resp, err := http.Get("https://badges.twitch.tv/v1/badges/global/display") 82 | if err != nil { 83 | return BadgeList{} 84 | } 85 | defer resp.Body.Close() 86 | 87 | // TODO: we can use json.RawMessage here to make it prettier 88 | data := map[string]map[string]map[string]map[string]BadgeResponseItem{} 89 | err = json.NewDecoder(resp.Body).Decode(&data) 90 | if err != nil { 91 | return BadgeList{} 92 | } 93 | 94 | var badges BadgeList 95 | for key, value := range data["badge_sets"] { 96 | versions := value["versions"] 97 | one, exists := versions["1"] 98 | if !exists { 99 | continue 100 | } 101 | badges = append(badges, Badge{Name: key, url: one.ImageURL2x}) 102 | } 103 | sort.Sort(badges) 104 | return badges 105 | } 106 | 107 | var BadgePath = "./pic/badges" 108 | var supportedBadges = Badges{} 109 | 110 | func Show(badges map[string]int) string { 111 | var out string 112 | for _, badge := range supportedBadges.get() { 113 | if _, ok := badges[string(badge.Name)]; ok { 114 | err := badge.download() 115 | if err != nil { 116 | // TODO: do something 117 | continue 118 | } 119 | out = out + image.Build(badge.Name, badge.path(), 2) 120 | } 121 | } 122 | return out 123 | } 124 | -------------------------------------------------------------------------------- /internal/color/pretty.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "math/rand" 7 | "sync" 8 | ) 9 | 10 | type MakePretty struct { 11 | mem map[int]int 12 | mu sync.Mutex 13 | } 14 | 15 | func NewPretty() *MakePretty { 16 | return &MakePretty{mem: map[int]int{}} 17 | } 18 | 19 | func (c *MakePretty) Colorize(userId int, nickname string) string { 20 | userColor, err := c.getColor(userId) 21 | if err != nil { 22 | userColor = getRandomColor() 23 | c.setUserColor(userId, userColor) 24 | } 25 | return fmt.Sprintf("\033[1;%dm%s\033[0m", userColor, nickname) 26 | } 27 | 28 | func (c *MakePretty) setUserColor(userId int, color int) { 29 | c.mu.Lock() 30 | defer c.mu.Unlock() 31 | c.mem[userId] = color 32 | } 33 | 34 | func (c *MakePretty) getColor(userId int) (int, error) { 35 | c.mu.Lock() 36 | defer c.mu.Unlock() 37 | 38 | for k, v := range c.mem { 39 | if k == userId { 40 | return v, nil 41 | } 42 | } 43 | return 0, errors.New("empty color") 44 | } 45 | 46 | func getRandomColor() int { 47 | colors := getColors() 48 | i := rand.Intn(len(colors)) 49 | return colors[i] 50 | } 51 | 52 | func getColors() []int { 53 | var colors []int 54 | for i := 30; i < 38; i++ { 55 | colors = append(colors, i) 56 | } 57 | return colors 58 | } 59 | -------------------------------------------------------------------------------- /internal/color/red.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type MakeRed struct{} 8 | 9 | func (m MakeRed) Colorize(userId int, text string) string { 10 | return fmt.Sprintf("\033[1;31m%s\033[0m", m) 11 | } 12 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "strings" 7 | ) 8 | 9 | type Config struct { 10 | Twitch string 11 | YoutubeLink string 12 | LogPath string 13 | Devices []string 14 | } 15 | 16 | func (c *Config) Valid() error { 17 | // TODO: validate log path 18 | if c.Twitch == "" && c.YoutubeLink == "" { 19 | return errors.New("Setup at least one provider") 20 | } 21 | return nil 22 | } 23 | 24 | func New() Config { 25 | twitch := flag.String("twitch", "", "Twitch channel name") 26 | youtubeLink := flag.String("youtube", "", "Youtube stream link") 27 | logPath := flag.String("log", "", "Save stream log to file") 28 | devices := flag.String("devices", "", "List tty devices") 29 | flag.Parse() 30 | 31 | devs := []string{} 32 | if *devices != "" { 33 | devs = strings.Split(*devices, ",") 34 | } 35 | return Config{Twitch: *twitch, YoutubeLink: *youtubeLink, LogPath: *logPath, Devices: devs} 36 | } 37 | -------------------------------------------------------------------------------- /internal/detector/detector.go: -------------------------------------------------------------------------------- 1 | package detector 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | func IsKitty() bool { 8 | term := os.Getenv("TERM") 9 | return term == "xterm-kitty" 10 | } 11 | -------------------------------------------------------------------------------- /internal/image/image.go: -------------------------------------------------------------------------------- 1 | /* 2 | Kitty image protocol - https://sw.kovidgoyal.net/kitty/graphics-protocol/ 3 | */ 4 | 5 | package image 6 | 7 | import ( 8 | b64 "encoding/base64" 9 | "fmt" 10 | "os" 11 | ) 12 | 13 | const NullColumns int = 0 14 | 15 | func stringToBase64(content []byte) string { 16 | return b64.StdEncoding.EncodeToString(content) 17 | } 18 | 19 | func Build(name, path string, columns int) string { 20 | content, err := os.ReadFile(path) 21 | if err != nil { 22 | return name 23 | } 24 | 25 | var out string 26 | for { 27 | var chunk []byte 28 | var m string 29 | chunkSize := 4096 30 | 31 | if len(content) > chunkSize { 32 | chunk = content[:chunkSize] 33 | content = content[chunkSize:] 34 | m = "1" 35 | } else { 36 | chunk = content 37 | content = []byte{} 38 | m = "0" 39 | } 40 | 41 | // TODO: delete hardcode 42 | out = out + "\033_G" 43 | if columns == NullColumns { 44 | out = out + fmt.Sprintf("m=%s,a=T,f=100,r=1;", m) 45 | } else { 46 | out = out + fmt.Sprintf("m=%s,a=T,f=100,r=1,c=%d;", m, columns) 47 | } 48 | out = out + stringToBase64(chunk) 49 | out = out + "\033\\" 50 | 51 | if len(content) == 0 { 52 | break 53 | } 54 | } 55 | return out 56 | } 57 | -------------------------------------------------------------------------------- /internal/msg/msg.go: -------------------------------------------------------------------------------- 1 | package msg 2 | 3 | import ( 4 | "cli-stream-chat/internal/badge" 5 | "cli-stream-chat/internal/color" 6 | "cli-stream-chat/internal/sticker" 7 | "fmt" 8 | ) 9 | 10 | type Platform string 11 | 12 | type Colorizer interface { 13 | Colorize(int, string) string 14 | } 15 | 16 | const ( 17 | // TODO: why is it here? 18 | TwitchPlatform Platform = "TW" 19 | YoutubePlatform Platform = "YT" 20 | ) 21 | 22 | // TODO: create an interface and two implementations 23 | // TODO: for twitch and youtube 24 | type Message struct { 25 | UserId int 26 | Nickname string 27 | Text string 28 | Platform Platform 29 | Badges map[string]int 30 | BroadcasterId string 31 | // TODO: do not use own type for a slice there 32 | Emotes sticker.TwitchEmotes 33 | Colorizer Colorizer 34 | } 35 | 36 | func NewTwitch( 37 | userId int, 38 | nickname string, 39 | text string, 40 | badges map[string]int, 41 | boId string, 42 | emotes sticker.TwitchEmotes, 43 | colorizer Colorizer, 44 | ) *Message { 45 | return &Message{ 46 | UserId: userId, 47 | Nickname: nickname, 48 | Text: text, 49 | Platform: TwitchPlatform, 50 | Badges: badges, 51 | BroadcasterId: boId, 52 | Emotes: emotes, 53 | Colorizer: colorizer, 54 | } 55 | } 56 | 57 | func NewYoutube( 58 | nickname string, 59 | text string, 60 | ) *Message { 61 | return &Message{ 62 | Nickname: nickname, 63 | Text: text, 64 | Platform: YoutubePlatform, 65 | Colorizer: color.MakeRed{}, 66 | } 67 | } 68 | 69 | func (m *Message) FullText() string { 70 | return fmt.Sprintf(fmt.Sprintf("%s: %s", m.Nickname, m.Text)) 71 | } 72 | 73 | func (m *Message) PrettyText() string { 74 | text := sticker.FindAndReplace(m.Text, m.Emotes, m.BroadcasterId) 75 | badges := badge.Show(m.Badges) 76 | nickname := m.Colorizer.Colorize(m.UserId, m.Nickname) 77 | // TODO: maybe we need some space between badges and a nickname 78 | return fmt.Sprintf("%s%s: %s", badges, nickname, text) 79 | } 80 | -------------------------------------------------------------------------------- /internal/pipe/log.go: -------------------------------------------------------------------------------- 1 | package pipe 2 | 3 | import ( 4 | "cli-stream-chat/internal/msg" 5 | "fmt" 6 | "os" 7 | "time" 8 | ) 9 | 10 | type Log struct { 11 | f *os.File 12 | } 13 | 14 | func NewLog(f *os.File) *Log { 15 | return &Log{f: f} 16 | } 17 | 18 | func (s *Log) Write(m msg.Message) error { 19 | _, err := s.f.WriteString(m.FullText() + "\n") 20 | if err != nil { 21 | return fmt.Errorf("problem with write to file: %w", err) 22 | } 23 | return nil 24 | } 25 | 26 | // TODO: move it somewhere? 27 | func GetFile(path string) (*os.File, error) { 28 | fullPath := getFileName(path) 29 | file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 30 | if err != nil { 31 | return file, err 32 | } 33 | return file, nil 34 | } 35 | 36 | func getFileName(path string) string { 37 | year, month, day := time.Now().Date() 38 | name := fmt.Sprintf("%d-%d-%d.log", year, month, day) 39 | return path + "/" + name 40 | } 41 | -------------------------------------------------------------------------------- /internal/pipe/stdout.go: -------------------------------------------------------------------------------- 1 | package pipe 2 | 3 | import ( 4 | "cli-stream-chat/internal/msg" 5 | "fmt" 6 | ) 7 | 8 | type Stdout struct{} 9 | 10 | func (s *Stdout) Write(m msg.Message) error { 11 | fmt.Println(m.PrettyText()) 12 | return nil 13 | } 14 | -------------------------------------------------------------------------------- /internal/pipe/terminal.go: -------------------------------------------------------------------------------- 1 | package pipe 2 | 3 | import ( 4 | "cli-stream-chat/internal/msg" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | type Device struct { 10 | Path string 11 | } 12 | 13 | func (s *Device) Write(m msg.Message) error { 14 | device, err := os.OpenFile(s.Path, os.O_WRONLY, 0644) 15 | if err != nil { 16 | return fmt.Errorf("problem when open device: %w", err) 17 | } 18 | defer device.Close() 19 | _, err = device.WriteString(m.PrettyText() + "\n") 20 | if err != nil { 21 | return fmt.Errorf("problem with write to device: %w", err) 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /internal/provider/provider.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "cli-stream-chat/internal/msg" 5 | "context" 6 | ) 7 | 8 | type provider interface { 9 | Listen(context.Context, chan msg.Message) error 10 | } 11 | 12 | type pipe interface { 13 | Write(msg.Message) error 14 | } 15 | 16 | type Stream struct { 17 | providers []provider 18 | pipes []pipe 19 | } 20 | 21 | func New() *Stream { 22 | return &Stream{} 23 | } 24 | func (s *Stream) AddProvider(providers ...provider) { 25 | for _, p := range providers { 26 | s.providers = append(s.providers, p) 27 | } 28 | } 29 | 30 | func (s *Stream) GetProviders() []provider { 31 | return s.providers 32 | } 33 | 34 | func (s *Stream) AddPipe(pipes ...pipe) { 35 | for _, p := range pipes { 36 | s.pipes = append(s.pipes, p) 37 | } 38 | } 39 | 40 | func (s *Stream) Run(ctx context.Context) error { 41 | out := make(chan msg.Message) 42 | errChan := make(chan error) 43 | for _, p := range s.providers { 44 | go func(p provider) { 45 | if err := p.Listen(ctx, out); err != nil { 46 | errChan <- err 47 | } 48 | }(p) 49 | } 50 | 51 | go func() { 52 | for { 53 | select { 54 | case m := <-out: 55 | for _, p := range s.pipes { 56 | go func(p pipe) { 57 | if err := p.Write(m); err != nil { 58 | errChan <- err 59 | } 60 | }(p) 61 | } 62 | } 63 | } 64 | }() 65 | 66 | return <-errChan 67 | } 68 | -------------------------------------------------------------------------------- /internal/provider/twitch.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "cli-stream-chat/internal/color" 5 | "cli-stream-chat/internal/msg" 6 | "cli-stream-chat/internal/sticker" 7 | "context" 8 | "fmt" 9 | "strconv" 10 | 11 | "github.com/gempir/go-twitch-irc/v3" 12 | ) 13 | 14 | type Twitch struct { 15 | client *twitch.Client 16 | channel string // twitch channel name 17 | colorizer msg.Colorizer 18 | } 19 | 20 | func NewTwitchProvider(channel string) *Twitch { 21 | return &Twitch{ 22 | channel: channel, 23 | client: twitch.NewAnonymousClient(), 24 | colorizer: color.NewPretty(), 25 | } 26 | } 27 | 28 | func (t *Twitch) Listen(ctx context.Context, out chan msg.Message) error { 29 | t.client.OnPrivateMessage(func(message twitch.PrivateMessage) { 30 | // TODO: move it somewhere 31 | emotes := sticker.TwitchEmotes{} 32 | for i := 0; i < len(message.Emotes); i++ { 33 | e := message.Emotes[i] 34 | emotes = append(emotes, sticker.TwitchEmote{ID: e.ID, Name: e.Name}) 35 | } 36 | 37 | userId, _ := strconv.Atoi(message.User.ID) 38 | out <- *msg.NewTwitch( 39 | userId, 40 | message.User.DisplayName, 41 | message.Message, 42 | message.User.Badges, 43 | message.RoomID, 44 | emotes, 45 | t.colorizer, 46 | ) 47 | }) 48 | 49 | t.client.Join(t.channel) 50 | 51 | if err := t.client.Connect(); err != nil { 52 | return fmt.Errorf("twitch error: %w", err) 53 | } 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/provider/youtube.go: -------------------------------------------------------------------------------- 1 | package provider 2 | 3 | import ( 4 | "cli-stream-chat/internal/msg" 5 | "context" 6 | "fmt" 7 | 8 | yt "github.com/abhinavxd/youtube-live-chat-downloader/v2" 9 | ) 10 | 11 | type Youtube struct { 12 | stream string 13 | } 14 | 15 | func NewYoutubeProvider(stream string) *Youtube { 16 | return &Youtube{ 17 | stream: stream, 18 | } 19 | } 20 | 21 | func (y *Youtube) Listen(ctx context.Context, out chan msg.Message) error { 22 | continuation, cfg, err := yt.ParseInitialData(y.stream) 23 | if err != nil { 24 | return fmt.Errorf("youtube error: %w", err) 25 | } 26 | for { 27 | chat, newContinuation, error := yt.FetchContinuationChat(continuation, cfg) 28 | if error != nil { 29 | return fmt.Errorf("youtube error: %w", err) 30 | } 31 | continuation = newContinuation 32 | for _, m := range chat { 33 | out <- *msg.NewYoutube(m.AuthorName, m.Message) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/sticker/betterttv.go: -------------------------------------------------------------------------------- 1 | /* 2 | https://gist.github.com/chuckxD/377211b3dd3e8ca8dc505500938555eb 3 | 4 | fetch channel stickers 5 | https://api.betterttv.net/3/cached/users/twitch/571574557 6 | 7 | fetch global stickers 8 | https://api.betterttv.net/3/cached/emotes/global 9 | 10 | fetch sticker 11 | https://cdn.betterttv.net/emote/5a970ab2122e4331029f0d7e/3x 12 | */ 13 | package sticker 14 | 15 | import ( 16 | "encoding/json" 17 | "errors" 18 | "fmt" 19 | "net/http" 20 | "os" 21 | "path/filepath" 22 | ) 23 | 24 | type channelStickersResponse struct { 25 | Avatar string `json:"avatar"` 26 | Bots []interface{} `json:"bots"` 27 | ChannelEmotes []interface{} `json:"channelEmotes"` 28 | ID string `json:"id"` 29 | SharedEmotes []struct { 30 | Code string `json:"code"` 31 | ID string `json:"id"` 32 | ImageType string `json:"imageType"` 33 | User struct { 34 | DisplayName string `json:"displayName"` 35 | ID string `json:"id"` 36 | Name string `json:"name"` 37 | ProviderID string `json:"providerId"` 38 | } `json:"user"` 39 | } `json:"sharedEmotes"` 40 | } 41 | 42 | type globalStickersResponse []struct { 43 | Code string `json:"code"` 44 | ID string `json:"id"` 45 | ImageType string `json:"imageType"` 46 | UserID string `json:"userId"` 47 | } 48 | 49 | type StickersCache struct { 50 | stickers []BTTVEmote 51 | } 52 | 53 | var cache = StickersCache{[]BTTVEmote{}} 54 | 55 | type BTTVEmote struct { 56 | id string 57 | Code string 58 | Ext string 59 | } 60 | 61 | func (s BTTVEmote) name() string { 62 | return s.Code 63 | } 64 | 65 | func (s BTTVEmote) filename() string { 66 | return filepath.Join(StickersPath, s.Code+"."+s.Ext) 67 | } 68 | 69 | func (s BTTVEmote) path() string { 70 | return fmt.Sprintf("https://cdn.betterttv.net/emote/%s/2x", s.id) 71 | } 72 | 73 | func (s BTTVEmote) IsSupported() bool { 74 | supported := [1]string{"png"} 75 | for _, ext := range supported { 76 | if ext == s.Ext { 77 | return true 78 | } 79 | } 80 | return false 81 | } 82 | 83 | func (s BTTVEmote) CheckIfExists() error { 84 | _, err := os.ReadFile(s.filename()) 85 | if err != nil { 86 | if errors.Is(err, os.ErrNotExist) { 87 | err = Download(s) 88 | if err != nil { 89 | return err 90 | } 91 | return nil 92 | } 93 | return err 94 | } 95 | return nil 96 | } 97 | 98 | type BTTVEmotes struct { 99 | data []BTTVEmote 100 | } 101 | 102 | // TODO: quite the same code for fetch global and user's stickers 103 | func (b BTTVEmotes) getGlobal() []BTTVEmote { 104 | resp, err := http.Get("https://api.betterttv.net/3/cached/emotes/global") 105 | if err != nil { 106 | return []BTTVEmote{} 107 | } 108 | defer resp.Body.Close() 109 | var data globalStickersResponse 110 | err = json.NewDecoder(resp.Body).Decode(&data) 111 | if err != nil { 112 | return []BTTVEmote{} 113 | } 114 | var stickers []BTTVEmote 115 | for i := 0; i < len(data); i++ { 116 | s := data[i] 117 | stickers = append(stickers, BTTVEmote{s.ID, s.Code, s.ImageType}) 118 | } 119 | return stickers 120 | 121 | } 122 | 123 | func (b BTTVEmotes) getUser(userId string) []BTTVEmote { 124 | resp, err := http.Get(fmt.Sprintf("https://api.betterttv.net/3/cached/users/twitch/%s", userId)) 125 | if err != nil { 126 | return []BTTVEmote{} 127 | } 128 | defer resp.Body.Close() 129 | var data channelStickersResponse 130 | err = json.NewDecoder(resp.Body).Decode(&data) 131 | if err != nil { 132 | return []BTTVEmote{} 133 | } 134 | var stickers []BTTVEmote 135 | for i := 0; i < len(data.SharedEmotes); i++ { 136 | s := data.SharedEmotes[i] 137 | stickers = append(stickers, BTTVEmote{s.ID, s.Code, s.ImageType}) 138 | } 139 | return stickers 140 | } 141 | 142 | func (b BTTVEmotes) get(broadcasterId string) []BTTVEmote { 143 | if len(b.data) > 0 { 144 | return b.data 145 | } 146 | 147 | var emotes []BTTVEmote 148 | emotes = append(emotes, b.getGlobal()...) 149 | emotes = append(emotes, b.getUser(broadcasterId)...) 150 | b.data = emotes 151 | return emotes 152 | } 153 | 154 | var bttvEmotes = BTTVEmotes{} 155 | 156 | func GetBTTVStickers(broadcasterId string) []BTTVEmote { 157 | return bttvEmotes.get(broadcasterId) 158 | } 159 | -------------------------------------------------------------------------------- /internal/sticker/downloader.go: -------------------------------------------------------------------------------- 1 | package sticker 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | func Download(emote Emote) error { 10 | resp, err := http.Get(emote.path()) 11 | if err != nil { 12 | return err 13 | } 14 | defer resp.Body.Close() 15 | f, err := os.Create(emote.filename()) 16 | defer f.Close() 17 | _, err = io.Copy(f, resp.Body) 18 | if err != nil { 19 | return err 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /internal/sticker/emote.go: -------------------------------------------------------------------------------- 1 | package sticker 2 | 3 | type Emote interface { 4 | name() string 5 | path() string 6 | filename() string 7 | IsSupported() bool 8 | CheckIfExists() error 9 | } 10 | -------------------------------------------------------------------------------- /internal/sticker/sticker.go: -------------------------------------------------------------------------------- 1 | package sticker 2 | 3 | import ( 4 | "cli-stream-chat/internal/detector" 5 | "cli-stream-chat/internal/image" 6 | "strings" 7 | ) 8 | 9 | // TODO: do not use relative path 10 | var StickersPath = "./pic/stickers" 11 | 12 | func FindAndReplace(text string, twitchEmotes TwitchEmotes, broadcasterId string) string { 13 | if !detector.IsKitty() { 14 | return text 15 | } 16 | 17 | bttvEmotes := GetBTTVStickers(broadcasterId) 18 | var emotes []Emote 19 | // TODO: ... doesn't work 20 | for i := 0; i < len(twitchEmotes); i++ { 21 | emotes = append(emotes, twitchEmotes[i]) 22 | } 23 | for i := 0; i < len(bttvEmotes); i++ { 24 | emotes = append(emotes, bttvEmotes[i]) 25 | } 26 | 27 | words := strings.Split(text, " ") 28 | for i := 0; i < len(words); i++ { 29 | word := words[i] 30 | for _, emote := range emotes { 31 | if emote.name() != word { 32 | continue 33 | } 34 | if !emote.IsSupported() { 35 | continue 36 | } 37 | err := emote.CheckIfExists() 38 | if err != nil { 39 | // TODO: handle me 40 | continue 41 | } 42 | 43 | buildedSticker := buildKittySticker(emote.name(), emote.filename()) 44 | words[i] = buildedSticker 45 | } 46 | } 47 | return strings.Join(words, " ") 48 | } 49 | 50 | func buildKittySticker(name, fn string) string { 51 | return image.Build(name, fn, image.NullColumns) 52 | } 53 | -------------------------------------------------------------------------------- /internal/sticker/twitch.go: -------------------------------------------------------------------------------- 1 | /* 2 | Image URL 3 | https://static-cdn.jtvnw.net/emoticons/v2/196892/static/light/1.0 4 | */ 5 | 6 | package sticker 7 | 8 | import ( 9 | "errors" 10 | "fmt" 11 | "os" 12 | "path/filepath" 13 | ) 14 | 15 | type TwitchEmote struct { 16 | ID string 17 | Name string 18 | } 19 | 20 | func (e TwitchEmote) name() string { 21 | return e.Name 22 | } 23 | 24 | func (e TwitchEmote) filename() string { 25 | return filepath.Join(StickersPath, e.Name+".png") 26 | } 27 | 28 | func (e TwitchEmote) path() string { 29 | return fmt.Sprintf("https://static-cdn.jtvnw.net/emoticons/v2/%s/static/light/2.0", e.ID) 30 | } 31 | 32 | func (e TwitchEmote) IsSupported() bool { 33 | return true 34 | } 35 | 36 | func (e TwitchEmote) CheckIfExists() error { 37 | _, err := os.ReadFile(e.filename()) 38 | if err != nil { 39 | if errors.Is(err, os.ErrNotExist) { 40 | err = Download(e) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | return err 47 | } 48 | return nil 49 | } 50 | 51 | type TwitchEmotes []TwitchEmote 52 | --------------------------------------------------------------------------------