/* StreamGoose Kick backend — Go reference implementation Created by Luxferre in 2024, released into public domain */ package main import ( "fmt" "log" "slices" "strings" "regexp" "time" "encoding/json" "net/http" "io/ioutil" "github.com/DaRealFreak/cloudflare-bp-go" ) /* global API root URL */ const KK_API_ROOT = "https://kick.com/api" /* global debug flag */ var KK_DEBUG bool /* global useragent config */ var KK_USERAGENT string /* recent time cache */ var KK_RECENT_TIME string const KK_ID_CACHE_LIMIT = 1000 /* global message ID cache */ var kk_msg_id_cache []string /* API request helper function */ func kk_api_get(endpoint string) map[string]interface{} { url := KK_API_ROOT + endpoint /* get full URL */ req, err := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", KK_USERAGENT) req.Header.Set("Alt-Used", "kick.com") req.Header.Set("Accept", "application/json; charset=utf-8") req.Header.Set("Accept-Language", "en-US") req.Header.Set("Connection", "keep-alive") client := &http.Client{} client.Transport = cloudflarebp.AddCloudFlareByPass(client.Transport) resp, err := client.Do(req) if err != nil { log.Fatal(err) } body, _ := ioutil.ReadAll(resp.Body) resp.Body.Close() /* print the response if the debug flag is set */ if KK_DEBUG { log.Printf("[kick] API response: %s", string(body)) } /* unmarshal the response */ var respbody map[string]interface{} err = json.Unmarshal(body, &respbody) if err != nil { log.Fatal(err) } return respbody } /* emote handling function */ /* the format is [emote:ID:NAME] */ /* then the client will substitute this id with https://files.kick.com/emotes/ID/fullsize image */ func kk_handle_emotes(text string) string { mbracket := regexp.QuoteMeta("]") re := regexp.MustCompile(regexp.QuoteMeta("[emote:") + "[^:]+:[^" + mbracket + "]+" + mbracket) text = re.ReplaceAllStringFunc(text, func(emotestr string) string { parts := strings.Split(emotestr, ":") return " #KKEMOTE-" + parts[1] + "# " }) return text } /* main message procesing function */ func kk_process_message(rawmsg map[string]interface{}) { /* if the message came from a real user, this is the one we should process */ if rawmsg["type"] == "message" { if !slices.Contains(kk_msg_id_cache, rawmsg["id"].(string)) { /* we have a fresh message here */ var author map[string]interface{} /* check if the "sender" field is present */ if _, ok := rawmsg["sender"]; ok { author = rawmsg["sender"].(map[string]interface{}) } else { author["username"] = "System" } KK_RECENT_TIME = rawmsg["created_at"].(string) /* convert the timestamp */ sent_dt, _ := time.Parse("2006-01-02T15:04:05Z", KK_RECENT_TIME) /* update the recent time variable */ KK_RECENT_TIME = fmt.Sprintf("%d", sent_dt.Unix()) /* prepare output message */ out_message := ChatMessage { Type: "kk", NativeId: rawmsg["id"].(string), AuthorId: string(int64(author["id"].(float64))), Username: author["username"].(string), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Text: rawmsg["content"].(string), Icon: "", } /* get user metadata */ metadata := author["identity"].(map[string]interface{}) /* process username color preference */ if _, ok := metadata["color"]; ok && len(metadata["color"].(string)) > 0 { out_message.Type += "^color" + metadata["color"].(string) } /* process username badges */ if _, ok := metadata["badges"]; ok && len(metadata["badges"].([]interface{})) > 0 { for _, badge := range metadata["badges"].([]interface{}) { if badge.(map[string]interface{})["active"].(bool) { out_message.Type += "^" + strings.ToLower(badge.(map[string]interface{})["text"].(string)) } } } /* process emotes */ out_message.Text = kk_handle_emotes(out_message.Text) /* finally, send it to the message broker via the message_transport channel */ message_transport <- out_message /* update the ID cache */ kk_msg_id_cache = append(kk_msg_id_cache, rawmsg["id"].(string)) cachelen := len(kk_msg_id_cache) if cachelen > KK_ID_CACHE_LIMIT { /* trim the excess */ kk_msg_id_cache = kk_msg_id_cache[cachelen-KK_ID_CACHE_LIMIT:cachelen] } } } } /* entry point */ func kick_run(config map[string]interface{}) { if _, ok := config["debug"]; ok { KK_DEBUG = config["debug"].(bool) } if _, ok := config["useragent"]; ok { KK_USERAGENT = config["useragent"].(string) } API_POLL_TIMEOUT := 2 KK_RECENT_TIME = "" /* init the recent time */ if _, ok := config["kk_api_poll_timeout"]; ok { API_POLL_TIMEOUT = int(config["kk_api_poll_timeout"].(float64)) } /* get chat ID by channel name */ channel_id_endpoint := fmt.Sprintf("/v2/channels/%s", config["kk_channel"].(string)) if KK_DEBUG { log.Printf("[kick] querying %s", channel_id_endpoint) } channel_id_data := kk_api_get(channel_id_endpoint) channel_id := 0 if _, ok := channel_id_data["id"]; ok && channel_id_data["id"].(float64) > 0 { channel_id = int(channel_id_data["id"].(float64)) } if channel_id < 1 { /* the channel ID is still empty */ log.Fatal("[kick] Unknown error while getting channel ID!") } log.Println("StreamGoose Kick Go backend started") var raw_chat_data map[string]interface{} kk_msg_id_cache = append(kk_msg_id_cache, "") /* init the cache */ for { /* main loop */ chat_api_endpoint := fmt.Sprintf("/v2/channels/%d/messages?start_time=%s", channel_id, KK_RECENT_TIME) raw_chat_data = kk_api_get(chat_api_endpoint) if raw_chat_data["status"].(map[string]interface{})["error"].(bool) == false { msg_arr := raw_chat_data["data"].(map[string]interface{})["messages"].([]interface{}) for _, rawmsg := range msg_arr { /* iterate over raw message objects */ kk_process_message(rawmsg.(map[string]interface{})) } } time.Sleep(time.Duration(API_POLL_TIMEOUT) * time.Second) /* wait for specified amount before polling next */ } }