From 707b9e611a724efc2a53d121063ecd95e1dd6dc4 Mon Sep 17 00:00:00 2001 From: Luxferre Date: Fri, 20 Sep 2024 16:17:20 +0300 Subject: [PATCH] Twitch single-message deletion basic support --- README.md | 8 +++-- src/kick.go | 2 ++ src/owncast.go | 23 ++++++++++++- src/server.go | 4 ++- src/twitch.go | 38 ++++++++++++++------- src/youtube-innertube.go | 2 ++ webroot/script.js | 71 ++++++++++++++++++++++++---------------- 7 files changed, 103 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 3c33b8b..b593c76 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ An **internal** StreamGoose backend is a Go program embedded as a goroutine into ```go type ChatMessage struct { Id uint64 `json:"id"` /* message id (auto-filled) */ + NativeId string `json:"native_id"` /* message id native to the platform */ + AuthorId string `json:"author_id"` /* author id native to the platform */ Type string `json:"type"` /* message type string */ Timestamp string `json:"timestamp"` /* timestamp string (formatted) */ - Username string `json:"username"` /* username */ + Username string `json:"username"` /* author username (as displayed) */ Text string `json:"text"` /* message text */ Icon string `json:"icon"` /* optional message icon URI */ } @@ -71,7 +73,9 @@ The `Id` field is populated automatically whenever a message enters the broker. An **external** StreamGoose backend is a program (written in any network-enabled programming language) that can make a POST request to the corresponding message broker URL (`http://[broker_host]:60053/new` endpoint). The request body shall be a JSON object with the following fields (all passed as strings): -- `type`: message type, consisting of `^`-delimited classes (e.g. `yt^moderator` or `tw^vip^color#A1B2C3` etc, see below in the "Customization" section of this README). This field defines how the message will be styled when being rendered on the frontend. +- `native_id`: an ID string assigned to the message by the streaming platform itself (Twitch, YouTube etc). Used to further handling deleted messages. +- `author_id`: an ID string assigned to the message author by the streaming platform itself. Used to further handle bans and subsequent message deletions. +- `type`: message type, consisting of `^`-delimited classes (e.g. `yt^moderator` or `tw^vip^color#A1B2C3` etc, see below in the "Customization" section of this README). This field defines how the message will be styled when being rendered on the frontend. There are two special message types, `delmsg` and `deluser`, that require `native_id` and `author_id` fields respectively to be set in order to perform message deletion on the frontend. - `timestamp`: an ISO 8601 **UTC** timestamp string denoting when the message was sent. The only recommended format (in strftime syntax) is `%Y-%m-%dT%H:%M:%SZ`. - `username`: the message author name that should be displayed in the chat. - `text`: the message text that should be displayed in the chat. diff --git a/src/kick.go b/src/kick.go index 18fae8f..0537964 100644 --- a/src/kick.go +++ b/src/kick.go @@ -108,6 +108,8 @@ func kk_process_message(rawmsg map[string]interface{}) { /* 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), diff --git a/src/owncast.go b/src/owncast.go index a389d31..62ddb97 100644 --- a/src/owncast.go +++ b/src/owncast.go @@ -65,7 +65,26 @@ func oc_api_get(endpoint string) []interface{} { func oc_process_message(rawmsg map[string]interface{}) { /* if the message came from a real user, this is the one we should process */ if rawmsg["type"] == "CHAT" { - if !slices.Contains(oc_msg_id_cache, rawmsg["id"].(string)) { /* we have a fresh message here */ + /* first, check if we need to include this message at all */ + msg_hidden := false + user_banned := false + if _, ok := rawmsg["hiddenAt"]; ok && rawmsg["hiddenAt"].(string) != "" { + msg_hidden = true + } + if _, ok := rawmsg["user"]; ok { + if _, ok := rawmsg["user"].(map[string]interface{})["disabledAt"]; ok { + if rawmsg["user"].(map[string]interface{})["disabledAt"].(string) != "" { + user_banned = true + } + } + } + msg_included := slices.Contains(oc_msg_id_cache, rawmsg["id"].(string)) + if msg_hidden || user_banned { /* don't include the message, check if it already is included */ + if msg_included { /* delete this message from the cache */ + msg_idx := slices.Index(oc_msg_id_cache, rawmsg["id"].(string)) + oc_msg_id_cache = slices.Delete(oc_msg_id_cache, msg_idx, msg_idx+1) + } + } else if !msg_included { /* we have a fresh message here */ var author map[string]interface{} /* check if the "user" field is present */ if _, ok := rawmsg["user"]; ok { @@ -80,6 +99,8 @@ func oc_process_message(rawmsg map[string]interface{}) { /* prepare output message */ out_message := ChatMessage { Type: "oc", + NativeId: rawmsg["id"].(string), + AuthorId: author["id"].(string), Username: author["displayName"].(string), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Text: rawmsg["body"].(string), diff --git a/src/server.go b/src/server.go index 6ba29e2..3597fc5 100644 --- a/src/server.go +++ b/src/server.go @@ -21,9 +21,11 @@ import ( /* main chat message structure */ type ChatMessage struct { Id uint64 `json:"id"` /* message id (auto-filled) */ + NativeId string `json:"native_id"` /* message id native to the platform */ + AuthorId string `json:"author_id"` /* author id native to the platform */ Type string `json:"type"` /* message type string */ Timestamp string `json:"timestamp"` /* timestamp string (formatted) */ - Username string `json:"username"` /* username */ + Username string `json:"username"` /* author username (as displayed) */ Text string `json:"text"` /* message text */ Icon string `json:"icon"` /* optional message icon URI */ } diff --git a/src/twitch.go b/src/twitch.go index 33b155a..48b086c 100644 --- a/src/twitch.go +++ b/src/twitch.go @@ -150,22 +150,34 @@ func tw_handle_emotes(text string, emote_data string) string { /* 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:], " :") + 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 := "" + if(len(parts) > 2) { + 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 */ + } + metadata := make(map[string]string) /* store parsed metadata here */ + parts = strings.Split(metadata_str, ";") + for _, field := range parts { /* iterate over metadata fields */ + if strings.Contains(field, "=") { fieldkey, fieldval, _ := strings.Cut(field, "=") metadata[fieldkey] = fieldval } - + } + /* if the message came from a real user, this is the one we should process */ + /* but first, check if this is a single-message deletion event */ + if strings.HasPrefix(rawmsg, "@login=") && strings.Contains(rawmsg, " CLEARMSG ") { + /* prepare output message */ + out_message := ChatMessage { + Type: "delmsg", + NativeId: metadata["target-msg-id"], + } + /* send it to the message broker via the message_transport channel */ + message_transport <- out_message + } else if strings.HasPrefix(rawmsg, "@badge-info=") { /* normal message or usernotice */ /* convert the timestamp */ ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0) if err != nil { /* fallback to current time if anything */ @@ -185,6 +197,8 @@ func tw_process_message(rawmsg string) { /* prepare output message */ out_message := ChatMessage { Type: "tw", + NativeId: metadata["id"], + AuthorId: metadata["user-id"], Username: metadata["display-name"], Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Text: strings.TrimSpace(msg_text), diff --git a/src/youtube-innertube.go b/src/youtube-innertube.go index 5bbdd75..18e8509 100644 --- a/src/youtube-innertube.go +++ b/src/youtube-innertube.go @@ -124,6 +124,8 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage { /* prepare output message */ out_message := ChatMessage { Type: "yt", + NativeId: renderer["id"].(string), + AuthorId: renderer["authorExternalChannelId"].(string), Username: author_name, Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Text: yt_inner_runs_to_text(mruns), diff --git a/webroot/script.js b/webroot/script.js index d70679c..db097ca 100644 --- a/webroot/script.js +++ b/webroot/script.js @@ -39,40 +39,52 @@ function overrideStyles(text) { } /* individual message appender */ -function gooseAppendMessage(id, type, timestamp, username, text, icon) { +function gooseAppendMessage(id, native_id, author_id, type, timestamp, username, text, icon) { var html = "" if(parseInt(id) > 0 && type.length > 0) { - var classes = type.split('^'), cl = classes.length, j, - colorpref = null, badges = [], platform, bname - for(j=0;j 0) { + var i, l = els.length + for(i=0;i' + /* handle badges and icons */ + html += '' + for(j=0,cl=badges.length;j 0) /* user icon, if present */ + html += '' + html += '' + html += '' + timestamp + '' + html += '' + html += '' + handleEmotes(text) + '' + document.getElementById("msglist").innerHTML += html } - html += '
' - /* handle badges and icons */ - html += '' - for(j=0,cl=badges.length;j 0) /* user icon, if present */ - html += '' - html += '' - html += '' + timestamp + '' - html += '' - html += '' + handleEmotes(text) + '
' } - document.getElementById("msglist").innerHTML += html /* autoscroll to the end */ - window.setTimeout(function(){window.scrollTo(0, document.body.scrollHeight)}, 100) + window.setTimeout(function(){window.scrollTo(0, document.body.scrollHeight)}, 150) } /* entry point, set up an event source */ @@ -80,6 +92,7 @@ window.addEventListener('DOMContentLoaded', function() { var msgSource = new EventSource('/sse') msgSource.onmessage = e => { var msg = JSON.parse(e.data) - gooseAppendMessage(msg.id, msg.type, msg.timestamp, msg.username, msg.text, msg.icon) + console.log("Message", msg) + gooseAppendMessage(msg.id, msg.native_id, msg.author_id, msg.type, msg.timestamp, msg.username, msg.text, msg.icon) } }, false)