initial upload
@@ -0,0 +1,19 @@
|
||||
# main StreamGoose build flow
|
||||
|
||||
SRC = src
|
||||
DIST = out
|
||||
|
||||
.PHONY: all test clean
|
||||
|
||||
all:
|
||||
$(MAKE) -C $(SRC)
|
||||
mkdir -p $(DIST)
|
||||
mv $(SRC)/streamgoose $(DIST)/
|
||||
cp -r webroot $(DIST)/
|
||||
cp -r auth $(DIST)/
|
||||
cp -r external $(DIST)/
|
||||
cp config.json $(DIST)/
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# StreamGoose: an open-source livestreaming chat aggregator and widget helper
|
||||
|
||||
StreamGoose (aka StreamGoOSE, Streamer's Go-based Open Source Enhancer) is an open tool to solve the problem of aggregating several live chats to display on a livestream.
|
||||
|
||||
As of now, most of the StreamGoose codebase is written in Go 1.23.1. The Web part is written in HTML5, CSS3 and JavaScript using the ES2015 standard.
|
||||
|
||||
## Current features
|
||||
|
||||
The status of StreamGoose is currently **alpha**. Use at your own risk.
|
||||
|
||||
Nevertheless, the following features are already implemented:
|
||||
|
||||
- Twitch and YouTube chat aggregation (see "Backend implementation status" section)
|
||||
- Handling Twitch emotes
|
||||
- Handling YouTube non-Unicode emotes (see "Backend implementation status" section)
|
||||
- Message styling based on Twitch and YouTube roles
|
||||
- Twitch username styling based on color preference
|
||||
- Message highlighting based on regular expressions
|
||||
- Third-party action (`/me`) command handling
|
||||
- YouTube avatar icon support
|
||||
- Twitch system message support (needs more testing)
|
||||
- [Noto Color Emoji](https://fonts.google.com/noto/specimen/Noto+Color+Emoji) font bundled for universal Unicode emoji support
|
||||
|
||||
## Planned features
|
||||
|
||||
- Moving to OAuth 2.0 for YouTube
|
||||
- "Deleted message" support for YouTube
|
||||
- Automated Twitch chat badges output (global and channel-specific)
|
||||
- Internal chat ranking system by username
|
||||
- Backend-independent support for oldschool plaintext emoticons ;-)
|
||||
- Starting window geometry customization via config (now doable via custom JS)
|
||||
- ...
|
||||
|
||||
## Architecture
|
||||
|
||||
StreamGoose consists of three components: a set of backends, a message broker server and a Web-based frontend.
|
||||
|
||||
### Message broker server
|
||||
|
||||
This is the main StreamGoose component. It provides three main functions:
|
||||
|
||||
- accepting new messages from backends via external (`POST /new`) and internal (`message_transport` Go channel) ways;
|
||||
- providing the unified message queue for the frontend side consumption;
|
||||
- serving static files that make up the frontend (`index.html`, `script.js`, `style.css` and images) over the plain HTTP protocol.
|
||||
|
||||
Additionally, the server binary also launches the backends it knows about and opens a simple GUI window to show the Web content locally, feeding its unified output directly into the JS side. The latter part became possible thanks to the [webview_go](https://pkg.go.dev/centrifuge.hectabit.org/HectaBit/webview_go) library.
|
||||
|
||||
### Backends
|
||||
|
||||
StreamGoose can operate with two kinds of backends: internal and external.
|
||||
|
||||
An **internal** StreamGoose backend is a Go program embedded as a goroutine into the main binary. It communicates with the message broker by sending messages to the `message_transport` Go channel. The messages are defined using this Go structure:
|
||||
|
||||
```go
|
||||
type ChatMessage struct {
|
||||
Id uint64 `json:"id"` /* message id (auto-filled) */
|
||||
Type string `json:"type"` /* message type string */
|
||||
Timestamp string `json:"timestamp"` /* timestamp string (formatted) */
|
||||
Username string `json:"username"` /* username */
|
||||
Text string `json:"text"` /* message text */
|
||||
Icon string `json:"icon"` /* optional message icon URI */
|
||||
}
|
||||
```
|
||||
|
||||
The `Id` field is populated automatically whenever a message enters the broker. For others, see below for the detailed description of the corresponding JSON fields in the external backends.
|
||||
|
||||
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 hyphenated 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.
|
||||
- `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.
|
||||
- `icon`: an optional icon URL passed to the chat if the message author has a personal user profile picture (avatar). This field can be left empty but should be included in the request anyway.
|
||||
|
||||
Whenever a new JSON message is shaped with the fields above, it shall be sent to the broker at the `/new` endpoint with the HTTP POST method. A `{"status":1}` response will be sent back if the request is successful. The message
|
||||
|
||||
As of now, StreamGoose provides two reference backends: for Twitch and for YouTube, both internal and written in Go. See "Backend implementation status" section for current notes and caveats.
|
||||
|
||||
### Frontend
|
||||
|
||||
The StreamGoose frontend is a Web-based (HTML5) application served by the message broker server. It is opened in the built-in WebView-based GUI and provides a constantly updated aggregated live chat display, message type dependent styling, emote handling, custom highlighting and so on. The users are free and even encouraged to modify the web assets located in the `/webroot` directory to fit the chat to their own visual preferences.
|
||||
|
||||
## Building and installation
|
||||
|
||||
### Linux
|
||||
|
||||
You need to have Go 1.23.1 and above, a POSIX-compatible "make" as well as the `webkit2gtk-4.1` dev package.
|
||||
|
||||
Just run `make` and get all the resulting files in the `out` directory.
|
||||
|
||||
### macOS
|
||||
|
||||
TBD
|
||||
|
||||
### Windows
|
||||
|
||||
TBD
|
||||
|
||||
## Usage
|
||||
|
||||
Enter the distribution directory and run the `./streamgoose` or `streamgoose.exe` binary. It should run with the default `config.json` file, start the server, spawn the internal backends and open the UI window. Alternatively, you can pass another JSON file as the first command line parameter.
|
||||
|
||||
### Configuration
|
||||
|
||||
StreamGoose is mostly configured through the `config.json` file distributed in the same directory as the main binary.
|
||||
|
||||
The following fields in the `config.json` are backend-agnostic:
|
||||
|
||||
- `server_port`: a local port to run the HTTP REST server on. Default value: `60053` (for "GOOSE").
|
||||
- `server_listen_ip`: a local IP address to bind the server to. Default value: `127.0.0.1`. Recommended to leave at this value, only change it if you plan on connecting external backends from other machines in your LAN.
|
||||
- `debug`: print out various debug information about incoming messages and events. Can be `true` or `false`.
|
||||
|
||||
The following fields in the `config.json` are specific to the Twitch internal backend:
|
||||
|
||||
- `tw_backend_enabled`: whether or not the Twitch internal backend is turned on.
|
||||
- `tw_nickname`: **your own** Twitch nickname. You need to use the same nickname as the one you used for obtaining the token (see ("Connecting Twitch internal backend" section below).
|
||||
- `tw_channel`: the Twitch channel that you want to receive messages from (needs to start with `#`).
|
||||
|
||||
The following fields in the `config.json` are specific to the YouTube internal backend:
|
||||
|
||||
- `yt_backend_enabled`: whether or not the YouTube internal backend is turned on.
|
||||
- `yt_api_root_url`: the API proxy root URL. Recommended to leave at `https://yt.lemnoslife.com/noKey` for now. This is a temporary field, it is subject to removal once the backend switches to using OAuth 2.0.
|
||||
- `yt_api_poll_timeout`: the interval (in seconds) to poll the YouTube chat with. For now, it's recommended to set to at least 3 seconds to not overload the API proxy.
|
||||
- `yt_channel`: the YouTube channel that you want to receive messages from (needs to start with `@`). If only this field is specified and the channel has several live broadcasts running, the backend will fetch the first broadcast from the list.
|
||||
- `yt_video_id`: an optional field that allows you to pass a particular broadcast video ID to get the live chat messages from. Overrides the `yt_channel` field.
|
||||
|
||||
Example `config.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_port": 60053,
|
||||
"server_listen_ip": "127.0.0.1",
|
||||
"debug": true,
|
||||
"tw_backend_enabled": true,
|
||||
"tw_nickname": "Suborg",
|
||||
"tw_channel": "#foxyshadow",
|
||||
"yt_backend_enabled": true,
|
||||
"yt_api_root_url": "https://yt.lemnoslife.com/noKey",
|
||||
"yt_api_poll_timeout": 3,
|
||||
"yt_channel": "@foxyshadow"
|
||||
}
|
||||
```
|
||||
|
||||
### Connecting Twitch internal backend
|
||||
|
||||
The following procedure needs to be only done once:
|
||||
|
||||
1. Go to the [Twitch Chat OAuth Password Generator](https://twitchapps.com/tmi/) website.
|
||||
2. Login with your own Twitch account and give it the access.
|
||||
3. Copy the token string that starts with `oauth:` and paste it into the `twitch-token.txt` file in the `auth` subdirectory in the application directory.
|
||||
4. Save the file. The Twitch connection should now work.
|
||||
5. If the connection doesn't work, make sure that the `tw_backend_enabled` field is set to `true` in `config.json` and that you used the same Twitch nickname to get the token as the one specified in the `tw_nickname` field.
|
||||
|
||||
### Connecting YouTube internal backend
|
||||
|
||||
As of now, nothing needs to be done except setting the `yt_backend_enabled` field to `true` in `config.json`, but this section will change soon once the backend moves to OAuth 2.0.
|
||||
|
||||
## Customization
|
||||
|
||||
Being a Web-based application, the StreamGoose frontend provides vast customization capabilities on both the layout and client-side logic sides.
|
||||
|
||||
### Styling
|
||||
|
||||
The `webroot/style.css` is the main point of customization. Here is a short (and vastly incomplete) reference on the CSS selectors and classes that you can operate on:
|
||||
|
||||
- `#msglist > div`: an individual message block
|
||||
- `.icon`: user avatar (e.g. from YouTube backend)
|
||||
- `.username`: username text
|
||||
- `.msg`: message text
|
||||
- `.timestamp`: message timestamp (hidden by default)
|
||||
- `.tw`: a Twitch message (applies to the whole message block, as well as **all the subsequent classes** listed here)
|
||||
- `.yt`: a YouTube message
|
||||
- `.me`: third-party action
|
||||
- `.broadcaster`: a message that comes from the Twitch/YouTube channel owner
|
||||
- `.moderator`: a message that comes from a Twitch/YouTube chat moderator
|
||||
- `.sponsor`: a message that comes from a YouTube chat sponsor
|
||||
- `.verified`: a message that comes from a YouTube verified account
|
||||
|
||||
Additionally, every Twitch badge is converted into a CSS class that can be applied to a message block, e.g.
|
||||
|
||||
- `.vip`: a message that comes from a Twitch VIP chat user
|
||||
- `.subscriber`: a message that comes from a Twitch subscriber
|
||||
- `.admin`: a message that comes from a Twitch admin
|
||||
|
||||
and so on. You can find a full list of badges and their IDs by issuing a GET request to `https://api.twitchinsights.net/v1/badges/global` URL and seeing the `setID` field.
|
||||
|
||||
### Regex-based message highlighting
|
||||
|
||||
The `webroot/custom.js` allows you to pass custom JavaScript, including the predefined `messageHighlightRules` JS object. The keys in this object are regular expression strings, the values are the colors to apply to the messages where these expressions match. The matching is always case-insensitive and Unicode-aware. For the color values, you can use whatever CSS supports: `#hex` notation, symbolic names, `rgba()` or `hsla()` notation.
|
||||
|
||||
A canonical example is for matching alternative words in addition to the nickname:
|
||||
|
||||
```js
|
||||
var messageHighlightRules = {
|
||||
"(^|\\s)(suborg|luxferre|lux)(\,|;|:|\\s|$)": "#ff0000",
|
||||
"(^|\\s)(foxy|foxyshadow|fox)(\,|;|:|\\s|$)": "orange",
|
||||
"(^|\\s)(bluepanda|bluebear|sinipandoo)(\,|;|:|\\s|$)": "blue"
|
||||
}
|
||||
```
|
||||
|
||||
Each regular expression captures: the string start or a whitespace character, then one of the nickname alternatives, then either a comma, a semicolon, a colon or another whitespace character or the end of the string. If the message text matches the first expression, it is colored in red, if it matches the second one, it is colored in orange, if it matches the third one, it is colored in blue.
|
||||
|
||||
## Backend implementation status
|
||||
|
||||
### Twitch (internal)
|
||||
|
||||
Mostly stable. Some edge cases still could be found and further investigated.
|
||||
|
||||
Most chat badges are still ignored when rendering the messages.
|
||||
|
||||
### YouTube (internal)
|
||||
|
||||
Currently, the YouTube backend doesn't have OAuth 2.0 flow implemented as of yet. A single API key would consume the quota too quickly, so, right now, the backend uses [an unofficial Youtube API proxy](https://yt.lemnoslife.com/) which can and sometimes does have downtimes.
|
||||
|
||||
Rewriting the backend to support OAuth is one of the highest priorities.
|
||||
|
||||
Additionally, YouTube does not have its own chat emote API as of now, so the emnote set is hardcoded in the `/webroot/img` directory and can become outdated over time.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What were the reasons for starting this project?
|
||||
|
||||
In the game livestreaming community, there is some demand for a saner and more flexible alternative to the commercial live chat aggregation solutions, such as RutonyChat. In fact, witnessing several streamers struggling with some RutonyChat issues was the main trigger to start the project. On top of that, once the main functionality is complete, there are some plans to extend StreamGoose to support more things beyond just chat aggregation.
|
||||
|
||||
### Why is it called StreamGoose?
|
||||
|
||||
This name was chosen as a winner of a poll offering 10 various names suggested by the members of two Discord servers with a lot of small-scale game livestreamers. It was one of the draft names too, given that, at the very beginning of the project, the broker server was written in C and based on the [Mongoose](https://mongoose.ws) library.
|
||||
|
||||
Additionally, this name turned out to be pretty unique — at the time of publishing this repo, no other desktop application with such a name existed.
|
||||
|
||||
### How can I contribute?
|
||||
|
||||
Everyone is free to send their suggestions and patches. Once you get a Codeberg account, you can participate here in various ways:
|
||||
|
||||
1. Discuss, report and propose things at the issue tracker.
|
||||
2. Create feature branches and merge requests if you know how to implement a particular feature in the Go-based StreamGoose core.
|
||||
3. Write entirely new external independent backends in the programming languages of your choice (please place them into the `/external` directory of this repo). As a bonus, if you write a backend in Go and observe the same internal `ChatMessage` structure, we can even consider turning it into a Goroutine and include internally into the next StreamGoose version, provided it is already tested and stable enough.
|
||||
|
||||
### The resulting binary size is around 6 megabytes or even more, is it normal?
|
||||
|
||||
Yes, due to the way Go links its networking client and server code and everything else statically (where possible). On Linux, you may try using GCC Go instead to achieve fully dynamic linking. Keep in mind that GCC Go is not fully supported yet, and if you come to our issue tracker with an issue that's only reproducible with GCC Go, it's less likely to be resolved.
|
||||
|
||||
### Beyond Twitch and YouTube, are there any other livestreaming platforms that you plan to support internally?
|
||||
|
||||
Sure! For instance, one of the next close targets will be [Owncast](https://owncast.online/) self-hosted livestreaming solution and its rather simple chat API. The problem with adding a new platform support, as always, is finding enough time to study the API and enough people that would find this support valuable. For example, with enough demand, Kick and Trovo support are also likely to be added.
|
||||
|
||||
Besides Twitch, YouTube and Owncast, however, your best bet would be to create an **external** backend for the platform you're interested in, and then incorporate it into this repo in the `/external` directory. When the backend is mature enough, we can always sit and think about how to convert it into an internal one to be distributed inside the StreamGoose binary.
|
||||
|
||||
|
||||
## Credits
|
||||
|
||||
Architectural design by [Luxferre](https://luxferre.top) and members of [@foxyshadow](https://twitch.tv/foxyshadow)'s Discord server.
|
||||
|
||||
Initial reference implementation by Luxferre.
|
||||
|
||||
Noto Color Emoji font by Google, distributed under the [Open Font License](https://openfontlicense.org).
|
||||
|
||||
All files, except the bundled font and image assets, are released into the public domain with no warranties.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"server_port": 60053,
|
||||
"server_listen_ip": "127.0.0.1",
|
||||
"debug": true,
|
||||
"tw_backend_enabled": true,
|
||||
"tw_nickname": "Suborg",
|
||||
"tw_channel": "#suborg",
|
||||
"yt_backend_enabled": false,
|
||||
"yt_api_root_url": "https://yt.lemnoslife.com/noKey",
|
||||
"yt_api_poll_timeout": 3,
|
||||
"yt_channel": "@foxyshadow"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
If you implement an external backend, please put it into this directory for us to be able to track it in the StreamGoose repo.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
LDFLAGS = -s -w
|
||||
GOFLAGS = -trimpath
|
||||
SOURCES = server.go twitch.go youtube.go
|
||||
BIN = streamgoose
|
||||
|
||||
all: $(SOURCES)
|
||||
go mod tidy
|
||||
go build -o $(BIN) $(GOFLAGS) -ldflags "$(LDFLAGS)" $(SOURCES)
|
||||
|
||||
clean:
|
||||
rm $(BIN)
|
||||
@@ -0,0 +1,5 @@
|
||||
module streamgoose
|
||||
|
||||
go 1.23.1
|
||||
|
||||
require centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16
|
||||
@@ -0,0 +1,2 @@
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16 h1:gOFnYZ+G9R7Prc3Mn7z/v+LkPf/kQ2oUh5NCQd6n39k=
|
||||
centrifuge.hectabit.org/HectaBit/webview_go v0.0.0-20240502074857-17ae24882d16/go.mod h1:cAr/pT090G1kcy9uuCEUbZyGhUQRGOo4KVnkocx8BMA=
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
StreamGoose main server/UI launcher — Go reference implementation
|
||||
|
||||
Created by Luxferre in 2024, released into public domain
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"io/ioutil"
|
||||
/* external */
|
||||
"centrifuge.hectabit.org/HectaBit/webview_go" /* for UI launch */
|
||||
)
|
||||
|
||||
/* main chat message structure */
|
||||
type ChatMessage struct {
|
||||
Id uint64 `json:"id"` /* message id (auto-filled) */
|
||||
Type string `json:"type"` /* message type string */
|
||||
Timestamp string `json:"timestamp"` /* timestamp string (formatted) */
|
||||
Username string `json:"username"` /* username */
|
||||
Text string `json:"text"` /* message text */
|
||||
Icon string `json:"icon"` /* optional message icon URI */
|
||||
}
|
||||
|
||||
var global_msg_id uint64 = 0 /* track global message ID */
|
||||
var message_transport chan ChatMessage /* global message channel */
|
||||
var bro webview.WebView /* global browser instance handle */
|
||||
|
||||
/* escape HTML entities in JS parameters */
|
||||
func esc(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
/* chat message appender thread */
|
||||
func appendChatMessages() {
|
||||
var msg ChatMessage
|
||||
for {
|
||||
msg = <-message_transport /* receive it from the channel */
|
||||
/* populate the running id */
|
||||
global_msg_id += 1
|
||||
msg.Id = global_msg_id
|
||||
|
||||
/* directly call the append function on the JS side */
|
||||
fmt_str := "gooseAppendMessage('%d', '%s', '%s', '%s', '%s', '%s')"
|
||||
bro.Eval(fmt.Sprintf(fmt_str, msg.Id, esc(msg.Type), esc(msg.Timestamp), esc(msg.Username), esc(msg.Text), esc(msg.Icon)))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* POST /new handler (for external backends) */
|
||||
func msgAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var msg ChatMessage /* create a new message */
|
||||
err := decoder.Decode(&msg) /* fill in the message structure */
|
||||
if err != nil {
|
||||
log.Println("Error decoding POST request:", err)
|
||||
return
|
||||
}
|
||||
/* post it to the transport channel */
|
||||
message_transport <- msg
|
||||
w.Write([]byte("{\"status\": 1}")) /* respond with a status */
|
||||
}
|
||||
|
||||
/* entry point */
|
||||
func main() {
|
||||
config_path := "config.json"
|
||||
if len(os.Args) > 1 {
|
||||
config_path = os.Args[1]
|
||||
}
|
||||
content, err := ioutil.ReadFile(config_path)
|
||||
if err != nil {
|
||||
log.Fatal("Error when opening config file: ", err)
|
||||
}
|
||||
var config map[string]interface{}
|
||||
err = json.Unmarshal(content, &config)
|
||||
if err != nil {
|
||||
log.Fatal("Error during Unmarshal(): ", err)
|
||||
}
|
||||
|
||||
/* create the message transport channel */
|
||||
message_transport = make(chan ChatMessage)
|
||||
|
||||
/* target server listening address */
|
||||
target_addr := fmt.Sprintf("%s:%d", config["server_listen_ip"].(string), int(config["server_port"].(float64)))
|
||||
|
||||
go appendChatMessages() /* start the appender thread */
|
||||
|
||||
go func() { /* start the server thread */
|
||||
/* serve the POST /new */
|
||||
http.HandleFunc("/new", msgAddHandler)
|
||||
/* serve the webroot directory */
|
||||
fs := http.FileServer(http.Dir("./webroot"))
|
||||
http.Handle("/", fs)
|
||||
log.Println(fmt.Sprintf("Listening on %s\n", target_addr))
|
||||
err = http.ListenAndServe(target_addr, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
if _, ok := config["tw_backend_enabled"]; ok && config["tw_backend_enabled"].(bool) == true {
|
||||
go twitch_run(config) /* start the Twitch backend */
|
||||
}
|
||||
|
||||
if _, ok := config["yt_backend_enabled"]; ok && config["yt_backend_enabled"].(bool) == true {
|
||||
go youtube_run(config) /* start the YouTube backend */
|
||||
}
|
||||
|
||||
/* start the GUI */
|
||||
debug := false
|
||||
if _, ok := config["debug"]; ok && config["debug"].(bool) == true {
|
||||
debug = true
|
||||
}
|
||||
bro = webview.New(debug) /* create a browser instance */
|
||||
bro.SetSize(320, 720, webview.HintNone)
|
||||
defer bro.Destroy()
|
||||
bro.SetTitle("StreamGoose v0.0.1")
|
||||
bro.Navigate("http://" + target_addr)
|
||||
bro.Run()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
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"
|
||||
"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])] = "#EMOTE-" + 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 {
|
||||
out_message.Text = strings.Join(strings.Fields(metadata["system-msg"]), " ") + ": " + out_message.Text
|
||||
}
|
||||
|
||||
/* 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 := "auth/twitch-token.txt"
|
||||
token, err := ioutil.ReadFile(token_path)
|
||||
token_err_msg := "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 auth/twitch-token.txt 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("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
StreamGoose YouTube backend — Go reference implementation
|
||||
|
||||
Created by Luxferre in 2024, released into public domain
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"slices"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
/* global API root URL placeholder */
|
||||
var API_ROOT string
|
||||
|
||||
const ID_CACHE_LIMIT = 1000
|
||||
|
||||
/* global message ID cache */
|
||||
var msg_id_cache []string
|
||||
|
||||
/* API request helper function */
|
||||
func api_get(endpoint string) map[string]interface{} {
|
||||
url := API_ROOT + endpoint /* get full URL */
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Accept", "application/json; charset=utf-8")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
/* unmarshal the response */
|
||||
|
||||
var respbody map[string]interface{}
|
||||
err = json.Unmarshal(body, &respbody)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return respbody
|
||||
}
|
||||
|
||||
/* main message procesing function */
|
||||
func yt_process_message(rawmsg map[string]interface{}) {
|
||||
/* if the message came from a real user, this is the one we should process */
|
||||
if rawmsg["kind"] == "youtube#liveChatMessage" && rawmsg["snippet"].(map[string]interface{})["type"] == "textMessageEvent" {
|
||||
if !slices.Contains(msg_id_cache, rawmsg["id"].(string)) { /* we have a fresh message here */
|
||||
snip := rawmsg["snippet"].(map[string]interface{})
|
||||
author := rawmsg["authorDetails"].(map[string]interface{})
|
||||
|
||||
/* convert the timestamp */
|
||||
sent_dt, _ := time.Parse("2006-01-02T15:04:05.000000-07:00", snip["publishedAt"].(string))
|
||||
|
||||
/* prepare output message */
|
||||
out_message := ChatMessage {
|
||||
Type: "yt",
|
||||
Username: author["displayName"].(string),
|
||||
Timestamp: sent_dt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
Text: snip["displayMessage"].(string),
|
||||
Icon: "",
|
||||
}
|
||||
|
||||
/* check for profile image */
|
||||
if _, ok := author["profileImageUrl"]; ok && len(author["profileImageUrl"].(string)) > 0 {
|
||||
out_message.Icon = author["profileImageUrl"].(string)
|
||||
}
|
||||
|
||||
/* check for verified role */
|
||||
if _, ok := author["isVerified"]; ok && author["isVerified"].(bool) == true {
|
||||
out_message.Type += "-verified"
|
||||
}
|
||||
|
||||
/* check for broadcaster role */
|
||||
if _, ok := author["isChatOwner"]; ok && author["isChatOwner"].(bool) == true {
|
||||
out_message.Type += "-broadcaster"
|
||||
}
|
||||
|
||||
/* check for moderator role */
|
||||
if _, ok := author["isChatModerator"]; ok && author["isChatModerator"].(bool) == true {
|
||||
out_message.Type += "-moderator"
|
||||
}
|
||||
|
||||
/* check for sponsor role */
|
||||
if _, ok := author["isChatSponsor"]; ok && author["isChatSponsor"].(bool) == true {
|
||||
out_message.Type += "-sponsor"
|
||||
}
|
||||
|
||||
/* finally, send it to the message broker via the message_transport channel */
|
||||
message_transport <- out_message
|
||||
|
||||
/* update the ID cache */
|
||||
msg_id_cache = append(msg_id_cache, rawmsg["id"].(string))
|
||||
cachelen := len(msg_id_cache)
|
||||
if cachelen > ID_CACHE_LIMIT { /* trim the excess */
|
||||
msg_id_cache = msg_id_cache[cachelen-ID_CACHE_LIMIT:cachelen]
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* entry point */
|
||||
func youtube_run(config map[string]interface{}) {
|
||||
API_POLL_TIMEOUT := 1 /* polling timeout in seconds */
|
||||
|
||||
if _, ok := config["yt_api_root_url"]; ok {
|
||||
API_ROOT = config["yt_api_root_url"].(string)
|
||||
} else {
|
||||
log.Fatal("API Root URL missing in config!")
|
||||
}
|
||||
|
||||
if _, ok := config["yt_api_poll_timeout"]; ok {
|
||||
API_POLL_TIMEOUT = int(config["yt_api_poll_timeout"].(float64))
|
||||
}
|
||||
|
||||
video_id := "" /* video broadcast ID is empty by default */
|
||||
if _, ok := config["yt_video_id"]; ok { /* override the video broadcast ID */
|
||||
video_id = config["yt_video_id"].(string)
|
||||
} else { /* no video ID supplied, begin with the usual flow */
|
||||
/* first, get channel ID by YouTube handle */
|
||||
channel_id_data := api_get("/channels?part=id&forHandle=" + config["yt_channel"].(string))
|
||||
channel_id := ""
|
||||
if _, ok := channel_id_data["items"]; ok && len(channel_id_data["items"].([]interface{})) > 0 {
|
||||
channel_id = channel_id_data["items"].([]interface{})[0].(map[string]interface{})["id"].(string)
|
||||
} else {
|
||||
log.Fatal("Error while getting channel ID!")
|
||||
}
|
||||
|
||||
/* then, get live broadcast video ID by channel ID */
|
||||
video_id_data := api_get("/search?part=snippet&eventType=live&type=video&channelId=" + channel_id)
|
||||
if _, ok := video_id_data["items"]; ok && len(video_id_data["items"].([]interface{})) > 0 {
|
||||
id_desc := video_id_data["items"].([]interface{})[0].(map[string]interface{})["id"]
|
||||
video_id = id_desc.(map[string]interface{})["videoId"].(string)
|
||||
} else {
|
||||
log.Fatal("Error while getting video ID!")
|
||||
}
|
||||
}
|
||||
|
||||
/* now, get active live chat ID by broadcast video ID */
|
||||
chat_id := ""
|
||||
chat_id_data := api_get("/videos?part=liveStreamingDetails&id=" + video_id)
|
||||
if _, ok := chat_id_data["items"]; ok && len(chat_id_data["items"].([]interface{})) > 0 {
|
||||
details := chat_id_data["items"].([]interface{})[0].(map[string]interface{})["liveStreamingDetails"]
|
||||
chat_id = details.(map[string]interface{})["activeLiveChatId"].(string)
|
||||
} else {
|
||||
log.Fatal("Error while getting live chat ID!")
|
||||
}
|
||||
|
||||
if len(chat_id) < 1 { /* the chat ID is still empty */
|
||||
log.Fatal("Unknown error while getting live chat ID!")
|
||||
}
|
||||
|
||||
log.Println("StreamGoose YouTube Go backend started")
|
||||
|
||||
var raw_chat_data map[string]interface{}
|
||||
msg_id_cache = append(msg_id_cache, "") /* init the cache */
|
||||
for { /* main loop */
|
||||
raw_chat_data = api_get("/liveChat/messages?part=snippet,authorDetails&liveChatId=" + chat_id)
|
||||
if _, ok := raw_chat_data["items"]; ok && len(raw_chat_data["items"].([]interface{})) > 0 {
|
||||
for _, rawmsg := range raw_chat_data["items"].([]interface{}) { /* iterate over raw message objects */
|
||||
yt_process_message(rawmsg.(map[string]interface{}))
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Duration(API_POLL_TIMEOUT) * time.Second) /* wait for specified amount before polling next */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* Custom scripting rules for rendering some aspects */
|
||||
|
||||
/* message highlighting overrides */
|
||||
/* supply regex (case-insensitive) as key, message color as value */
|
||||
|
||||
var messageHighlightRules = {
|
||||
"(^|\\s)(suborg|luxferre|lux|люкс|сюборг|суборг)(\,|;|:|\\s|$)": "#ff0000",
|
||||
"(^|\\s)(foxy|foxyshadow|fox)(\,|;|:|\\s|$)": "orange",
|
||||
"(^|\\s)(bluepanda|bluebear|sinipandoo)(\,|;|:|\\s|$)": "blue"
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 1006 B |
|
After Width: | Height: | Size: 556 B |
|
After Width: | Height: | Size: 686 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 606 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 774 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 860 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 936 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1023 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 890 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 648 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 938 B |
|
After Width: | Height: | Size: 833 B |
|
After Width: | Height: | Size: 960 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 821 B |
|
After Width: | Height: | Size: 929 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 880 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 823 B |
|
After Width: | Height: | Size: 662 B |
|
After Width: | Height: | Size: 738 B |
|
After Width: | Height: | Size: 477 B |