Twitch single-message deletion basic support

This commit is contained in:
Luxferre
2024-09-20 16:17:20 +03:00
parent 3d65d68d8a
commit 707b9e611a
7 changed files with 103 additions and 45 deletions
+6 -2
View File
@@ -59,9 +59,11 @@ An **internal** StreamGoose backend is a Go program embedded as a goroutine into
```go ```go
type ChatMessage struct { type ChatMessage struct {
Id uint64 `json:"id"` /* message id (auto-filled) */ 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 */ Type string `json:"type"` /* message type string */
Timestamp string `json:"timestamp"` /* timestamp string (formatted) */ 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 */ Text string `json:"text"` /* message text */
Icon string `json:"icon"` /* optional message icon URI */ 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): 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`. - `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. - `username`: the message author name that should be displayed in the chat.
- `text`: the message text that should be displayed in the chat. - `text`: the message text that should be displayed in the chat.
+2
View File
@@ -108,6 +108,8 @@ func kk_process_message(rawmsg map[string]interface{}) {
/* prepare output message */ /* prepare output message */
out_message := ChatMessage { out_message := ChatMessage {
Type: "kk", Type: "kk",
NativeId: rawmsg["id"].(string),
AuthorId: string(int64(author["id"].(float64))),
Username: author["username"].(string), Username: author["username"].(string),
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: rawmsg["content"].(string), Text: rawmsg["content"].(string),
+22 -1
View File
@@ -65,7 +65,26 @@ func oc_api_get(endpoint string) []interface{} {
func oc_process_message(rawmsg map[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 the message came from a real user, this is the one we should process */
if rawmsg["type"] == "CHAT" { 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{} var author map[string]interface{}
/* check if the "user" field is present */ /* check if the "user" field is present */
if _, ok := rawmsg["user"]; ok { if _, ok := rawmsg["user"]; ok {
@@ -80,6 +99,8 @@ func oc_process_message(rawmsg map[string]interface{}) {
/* prepare output message */ /* prepare output message */
out_message := ChatMessage { out_message := ChatMessage {
Type: "oc", Type: "oc",
NativeId: rawmsg["id"].(string),
AuthorId: author["id"].(string),
Username: author["displayName"].(string), Username: author["displayName"].(string),
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: rawmsg["body"].(string), Text: rawmsg["body"].(string),
+3 -1
View File
@@ -21,9 +21,11 @@ import (
/* main chat message structure */ /* main chat message structure */
type ChatMessage struct { type ChatMessage struct {
Id uint64 `json:"id"` /* message id (auto-filled) */ 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 */ Type string `json:"type"` /* message type string */
Timestamp string `json:"timestamp"` /* timestamp string (formatted) */ 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 */ Text string `json:"text"` /* message text */
Icon string `json:"icon"` /* optional message icon URI */ Icon string `json:"icon"` /* optional message icon URI */
} }
+26 -12
View File
@@ -150,22 +150,34 @@ func tw_handle_emotes(text string, emote_data string) string {
/* main message procesing function */ /* main message procesing function */
func tw_process_message(rawmsg string) { func tw_process_message(rawmsg string) {
/* if the message came from a real user, this is the one we should process */ parts := strings.Split(rawmsg, " :") /* get raw message parts */
if strings.HasPrefix(rawmsg, "@badge-info=") { /* first part is metadata, second is a full IRC username,
parts := strings.Split(rawmsg, " :") /* get raw message parts */ the rest is the message ending with \r\n */
/* first part is metadata, second is a full IRC username, metadata_str := strings.TrimSpace(parts[0])
the rest is the message ending with \r\n */ msg_text := ""
metadata_str := strings.TrimSpace(parts[0]) if(len(parts) > 2) {
msg_text := strings.Join(parts[2:], " :") msg_text = strings.Join(parts[2:], " :")
msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */ msg_text = strings.ReplaceAll(msg_text, "\u0000", "") /* remove excess null bytes */
}
metadata := make(map[string]string) /* store parsed metadata here */ metadata := make(map[string]string) /* store parsed metadata here */
parts = strings.Split(metadata_str, ";") parts = strings.Split(metadata_str, ";")
for _, field := range parts { /* iterate over metadata fields */ for _, field := range parts { /* iterate over metadata fields */
if strings.Contains(field, "=") {
fieldkey, fieldval, _ := strings.Cut(field, "=") fieldkey, fieldval, _ := strings.Cut(field, "=")
metadata[fieldkey] = fieldval 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 */ /* convert the timestamp */
ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0) ts, err := strconv.ParseInt(metadata["tmi-sent-ts"], 10, 0)
if err != nil { /* fallback to current time if anything */ if err != nil { /* fallback to current time if anything */
@@ -185,6 +197,8 @@ func tw_process_message(rawmsg string) {
/* prepare output message */ /* prepare output message */
out_message := ChatMessage { out_message := ChatMessage {
Type: "tw", Type: "tw",
NativeId: metadata["id"],
AuthorId: metadata["user-id"],
Username: metadata["display-name"], Username: metadata["display-name"],
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: strings.TrimSpace(msg_text), Text: strings.TrimSpace(msg_text),
+2
View File
@@ -124,6 +124,8 @@ func yt_inner_action_to_chatitem(action map[string]interface{}) ChatMessage {
/* prepare output message */ /* prepare output message */
out_message := ChatMessage { out_message := ChatMessage {
Type: "yt", Type: "yt",
NativeId: renderer["id"].(string),
AuthorId: renderer["authorExternalChannelId"].(string),
Username: author_name, Username: author_name,
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"), Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
Text: yt_inner_runs_to_text(mruns), Text: yt_inner_runs_to_text(mruns),
+42 -29
View File
@@ -39,40 +39,52 @@ function overrideStyles(text) {
} }
/* individual message appender */ /* 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 = "" var html = ""
if(parseInt(id) > 0 && type.length > 0) { if(parseInt(id) > 0 && type.length > 0) {
var classes = type.split('^'), cl = classes.length, j, if(type == "delmsg") { /* message deletion event */
colorpref = null, badges = [], platform, bname var el = document.querySelector('[data-native-id="' + esc(native_id) + '"]')
for(j=0;j<cl;j++) { /* detect color preference */ if(el) el.parentNode.removeChild(el)
if(classes[j].startsWith('color')) } else if(type == "deluser") { /* bulk deletion event by username */
colorpref = classes[j].slice(5) /* remove color word */ var els = document.querySelector('[data-author-id="' + esc(author_id) + '"]')
if(classes[j].startsWith('badge')) { /* remove the class but cache it */ if(els && els.length > 0) {
badges.push(classes[j].slice(5)) /* remove badge word */ var i, l = els.length
classes[j] = "" /* remove the class from the list */ for(i=0;i<l;i++) els[i].parentNode.removeChild(els[i])
} }
} else { /* message appending event */
var classes = type.split('^'), cl = classes.length, j,
colorpref = null, badges = [], platform, bname
for(j=0;j<cl;j++) { /* detect color preference */
if(classes[j].startsWith('color'))
colorpref = classes[j].slice(5) /* remove color word */
if(classes[j].startsWith('badge')) { /* remove the class but cache it */
badges.push(classes[j].slice(5)) /* remove badge word */
classes[j] = "" /* remove the class from the list */
}
}
html += '<div data-msg-id="' + id + '" data-native-id="' + esc(native_id) + '" data-author-id="'
+ esc(author_id) + '" class="' + classes.join(' ') +'">'
/* handle badges and icons */
html += '<span class=icon>'
for(j=0,cl=badges.length;j<cl;j++) { /* iterate over badges */
platform = badges[j].slice(0,2) /* first 2 characters denote the platform */
bname = badges[j].slice(2) /* the rest is the badge name itself */
html += '<img class=badge src="/badge/{platform}/{badgename}">'
.replace('{platform}', platform).replace('{badgename}', bname)
}
if(icon.length > 0) /* user icon, if present */
html += '<img src="' + icon + '">'
html += '</span>'
html += '<span class=timestamp>' + timestamp + '</span>'
html += '<span class=username'
if(colorpref) html += ' style="color:' + colorpref + '"'
html += '>' + username + '</span>'
html += '<span class=msg' + overrideStyles(text) +'>' + handleEmotes(text) + '</span></div>'
document.getElementById("msglist").innerHTML += html
} }
html += '<div data-msg-id="' + id + '" class="' + classes.join(' ') +'">'
/* handle badges and icons */
html += '<span class=icon>'
for(j=0,cl=badges.length;j<cl;j++) { /* iterate over badges */
platform = badges[j].slice(0,2) /* first 2 characters denote the platform */
bname = badges[j].slice(2) /* the rest is the badge name itself */
html += '<img class=badge src="/badge/{platform}/{badgename}">'
.replace('{platform}', platform).replace('{badgename}', bname)
}
if(icon.length > 0) /* user icon, if present */
html += '<img src="' + icon + '">'
html += '</span>'
html += '<span class=timestamp>' + timestamp + '</span>'
html += '<span class=username'
if(colorpref) html += ' style="color:' + colorpref + '"'
html += '>' + username + '</span>'
html += '<span class=msg' + overrideStyles(text) +'>' + handleEmotes(text) + '</span></div>'
} }
document.getElementById("msglist").innerHTML += html
/* autoscroll to the end */ /* 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 */ /* entry point, set up an event source */
@@ -80,6 +92,7 @@ window.addEventListener('DOMContentLoaded', function() {
var msgSource = new EventSource('/sse') var msgSource = new EventSource('/sse')
msgSource.onmessage = e => { msgSource.onmessage = e => {
var msg = JSON.parse(e.data) 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) }, false)