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)