diff --git a/.forgejo/workflows/bake.yml b/.forgejo/workflows/bake.yml new file mode 100644 index 0000000..a28de2b --- /dev/null +++ b/.forgejo/workflows/bake.yml @@ -0,0 +1,54 @@ +on: + push: + branches: + - 'main' + tags: + - 'v*' + paths: + - '**.go' + - '**.html' + - 'Dockerfile' + - 'Dockerfile.*' + - 'docker-bake.hcl' + workflow_dispatch: + +jobs: + bake: + runs-on: docker + steps: + - name: Prepare Registry FQDN + id: registry + run: | + registry=${{ github.server_url }} + registry=${registry##http*://} + echo "registry=${registry}" >> "$GITHUB_OUTPUT" + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ steps.registry.outputs.registry }} + username: ${{ github.repository_owner }} + password: ${{ secrets.TOKEN }} + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Build and push + uses: docker/bake-action@v6 + with: + source: . + env: + TAG: ${{ steps.meta.outputs.tags }} diff --git a/cmd/tweeter/webhook.go b/cmd/tweeter/webhook.go index 0c69601..7995371 100644 --- a/cmd/tweeter/webhook.go +++ b/cmd/tweeter/webhook.go @@ -3,6 +3,7 @@ package tweeter import ( "bytes" "encoding/json" + "html" "log" "net/http" "strings" @@ -80,7 +81,7 @@ func (app App) SendToWebhook(tweets []*ts.Tweet) { tweetText = strings.ReplaceAll(tweetText, video.URL, "") } - mainEmbed.SetText(strings.TrimSpace(tweetText)) + mainEmbed.SetText(html.UnescapeString(strings.TrimSpace(tweetText))) for _, data := range webhooksToSend { err := sendRequest(app.config.Webhook, data) diff --git a/config.example.toml b/config.example.toml index b4da53d..fdcd679 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,12 +1,19 @@ username = "" password = "asd123" #proxyaddr = "socks5://localhost:5555" -hostURL = "https://my.domain.tld" + +#usewebserver = false +indextarget = "https://discord.gg/Kw4MFGxYEj" # Optional +hostURL = "https://my.domain.tld" # Can be omitted if not using webserver + +webhook = "https://domain.tld/api/webhooks/" + channels = [ "NinEverything", "NintendoEurope", "NintendoAmerica", ] + # Binary representation for efficient filtering # Bit from left to right (most to least significant bit): IsSelfThread, IsRetweet, IsReply, IsPin, IsQuoted filter = [ diff --git a/docker-bake.hcl b/docker-bake.hcl index e18ac8c..7cb61be 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -8,8 +8,8 @@ variable "TAG" { function "generate_tags" { params = [images, versions] result = distinct(flatten( - [for i in split(",", images) : - [for v in split(",", versions) : + [for i in split(",", replace(images, "\n", ",")) : + [for v in split(",", replace(versions, "\n", ",")) : "${i}:${v}" ] ])) @@ -24,8 +24,8 @@ target "default" { } target "prod" { inherits = ["default"] - //platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/riscv64"] - platforms = ["linux/amd64", "linux/arm64"] + platforms = ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/riscv64"] + #platforms = ["linux/amd64", "linux/arm64"] dockerfile = "Dockerfile.multiarch" output = ["type=registry"] attest = [ diff --git a/nix/default.nix b/nix/default.nix new file mode 100644 index 0000000..b07d863 --- /dev/null +++ b/nix/default.nix @@ -0,0 +1,21 @@ +{ pkgs ? ( + let + inherit (builtins) fetchTree fromJSON readFile; + inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix; + in + import (fetchTree nixpkgs.locked) { + overlays = [ + (import "${fetchTree gomod2nix.locked}/overlay.nix") + ]; + } +) +, buildGoApplication ? pkgs.buildGoApplication +}: + +buildGoApplication { + pname = "discord-tweeter"; + version = "0.1"; + pwd = ./.; + src = ../.; + modules = ./gomod2nix.toml; +} diff --git a/nix/flake.lock b/nix/flake.lock new file mode 100644 index 0000000..a0e83ed --- /dev/null +++ b/nix/flake.lock @@ -0,0 +1,85 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1742209644, + "narHash": "sha256-jMy1XqXqD0/tJprEbUmKilTkvbDY/C0ZGSsJJH4TNCE=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "8f3534eb8f6c5c3fce799376dc3b91bae6b11884", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1742288794, + "narHash": "sha256-Txwa5uO+qpQXrNG4eumPSD+hHzzYi/CdaM80M9XRLCo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b6eaf97c6960d97350c584de1b6dcff03c9daf42", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/flake.nix b/nix/flake.nix new file mode 100644 index 0000000..80ad039 --- /dev/null +++ b/nix/flake.nix @@ -0,0 +1,24 @@ +{ + description = "NixOS flake for discord-tweeter"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs.gomod2nix.url = "github:nix-community/gomod2nix"; + inputs.gomod2nix.inputs.nixpkgs.follows = "nixpkgs"; + inputs.gomod2nix.inputs.flake-utils.follows = "flake-utils"; + + outputs = { self, nixpkgs, flake-utils, gomod2nix }: + (flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + + callPackage = pkgs.darwin.apple_sdk_11_0.callPackage or pkgs.callPackage; + in + { + nixosModules.default = callPackage ./services.nix { inherit self; }; + packages.default = callPackage ./. { inherit (gomod2nix.legacyPackages.${system}) buildGoApplication; }; + devShells.default = callPackage ./shell.nix { inherit (gomod2nix.legacyPackages.${system}) mkGoEnv gomod2nix; }; + }) + ); +} diff --git a/nix/gomod2nix.toml b/nix/gomod2nix.toml new file mode 100644 index 0000000..5edd8c3 --- /dev/null +++ b/nix/gomod2nix.toml @@ -0,0 +1,21 @@ +schema = 3 + +[mod] + [mod."github.com/AlexEidt/Vidio"] + version = "v1.5.1" + hash = "sha256-WdnCYjxbFqDmh/IVaVCCc1TzvklKdJtDMdIgn6m6NTM=" + [mod."github.com/BurntSushi/toml"] + version = "v1.5.0" + hash = "sha256-wX8bEVo7swuuAlm0awTIiV1KNCAXnm7Epzwl+wzyqhw=" + [mod."github.com/imperatrona/twitter-scraper"] + version = "v0.0.16" + hash = "sha256-nbv9fI6/3OxnC8NaLO82UfcwbJMz1jxvfNjZPOrILzM=" + [mod."github.com/jmoiron/sqlx"] + version = "v1.4.0" + hash = "sha256-0H132+A983nBr2zEyCKsJoBCZlC9pG+ylEcGysxKL4M=" + [mod."github.com/mattn/go-sqlite3"] + version = "v1.14.24" + hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" + [mod."golang.org/x/net"] + version = "v0.37.0" + hash = "sha256-sZKbJISVdBwyuYRQgrraTKxeIORWlzK5hScceQ2dE58=" diff --git a/nix/services.nix b/nix/services.nix new file mode 100644 index 0000000..6658611 --- /dev/null +++ b/nix/services.nix @@ -0,0 +1,51 @@ +{config, lib, pkgs, ...}: + +with lib; + +let + cfg = config.services.discord-tweeter; + + format = pkgs.formats.toml { }; + configFile = format.generate "config.toml" cfg.settings; +in +{ + options.services.discord-tweeter = { + enable = mkEnableOption (lib.mdDoc "discord-tweeter"); + + settings = mkOption { + type = format.type; + default = { + username = ""; + password = ""; + proxyaddr = ""; + webhook = ""; + DbPath = "/var/lib/discord-tweeter/"; + CookiePath = "/var/lib/discord-tweeter/"; + channels = [ ]; + filter = [ 0b11111 ]; + UseWebServer = true; + HostURL = ""; + WebPort = 8080; + UserAgents = [ "discordbot" "curl" "httpie" "lwp-request" "wget" "python-requests" "openbsd ftp" "powershell" ]; + NitterBase = "https://xcancel.com"; + }; + description = lib.mdDoc '' + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.discord-tweeter = { + description = "Send tweets to Discord by scraping Twitter (now X)"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + DynamicUser = true; + ExecStart = "${self.packages.${pkgs.system}.default}/bin/discord-tweeter ${configFile}"; + Restart = "on-failure"; + }; + }; + }; +} + diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 0000000..8d806a2 --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,24 @@ +{ pkgs ? ( + let + inherit (builtins) fetchTree fromJSON readFile; + inherit ((fromJSON (readFile ./flake.lock)).nodes) nixpkgs gomod2nix; + in + import (fetchTree nixpkgs.locked) { + overlays = [ + (import "${fetchTree gomod2nix.locked}/overlay.nix") + ]; + } + ) +, mkGoEnv ? pkgs.mkGoEnv +, gomod2nix ? pkgs.gomod2nix +}: + +let + goEnv = mkGoEnv { pwd = ../.; }; +in +pkgs.mkShell { + packages = [ + goEnv + gomod2nix + ]; +} diff --git a/pkg/config/config.go b/pkg/config/config.go index dd540fb..499badb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -20,6 +20,7 @@ type Config struct { WebPort uint16 UserAgents []string NitterBase string + IndexTarget string } func ConfigFromFile(filePath string) (*Config, error) { diff --git a/pkg/web/templates/tweet.html b/pkg/web/templates/tweet.html index d019a1b..96165d5 100644 --- a/pkg/web/templates/tweet.html +++ b/pkg/web/templates/tweet.html @@ -5,8 +5,8 @@ - - + + {{- range $idx, $e := .Images }} diff --git a/pkg/web/templates/video.html b/pkg/web/templates/video.html index c4dc13b..2c02e15 100644 --- a/pkg/web/templates/video.html +++ b/pkg/web/templates/video.html @@ -6,8 +6,8 @@ {{- if .Videos }} - - + + {{- range $idx, $e := .Videos }} diff --git a/pkg/web/web.go b/pkg/web/web.go index 161bcc0..a8b5a5a 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -23,7 +23,7 @@ const ( //go:embed templates/*.html var templateFiles embed.FS -type Reponse struct { +type Response struct { StatusCode int ContentType string Content string @@ -34,7 +34,7 @@ type WebServer struct { scraper *ts.Scraper templates *template.Template avatarCache *cache.Cache[string, string] - responseCache *cache.Cache[string, Reponse] + responseCache *cache.Cache[string, Response] Server *http.Server } @@ -64,7 +64,7 @@ func New(config *config.Config, scraper *ts.Scraper) (*WebServer, error) { scraper, tmpl, cache.New[string, string](), - cache.New[string, Reponse](), + cache.New[string, Response](), &http.Server{ Handler: sm, Addr: fmt.Sprintf(":%d", config.WebPort), @@ -72,12 +72,19 @@ func New(config *config.Config, scraper *ts.Scraper) (*WebServer, error) { WriteTimeout: 30 * time.Second, }, } + sm.HandleFunc("GET /", ws.handleIndex) sm.HandleFunc("GET /avatar/{username}", ws.handleAvatar) sm.HandleFunc("GET /tweet/{id}", ws.handleTweet) sm.HandleFunc("GET /video/{id}", ws.handleVideo) return ws, nil } +func (ws WebServer) handleIndex(w http.ResponseWriter, r *http.Request) { + if ws.config.IndexTarget != "" { + http.Redirect(w, r, ws.config.IndexTarget, http.StatusPermanentRedirect) + } +} + func (ws WebServer) handleAvatar(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") @@ -130,7 +137,7 @@ func (ws WebServer) handleTemplate(w http.ResponseWriter, r *http.Request, id st } if !slices.Contains(ws.config.Channels, tweet.Username) { - res := Reponse{http.StatusBadRequest, "text/plain", "Bad Request"} + res := Response{http.StatusBadRequest, "text/plain", "Bad Request"} ws.responseCache.Set(template+"-"+id, res, TweetCacheTime) response(w, res) return @@ -182,7 +189,7 @@ func (ws WebServer) handleTemplate(w http.ResponseWriter, r *http.Request, id st return } - res := Reponse{http.StatusOK, "text/html", tpl.String()} + res := Response{http.StatusOK, "text/html", tpl.String()} ws.responseCache.Set(template+"-"+id, res, TweetCacheTime) response(w, res) } @@ -197,14 +204,14 @@ func validUserAgent(ua string, uas []string) bool { } func badRequest(w http.ResponseWriter) { - response(w, Reponse{http.StatusBadRequest, "text/plain", "Bad Request"}) + response(w, Response{http.StatusBadRequest, "text/plain", "Bad Request"}) } func serverError(w http.ResponseWriter) { - response(w, Reponse{http.StatusInternalServerError, "text/plain", "Internal Server Error"}) + response(w, Response{http.StatusInternalServerError, "text/plain", "Internal Server Error"}) } -func response(w http.ResponseWriter, res Reponse) { +func response(w http.ResponseWriter, res Response) { w.WriteHeader(res.StatusCode) w.Header().Set("Content-Type", res.ContentType) fmt.Fprint(w, res.Content)