Files
StreamGoose/src/twitch.go
T
2024-09-19 21:46:10 +03:00

192 lines
6.3 KiB
Go

/*
StreamGoose Twitch backend — Go reference implementation
Created by Luxferre in 2024, released into public domain
*/
package main
import (
"fmt"
"log"
"strings"
"strconv"
"time"
"net"
"io/ioutil"
"path/filepath"
"unicode/utf8"
)
/* helper function to write a string to the socket */
func tcpwritestr(conn net.Conn, str string) int {
i, err := conn.Write([]byte(str))
if err != nil {
log.Fatal("Error writing to socket: ", err)
}
return i
}
/* helper function to read a string from the socket */
func tcpreadstr(conn net.Conn) string {
reply := make([]byte, 2048)
_, err := conn.Read(reply)
if err != nil {
log.Fatal("Error reading from socket: ", err)
}
return string(reply)
}
/* emote handling function */
func tw_handle_emotes(text string, emote_data string) string {
replacements := make(map[string]string) /* future replacement string cache */
parts := strings.Split(emote_data, "/")
textlen := utf8.RuneCountInString(text)
for _, emotedesc := range parts { /* iterate over emote descriptors */
emote_id, emote_range, _ := strings.Cut(emotedesc, ":")
emote_ranges := strings.Split(emote_range, ",") /* there can be several ranges */
for _, r := range emote_ranges {
estart, eend, _ := strings.Cut(r, "-")
estart_int, _ := strconv.Atoi(estart)
eend_int, _ := strconv.Atoi(eend)
if estart_int < 0 {
estart_int = 0
}
if eend_int >= textlen {
eend_int = textlen - 1
}
/* use rune conversion for correct offsets */
replacements[string([]rune(text)[estart_int:eend_int + 1])] = "#TWEMOTE-" + emote_id + "#"
}
}
for from, to := range replacements { /* iterate over replacements */
text = strings.ReplaceAll(text, from, to)
}
return text
}
/* main message procesing function */
func tw_process_message(rawmsg string) {
/* if the message came from a real user, this is the one we should process */
if strings.HasPrefix(rawmsg, "@badge-info=") {
parts := strings.Split(rawmsg, " :") /* get raw message parts */
/* first part is metadata, second is a full IRC username,
the rest is the message ending with \r\n */
metadata_str := strings.TrimSpace(parts[0])
msg_text := strings.Join(parts[2:], " :")
msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */
metadata := make(map[string]string) /* store parsed metadata here */
parts = strings.Split(metadata_str, ";")
for _, field := range parts { /* iterate over metadata fields */
fieldkey, fieldval, _ := strings.Cut(field, "=")
metadata[fieldkey] = fieldval
}
/* convert the timestamp */
ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0)
if err != nil { /* fallback to current time if anything */
ts = time.Now().UnixMilli()
}
sent_dt := time.UnixMilli(ts)
/* process Twitch emotes before any positional changes can occur */
if _, ok := metadata["emotes"]; ok && len(metadata["emotes"]) > 0 {
msg_text = tw_handle_emotes(msg_text, metadata["emotes"])
}
/* sanitize \x01ACTION... \x01 in the text*/
msg_text = strings.ReplaceAll(msg_text, "\u0001ACTION", "/me")
msg_text = strings.ReplaceAll(msg_text, "\u0001", "")
/* prepare output message */
out_message := ChatMessage {
Type: "tw",
Username: metadata["display-name"],
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: strings.TrimSpace(msg_text),
Icon: "",
}
/* process username badges */
if _, ok := metadata["badges"]; ok {
badges := strings.Split(metadata["badges"], ",") /* they are comma-delimited */
if len(badges) > 0 { /* process badge names */
for i, badge := range badges { /* extract the badge name */
bname, _, _ := strings.Cut(badge, "/")
badges[i] = bname /* reassign */
}
out_message.Type += "-" + strings.Join(badges, "-")
}
}
/* process username color preference */
if _, ok := metadata["color"]; ok && len(metadata["color"]) > 0 {
out_message.Type += "-color" + metadata["color"]
}
/* process third-party action */
if strings.HasPrefix(out_message.Text, "/me") {
out_message.Type += "-me"
out_message.Text = strings.TrimSpace(out_message.Text[4:])
}
/* process system messages (e.g. in USERNOTICE event) */
if _, ok := metadata["system-msg"]; ok && len(metadata["system-msg"]) > 0 {
sysmsg := strings.ReplaceAll(metadata["system-msg"], "\\s", " ")
oldtext := out_message.Text
out_message.Text = strings.Join(strings.Fields(sysmsg), " ")
if len(oldtext) > 0 {
out_message.Text += ": " + oldtext
}
}
/* finally, send it to the message broker via the message_transport channel */
message_transport <- out_message
}
}
/* entry point */
func twitch_run(config map[string]interface{}) {
token_path := filepath.Join(APP_ROOT_DIR, "auth", "twitch-token.txt")
token, err := ioutil.ReadFile(token_path)
token_err_msg := "[twitch] No Twitch token file provided or it is empty. "
token_err_msg += "Please visit https://twitchapps.com/tmi/, generate a token there "
token_err_msg += "and paste it into the " + token_path +" file!"
if err != nil {
log.Fatal(token_err_msg + "\nDetailed error: ", err)
}
config["tw_token"] = strings.TrimSpace(string(token))
if len(config["tw_token"].(string)) < 1 {
log.Fatal(token_err_msg)
}
/* Connect to the Twitch IRC server */
conn, err := net.Dial("tcp", "irc.chat.twitch.tv:6667")
if err != nil {
log.Fatal("[twitch] Error connecting to Twitch: ", err)
}
tcpwritestr(conn, fmt.Sprintf("PASS %s\n", config["tw_token"]))
tcpwritestr(conn, fmt.Sprintf("NICK %s\n", config["tw_nickname"]))
tcpwritestr(conn, fmt.Sprintf("JOIN %s\n", config["tw_channel"]))
/* request advanced chat message capabilities */
tcpwritestr(conn, "CAP REQ :twitch.tv/membership twitch.tv/tags twitch.tv/commands\n")
log.Println("StreamGoose Twitch Go backend connected")
var rawmsg string
for { /* main loop */
rawmsg = tcpreadstr(conn)
if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
log.Println(rawmsg)
}
if strings.HasPrefix(rawmsg, "PING") { /* respond to the PING message */
tcpwritestr(conn, "PONG\n")
} else { /* run main procesing */
tw_process_message(rawmsg)
}
}
}