├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .golangci.yml ├── cmd └── lightning │ └── main.go ├── containerfile ├── go.mod ├── go.sum ├── internal ├── bridge │ ├── bridge.go │ ├── config.go │ ├── log.go │ └── setup.go ├── cache │ └── cache.go ├── commands │ ├── basics.go │ ├── bridge.go │ ├── commands.go │ ├── create.go │ ├── join.go │ ├── leave.go │ ├── status.go │ └── toggle.go ├── data │ ├── database.go │ ├── postgres.go │ ├── queries.go │ └── types.go ├── emoji │ └── names.go ├── rvapi │ ├── fetch.go │ ├── methods.go │ ├── permissions.go │ ├── rvapi.go │ ├── socket.go │ ├── types.gen.go │ └── upload.go ├── tgmd │ └── markdown.go └── workaround │ └── tls.go ├── license ├── logo.svg ├── pkg ├── lightning │ ├── addhandler.go │ ├── bot.go │ ├── commands.go │ ├── embed.go │ ├── errors.go │ ├── methods.go │ ├── plugin.go │ └── types.go └── platforms │ ├── discord │ ├── command.go │ ├── errors.go │ ├── incoming.go │ ├── outgoing.go │ └── plugin.go │ ├── guilded │ ├── api.go │ ├── errors.go │ ├── guilded.go │ ├── incoming.go │ ├── outgoing.go │ ├── plugin.go │ ├── send.go │ └── setup.go │ ├── matrix │ ├── errors.go │ ├── events.go │ ├── login.go │ ├── outgoing.go │ └── plugin.go │ ├── stoat │ ├── errors.go │ ├── incoming.go │ ├── methods.go │ ├── outgoing.go │ └── plugin.go │ └── telegram │ ├── incoming.go │ ├── outgoing.go │ ├── plugin.go │ ├── proxy.go │ └── retrier.go └── readme.md /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: { interval: daily } 6 | - package-ecosystem: docker 7 | directory: / 8 | schedule: { interval: daily } 9 | - package-ecosystem: github-actions 10 | directory: / 11 | schedule: { interval: daily } 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: { branches: [develop] } 5 | permissions: 6 | contents: read 7 | pull-requests: read 8 | jobs: 9 | ci: 10 | name: lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | - uses: actions/setup-go@v6 15 | with: { go-version: stable, cache: true } 16 | - name: golangci-lint 17 | uses: golangci/golangci-lint-action@v8 18 | with: { version: v2.4.0 } 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | workflow_dispatch: 4 | release: { types: [published] } 5 | permissions: 6 | contents: read 7 | packages: write 8 | jobs: 9 | publish: 10 | name: publish to ghcr 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v5 15 | - name: set up qemu 16 | uses: docker/setup-qemu-action@v3 17 | - name: setup buildx 18 | uses: docker/setup-buildx-action@v3 19 | - name: login to ghcr 20 | uses: docker/login-action@v3 21 | with: 22 | registry: ghcr.io 23 | username: ${{ github.actor }} 24 | password: ${{ secrets.GITHUB_TOKEN }} 25 | - name: build image 26 | uses: docker/build-push-action@v6 27 | with: 28 | push: true 29 | platforms: linux/amd64,linux/arm64 30 | context: . 31 | file: ./containerfile 32 | tags: | 33 | ghcr.io/williamhorning/lightning:latest 34 | ghcr.io/williamhorning/lightning:${{ github.ref_name }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lightning.toml 2 | /internal/rvapi/unused.gen.go -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: { build-tags: [goolm] } 3 | linters: { 4 | default: all, 5 | disable: [depguard, exhaustruct, mnd, noinlineerr, tagliatelle, wsl], 6 | settings: { 7 | govet: { enable-all: true }, 8 | ireturn: { allow: [anon, error, empty, stdlib, Plugin, Database] }, 9 | lll: { tab-width: 2 }, 10 | revive: { 11 | enable-all-rules: true, 12 | rules: [ 13 | { name: add-constant, disabled: true }, 14 | { name: cognitive-complexity, disabled: true }, 15 | { name: cyclomatic, disabled: true }, 16 | { name: line-length-limit, arguments: [120] }, 17 | { name: max-public-structs, disabled: true }, 18 | ], 19 | }, 20 | varnamelen: { 21 | ignore-type-assert-ok: true, 22 | ignore-map-index-ok: true, 23 | ignore-chan-recv-ok: true, 24 | ignore-names: [err, ok], 25 | }, 26 | }, 27 | exclusions: { warn-unused: false, paths: [internal/cache] }, 28 | } 29 | formatters: { 30 | enable: [gci, gofmt, gofumpt, goimports, golines, swaggo], 31 | settings: { golines: { max-len: 120, tab-len: 2 } }, 32 | } 33 | -------------------------------------------------------------------------------- /cmd/lightning/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entrypoint for Lightning, the bridge bot thing. 2 | package main 3 | 4 | import ( 5 | "errors" 6 | "flag" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/williamhorning/lightning/internal/bridge" 13 | "github.com/williamhorning/lightning/pkg/lightning" 14 | "github.com/williamhorning/lightning/pkg/platforms/discord" 15 | "github.com/williamhorning/lightning/pkg/platforms/guilded" 16 | "github.com/williamhorning/lightning/pkg/platforms/matrix" 17 | "github.com/williamhorning/lightning/pkg/platforms/stoat" 18 | "github.com/williamhorning/lightning/pkg/platforms/telegram" 19 | ) 20 | 21 | func main() { 22 | config := flag.String("config", "lightning.toml", "path to the configuration file") 23 | flag.Parse() 24 | 25 | handler := bridge.SetupLogging() 26 | 27 | cfg, ok := bridge.GetConfig(*config) 28 | if !ok { 29 | os.Exit(1) 30 | } 31 | 32 | handler.URL = cfg.ErrorURL 33 | 34 | bot := lightning.NewBot(lightning.BotOptions{ 35 | Prefix: cfg.CommandPrefix, 36 | }) 37 | 38 | if err := errors.Join( 39 | bot.AddPluginType("discord", discord.New), 40 | bot.AddPluginType("guilded", guilded.New), 41 | bot.AddPluginType("revolt", stoat.New), 42 | bot.AddPluginType("telegram", telegram.New), 43 | bot.AddPluginType("matrix", matrix.New), 44 | ); err != nil { 45 | log.Fatalf("failed to setup platform plugins: %v\n", err) 46 | } 47 | 48 | database, err := cfg.DatabaseConfig.GetDatabase() 49 | if err != nil { 50 | log.Fatalf("failed to setup database: %v\n", err) 51 | } 52 | 53 | bridge.Setup(bot, cfg.Author, database) 54 | 55 | for plugin, cfg := range cfg.Plugins { 56 | if err := bot.UsePluginType(plugin, "", cfg); err != nil { 57 | log.Fatalf("failed to setup plugin for %s: %v\n", plugin, err) 58 | } 59 | } 60 | 61 | quitChannel := make(chan os.Signal, 1) 62 | signal.Notify(quitChannel, os.Interrupt, syscall.SIGTERM) 63 | <-quitChannel 64 | 65 | log.Println("bot stopped") 66 | } 67 | -------------------------------------------------------------------------------- /containerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.3-alpine AS builder 2 | WORKDIR /src 3 | COPY . . 4 | RUN CGO_ENABLED=0 go build -tags goolm -o /out/lightning ./cmd/lightning/main.go 5 | 6 | FROM scratch 7 | 8 | LABEL maintainer="William Horning" 9 | LABEL version="0.8.0-rc.7" 10 | LABEL description="Lightning" 11 | LABEL org.opencontainers.image.title="Lightning" 12 | LABEL org.opencontainers.image.description="extensible chatbot connecting communities" 13 | LABEL org.opencontainers.image.version="0.8.0-rc.7" 14 | LABEL org.opencontainers.image.source="https://github.com/williamhorning/lightning" 15 | LABEL org.opencontainers.image.licenses="MIT" 16 | 17 | USER 1001:1001 18 | COPY --from=builder /out/lightning /lightning 19 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 20 | VOLUME [ "/data" ] 21 | WORKDIR /data 22 | 23 | ENTRYPOINT ["/lightning"] 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/williamhorning/lightning 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.5.0 7 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.33 8 | github.com/bwmarrin/discordgo v0.29.0 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/jackc/pgx/v5 v5.7.6 11 | github.com/oklog/ulid/v2 v2.1.1 12 | github.com/yuin/goldmark v1.7.13 13 | maunium.net/go/mautrix v0.25.2 14 | ) 15 | 16 | require ( 17 | filippo.io/edwards25519 v1.1.0 // indirect 18 | github.com/jackc/pgpassfile v1.0.0 // indirect 19 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 20 | github.com/jackc/puddle/v2 v2.2.2 // indirect 21 | github.com/mattn/go-colorable v0.1.14 // indirect 22 | github.com/mattn/go-isatty v0.0.20 // indirect 23 | github.com/mattn/go-sqlite3 v1.14.32 // indirect 24 | github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 // indirect 25 | github.com/rs/zerolog v1.34.0 // indirect 26 | github.com/tidwall/gjson v1.18.0 // indirect 27 | github.com/tidwall/match v1.1.1 // indirect 28 | github.com/tidwall/pretty v1.2.1 // indirect 29 | github.com/tidwall/sjson v1.2.5 // indirect 30 | go.mau.fi/util v0.9.2 // indirect 31 | golang.org/x/crypto v0.43.0 // indirect 32 | golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect 33 | golang.org/x/net v0.46.0 // indirect 34 | golang.org/x/sync v0.17.0 // indirect 35 | golang.org/x/sys v0.37.0 // indirect 36 | golang.org/x/text v0.30.0 // indirect 37 | ) 38 | 39 | retract v0.0.0 // test release 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 4 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 5 | github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 6 | github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= 7 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.33 h1:uyVD1QSS7ftd/DE2x5OFRx4PYyhq9n4edvFJRExVWVk= 8 | github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.33/go.mod h1:BSzsfjlE0wakLw2/U1FtO8rdVt+Z+4VyoGo/YcGD9QQ= 9 | github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= 10 | github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 11 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 16 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 17 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 18 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 19 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 20 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 21 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 22 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 23 | github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= 24 | github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 25 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 26 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 28 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 29 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 34 | github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 35 | github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 36 | github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= 37 | github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 38 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 39 | github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490 h1:QTvNkZ5ylY0PGgA+Lih+GdboMLY/G9SEGLMEGVjTVA4= 40 | github.com/petermattis/goid v0.0.0-20250904145737-900bdf8bb490/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 41 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 42 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 43 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 44 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 45 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 46 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 47 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 48 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 49 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 50 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 51 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 52 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 53 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 54 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 55 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 56 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 57 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 58 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 59 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 60 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 61 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 62 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 63 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 64 | go.mau.fi/util v0.9.2 h1:+S4Z03iCsGqU2WY8X2gySFsFjaLlUHFRDVCYvVwynKM= 65 | go.mau.fi/util v0.9.2/go.mod h1:055elBBCJSdhRsmub7ci9hXZPgGr1U6dYg44cSgRgoU= 66 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 67 | golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 68 | golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 69 | golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA= 70 | golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 71 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 72 | golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 73 | golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 74 | golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= 75 | golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 76 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 77 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 81 | golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 82 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 83 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 84 | golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 85 | golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 86 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 87 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 90 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 91 | maunium.net/go/mautrix v0.25.2 h1:CUG23zp754yGOTMh9Q4mVSENS9FyweE/G+6ZsPDMCUU= 92 | maunium.net/go/mautrix v0.25.2/go.mod h1:EWgYyp2iFZP7pnSm+rufHlO8YVnA2KnoNBDpwekiAwI= 93 | -------------------------------------------------------------------------------- /internal/bridge/bridge.go: -------------------------------------------------------------------------------- 1 | // Package bridge implements a bridge bot based on Lightning, the framework, for Lightning, the bot. 2 | package bridge 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "log" 8 | "sync" 9 | 10 | "github.com/williamhorning/lightning/internal/data" 11 | "github.com/williamhorning/lightning/pkg/lightning" 12 | ) 13 | 14 | func handleBridgeMessage(bot *lightning.Bot, database data.Database, event data.EventType, dat any) error { 15 | base := extractBase(dat) 16 | 17 | bridge, priorMsg, err := resolveBridgeData(database, base, event) 18 | if err != nil { 19 | return fmt.Errorf("failed to get bridge for (%s in %s): %w", base.EventID, base.ChannelID, err) 20 | } 21 | 22 | if bridge == nil || bridge.ID == "" || bridge.GetChannelDisabled(base.ChannelID).Read { 23 | return nil 24 | } 25 | 26 | repliedTo := getRepliedToMessage(database, dat) 27 | messages := processMessages(bot, database, bridge, event, base, dat, repliedTo, priorMsg) 28 | 29 | return updateDatabase(database, event, base, bridge, messages) 30 | } 31 | 32 | func resolveBridgeData( 33 | database data.Database, 34 | base lightning.BaseMessage, 35 | event data.EventType, 36 | ) (*data.Bridge, *data.BridgeMessageCollection, error) { 37 | switch event { 38 | case data.TypeCreate: 39 | bridge, err := database.GetBridgeByChannel(base.ChannelID) 40 | if err != nil { 41 | return nil, nil, fmt.Errorf("failed to get bridge from database: %w", err) 42 | } 43 | 44 | return &bridge, nil, nil 45 | case data.TypeEdit, data.TypeDelete: 46 | prior, err := database.GetMessage(base.EventID) 47 | if err != nil { 48 | return nil, nil, fmt.Errorf("failed to get message from database: %w", err) 49 | } 50 | 51 | if event == data.TypeEdit && prior.ID != base.EventID { 52 | return nil, nil, nil 53 | } 54 | 55 | bridge, err := database.GetBridge(prior.BridgeID) 56 | if err != nil { 57 | return nil, nil, fmt.Errorf("failed to get bridge from database: %w", err) 58 | } 59 | 60 | return &bridge, &prior, nil 61 | default: 62 | return nil, nil, nil 63 | } 64 | } 65 | 66 | func extractBase(dat any) lightning.BaseMessage { 67 | switch evt := dat.(type) { 68 | case lightning.EditedMessage: 69 | return evt.Message.BaseMessage 70 | case lightning.Message: 71 | return evt.BaseMessage 72 | case lightning.BaseMessage: 73 | return evt 74 | default: 75 | return lightning.BaseMessage{} 76 | } 77 | } 78 | 79 | func extractMessage(dat any) lightning.Message { 80 | switch v := dat.(type) { 81 | case lightning.EditedMessage: 82 | return *v.Message 83 | case lightning.Message: 84 | return v 85 | default: 86 | return lightning.Message{} 87 | } 88 | } 89 | 90 | func getRepliedToMessage(database data.Database, dat any) *data.BridgeMessageCollection { 91 | msg := extractMessage(dat) 92 | 93 | if len(msg.RepliedTo) == 0 { 94 | return nil 95 | } 96 | 97 | repliedTo, err := database.GetMessage(msg.RepliedTo[0]) 98 | if err != nil { 99 | log.Printf("bridge: failed to get replied_to for %s: %v\n", msg.RepliedTo[0], err) 100 | 101 | return nil 102 | } 103 | 104 | return &repliedTo 105 | } 106 | 107 | func updateDatabase( 108 | database data.Database, 109 | event data.EventType, 110 | base lightning.BaseMessage, 111 | bridge *data.Bridge, 112 | messages []data.ChannelMessage, 113 | ) error { 114 | switch event { 115 | case data.TypeCreate, data.TypeEdit: 116 | err := database.CreateMessage(data.BridgeMessageCollection{ 117 | ID: base.EventID, 118 | BridgeID: bridge.ID, 119 | Messages: messages, 120 | }) 121 | if err != nil { 122 | return fmt.Errorf("updateDatabase failed: %w", err) 123 | } 124 | case data.TypeDelete: 125 | err := database.DeleteMessage(base.EventID) 126 | if err != nil { 127 | return fmt.Errorf("updateDatabase failed: %w", err) 128 | } 129 | default: 130 | } 131 | 132 | return nil 133 | } 134 | 135 | func processMessages( 136 | bot *lightning.Bot, 137 | database data.Database, 138 | bridge *data.Bridge, 139 | event data.EventType, 140 | base lightning.BaseMessage, 141 | dat any, 142 | repliedTo *data.BridgeMessageCollection, 143 | priorMsg *data.BridgeMessageCollection, 144 | ) []data.ChannelMessage { 145 | messages := make([]data.ChannelMessage, 0, len(bridge.Channels)+1) 146 | results := make(chan *data.ChannelMessage, len(bridge.Channels)) 147 | wait := sync.WaitGroup{} 148 | 149 | for _, channel := range bridge.Channels { 150 | if channel.ID == base.ChannelID || channel.Disabled.Write { 151 | continue 152 | } 153 | 154 | wait.Go(func() { 155 | priorIDs := priorMsg.GetChannelMessageIDs(channel.ID) 156 | if event != data.TypeCreate && len(priorIDs) == 0 { 157 | return 158 | } 159 | 160 | message := handleChannel(bot, database, bridge, &channel, event, dat, repliedTo, priorIDs) 161 | if message != nil { 162 | results <- message 163 | } 164 | }) 165 | } 166 | 167 | wait.Wait() 168 | close(results) 169 | 170 | for msg := range results { 171 | messages = append(messages, *msg) 172 | } 173 | 174 | messages = append(messages, data.ChannelMessage{ 175 | ChannelID: base.ChannelID, 176 | MessageIDs: []string{base.EventID}, 177 | }) 178 | 179 | return messages 180 | } 181 | 182 | func handleChannel( 183 | bot *lightning.Bot, 184 | database data.Database, 185 | bridge *data.Bridge, 186 | channel *data.BridgeChannel, 187 | event data.EventType, 188 | dat any, 189 | repliedTo *data.BridgeMessageCollection, 190 | priorIDs []string, 191 | ) *data.ChannelMessage { 192 | defer func() { 193 | if r := recover(); r != nil { 194 | log.Printf("bridge: panic in channel %s: %#+v", channel.ID, r) 195 | } 196 | }() 197 | 198 | opts := &lightning.SendOptions{ 199 | AllowEveryonePings: bridge.Settings.AllowEveryone, 200 | ChannelData: channel.Data, 201 | } 202 | 203 | var err error 204 | 205 | resultIDs := priorIDs 206 | 207 | switch event { 208 | case data.TypeCreate, data.TypeEdit: 209 | msg := extractMessage(dat) 210 | msg.ChannelID = channel.ID 211 | msg.RepliedTo = repliedTo.GetChannelMessageIDs(channel.ID) 212 | 213 | if event == data.TypeCreate { 214 | resultIDs, err = bot.SendMessage(&msg, opts) 215 | } else if len(priorIDs) != 0 { 216 | err = bot.EditMessage(&msg, priorIDs, opts) 217 | } 218 | 219 | case data.TypeDelete: 220 | err = bot.DeleteMessages(channel.ID, priorIDs) 221 | default: 222 | } 223 | 224 | if err != nil { 225 | handleError(database, err, channel, bridge, event) 226 | 227 | return nil 228 | } 229 | 230 | return &data.ChannelMessage{ChannelID: channel.ID, MessageIDs: resultIDs} 231 | } 232 | 233 | func handleError( 234 | database data.Database, 235 | err error, 236 | channel *data.BridgeChannel, 237 | bridge *data.Bridge, 238 | event data.EventType, 239 | ) { 240 | var disabled lightning.ChannelDisabled 241 | 242 | disabler := new(lightning.ChannelDisabler) 243 | if errors.As(err, disabler) { 244 | if result := (*disabler).Disable(); result != nil { 245 | disabled = *result 246 | } 247 | } 248 | 249 | log.Printf("bridge: error in channel %s in bridge %s on %s: %v\n", channel.ID, bridge.ID, event, err) 250 | 251 | if !disabled.Read && !disabled.Write { 252 | return 253 | } 254 | 255 | for i, ch := range bridge.Channels { 256 | if ch.ID == channel.ID { 257 | bridge.Channels[i].Disabled = disabled 258 | 259 | break 260 | } 261 | } 262 | 263 | log.Printf("bridge: disabling channel %s in bridge %s on %s\n\tdisable: %#+v\n", 264 | bridge.ID, channel.ID, event, disabled) 265 | 266 | if err := database.CreateBridge(*bridge); err != nil { 267 | log.Printf("bridge: failed to disable %s in bridge %s: %v\n", channel.ID, bridge.ID, err) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /internal/bridge/config.go: -------------------------------------------------------------------------------- 1 | package bridge 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/williamhorning/lightning/internal/data" 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | ) 10 | 11 | // Config is the configuration for the bridge bot. 12 | type Config struct { 13 | Author *lightning.MessageAuthor `toml:"author,omitempty"` 14 | DatabaseConfig data.DatabaseConfig `toml:"database"` 15 | Plugins map[string]map[string]string `toml:"plugins"` 16 | CommandPrefix string `toml:"prefix,omitempty"` 17 | ErrorURL string `toml:"error_url"` 18 | LogLevel int `toml:"log_level"` 19 | } 20 | 21 | // GetConfig loads the configuration from the given file. 22 | func GetConfig(file string) (Config, bool) { 23 | var config Config 24 | 25 | if _, err := toml.DecodeFile(file, &config); err != nil { 26 | log.Printf("bridge: error loading config: %v\n", err) 27 | 28 | return config, false 29 | } 30 | 31 | if config.Author == nil { 32 | picture := "https://williamhorning.dev/assets/lightning.png" 33 | 34 | config.Author = &lightning.MessageAuthor{ 35 | ID: "lightning", 36 | Nickname: "Lightning", 37 | Username: "lightning", 38 | ProfilePicture: &picture, 39 | Color: "#487C7E", 40 | } 41 | } 42 | 43 | return config, true 44 | } 45 | -------------------------------------------------------------------------------- /internal/bridge/log.go: -------------------------------------------------------------------------------- 1 | package bridge 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | ) 12 | 13 | // SetupLogging creates a logger that deals with color and webhooks. 14 | func SetupLogging() *WebhookLogger { 15 | log.SetFlags(log.Ltime | log.Lshortfile) 16 | 17 | log.SetPrefix("") 18 | 19 | instance := &WebhookLogger{} 20 | 21 | log.SetOutput(io.MultiWriter(os.Stderr, instance)) 22 | 23 | return instance 24 | } 25 | 26 | // WebhookLogger is a custom log handler that sends logs to a webhook. 27 | type WebhookLogger struct { 28 | URL string 29 | } 30 | 31 | func (l *WebhookLogger) Write(output []byte) (int, error) { 32 | go func() { 33 | if l.URL == "" { 34 | return 35 | } 36 | 37 | data, err := json.Marshal(map[string]string{"content": "```ansi\n" + string(output) + "\n```"}) 38 | if err != nil { 39 | return 40 | } 41 | 42 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, l.URL, bytes.NewBuffer(data)) 43 | if err != nil { 44 | return 45 | } 46 | 47 | req.Header["Content-Type"] = []string{"application/json"} 48 | 49 | resp, err := http.DefaultClient.Do(req) 50 | if err != nil { 51 | return 52 | } 53 | 54 | if err := resp.Body.Close(); err != nil { 55 | return 56 | } 57 | }() 58 | 59 | return len(output), nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/bridge/setup.go: -------------------------------------------------------------------------------- 1 | package bridge 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/williamhorning/lightning/internal/commands" 7 | "github.com/williamhorning/lightning/internal/data" 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | ) 10 | 11 | // Setup the bridge system with the given database. 12 | func Setup(bot *lightning.Bot, author *lightning.MessageAuthor, database data.Database) { 13 | if err := bot.AddCommand( 14 | commands.BridgeCommand(database), commands.HelpCommand(author.Nickname), commands.PingCommand()); err != nil { 15 | log.Printf("bridge: failed to add commands: %v\n", err) 16 | } 17 | 18 | bot.AddHandler(func(_ *lightning.Bot, event *lightning.Message) { 19 | if err := handleBridgeMessage(bot, database, "create", *event); err != nil { 20 | log.Printf("bridge: creation failed: %v\n\tevent: %s\n", err, event.EventID) 21 | } 22 | }) 23 | 24 | bot.AddHandler(func(_ *lightning.Bot, event *lightning.EditedMessage) { 25 | if err := handleBridgeMessage(bot, database, "edit", *event); err != nil { 26 | log.Printf("bridge: editing failed: %v\n\tevent: %s\n", err, event.Message.EventID) 27 | } 28 | }) 29 | 30 | bot.AddHandler(func(_ *lightning.Bot, event *lightning.DeletedMessage) { 31 | if err := handleBridgeMessage(bot, database, "delete", *event); err != nil { 32 | log.Printf("bridge: deletion failed: %v\n\tevent: %s\n", err, event.EventID) 33 | } 34 | }) 35 | 36 | log.Println("bridge: set up!") 37 | } 38 | -------------------------------------------------------------------------------- /internal/cache/cache.go: -------------------------------------------------------------------------------- 1 | // Package cache provides a simple expiring cache 2 | package cache 3 | 4 | import ( 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // DefaultTTL is the default time-to-live for cache items. 10 | const DefaultTTL = time.Second * 30 11 | 12 | type cacheItem[T any] struct { 13 | Value T 14 | ExpiresAt time.Time 15 | } 16 | 17 | // An Expiring cache that automatically removes items, when given a TTL. 18 | type Expiring[K comparable, V any] struct { 19 | items map[K]cacheItem[V] 20 | mu sync.RWMutex 21 | TTL time.Duration 22 | } 23 | 24 | // Get a key from the cache, returning its value and whether it exists. 25 | func (c *Expiring[K, V]) Get(key K) (V, bool) { 26 | c.mu.RLock() 27 | item, exists := c.items[key] 28 | c.mu.RUnlock() 29 | 30 | if !exists { 31 | var zero V 32 | return zero, false 33 | } 34 | 35 | if time.Now().After(item.ExpiresAt) { 36 | c.mu.Lock() 37 | defer c.mu.Unlock() 38 | 39 | item, exists := c.items[key] 40 | if !exists || time.Now().After(item.ExpiresAt) { 41 | delete(c.items, key) 42 | var zero V 43 | return zero, false 44 | } 45 | } 46 | 47 | return item.Value, true 48 | } 49 | 50 | // Set a key in the cache, replacing any existing value. 51 | func (c *Expiring[K, V]) Set(key K, value V) { 52 | c.mu.Lock() 53 | defer c.mu.Unlock() 54 | 55 | if c.TTL == 0 { 56 | c.TTL = DefaultTTL 57 | } 58 | 59 | if c.items == nil { 60 | c.items = make(map[K]cacheItem[V]) 61 | } 62 | 63 | c.items[key] = cacheItem[V]{ 64 | Value: value, 65 | ExpiresAt: time.Now().Add(c.TTL), 66 | } 67 | } 68 | 69 | // Delete a key from the cache. 70 | func (c *Expiring[K, V]) Delete(key K) { 71 | c.mu.Lock() 72 | defer c.mu.Unlock() 73 | 74 | delete(c.items, key) 75 | } 76 | -------------------------------------------------------------------------------- /internal/commands/basics.go: -------------------------------------------------------------------------------- 1 | // Package commands implements the commands used by the Lightning bridge bot. 2 | package commands 3 | 4 | import ( 5 | "log" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/williamhorning/lightning/pkg/lightning" 10 | ) 11 | 12 | // HelpCommand provides `!help`. 13 | func HelpCommand(username string) *lightning.Command { 14 | return &lightning.Command{ 15 | Name: "help", 16 | Description: "get help with the bot", 17 | Executor: func(opts *lightning.CommandOptions) { 18 | msg := getMessage( 19 | username+" help:", 20 | "hi! i'm "+username+" "+lightning.VERSION+"!\n\n"+ 21 | "available commands are:\n"+ 22 | "- `bridge`: manage bridges between channels\n"+ 23 | "- `help`: get help with the bot\n"+ 24 | "- `ping`: check if the bot is alive\n\n"+ 25 | "read the [docs](https://williamhorning.dev/lightning) for more help", 26 | ) 27 | 28 | if err := opts.Reply(msg, false); err != nil { 29 | log.Printf("failed to reply to help command: %v\n", err) 30 | } 31 | }, 32 | } 33 | } 34 | 35 | // PingCommand provides `!ping`. 36 | func PingCommand() *lightning.Command { 37 | return &lightning.Command{ 38 | Name: "ping", 39 | Description: "check if the bot is alive", 40 | Executor: func(opts *lightning.CommandOptions) { 41 | if err := opts.Reply(getMessage("Pong! 🏓 ", 42 | strconv.FormatInt(time.Since(*opts.Time).Milliseconds(), 10)+"ms"), false); err != nil { 43 | log.Printf("failed to reply to ping command: %v\n", err) 44 | } 45 | }, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/commands/bridge.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/williamhorning/lightning/internal/data" 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | ) 10 | 11 | // BridgeCommand provides `!bridge`. 12 | func BridgeCommand(database data.Database) *lightning.Command { 13 | return &lightning.Command{ 14 | Name: "bridge", 15 | Description: "manage bridges between channels", 16 | Executor: func(opts *lightning.CommandOptions) { 17 | if err := opts.Reply(getMessage("the `bridge` command", 18 | "This command allows you to create and manage bridges between channels on different platforms. "+ 19 | "Subcommands that are available are:\n"+ 20 | "- `create`: Create a new bridge in this channel.\n"+ 21 | "- `join `: Join an existing bridge with the given ID.\n"+ 22 | "- `subscribe `: Subscribe to an existing bridge with the given ID (read-only).\n"+ 23 | "- `leave `: Leave the bridge that this channel is part of.\n"+ 24 | "- `toggle `: Toggle a setting for the bridge that this channel is part of.\n"+ 25 | "- `status`: Get the status of the bridge that this channel is part of.\n\n"+ 26 | "Available settings are: `allow_everyone`."), false); err != nil { 27 | log.Printf("failed to reply to bridge command: %v\n", err) 28 | } 29 | }, 30 | Subcommands: map[string]*lightning.Command{ 31 | "create": bridgeCreate(database), "join": bridgeJoin(database, "join"), 32 | "subscribe": bridgeJoin(database, "subscribe"), "leave": bridgeLeave(database), 33 | "toggle": bridgeToggle(database), "status": bridgeStatus(database), 34 | }, 35 | } 36 | } 37 | 38 | func prepareChannelForBridge(db data.Database, opts *lightning.CommandOptions) (*data.BridgeChannel, error) { 39 | if br, err := db.GetBridgeByChannel(opts.ChannelID); br.ID != "" || err != nil { 40 | return nil, alreadyInBridgeError{} 41 | } 42 | 43 | channelData, err := opts.Bot.SetupChannel(opts.ChannelID) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to setup channel for bridge: %w\n\tchannel: %s", err, opts.ChannelID) 46 | } 47 | 48 | return &data.BridgeChannel{Data: channelData, ID: opts.ChannelID, Disabled: lightning.ChannelDisabled{}}, nil 49 | } 50 | 51 | type alreadyInBridgeError struct{} 52 | 53 | func (alreadyInBridgeError) Error() string { 54 | return "this channel is already part of a bridge. please leave the bridge first" 55 | } 56 | 57 | type bridgeNotFoundError struct{} 58 | 59 | func (bridgeNotFoundError) Error() string { 60 | return "bridge not found" 61 | } 62 | 63 | type missingArgumentError struct { 64 | argument string 65 | } 66 | 67 | func (e missingArgumentError) Error() string { 68 | return "argument " + e.argument + " is required" 69 | } 70 | -------------------------------------------------------------------------------- /internal/commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/williamhorning/lightning/pkg/lightning" 8 | ) 9 | 10 | func sendErr(err error, msg string, opts *lightning.CommandOptions) { 11 | log.Printf("command error: %v\n", err) 12 | 13 | if err = opts.Reply(getMessage("something went wrong :(", "uh oh! looks like you got struck by an error: "+ 14 | msg+"\n\n```\n"+err.Error()+"\n```\nif you think this is a bug, or need more help, see the "+ 15 | "[docs](https://williamhorning.dev/lightning/bridge)"), false); err != nil { 16 | log.Printf("failed to reply with error to command: %v\n", err) 17 | } 18 | } 19 | 20 | func getTime() *string { 21 | str := time.Now().Format(time.RFC3339) 22 | 23 | return &str 24 | } 25 | 26 | func getMessage(title, description string) *lightning.Message { 27 | color := 0x487C7E 28 | lightningProfileURL := "https://williamhorning.dev/assets/lightning.png" 29 | 30 | if title == "something went wrong :(" { 31 | color = 0xFF0000 32 | } 33 | 34 | return &lightning.Message{Embeds: []lightning.Embed{{ 35 | Title: &title, 36 | Description: &description, 37 | Color: &color, 38 | Footer: &lightning.EmbedFooter{ 39 | Text: "powered by lightning", 40 | IconURL: &lightningProfileURL, 41 | }, 42 | Timestamp: getTime(), 43 | }}} 44 | } 45 | -------------------------------------------------------------------------------- /internal/commands/create.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/oklog/ulid/v2" 7 | "github.com/williamhorning/lightning/internal/data" 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | ) 10 | 11 | func bridgeCreate(database data.Database) *lightning.Command { 12 | return &lightning.Command{ 13 | Name: "create", 14 | Description: "create a new bridge in this channel", 15 | Executor: func(opts *lightning.CommandOptions) { 16 | channel, err := prepareChannelForBridge(database, opts) 17 | if err != nil { 18 | sendErr(err, "failed to setup channel", opts) 19 | 20 | return 21 | } 22 | 23 | bridge := data.Bridge{ 24 | ID: ulid.Make().String(), 25 | Channels: []data.BridgeChannel{*channel}, 26 | Settings: data.BridgeSettings{}, 27 | } 28 | 29 | if err = database.CreateBridge(bridge); err != nil { 30 | sendErr(err, "failed to save to database", opts) 31 | 32 | return 33 | } 34 | 35 | if err = opts.Reply(getMessage("created bridge!", 36 | "you can now join the bridge you made in other channels by using ||`"+opts.Prefix+"bridge join "+ 37 | bridge.ID+"`||. Keep that command secret!"), true); err != nil { 38 | sendErr(err, "failed to respond to create command", opts) 39 | 40 | bridge.Channels = []data.BridgeChannel{} 41 | 42 | if err = database.CreateBridge(bridge); err != nil { 43 | log.Printf("failed to remove bridge after failed command response: %v\n", err) 44 | } 45 | } 46 | }, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/commands/join.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/williamhorning/lightning/internal/data" 5 | "github.com/williamhorning/lightning/pkg/lightning" 6 | ) 7 | 8 | func bridgeJoin(database data.Database, name string) *lightning.Command { 9 | cmd := &lightning.Command{ 10 | Name: name, 11 | Description: "join an existing bridge with the given ID", 12 | Arguments: []*lightning.CommandArgument{{Name: "id", Description: "the bridge id to use", Required: true}}, 13 | Executor: func(opts *lightning.CommandOptions) { 14 | if opts.Arguments["id"] == "" { 15 | sendErr(missingArgumentError{argument: "id"}, "missing argument", opts) 16 | 17 | return 18 | } 19 | 20 | bridge, err := database.GetBridge(opts.Arguments["id"]) 21 | if err != nil { 22 | sendErr(err, "failed to get bridge by id", opts) 23 | 24 | return 25 | } else if bridge.ID == "" { 26 | sendErr(bridgeNotFoundError{}, "no bridge with that ID exists", opts) 27 | 28 | return 29 | } 30 | 31 | channel, err := prepareChannelForBridge(database, opts) 32 | if err != nil { 33 | sendErr(err, "failed to setup channel for bridge", opts) 34 | 35 | return 36 | } 37 | 38 | channel.Disabled = lightning.ChannelDisabled{Read: name == "subscribe", Write: false} 39 | bridge.Channels = append(bridge.Channels, *channel) 40 | 41 | if err = database.CreateBridge(bridge); err != nil { 42 | sendErr(err, "failed to update bridge in the database", opts) 43 | 44 | return 45 | } 46 | 47 | if err = opts.Reply( 48 | getMessage("joined bridge!", "you successfully joined the bridge `"+bridge.ID+"`!"), true, 49 | ); err != nil { 50 | sendErr(err, "failed to respond to command", opts) 51 | } 52 | }, 53 | } 54 | 55 | if name == "subscribe" { 56 | cmd.Description = "subscribe to an existing bridge with the given ID (read-only)" 57 | } 58 | 59 | return cmd 60 | } 61 | -------------------------------------------------------------------------------- /internal/commands/leave.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "slices" 5 | 6 | "github.com/williamhorning/lightning/internal/data" 7 | "github.com/williamhorning/lightning/pkg/lightning" 8 | ) 9 | 10 | func bridgeLeave(database data.Database) *lightning.Command { 11 | return &lightning.Command{ 12 | Name: "leave", 13 | Description: "leave the bridge that this channel is part of", 14 | Arguments: []*lightning.CommandArgument{{Name: "id", Description: "the bridge id to use", Required: true}}, 15 | Executor: func(opts *lightning.CommandOptions) { 16 | if opts.Arguments["id"] == "" { 17 | sendErr(missingArgumentError{argument: "id"}, "missing argument", opts) 18 | 19 | return 20 | } 21 | 22 | bridge, err := database.GetBridgeByChannel(opts.ChannelID) 23 | if err != nil { 24 | sendErr(err, "failed to get bridge from channel", opts) 25 | 26 | return 27 | } else if bridge.ID == "" { 28 | sendErr(bridgeNotFoundError{}, "this channel is not part of a bridge", opts) 29 | 30 | return 31 | } 32 | 33 | if opts.Arguments["id"] != bridge.ID { 34 | sendErr(bridgeNotFoundError{}, "this channel is not part of a bridge with that ID", opts) 35 | 36 | return 37 | } 38 | 39 | for idx, channel := range bridge.Channels { 40 | if channel.ID == opts.ChannelID { 41 | bridge.Channels = slices.Delete(bridge.Channels, idx, idx+1) 42 | 43 | break 44 | } 45 | } 46 | 47 | if err = database.CreateBridge(bridge); err != nil { 48 | sendErr(err, "failed to update database", opts) 49 | 50 | return 51 | } 52 | 53 | if err = opts.Reply( 54 | getMessage("left bridge!", "you successfully left the bridge `"+bridge.ID+"`!"), true, 55 | ); err != nil { 56 | sendErr(err, "failed to respond to command", opts) 57 | } 58 | }, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /internal/commands/status.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/williamhorning/lightning/internal/data" 7 | "github.com/williamhorning/lightning/pkg/lightning" 8 | ) 9 | 10 | func bridgeStatus(database data.Database) *lightning.Command { 11 | return &lightning.Command{ 12 | Name: "status", 13 | Description: "get the status of the bridge that this channel is part of", 14 | Executor: func(opts *lightning.CommandOptions) { 15 | bridge, err := database.GetBridgeByChannel(opts.ChannelID) 16 | if err != nil { 17 | sendErr(err, "failed to get channel for bridge", opts) 18 | 19 | return 20 | } else if bridge.ID == "" { 21 | sendErr(bridgeNotFoundError{}, "this channel is not part of a bridge", opts) 22 | 23 | return 24 | } 25 | 26 | status := "Channels:\n\n" 27 | 28 | for i, channel := range bridge.Channels { 29 | status += strconv.FormatInt(int64(i+1), 10) + ". `" + channel.ID + "`" 30 | 31 | if channel.Disabled.Read { 32 | status += " (subscribed)" 33 | } 34 | 35 | if channel.Disabled.Write { 36 | status += " (read only: you may need to check your permissions, and rejoin the bridge)" 37 | } 38 | 39 | status += "\n" 40 | } 41 | 42 | status += "\n\n" + getSettingsString(&bridge) 43 | 44 | if err = opts.Reply(getMessage("bridge status", status), false); err != nil { 45 | sendErr(err, "failed to respond to command", opts) 46 | } 47 | }, 48 | } 49 | } 50 | 51 | func getSettingsString(bridge *data.Bridge) string { 52 | emoji := map[bool]string{true: "✔", false: "❌"} 53 | 54 | return "Settings: \n\n- AllowEveryone: `" + emoji[bridge.Settings.AllowEveryone] + "`\n" 55 | } 56 | -------------------------------------------------------------------------------- /internal/commands/toggle.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/williamhorning/lightning/internal/data" 5 | "github.com/williamhorning/lightning/pkg/lightning" 6 | ) 7 | 8 | func bridgeToggle(database data.Database) *lightning.Command { 9 | return &lightning.Command{ 10 | Name: "toggle", 11 | Description: "toggle a setting for the bridge that this channel is part of", 12 | Arguments: []*lightning.CommandArgument{{Name: "setting", Description: "setting to toggle", Required: true}}, 13 | Executor: func(opts *lightning.CommandOptions) { 14 | if opts.Arguments["setting"] == "" { 15 | sendErr(missingArgumentError{argument: "setting"}, "missing argument", opts) 16 | 17 | return 18 | } 19 | 20 | bridge, err := database.GetBridgeByChannel(opts.ChannelID) 21 | if err != nil { 22 | sendErr(err, "failed to get bridge for channel", opts) 23 | 24 | return 25 | } else if bridge.ID == "" { 26 | sendErr(bridgeNotFoundError{}, "this channel is not part of a bridge", opts) 27 | 28 | return 29 | } 30 | 31 | if opts.Arguments["setting"] != "allow_everyone" { 32 | sendErr(missingArgumentError{argument: "setting"}, "invalid argument", opts) 33 | 34 | return 35 | } 36 | 37 | bridge.Settings.AllowEveryone = !bridge.Settings.AllowEveryone 38 | 39 | if err = database.CreateBridge(bridge); err != nil { 40 | sendErr(err, "failed to set settings", opts) 41 | 42 | return 43 | } 44 | 45 | if err = opts.Reply(getMessage("toggled setting!", getSettingsString(&bridge)), false); err != nil { 46 | sendErr(err, "failed to respond to command", opts) 47 | } 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/data/database.go: -------------------------------------------------------------------------------- 1 | // Package data provides the database used by the Lightning bridge bot. 2 | package data 3 | 4 | // The Database implementation used by the bridge system. 5 | type Database interface { 6 | CreateBridge(bridge Bridge) error 7 | GetBridge(id string) (Bridge, error) 8 | GetBridgeByChannel(channelID string) (Bridge, error) 9 | CreateMessage(message BridgeMessageCollection) error 10 | DeleteMessage(id string) error 11 | GetMessage(id string) (BridgeMessageCollection, error) 12 | } 13 | 14 | // DatabaseConfig is the configuration for a database used by the bridge system. 15 | type DatabaseConfig struct { 16 | Type string `toml:"type"` 17 | Connection string `toml:"connection"` 18 | } 19 | 20 | // GetDatabase returns a Database based on the configuration. 21 | func (config DatabaseConfig) GetDatabase() (Database, error) { 22 | switch config.Type { 23 | case "postgres": 24 | return newPostgresDatabase(config.Connection) 25 | default: 26 | return nil, UnsupportedDatabaseTypeError{} 27 | } 28 | } 29 | 30 | // UnsupportedDatabaseTypeError is returned when an unsupported database type is given in configuration. 31 | type UnsupportedDatabaseTypeError struct{} 32 | 33 | func (UnsupportedDatabaseTypeError) Error() string { 34 | return "unsupported database type, must be 'postgres'" 35 | } 36 | -------------------------------------------------------------------------------- /internal/data/postgres.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log" 10 | 11 | "github.com/jackc/pgx/v5/pgxpool" 12 | "github.com/jackc/pgx/v5/stdlib" 13 | ) 14 | 15 | type postgresDatabase struct { 16 | db *sql.DB 17 | } 18 | 19 | func newPostgresDatabase(conn string) (Database, error) { 20 | pool, err := pgxpool.New(context.Background(), conn) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to make connection pool: %w", err) 23 | } 24 | 25 | pgdb := &postgresDatabase{stdlib.OpenDBFromPool(pool)} 26 | 27 | if err := pgdb.setupDatabase(); err != nil { 28 | if closeErr := pgdb.db.Close(); closeErr != nil { 29 | log.Printf("data: failed to close connection: %v\n", err) 30 | } 31 | 32 | return nil, fmt.Errorf("failed to setup schema: %w", err) 33 | } 34 | 35 | return pgdb, nil 36 | } 37 | 38 | func (p *postgresDatabase) CreateBridge(bridgeData Bridge) error { 39 | return p.withTx(func(txn *sql.Tx) error { 40 | settings, err := json.Marshal(bridgeData.Settings) 41 | if err != nil { 42 | return fmt.Errorf("failed to marshal settings: %w", err) 43 | } 44 | 45 | if _, err := txn.ExecContext(context.Background(), insertBridge, bridgeData.ID, settings); err != nil { 46 | return fmt.Errorf("failed to insert bridge: %w", err) 47 | } 48 | 49 | if _, err := txn.ExecContext(context.Background(), deleteBridgeChannelsQuery, bridgeData.ID); err != nil { 50 | return fmt.Errorf("failed to delete old channels: %w", err) 51 | } 52 | 53 | for _, channel := range bridgeData.Channels { 54 | data, err := json.Marshal(channel.Data) 55 | if err != nil { 56 | return fmt.Errorf("failed to marshal channel data: %w", err) 57 | } 58 | 59 | disabled, err := json.Marshal(channel.Disabled) 60 | if err != nil { 61 | return fmt.Errorf("failed to marshal channel disable information: %w", err) 62 | } 63 | 64 | if _, err := txn.ExecContext(context.Background(), insertChannel, 65 | bridgeData.ID, channel.ID, data, disabled); err != nil { 66 | return fmt.Errorf("failed to insert channel: %w", err) 67 | } 68 | } 69 | 70 | return nil 71 | }) 72 | } 73 | 74 | func (p *postgresDatabase) GetBridge(brID string) (Bridge, error) { 75 | var ( 76 | bridgeData Bridge 77 | settings json.RawMessage 78 | ) 79 | 80 | bridgeData.ID = brID 81 | 82 | if err := p.db.QueryRowContext(context.Background(), selectBridgeSettingsByID, brID).Scan(&settings); err != nil { 83 | if errors.Is(err, sql.ErrNoRows) { 84 | return Bridge{}, nil 85 | } 86 | 87 | return Bridge{}, fmt.Errorf("failed to query bridge settings: %w", err) 88 | } 89 | 90 | if err := json.Unmarshal(settings, &bridgeData.Settings); err != nil { 91 | return Bridge{}, fmt.Errorf("failed to unmarshal settings: %w", err) 92 | } 93 | 94 | rows, err := p.db.QueryContext(context.Background(), selectBridgeChannelsQuery, brID) 95 | if err != nil { 96 | return Bridge{}, fmt.Errorf("failed to query channels: %w", err) 97 | } 98 | 99 | defer func() { 100 | if err := rows.Close(); err != nil { 101 | log.Printf("data: failed to close rows: %v\n", err) 102 | } 103 | }() 104 | 105 | for rows.Next() { 106 | channel, err := getChannelRow(rows) 107 | if err != nil { 108 | return Bridge{}, fmt.Errorf("failed to get channels: %w", err) 109 | } 110 | 111 | bridgeData.Channels = append(bridgeData.Channels, channel) 112 | } 113 | 114 | if err := rows.Err(); err != nil { 115 | return Bridge{}, fmt.Errorf("failed to iterate channels: %w", err) 116 | } 117 | 118 | return bridgeData, nil 119 | } 120 | 121 | func (p *postgresDatabase) GetBridgeByChannel(chID string) (Bridge, error) { 122 | var bID string 123 | 124 | err := p.db.QueryRowContext(context.Background(), 125 | selectBridgeByChannelQuery, chID).Scan(&bID) 126 | if err != nil { 127 | if errors.Is(err, sql.ErrNoRows) { 128 | return Bridge{}, nil 129 | } 130 | 131 | return Bridge{}, fmt.Errorf("failed to query channel in bridge: %w", err) 132 | } 133 | 134 | return p.GetBridge(bID) 135 | } 136 | 137 | func (p *postgresDatabase) CreateMessage(message BridgeMessageCollection) error { 138 | data, err := json.Marshal(message.Messages) 139 | if err != nil { 140 | return fmt.Errorf("failed to marshal messages: %w", err) 141 | } 142 | 143 | return p.exec(insertMessage, message.ID, message.BridgeID, data) 144 | } 145 | 146 | func (p *postgresDatabase) GetMessage(msgID string) (BridgeMessageCollection, error) { 147 | var ( 148 | message BridgeMessageCollection 149 | data sql.NullString 150 | ) 151 | 152 | err := p.db.QueryRowContext(context.Background(), selectMessageCollectionQuery, msgID). 153 | Scan(&message.ID, &message.BridgeID, &data) 154 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 155 | return BridgeMessageCollection{}, fmt.Errorf("failed to query message: %w", err) 156 | } else if errors.Is(err, sql.ErrNoRows) { 157 | return BridgeMessageCollection{}, nil 158 | } 159 | 160 | if err := json.Unmarshal([]byte(data.String), &message.Messages); err != nil { 161 | return BridgeMessageCollection{}, fmt.Errorf("failed to unmarshal message: %w", err) 162 | } 163 | 164 | return message, nil 165 | } 166 | 167 | func (p *postgresDatabase) DeleteMessage(id string) error { 168 | var realID string 169 | 170 | err := p.db.QueryRowContext(context.Background(), selectMessageIDQuery, id).Scan(&realID) 171 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 172 | return fmt.Errorf("failed to query message: %w", err) 173 | } 174 | 175 | if realID != "" { 176 | return p.exec(deleteMessageCollectionQuery, realID) 177 | } 178 | 179 | return nil 180 | } 181 | 182 | func (p *postgresDatabase) setupDatabase() error { 183 | if err := p.exec(createTables); err != nil { 184 | return fmt.Errorf("failed to create tables: %w", err) 185 | } 186 | 187 | version := "0.8.1" 188 | 189 | err := p.db.QueryRowContext(context.Background(), selectDatabaseVersionQuery).Scan(&version) 190 | if errors.Is(err, sql.ErrNoRows) { 191 | if err = p.exec(insertDatabaseVersionQuery); err != nil { 192 | return fmt.Errorf("failed to get init version: %w", err) 193 | } 194 | } else if err != nil { 195 | return fmt.Errorf("failed to get db version: %w", err) 196 | } 197 | 198 | switch version { 199 | case "0.8.2": 200 | return nil 201 | case "0.8.1": 202 | if err = p.exec(`UPDATE lightning SET value='0.8.2' WHERE prop='db_data_version';`); err != nil { 203 | return fmt.Errorf("error setting db data version to the latest: %w", err) 204 | } 205 | 206 | return nil 207 | default: 208 | log.Println("migration from databases from before v0.8.0-beta.8 are not supported.") 209 | 210 | return UnsupportedDatabaseTypeError{} 211 | } 212 | } 213 | 214 | func (p *postgresDatabase) exec(query string, args ...any) error { 215 | if _, err := p.db.ExecContext(context.Background(), query, args...); err != nil { 216 | return fmt.Errorf("exec failed: %w", err) 217 | } 218 | 219 | return nil 220 | } 221 | 222 | func (p *postgresDatabase) withTx(txnfn func(*sql.Tx) error) error { 223 | txn, err := p.db.BeginTx(context.Background(), nil) 224 | if err != nil { 225 | return fmt.Errorf("failed to begin txn: %w", err) 226 | } 227 | 228 | defer func() { 229 | if err := txn.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) { 230 | log.Printf("data: txn rollback failed: %v\n", err) 231 | } 232 | }() 233 | 234 | if err := txnfn(txn); err != nil { 235 | return err 236 | } 237 | 238 | if err := txn.Commit(); err != nil { 239 | return fmt.Errorf("failed to commit txn: %w", err) 240 | } 241 | 242 | return nil 243 | } 244 | 245 | func getChannelRow(rows *sql.Rows) (BridgeChannel, error) { 246 | var ( 247 | channel BridgeChannel 248 | data, disabled json.RawMessage 249 | ) 250 | 251 | if err := rows.Scan(&channel.ID, &data, &disabled); err != nil { 252 | return BridgeChannel{}, fmt.Errorf("failed to scan channel row: %w", err) 253 | } 254 | 255 | if err := json.Unmarshal(data, &channel.Data); err != nil { 256 | return BridgeChannel{}, fmt.Errorf("failed to unmarshal channel data: %w", err) 257 | } 258 | 259 | if err := json.Unmarshal(disabled, &channel.Disabled); err != nil { 260 | return BridgeChannel{}, fmt.Errorf("failed to unmarshal disabled information: %w", err) 261 | } 262 | 263 | return channel, nil 264 | } 265 | -------------------------------------------------------------------------------- /internal/data/queries.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | const ( 4 | createTables = ` 5 | CREATE TABLE IF NOT EXISTS bridges ( 6 | id TEXT PRIMARY KEY, 7 | settings JSONB NOT NULL DEFAULT '{"allow_everyone": false}'::jsonb 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS bridge_channels ( 11 | bridge_id TEXT NOT NULL REFERENCES bridges(id) ON DELETE CASCADE, 12 | channel_id TEXT NOT NULL, 13 | data JSONB DEFAULT '{}'::jsonb, 14 | disabled JSONB NOT NULL DEFAULT '{"read": false, "write": false}'::jsonb, 15 | PRIMARY KEY (bridge_id, channel_id) 16 | ); 17 | 18 | CREATE TABLE IF NOT EXISTS bridge_messages ( 19 | id TEXT PRIMARY KEY, 20 | bridge_id TEXT NOT NULL REFERENCES bridges(id) ON DELETE CASCADE, 21 | messages JSONB NOT NULL DEFAULT '[]'::jsonb 22 | ); 23 | 24 | CREATE TABLE IF NOT EXISTS lightning ( 25 | prop TEXT PRIMARY KEY, 26 | value TEXT NOT NULL 27 | ); 28 | 29 | CREATE INDEX IF NOT EXISTS idx_bridge_channels_channel_id ON bridge_channels (channel_id); 30 | CREATE INDEX IF NOT EXISTS idx_bridge_channels_bridge_id ON bridge_channels (bridge_id); 31 | CREATE INDEX IF NOT EXISTS idx_bridge_messages_bridge_id ON bridge_messages (bridge_id); 32 | CREATE INDEX IF NOT EXISTS idx_bridge_messages_gin ON bridge_messages USING GIN (messages jsonb_path_ops);` 33 | 34 | insertBridge = ` 35 | INSERT INTO bridges (id, settings) VALUES ($1, $2) 36 | ON CONFLICT (id) DO UPDATE SET settings = EXCLUDED.settings 37 | WHERE bridges.settings IS DISTINCT FROM EXCLUDED.settings;` 38 | 39 | insertChannel = `INSERT INTO bridge_channels (bridge_id, channel_id, data, disabled) VALUES ($1, $2, $3, $4);` 40 | 41 | insertMessage = ` 42 | INSERT INTO bridge_messages (id, bridge_id, messages) 43 | VALUES ($1, $2, $3) 44 | ON CONFLICT (id) DO UPDATE 45 | SET messages = EXCLUDED.messages, bridge_id = EXCLUDED.bridge_id 46 | WHERE bridge_messages.messages IS DISTINCT FROM EXCLUDED.messages;` 47 | 48 | selectBridgeSettingsByID = `SELECT settings FROM bridges WHERE id = $1;` 49 | 50 | selectBridgeByChannelQuery = `SELECT bridge_id FROM bridge_channels WHERE channel_id = $1;` 51 | 52 | selectBridgeChannelsQuery = ` 53 | SELECT channel_id, COALESCE(data, '{}'), disabled FROM bridge_channels WHERE bridge_id = $1;` 54 | 55 | selectMessageCollectionQuery = ` 56 | SELECT id, bridge_id, messages FROM bridge_messages 57 | WHERE messages @> format('[{"message_ids":["%s"]}]', $1::text)::jsonb LIMIT 1;` 58 | 59 | selectMessageIDQuery = ` 60 | SELECT id FROM bridge_messages 61 | WHERE messages @> format('[{"message_ids":["%s"]}]', $1::text)::jsonb LIMIT 1;` 62 | 63 | deleteBridgeChannelsQuery = `DELETE FROM bridge_channels WHERE bridge_id = $1;` 64 | 65 | deleteMessageCollectionQuery = `DELETE FROM bridge_messages WHERE id = $1;` 66 | 67 | selectDatabaseVersionQuery = `SELECT value FROM lightning WHERE prop = 'db_data_version';` 68 | 69 | insertDatabaseVersionQuery = `INSERT INTO lightning (prop, value) VALUES ('db_data_version', '0.8.2');` 70 | ) 71 | -------------------------------------------------------------------------------- /internal/data/types.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/williamhorning/lightning/pkg/lightning" 4 | 5 | // EventType is the event type used by the bridge. 6 | type EventType string 7 | 8 | // These event types are supported by the bridge. 9 | const ( 10 | TypeCreate EventType = "create" 11 | TypeEdit EventType = "edit" 12 | TypeDelete EventType = "delete" 13 | ) 14 | 15 | // BridgeSettings are used to configure the bridge. 16 | type BridgeSettings struct { 17 | AllowEveryone bool `json:"allow_everyone"` 18 | } 19 | 20 | // BridgeChannel represents a channel in a bridge. 21 | type BridgeChannel struct { 22 | Data any `json:"data,omitempty"` 23 | ID string `json:"id"` 24 | Disabled lightning.ChannelDisabled `json:"disabled"` 25 | } 26 | 27 | // Bridge represents a collection of channels to send and receive messages between. 28 | type Bridge struct { 29 | ID string `json:"id"` 30 | Channels []BridgeChannel `json:"channels"` 31 | Settings BridgeSettings `json:"settings"` 32 | } 33 | 34 | // ChannelMessage represents a collection of message IDs for a specific channel. 35 | type ChannelMessage struct { 36 | ChannelID string `json:"channel_id"` 37 | MessageIDs []string `json:"message_ids"` 38 | } 39 | 40 | // BridgeMessageCollection represents a collection of messages for a specific bridge. 41 | type BridgeMessageCollection struct { 42 | ID string `json:"id"` 43 | BridgeID string `json:"bridge_id"` 44 | Messages []ChannelMessage `json:"messages"` 45 | } 46 | 47 | // GetChannelMessageIDs returns the message IDs for a specific channel in the bridge message collection. 48 | func (m *BridgeMessageCollection) GetChannelMessageIDs(channelID string) []string { 49 | if m == nil { 50 | return nil 51 | } 52 | 53 | for _, msg := range m.Messages { 54 | if msg.ChannelID == channelID { 55 | return msg.MessageIDs 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | // GetChannelDisabled returns the disabled status for a specific channel in the bridge. 63 | func (b *Bridge) GetChannelDisabled(channelID string) lightning.ChannelDisabled { 64 | for _, channel := range b.Channels { 65 | if channel.ID == channelID { 66 | return channel.Disabled 67 | } 68 | } 69 | 70 | return lightning.ChannelDisabled{Read: false, Write: false} 71 | } 72 | -------------------------------------------------------------------------------- /internal/rvapi/fetch.go: -------------------------------------------------------------------------------- 1 | package rvapi 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | // Get creates a request, setting the pointer to the body, and returning encountered errors. 14 | func Get[T any](s *Session, path string, val *T) error { 15 | body, code, err := s.Fetch(http.MethodGet, path, nil) 16 | if err != nil || code != 200 { 17 | return err 18 | } 19 | 20 | defer func() { 21 | if err = body.Close(); err != nil { 22 | log.Printf("rvapi: failed to close body: %v\n", err) 23 | } 24 | }() 25 | 26 | bytes, err := io.ReadAll(body) 27 | if err != nil { 28 | return fmt.Errorf("rvapi: failed to read body: %w", err) 29 | } 30 | 31 | if err = json.Unmarshal(bytes, val); err != nil { 32 | return fmt.Errorf("rvapi: failed to unmarshal body: %w", err) 33 | } 34 | 35 | return nil 36 | } 37 | 38 | // Fetch returns a request body, status code, and/or possible error from the Stoat API. 39 | func (s *Session) Fetch(method, endpoint string, body io.Reader) (io.ReadCloser, int, error) { 40 | url := "https://api.stoat.chat/0.8" + endpoint 41 | 42 | req, err := http.NewRequestWithContext(context.Background(), method, url, body) 43 | if err != nil { 44 | return nil, 0, fmt.Errorf("rvapi: failed to create request: %w\n\tendpoint: %s\n\tmethod: %s", 45 | err, endpoint, method) 46 | } 47 | 48 | req.Header["X-Bot-Token"] = []string{s.Token} 49 | req.Header["Content-Type"] = []string{"application/json"} 50 | req.Header["User-Agent"] = []string{"rvapi/0.8.0-rc.7"} 51 | 52 | resp, err := http.DefaultClient.Do(req) 53 | if err != nil { 54 | return nil, 0, fmt.Errorf("rvapi: failed to make request: %w\n\tendpoint: %s\n\tmethod: %s", 55 | err, endpoint, method) 56 | } 57 | 58 | if method != http.MethodGet && resp.StatusCode == http.StatusTooManyRequests { 59 | return handleRatelimiting(s, resp, method, endpoint, body) 60 | } 61 | 62 | return resp.Body, resp.StatusCode, nil 63 | } 64 | 65 | func handleRatelimiting( 66 | session *Session, 67 | resp *http.Response, 68 | method, endpoint string, 69 | body io.Reader, 70 | ) (io.ReadCloser, int, error) { 71 | retryAfter, ok := resp.Header["X-Ratelimit-Retry-After"] 72 | 73 | if !ok || len(retryAfter) == 0 { 74 | retryAfter = []string{"1000"} 75 | } 76 | 77 | retryAfterDuration, err := time.ParseDuration(retryAfter[0] + "ms") 78 | if err != nil { 79 | retryAfterDuration = time.Second 80 | } 81 | 82 | time.Sleep(retryAfterDuration) 83 | 84 | return session.Fetch(method, endpoint, body) 85 | } 86 | -------------------------------------------------------------------------------- /internal/rvapi/methods.go: -------------------------------------------------------------------------------- 1 | package rvapi 2 | 3 | // Channel gets a channel. 4 | func (session *Session) Channel(chID string) *Channel { 5 | if channel, ok := session.ChannelCache.Get(chID); ok { 6 | return &channel 7 | } 8 | 9 | var channel Channel 10 | 11 | if Get(session, "/channels/"+chID, &channel) != nil { 12 | return nil 13 | } 14 | 15 | return &channel 16 | } 17 | 18 | // Member gets a member. 19 | func (session *Session) Member(server, user string) *Member { 20 | if member, ok := session.MemberCache.Get(server + "-" + user); ok { 21 | return &member 22 | } 23 | 24 | var member Member 25 | 26 | if Get(session, "/servers/"+server+"/members/"+user, &member) != nil { 27 | return nil 28 | } 29 | 30 | return &member 31 | } 32 | 33 | // User gets a user. 34 | func (session *Session) User(userID string) *User { 35 | if user, ok := session.UserCache.Get(userID); ok { 36 | return &user 37 | } 38 | 39 | var user User 40 | 41 | if Get(session, "/users/"+userID, &user) != nil { 42 | return nil 43 | } 44 | 45 | return &user 46 | } 47 | 48 | // ServerEmoji gets emoji for a server. 49 | func (session *Session) ServerEmoji(server string) []Emoji { 50 | if emoji, ok := session.ServerEmojiCache.Get(server); ok { 51 | return emoji 52 | } 53 | 54 | var emoji []Emoji 55 | 56 | if Get(session, "/servers/"+server+"/emojis", &emoji) != nil { 57 | return nil 58 | } 59 | 60 | return emoji 61 | } 62 | 63 | // Emoji gets emoji. 64 | func (session *Session) Emoji(emojiID string) *Emoji { 65 | if emoji, ok := session.EmojiCache.Get(emojiID); ok { 66 | return &emoji 67 | } 68 | 69 | var emoji Emoji 70 | 71 | if Get(session, "/custom/emoji/"+emojiID, &emoji) != nil { 72 | return nil 73 | } 74 | 75 | return &emoji 76 | } 77 | 78 | // Server gets server. 79 | func (session *Session) Server(svr string) *Server { 80 | if server, ok := session.ServerCache.Get(svr); ok { 81 | return &server 82 | } 83 | 84 | var server Server 85 | 86 | if Get(session, "/servers/"+svr, &server) != nil { 87 | return nil 88 | } 89 | 90 | return &server 91 | } 92 | -------------------------------------------------------------------------------- /internal/rvapi/permissions.go: -------------------------------------------------------------------------------- 1 | package rvapi 2 | 3 | import ( 4 | "slices" 5 | "time" 6 | ) 7 | 8 | // GetPermissions returns the permissions for the user in the given channel. 9 | func (s *Session) GetPermissions(user *User, channel *Channel) Permission { 10 | switch channel.ChannelType { 11 | case ChannelTypeDM: 12 | return calculateUserPermissions(s, user, channel) 13 | case ChannelTypeGroup: 14 | if channel.Owner != nil && *channel.Owner == user.ID { 15 | return PermissionAll 16 | } 17 | 18 | if channel.Permissions == nil { 19 | return PermissionSet1 20 | } 21 | 22 | return *channel.Permissions 23 | case ChannelTypeSavedMessages: 24 | return PermissionAll 25 | case ChannelTypeText, ChannelTypeVoice: 26 | return calculateServerPermissions(s, channel, user) 27 | default: 28 | return 0 29 | } 30 | } 31 | 32 | func calculateUserPermissions(session *Session, self *User, channel *Channel) Permission { 33 | userID := "" 34 | 35 | for _, recipient := range channel.Recipients { 36 | if recipient != self.ID { 37 | userID = recipient 38 | 39 | break 40 | } 41 | } 42 | 43 | if userID == "" { 44 | return PermissionSet3 45 | } 46 | 47 | recipient := session.User(userID) 48 | if recipient == nil { 49 | return PermissionSet3 50 | } 51 | 52 | if recipient.Relationship == RelationshipFriend || recipient.Relationship == RelationshipUser { 53 | return PermissionSet1 54 | } 55 | 56 | return PermissionSet3 57 | } 58 | 59 | func calculateServerPermissions(session *Session, channel *Channel, user *User) Permission { 60 | server := session.Server(*channel.Server) 61 | if server == nil { 62 | return 0 63 | } 64 | 65 | if server.Owner == user.ID { 66 | return PermissionAll 67 | } 68 | 69 | member := session.Member(*channel.Server, user.ID) 70 | if member == nil { 71 | return 0 72 | } 73 | 74 | return getMemberPermissions(member, server, server.DefaultPermissions, channel) 75 | } 76 | 77 | func getMemberPermissions(member *Member, server *Server, permissions Permission, channel *Channel) Permission { 78 | for _, roleID := range member.Roles { 79 | role, ok := server.Roles[roleID] 80 | if ok { 81 | permissions |= role.Permissions.Allow 82 | permissions &= ^role.Permissions.Deny 83 | } 84 | } 85 | 86 | if member.Timeout.After(time.Now()) { 87 | permissions &= PermissionSet3 88 | } 89 | 90 | if channel.DefaultPerms != nil { 91 | permissions |= channel.DefaultPerms.Allow 92 | permissions &= ^channel.DefaultPerms.Deny 93 | } 94 | 95 | for id, role := range channel.RolePermissions { 96 | if slices.Contains(member.Roles, id) { 97 | permissions |= role.Allow 98 | permissions &= ^role.Deny 99 | } 100 | } 101 | 102 | if member.Timeout.After(time.Now()) { 103 | permissions &= PermissionSet3 104 | } 105 | 106 | return permissions 107 | } 108 | -------------------------------------------------------------------------------- /internal/rvapi/rvapi.go: -------------------------------------------------------------------------------- 1 | // Package rvapi implements the Stoat API 2 | package rvapi 3 | 4 | import ( 5 | "sync/atomic" 6 | 7 | "github.com/gorilla/websocket" 8 | "github.com/williamhorning/lightning/internal/cache" 9 | ) 10 | 11 | // VERSION is the version of the rvapi library. Currently, it's the same as the 12 | // version of lightning in the repo, though it may change. 13 | const VERSION = "0.8.0-rc.7" 14 | 15 | // Session represents the Stoat API session a bot may have. 16 | type Session struct { 17 | MessageDeleted chan *MessageDeleteEvent 18 | conn *websocket.Conn 19 | Ready chan *ReadyEvent 20 | MessageCreated chan *MessageEvent 21 | MessageUpdated chan *MessageUpdateEvent 22 | Token string 23 | ChannelCache cache.Expiring[string, Channel] 24 | MemberCache cache.Expiring[string, Member] 25 | UserCache cache.Expiring[string, User] 26 | ServerEmojiCache cache.Expiring[string, []Emoji] 27 | EmojiCache cache.Expiring[string, Emoji] 28 | ServerCache cache.Expiring[string, Server] 29 | connected atomic.Bool 30 | } 31 | -------------------------------------------------------------------------------- /internal/rvapi/socket.go: -------------------------------------------------------------------------------- 1 | package rvapi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/gorilla/websocket" 10 | ) 11 | 12 | // Connect to the Stoat socket. 13 | func (s *Session) Connect() error { 14 | if s.connected.Load() { 15 | return nil 16 | } 17 | 18 | conn, resp, err := websocket.DefaultDialer.Dial( 19 | "wss://events.stoat.chat/?version=1&format=json&token="+s.Token, 20 | map[string][]string{"User-Agent": {"rvapi/0.8.0-rc.7"}}, 21 | ) 22 | if err != nil { 23 | return fmt.Errorf("rvapi: failed to dial: %w", err) 24 | } 25 | 26 | if err = resp.Body.Close(); err != nil { 27 | log.Printf("rvapi: failed to close body: %v\n", err) 28 | } 29 | 30 | s.conn = conn 31 | s.connected.Swap(true) 32 | 33 | go ping(s) 34 | go readMessages(s) 35 | 36 | return nil 37 | } 38 | 39 | func ping(session *Session) { 40 | for session.connected.Load() && session.conn != nil { 41 | time.Sleep(10 * time.Second) 42 | 43 | err := session.conn.WriteMessage(websocket.TextMessage, []byte("{\"type\":\"Ping\"}")) 44 | if err != nil { 45 | log.Printf("rvapi: error pinging: %v\n", err) 46 | } 47 | } 48 | } 49 | 50 | func readMessages(session *Session) { 51 | for session.connected.Load() && session.conn != nil { 52 | _, message, err := session.conn.ReadMessage() 53 | if err != nil { 54 | if !websocket.IsCloseError(err, websocket.CloseNormalClosure) { 55 | log.Printf("rvapi: error reading socket: %v\n", err) 56 | } 57 | 58 | break 59 | } 60 | 61 | handleEvent(session, message) 62 | } 63 | 64 | session.connected.Store(false) 65 | 66 | if session.conn != nil { 67 | if err := session.conn.Close(); err != nil { 68 | log.Printf("rvapi: failed to close connection: %v\n", err) 69 | } 70 | 71 | session.conn = nil 72 | } 73 | 74 | go handleReconnect(session.Connect) 75 | } 76 | 77 | func handleReconnect(connect func() error) { 78 | attempt := 0 79 | backoff := 100 * time.Millisecond 80 | 81 | for { 82 | attempt++ 83 | 84 | time.Sleep(backoff) 85 | 86 | if connect() == nil { 87 | return 88 | } 89 | 90 | backoff = min(time.Duration(float64(backoff)*1.5), time.Second) 91 | 92 | log.Printf("rvapi: attempting reconnect #%d after %s\n", attempt, backoff.String()) 93 | } 94 | } 95 | 96 | func handleEvent(session *Session, message []byte) { 97 | var data BaseEvent 98 | if err := json.Unmarshal(message, &data); err != nil { 99 | log.Printf("rvapi: failed unmarshaling event wrapper: %v\n\tdata: %s\n", err, string(message)) 100 | 101 | return 102 | } 103 | 104 | switch data.Type { 105 | case "Bulk": 106 | handleBulkEvent(session, message) 107 | case "Ready": 108 | handleReadyEvent(session, message) 109 | case "Message": 110 | handleGenericEvent(message, session.MessageCreated) 111 | case "MessageUpdate": 112 | handleGenericEvent(message, session.MessageUpdated) 113 | case "MessageDelete": 114 | handleGenericEvent(message, session.MessageDeleted) 115 | default: 116 | } 117 | } 118 | 119 | func handleBulkEvent(session *Session, message []byte) { 120 | var bulk BulkEvent 121 | if err := json.Unmarshal(message, &bulk); err != nil { 122 | log.Printf("rvapi: failed unmarshaling bulk event: %v\n\tdata: %s\n", err, string(message)) 123 | 124 | return 125 | } 126 | 127 | for _, event := range bulk.V { 128 | handleEvent(session, event) 129 | } 130 | } 131 | 132 | func handleReadyEvent(session *Session, message []byte) { 133 | var ready ReadyEvent 134 | if err := json.Unmarshal(message, &ready); err != nil { 135 | log.Printf("rvapi: failed unmarshaling ready event: %v\n\tdata: %s\n", err, string(message)) 136 | 137 | return 138 | } 139 | 140 | session.Ready <- &ready 141 | 142 | for _, channel := range ready.Channels { 143 | session.ChannelCache.Set(channel.ID, channel) 144 | } 145 | 146 | for _, server := range ready.Servers { 147 | session.ServerCache.Set(server.ID, server) 148 | session.ServerEmojiCache.Set(server.ID, []Emoji{}) 149 | } 150 | 151 | for _, user := range ready.Users { 152 | session.UserCache.Set(user.ID, user) 153 | } 154 | 155 | for _, member := range ready.Members { 156 | session.MemberCache.Set(member.ID.Server+"-"+member.ID.User, member) 157 | } 158 | 159 | for _, emoji := range ready.Emojis { 160 | session.EmojiCache.Set(emoji.ID, emoji) 161 | 162 | emojis, _ := session.ServerEmojiCache.Get(emoji.Parent.ID) 163 | session.ServerEmojiCache.Set(emoji.Parent.ID, append(emojis, emoji)) 164 | } 165 | } 166 | 167 | func handleGenericEvent[T any](message []byte, channel chan *T) { 168 | var decoded T 169 | if err := json.Unmarshal(message, &decoded); err != nil { 170 | log.Printf("rvapi: failed unmarshaling generic event: %v\n\tdata: %s\n", err, string(message)) 171 | 172 | return 173 | } 174 | 175 | channel <- &decoded 176 | } 177 | -------------------------------------------------------------------------------- /internal/rvapi/upload.go: -------------------------------------------------------------------------------- 1 | package rvapi 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "mime/multipart" 11 | "net/http" 12 | 13 | "github.com/williamhorning/lightning/internal/workaround" 14 | ) 15 | 16 | // UploadFile to Autumn. 17 | func (s *Session) UploadFile(tag, name string, reader io.Reader) (*CDNFile, error) { 18 | buf := &bytes.Buffer{} 19 | payload := multipart.NewWriter(buf) 20 | 21 | fileWriter, err := payload.CreateFormFile("file", name) 22 | if err != nil { 23 | return nil, fmt.Errorf("rvapi: failed to add form file: %w", err) 24 | } 25 | 26 | if _, err = io.Copy(fileWriter, reader); err != nil { 27 | return nil, fmt.Errorf("rvapi: failed to copy file: %w", err) 28 | } 29 | 30 | if err = payload.Close(); err != nil { 31 | log.Printf("rvapi: failed to close file payload: %v\n", err) 32 | } 33 | 34 | url := "https://cdn.stoatusercontent.com/" + tag 35 | 36 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, buf) 37 | if err != nil { 38 | return nil, fmt.Errorf("rvapi: failed to create request: %w", err) 39 | } 40 | 41 | req.Header["Content-Type"] = []string{payload.FormDataContentType()} 42 | req.Header["X-Bot-Token"] = []string{s.Token} 43 | 44 | resp, err := workaround.Client.Do(req) 45 | if err != nil { 46 | return nil, fmt.Errorf("stoat: failed to do request in upload: %w\n\tname: %s\n\ttag: %s", err, name, tag) 47 | } 48 | 49 | defer func() { 50 | if err = resp.Body.Close(); err != nil { 51 | log.Printf("rvapi: failed to close upload body: %v\n", err) 52 | } 53 | }() 54 | 55 | body, err := io.ReadAll(resp.Body) 56 | if err != nil { 57 | return nil, fmt.Errorf("rvapi: failed to read response: %w\n\tname: %s\n\ttag: %s", err, name, tag) 58 | } 59 | 60 | var response CDNFile 61 | if err = json.Unmarshal(body, &response); err != nil { 62 | return nil, fmt.Errorf("rvapi: failed to unmarshal response: %w\n\tbody: %s", err, string(body)) 63 | } 64 | 65 | return &response, nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/tgmd/markdown.go: -------------------------------------------------------------------------------- 1 | // Package tgmd handles Telegram markdown. 2 | package tgmd 3 | 4 | import ( 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/yuin/goldmark" 9 | "github.com/yuin/goldmark/ast" 10 | "github.com/yuin/goldmark/text" 11 | ) 12 | 13 | // GetMarkdownV2 takes in CommonMark-like Markdown and turns it into Telegram's MarkdownV2. 14 | func GetMarkdownV2(input string) string { 15 | md := goldmark.New() 16 | reader := text.NewReader([]byte(input)) 17 | result := nodeToTelegram(md.Parser().Parse(reader), reader.Source()) 18 | 19 | return strings.TrimSpace(result) 20 | } 21 | 22 | func escapeTelegramText(input string) string { 23 | specialChars := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} 24 | for _, ch := range specialChars { 25 | input = strings.ReplaceAll(input, ch, "\\"+ch) 26 | } 27 | 28 | return input 29 | } 30 | 31 | //nolint:cyclop // how on earth would i simplify this more 32 | func nodeToTelegram(astNode ast.Node, source []byte) string { 33 | switch node := astNode.(type) { 34 | case *ast.Text: 35 | return handleTextNode(node, source) 36 | case *ast.Emphasis: 37 | return handleEmphasisNode(node, source) 38 | case *ast.Link: 39 | return handleLinkNode(node, source) 40 | case *ast.Paragraph: 41 | return handleParagraphNode(node, source) 42 | case *ast.CodeSpan: 43 | return handleCodeSpanNode(node, source) 44 | case *ast.Blockquote: 45 | return handleBlockquoteNode(node, source) 46 | case *ast.FencedCodeBlock: 47 | return handleFencedCodeBlockNode(node, source) 48 | case *ast.List: 49 | return handleListNode(node, source) 50 | case *ast.ListItem: 51 | return handleListItemNode(node, source) 52 | default: 53 | return handleOtherNode(node, source) 54 | } 55 | } 56 | 57 | func handleTextNode(node *ast.Text, source []byte) string { 58 | return escapeTelegramText(string(node.Segment.Value(source))) 59 | } 60 | 61 | func handleEmphasisNode(node *ast.Emphasis, source []byte) string { 62 | res := "" 63 | 64 | var emphasisChar string 65 | 66 | if node.Level == 1 { 67 | emphasisChar = "_" 68 | } else { 69 | emphasisChar = "*" 70 | } 71 | 72 | res += emphasisChar 73 | 74 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 75 | res += nodeToTelegram(child, source) 76 | } 77 | 78 | return res + emphasisChar 79 | } 80 | 81 | func handleLinkNode(node *ast.Link, source []byte) string { 82 | var textContent string 83 | 84 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 85 | textContent += nodeToTelegram(child, source) 86 | } 87 | 88 | return "[" + textContent + "](" + escapeTelegramText(string(node.Destination)) + ")" 89 | } 90 | 91 | func handleParagraphNode(node *ast.Paragraph, source []byte) string { 92 | res := "" 93 | 94 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 95 | res += nodeToTelegram(child, source) 96 | } 97 | 98 | return res + "\n\n" 99 | } 100 | 101 | func handleCodeSpanNode(node *ast.CodeSpan, source []byte) string { 102 | var content []byte 103 | 104 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 105 | if textNode, ok := child.(*ast.Text); ok { 106 | segment := textNode.Segment 107 | content = append(content, segment.Value(source)...) 108 | } 109 | } 110 | 111 | return "`" + escapeTelegramText(string(content)) + "`" 112 | } 113 | 114 | func handleBlockquoteNode(node *ast.Blockquote, source []byte) string { 115 | res := ">" 116 | 117 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 118 | res += nodeToTelegram(child, source) 119 | } 120 | 121 | return res + "\n" 122 | } 123 | 124 | func handleFencedCodeBlockNode(node *ast.FencedCodeBlock, source []byte) string { 125 | res := "```" 126 | 127 | if len(node.Language(source)) > 0 { 128 | res += string(node.Language(source)) 129 | } 130 | 131 | res += "\n" 132 | res += escapeTelegramText(string(node.Lines().Value(source))) 133 | 134 | return res + "\n```\n" 135 | } 136 | 137 | func handleListNode(node *ast.List, source []byte) string { 138 | res := "" 139 | 140 | for index, child := 0, node.FirstChild(); child != nil; child = child.NextSibling() { 141 | index++ 142 | 143 | prefix := "\\- " 144 | 145 | if node.IsOrdered() { 146 | prefix = strconv.FormatInt(int64(index), 10) + "\\. " 147 | } 148 | 149 | res += prefix 150 | res += nodeToTelegram(child, source) 151 | res += "\n" 152 | } 153 | 154 | return res 155 | } 156 | 157 | func handleListItemNode(node *ast.ListItem, source []byte) string { 158 | res := "" 159 | 160 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 161 | res += nodeToTelegram(child, source) 162 | } 163 | 164 | return res 165 | } 166 | 167 | func handleOtherNode(node ast.Node, source []byte) string { 168 | res := "" 169 | 170 | for child := node.FirstChild(); child != nil; child = child.NextSibling() { 171 | res += nodeToTelegram(child, source) 172 | } 173 | 174 | return res 175 | } 176 | -------------------------------------------------------------------------------- /internal/workaround/tls.go: -------------------------------------------------------------------------------- 1 | // Package workaround exists to avoid TLS errors from Stoat 2 | package workaround 3 | 4 | import "net/http" 5 | 6 | // Client is a workaround for cloudflare issuing invalid certificates... pls fix >:(. 7 | var Client *http.Client //nolint:gochecknoglobals 8 | 9 | func init() { //nolint:gochecknoinits 10 | transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert 11 | transport.TLSClientConfig.InsecureSkipVerify = true 12 | 13 | Client = &http.Client{Transport: transport} 14 | } 15 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) William Horning and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /pkg/lightning/addhandler.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import "sync/atomic" 4 | 5 | // AddHandler allows you to register a listener for a given event type. 6 | // Each handler must take in a *Bot and a pointer to a struct that corresponds 7 | // with the event you want to listen to. 8 | func (b *Bot) AddHandler(listener any) { 9 | switch listener := listener.(type) { 10 | case func(*Bot, *EditedMessage): 11 | go processEventHandlers(&listener, b.editChannel, &b.editHandlers, &b.editProcessorActive, b) 12 | case func(*Bot, *Message): 13 | go processEventHandlers(&listener, b.messageChannel, &b.messageHandlers, &b.messageProcessorActive, b) 14 | case func(*Bot, *BaseMessage): 15 | go processEventHandlers(&listener, b.delChannel, &b.delHandlers, &b.delProcessorActive, b) 16 | case func(*Bot, *CommandEvent): 17 | go processEventHandlers(&listener, b.commandChannel, &b.commandHandlers, &b.commandProcessorActive, b) 18 | } 19 | } 20 | 21 | func processEventHandlers[C any]( 22 | listener *func(*Bot, C), 23 | incoming <-chan C, 24 | handlers *atomic.Pointer[[]func(*Bot, C)], 25 | store *atomic.Bool, 26 | bot *Bot, 27 | ) { 28 | if listener != nil { 29 | newHandlers := append(*handlers.Load(), *listener) 30 | handlers.Store(&newHandlers) 31 | } 32 | 33 | if store.Swap(true) { 34 | return 35 | } 36 | 37 | for msg := range incoming { 38 | for _, handler := range *handlers.Load() { 39 | localMsg := msg 40 | go handler(bot, localMsg) 41 | } 42 | } 43 | 44 | store.Store(false) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/lightning/bot.go: -------------------------------------------------------------------------------- 1 | // Package lightning provides a framework for creating a cross-platform chatbot 2 | package lightning 3 | 4 | import ( 5 | "sync" 6 | "sync/atomic" 7 | ) 8 | 9 | // VERSION is the version of the lightning bot framework. 10 | const VERSION = "0.8.0-rc.7" 11 | 12 | // BotOptions allows you to configure the prefix used by the bot for registered 13 | // commands, in addition to any platform specifics (like slash commands). If a 14 | // zero value is provided for the Prefix, it will default to "!". 15 | type BotOptions struct { 16 | Prefix string 17 | } 18 | 19 | // Bot represents the collection of commands, plugins, and events that are 20 | // used to make a bot using Lightning. 21 | type Bot struct { 22 | messageHandlers atomic.Pointer[[]func(*Bot, *Message)] 23 | editHandlers atomic.Pointer[[]func(*Bot, *EditedMessage)] 24 | delHandlers atomic.Pointer[[]func(*Bot, *BaseMessage)] 25 | commandHandlers atomic.Pointer[[]func(*Bot, *CommandEvent)] 26 | 27 | messageChannel chan *Message 28 | editChannel chan *EditedMessage 29 | delChannel chan *BaseMessage 30 | commandChannel chan *CommandEvent 31 | 32 | commands map[string]*Command 33 | plugins map[string]Plugin 34 | types map[string]PluginConstructor 35 | 36 | prefix string 37 | 38 | pluginMutex sync.RWMutex 39 | typesMutex sync.RWMutex 40 | 41 | messageProcessorActive atomic.Bool 42 | editProcessorActive atomic.Bool 43 | delProcessorActive atomic.Bool 44 | commandProcessorActive atomic.Bool 45 | } 46 | 47 | // NewBot creates a new *Bot based on the [BotOptions] provided to it. 48 | func NewBot(opts BotOptions) *Bot { 49 | if opts.Prefix == "" { 50 | opts.Prefix = "!" 51 | } 52 | 53 | bot := &Bot{ 54 | prefix: opts.Prefix, 55 | 56 | commands: make(map[string]*Command), 57 | plugins: make(map[string]Plugin), 58 | types: make(map[string]PluginConstructor), 59 | 60 | messageChannel: make(chan *Message, 1000), 61 | editChannel: make(chan *EditedMessage, 1000), 62 | delChannel: make(chan *BaseMessage, 1000), 63 | commandChannel: make(chan *CommandEvent, 1000), 64 | } 65 | 66 | bot.messageHandlers.Store(&[]func(*Bot, *Message){}) 67 | bot.editHandlers.Store(&[]func(*Bot, *EditedMessage){}) 68 | bot.delHandlers.Store(&[]func(*Bot, *BaseMessage){}) 69 | bot.commandHandlers.Store(&[]func(*Bot, *CommandEvent){}) 70 | 71 | bot.AddHandler(handleCommandEvent) 72 | bot.AddHandler(handleMessageCommand) 73 | 74 | return bot 75 | } 76 | -------------------------------------------------------------------------------- /pkg/lightning/commands.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import "strings" 4 | 5 | // AddCommand takes [Command]s and registers it with the built-in 6 | // text command handler and any platform-specific command systems. 7 | func (b *Bot) AddCommand(commands ...*Command) error { 8 | var errs []error 9 | 10 | for _, command := range commands { 11 | if command == nil { 12 | continue 13 | } 14 | 15 | b.commands[command.Name] = command 16 | } 17 | 18 | for _, plugin := range b.plugins { 19 | if err := plugin.SetupCommands(b.commands); err != nil { 20 | errs = append(errs, err) 21 | } 22 | } 23 | 24 | if len(errs) > 0 { 25 | return &PluginMethodError{"", "AddCommand", "failed to register command", errs} 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func handleMessageCommand(bot *Bot, event *Message) { 32 | if len(event.Content) <= len(bot.prefix) || event.Content[:len(bot.prefix)] != bot.prefix { 33 | return 34 | } 35 | 36 | args := strings.Fields(event.Content[len(bot.prefix):]) 37 | if len(args) == 0 { 38 | args = []string{"help"} 39 | } 40 | 41 | commandName := args[0] 42 | options := args[1:] 43 | 44 | reply := func(msg *Message, sensitive bool) error { 45 | plugin, channel, ok := bot.getPluginFromChannel(event.ChannelID) 46 | if !ok { 47 | return MissingPluginError{} 48 | } 49 | 50 | msg.ChannelID = channel 51 | 52 | var err error 53 | 54 | if sensitive { 55 | _, err = plugin.SendCommandResponse(msg, nil, event.Author.ID) 56 | } else { 57 | _, err = plugin.SendMessage(msg, nil) 58 | } 59 | 60 | if err == nil { 61 | return nil 62 | } 63 | 64 | return &PluginMethodError{event.ChannelID, "CommandReply", "failed to send command response", []error{err}} 65 | } 66 | 67 | handleCommandEvent(bot, &CommandEvent{ 68 | CommandOptions: &CommandOptions{&event.BaseMessage, make(map[string]string), bot, reply, bot.prefix}, 69 | Command: commandName, 70 | Options: options, 71 | }) 72 | } 73 | 74 | func handleCommandEvent(bot *Bot, event *CommandEvent) { 75 | event.Bot = bot 76 | 77 | command, exists := bot.commands[event.Command] 78 | if !exists { 79 | command = bot.commands["help"] 80 | } 81 | 82 | if len(command.Subcommands) != 0 && len(event.Options) != 0 && event.Subcommand == nil { 83 | event.Subcommand = &event.Options[0] 84 | event.Options = event.Options[1:] 85 | } 86 | 87 | if event.Subcommand != nil { 88 | if cmd, ok := command.Subcommands[*event.Subcommand]; ok { 89 | command = cmd 90 | } 91 | } 92 | 93 | for _, arg := range command.Arguments { 94 | if event.Arguments[arg.Name] == "" && len(event.Options) > 0 { 95 | event.Arguments[arg.Name] = event.Options[0] 96 | event.Options = event.Options[1:] 97 | } 98 | } 99 | 100 | command.Executor(event.CommandOptions) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/lightning/embed.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | // ToMarkdown converts a lightning Embed to a Markdown string 4 | // and it handles every field except for the color, which can't 5 | // be represented in Markdown. 6 | func (embed *Embed) ToMarkdown() string { 7 | if embed == nil { 8 | return "" 9 | } 10 | 11 | str := "" 12 | 13 | if embed.Title != nil && embed.URL != nil { 14 | str += "[" + *embed.Title + "](" + *embed.URL + ")" 15 | } else if embed.Title != nil { 16 | str += *embed.Title 17 | } 18 | 19 | if embed.Timestamp != nil { 20 | str += " (" + *embed.Timestamp + ")" 21 | } 22 | 23 | str += "\n\n" 24 | 25 | if embed.Author != nil && embed.Author.URL != nil { 26 | str += "[" + embed.Author.Name + "](" + *embed.Author.URL + ")\n\n" 27 | } else if embed.Author != nil { 28 | str += embed.Author.Name + "\n\n" 29 | } 30 | 31 | if embed.Description != nil { 32 | str += *embed.Description + "\n\n" 33 | } 34 | 35 | str += formatMedia(embed.Image) + formatMedia(embed.Thumbnail) + formatMedia(embed.Video) + formatFooter(embed) 36 | 37 | return str 38 | } 39 | 40 | func formatFooter(embed *Embed) string { 41 | str := "" 42 | 43 | for _, field := range embed.Fields { 44 | str += "**" + field.Name + "**\n" + field.Value + "\n\n" 45 | } 46 | 47 | if embed.Footer != nil && embed.Footer.IconURL != nil { 48 | str += "[" + embed.Footer.Text + "](" + *embed.Footer.IconURL + ")\n" 49 | } else if embed.Footer != nil { 50 | str += embed.Footer.Text + "\n" 51 | } 52 | 53 | return str 54 | } 55 | 56 | func formatMedia(media *Media) string { 57 | if media == nil { 58 | return "" 59 | } 60 | 61 | return "![](" + media.URL + ")\n\n" 62 | } 63 | -------------------------------------------------------------------------------- /pkg/lightning/errors.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | // ChannelDisabler is an interface that allows a channel to be disabled in an external system. 4 | type ChannelDisabler interface { 5 | Disable() *ChannelDisabled 6 | } 7 | 8 | // PluginRegisteredError only occurs when a plugin/type is already registered and 9 | // can't be registered again. 10 | type PluginRegisteredError struct{} 11 | 12 | func (PluginRegisteredError) Error() string { 13 | return "plugin (or type) already registered: this is a bug or misconfiguration" 14 | } 15 | 16 | // MissingPluginError only occurs when a plugin/type is not found. 17 | type MissingPluginError struct{} 18 | 19 | func (MissingPluginError) Error() string { 20 | return "plugin not found internally: this is a bug or misconfiguration" 21 | } 22 | 23 | // PluginConfigError only occurs when a plugin is passed an invalid config on registration. 24 | type PluginConfigError struct { 25 | Plugin string 26 | Message string 27 | } 28 | 29 | func (p PluginConfigError) Error() string { 30 | return "plugin configuration error: " + p.Plugin + ": " + p.Message 31 | } 32 | 33 | // PluginMethodError is a wrapped error that occurs when a plugin method fails. 34 | type PluginMethodError struct { 35 | ID string 36 | Method string 37 | Message string 38 | err []error 39 | } 40 | 41 | func (p PluginMethodError) Error() string { 42 | str := "plugin " + p.ID + " method " + p.Method + " failed: " + p.Message + ": " 43 | 44 | for _, err := range p.err { 45 | str += "\n\t" + err.Error() 46 | } 47 | 48 | return str 49 | } 50 | 51 | func (p PluginMethodError) Unwrap() []error { 52 | return p.err 53 | } 54 | -------------------------------------------------------------------------------- /pkg/lightning/methods.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import "strings" 4 | 5 | // SetupChannel allows you to create the platform-specific equivalent of 6 | // a webhook and allows you to send messages with a different author, when 7 | // then return value is passed as ChannelData in [*SendOptions]. 8 | func (b *Bot) SetupChannel(channelID string) (any, error) { 9 | plugin, channel, ok := b.getPluginFromChannel(channelID) 10 | if !ok { 11 | return nil, MissingPluginError{} 12 | } 13 | 14 | result, err := plugin.SetupChannel(channel) 15 | if err == nil { 16 | return result, nil 17 | } 18 | 19 | return nil, &PluginMethodError{channelID, "SetupChannel", "failed to setup channel", []error{err}} 20 | } 21 | 22 | // SendMessage allows you to send a message to the channel and plugin specified 23 | // on the provided [Message]. You may additionally provide [*SendOptions]. It 24 | // returns the IDs of the messages sent, which may be nil if an error occurs. 25 | func (b *Bot) SendMessage(message *Message, opts *SendOptions) ([]string, error) { 26 | plugin, channel, ok := b.getPluginFromChannel(message.ChannelID) 27 | if !ok { 28 | return nil, MissingPluginError{} 29 | } 30 | 31 | oldID := message.ChannelID 32 | message.ChannelID = channel 33 | 34 | result, err := plugin.SendMessage(message, opts) 35 | if err == nil { 36 | return result, nil 37 | } 38 | 39 | return nil, &PluginMethodError{oldID, "SendMessage", "failed to send message", []error{err}} 40 | } 41 | 42 | // EditMessage allows you to edit a message in the channel and plugin specified. 43 | // The 'ids' parameter should contain the IDs of the messages to be edited, as 44 | // returned by SendMessage. 45 | func (b *Bot) EditMessage(message *Message, ids []string, opts *SendOptions) error { 46 | plugin, channel, ok := b.getPluginFromChannel(message.ChannelID) 47 | if !ok { 48 | return MissingPluginError{} 49 | } 50 | 51 | oldID := message.ChannelID 52 | message.ChannelID = channel 53 | 54 | if err := plugin.EditMessage(message, ids, opts); err != nil { 55 | return &PluginMethodError{oldID, "EditMessage", "failed to edit message", []error{err}} 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // DeleteMessages allows you to delete messages in the channel and plugin specified. 62 | // The 'ids' parameter should contain the IDs of the messages to be edited, as 63 | // returned by SendMessage. 64 | func (b *Bot) DeleteMessages(channelID string, ids []string) error { 65 | plugin, channel, ok := b.getPluginFromChannel(channelID) 66 | if !ok { 67 | return MissingPluginError{} 68 | } 69 | 70 | if err := plugin.DeleteMessage(channel, ids); err != nil { 71 | return &PluginMethodError{channelID, "DeleteMessages", "failed to delete messages", []error{err}} 72 | } 73 | 74 | return nil 75 | } 76 | 77 | func (b *Bot) getPluginFromChannel(channel string) (Plugin, string, bool) { 78 | pluginName, channelName, ok := strings.Cut(channel, "::") 79 | if !ok { 80 | return nil, "", false 81 | } 82 | 83 | b.pluginMutex.RLock() 84 | plugin, ok := b.plugins[pluginName] 85 | b.pluginMutex.RUnlock() 86 | 87 | return plugin, channelName, ok 88 | } 89 | -------------------------------------------------------------------------------- /pkg/lightning/plugin.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | // PluginConstructor makes a [Plugin] with the specified config. 4 | type PluginConstructor func(config map[string]string) (Plugin, error) 5 | 6 | // A Plugin provides methods used by [Bot] to allow bots to not worry 7 | // about platform specifics, as each Plugin handles that. 8 | type Plugin interface { 9 | SetupChannel(channel string) (any, error) 10 | SendCommandResponse(message *Message, opts *SendOptions, user string) ([]string, error) 11 | SendMessage(message *Message, opts *SendOptions) ([]string, error) 12 | EditMessage(message *Message, ids []string, opts *SendOptions) error 13 | DeleteMessage(channel string, ids []string) error 14 | SetupCommands(command map[string]*Command) error 15 | ListenMessages() <-chan *Message 16 | ListenEdits() <-chan *EditedMessage 17 | ListenDeletes() <-chan *BaseMessage 18 | ListenCommands() <-chan *CommandEvent 19 | } 20 | 21 | // AddPluginType takes in a [PluginConstructor] and registers it so you can later 22 | // use it. It only returns an error if the plugin type is already registered. 23 | func (b *Bot) AddPluginType(name string, constructor PluginConstructor) error { 24 | b.typesMutex.Lock() 25 | defer b.typesMutex.Unlock() 26 | 27 | if _, exists := b.types[name]; exists { 28 | return PluginRegisteredError{} 29 | } 30 | 31 | b.types[name] = constructor 32 | 33 | return nil 34 | } 35 | 36 | // UsePluginType takes in a plugin name and config to use a plugin with your bot. 37 | // It only returns an error if a plugin already exists *or* if the plugin type is 38 | // not found. If you pass an empty string to instanceName, it will default to 39 | // typeName, but that value must be unique. 40 | func (b *Bot) UsePluginType(typeName, instanceName string, config map[string]string) error { 41 | if instanceName == "" { 42 | instanceName = typeName 43 | } 44 | 45 | b.pluginMutex.RLock() 46 | 47 | if _, exists := b.plugins[instanceName]; exists { 48 | return PluginRegisteredError{} 49 | } 50 | 51 | b.pluginMutex.RUnlock() 52 | 53 | b.typesMutex.RLock() 54 | 55 | constructor, ok := b.types[typeName] 56 | 57 | b.typesMutex.RUnlock() 58 | 59 | if !ok { 60 | return MissingPluginError{} 61 | } 62 | 63 | instance, err := constructor(config) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | b.pluginMutex.Lock() 69 | 70 | b.plugins[instanceName] = instance 71 | 72 | b.pluginMutex.Unlock() 73 | 74 | go processEventHandlers(nil, b.editChannel, &b.editHandlers, &b.editProcessorActive, b) 75 | go processEventHandlers(nil, b.messageChannel, &b.messageHandlers, &b.messageProcessorActive, b) 76 | go processEventHandlers(nil, b.delChannel, &b.delHandlers, &b.delProcessorActive, b) 77 | go processEventHandlers(nil, b.commandChannel, &b.commandHandlers, &b.commandProcessorActive, b) 78 | 79 | b.startPluginListeners(instanceName, instance) 80 | 81 | return nil 82 | } 83 | 84 | // startPluginListeners listens for events from a plugin and forwards them. 85 | // do NOT rely on the ChannelID format, treat it as an opaque string. 86 | func (b *Bot) startPluginListeners(name string, instance Plugin) { 87 | go func() { 88 | for msg := range instance.ListenMessages() { 89 | msg.ChannelID = name + "::" + msg.ChannelID 90 | b.messageChannel <- msg 91 | } 92 | }() 93 | go func() { 94 | for edit := range instance.ListenEdits() { 95 | edit.Message.ChannelID = name + "::" + edit.Message.ChannelID 96 | b.editChannel <- edit 97 | } 98 | }() 99 | go func() { 100 | for del := range instance.ListenDeletes() { 101 | del.ChannelID = name + "::" + del.ChannelID 102 | b.delChannel <- del 103 | } 104 | }() 105 | go func() { 106 | for cmd := range instance.ListenCommands() { 107 | cmd.ChannelID = name + "::" + cmd.ChannelID 108 | b.commandChannel <- cmd 109 | } 110 | }() 111 | } 112 | -------------------------------------------------------------------------------- /pkg/lightning/types.go: -------------------------------------------------------------------------------- 1 | package lightning 2 | 3 | import "time" 4 | 5 | // An Attachment on a [Message]. 6 | type Attachment struct { 7 | URL string 8 | Name string 9 | Size int64 10 | } 11 | 12 | // BaseMessage is basic message information, such as an ID, channel, and timestamp. 13 | type BaseMessage struct { 14 | Time *time.Time 15 | EventID string 16 | ChannelID string 17 | } 18 | 19 | // ChannelDisabled represents whether to disable a channel due to possible errors. 20 | type ChannelDisabled struct { 21 | Read bool `json:"read"` 22 | Write bool `json:"write"` 23 | } 24 | 25 | // A CommandArgument is a possible argument for a [Command]. 26 | type CommandArgument struct { 27 | Name string 28 | Description string 29 | Required bool 30 | } 31 | 32 | // CommandOptions are provided to a [Command] executor. 33 | type CommandOptions struct { 34 | *BaseMessage 35 | 36 | Arguments map[string]string 37 | Bot *Bot 38 | Reply func(message *Message, sensitive bool) error 39 | Prefix string 40 | } 41 | 42 | // A Command registered with [Bot]. 43 | type Command struct { 44 | Executor func(options *CommandOptions) 45 | Name string 46 | Description string 47 | Subcommands map[string]*Command 48 | Arguments []*CommandArgument 49 | } 50 | 51 | // CommandEvent represents an execution of a command on a platform. 52 | type CommandEvent struct { 53 | *CommandOptions 54 | 55 | Subcommand *string 56 | Command string 57 | Options []string 58 | } 59 | 60 | // DeletedMessage is information about a deleted message. 61 | type DeletedMessage = BaseMessage 62 | 63 | // EditedMessage is information about an edited message. 64 | type EditedMessage struct { 65 | Edited *time.Time 66 | Message *Message 67 | } 68 | 69 | // EmbedAuthor is an author on an [Embed]. 70 | type EmbedAuthor struct { 71 | URL *string 72 | IconURL *string 73 | Name string 74 | } 75 | 76 | // EmbedField is a field on an [Embed]. 77 | type EmbedField struct { 78 | Name string 79 | Value string 80 | Inline bool 81 | } 82 | 83 | // EmbedFooter is a footer on an [Embed]. 84 | type EmbedFooter struct { 85 | IconURL *string 86 | Text string 87 | } 88 | 89 | // Embed is a Discord-style embed. 90 | type Embed struct { 91 | Author *EmbedAuthor 92 | Footer *EmbedFooter 93 | Image *Media 94 | Thumbnail *Media 95 | Video *Media 96 | Timestamp *string 97 | Color *int 98 | Title *string 99 | URL *string 100 | Description *string 101 | Fields []EmbedField 102 | } 103 | 104 | // Emoji represents custom emoji in a [Message]. 105 | type Emoji struct { 106 | URL *string 107 | ID string 108 | Name string 109 | } 110 | 111 | // Media represents images/videos on an [Embed]. 112 | type Media struct { 113 | URL string 114 | Height int 115 | Width int 116 | } 117 | 118 | // MessageAuthor is an author on an [Message]. 119 | type MessageAuthor struct { 120 | ID string `toml:"id"` 121 | Nickname string `toml:"nickname"` 122 | Username string `toml:"username"` 123 | ProfilePicture *string `toml:"profile_picture,omitempty"` 124 | Color string `toml:"color,omitempty"` 125 | } 126 | 127 | // Message is a representation of a message on a platform. 128 | type Message struct { 129 | BaseMessage 130 | 131 | Author *MessageAuthor 132 | Content string 133 | Attachments []Attachment 134 | Embeds []Embed 135 | Emoji []Emoji 136 | RepliedTo []string 137 | } 138 | 139 | // SendOptions is possible options to use when sending a message. 140 | type SendOptions struct { 141 | ChannelData any 142 | AllowEveryonePings bool 143 | } 144 | -------------------------------------------------------------------------------- /pkg/platforms/discord/command.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/bwmarrin/discordgo" 7 | "github.com/williamhorning/lightning/pkg/lightning" 8 | ) 9 | 10 | func getDiscordCommandOptions(arguments *lightning.Command) []*discordgo.ApplicationCommandOption { 11 | options := make([]*discordgo.ApplicationCommandOption, 0, len(arguments.Arguments)+len(arguments.Subcommands)) 12 | 13 | for _, arg := range arguments.Arguments { 14 | options = append(options, &discordgo.ApplicationCommandOption{ 15 | Name: arg.Name, 16 | Description: arg.Description, 17 | Required: arg.Required, 18 | Type: discordgo.ApplicationCommandOptionString, 19 | }) 20 | } 21 | 22 | for _, subcommand := range arguments.Subcommands { 23 | options = append(options, &discordgo.ApplicationCommandOption{ 24 | Name: subcommand.Name, 25 | Description: subcommand.Description, 26 | Type: discordgo.ApplicationCommandOptionSubCommand, 27 | Options: getDiscordCommandOptions(subcommand), 28 | }) 29 | } 30 | 31 | return options 32 | } 33 | 34 | func getDiscordCommand(command map[string]*lightning.Command) []*discordgo.ApplicationCommand { 35 | commands := make([]*discordgo.ApplicationCommand, 0, len(command)) 36 | 37 | for _, cmd := range command { 38 | commands = append(commands, &discordgo.ApplicationCommand{ 39 | Name: cmd.Name, 40 | Type: discordgo.ChatApplicationCommand, 41 | Description: cmd.Description, 42 | Options: getDiscordCommandOptions(cmd), 43 | }) 44 | } 45 | 46 | return commands 47 | } 48 | 49 | func getLightningCommand(session *discordgo.Session, interaction *discordgo.InteractionCreate) *lightning.CommandEvent { 50 | if interaction.Type != discordgo.InteractionApplicationCommand { 51 | return nil 52 | } 53 | 54 | args := make(map[string]string) 55 | data := interaction.ApplicationCommandData() 56 | 57 | var subcommand *string 58 | 59 | for _, option := range data.Options { 60 | if option.Type == discordgo.ApplicationCommandOptionSubCommand { 61 | subcommand = &option.Name 62 | 63 | for _, subOption := range option.Options { 64 | if subOption.Type == discordgo.ApplicationCommandOptionString { 65 | args[subOption.Name] = subOption.StringValue() 66 | } 67 | } 68 | } else { 69 | args[option.Name] = option.StringValue() 70 | } 71 | } 72 | 73 | timestamp, err := discordgo.SnowflakeTimestamp(interaction.ID) 74 | if err != nil { 75 | timestamp = time.Now() 76 | } 77 | 78 | return &lightning.CommandEvent{ 79 | CommandOptions: &lightning.CommandOptions{ 80 | Arguments: args, 81 | BaseMessage: &lightning.BaseMessage{ 82 | EventID: interaction.ID, 83 | ChannelID: interaction.ChannelID, 84 | Time: ×tamp, 85 | }, 86 | Prefix: "/", 87 | Reply: func(message *lightning.Message, sensitive bool) error { 88 | flags := discordgo.MessageFlags(0) 89 | 90 | if sensitive { 91 | flags = discordgo.MessageFlagsEphemeral 92 | } 93 | 94 | msg := getOutgoingMessage(session, message, nil) 95 | 96 | return session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ 97 | Type: discordgo.InteractionResponseChannelMessageWithSource, 98 | Data: &discordgo.InteractionResponseData{ 99 | AllowedMentions: msg.allowedMentions, Components: msg.components, Content: msg.content, 100 | Embeds: msg.embeds, Flags: flags, 101 | }, 102 | }) 103 | }, 104 | }, 105 | Command: data.Name, 106 | Subcommand: subcommand, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /pkg/platforms/discord/errors.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/bwmarrin/discordgo" 9 | "github.com/williamhorning/lightning/pkg/lightning" 10 | ) 11 | 12 | type discordInvalidWebhookError struct { 13 | channelID string 14 | } 15 | 16 | func (discordInvalidWebhookError) Disable() *lightning.ChannelDisabled { 17 | return &lightning.ChannelDisabled{Read: false, Write: true} 18 | } 19 | 20 | func (err discordInvalidWebhookError) Error() string { 21 | return "invalid webhook data for Discord channel: " + err.channelID 22 | } 23 | 24 | type discordAPIError struct { 25 | extra map[string]any 26 | message string 27 | code int 28 | } 29 | 30 | func (e discordAPIError) Disable() *lightning.ChannelDisabled { 31 | switch e.code { 32 | case discordgo.ErrCodeUnknownChannel: 33 | return &lightning.ChannelDisabled{Read: true, Write: true} 34 | case discordgo.ErrCodeMaximumNumberOfWebhooksReached, 35 | discordgo.ErrCodeMissingPermissions, 36 | discordgo.ErrCodeUnknownWebhook, 37 | discordgo.ErrCodeInvalidWebhookTokenProvided: 38 | return &lightning.ChannelDisabled{Read: false, Write: true} 39 | default: 40 | return &lightning.ChannelDisabled{Read: false, Write: false} 41 | } 42 | } 43 | 44 | func (e discordAPIError) Error() string { 45 | return "Discord API Error " + strconv.Itoa(e.code) + ": " + 46 | fmt.Sprintf("%#+v, disable %#+v", e.extra, e.Disable()) + ": " + e.message 47 | } 48 | 49 | func getError(err error, extra map[string]any, message string) error { 50 | var restErr *discordgo.RESTError 51 | if errors.As(err, &restErr) { 52 | if restErr.Message.Code == discordgo.ErrCodeUnknownMessage { 53 | return nil 54 | } 55 | 56 | return &discordAPIError{extra, message + ": " + restErr.Message.Message, restErr.Message.Code} 57 | } 58 | 59 | return fmt.Errorf("discord: unknown error: %w\n\textra: %#+v", err, extra) 60 | } 61 | -------------------------------------------------------------------------------- /pkg/platforms/discord/incoming.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/bwmarrin/discordgo" 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | ) 10 | 11 | func (p *discordPlugin) getLightningMessage(msg *discordgo.Message) *lightning.Message { 12 | if msg.Type != discordgo.MessageTypeDefault && 13 | msg.Type != discordgo.MessageTypeReply && 14 | msg.Type != discordgo.MessageTypeChatInputCommand && 15 | msg.Type != discordgo.MessageTypeContextMenuCommand { 16 | return nil 17 | } 18 | 19 | if exists, _ := p.webhookCache.Get(msg.WebhookID); exists { 20 | return nil 21 | } 22 | 23 | message := &lightning.Message{ 24 | BaseMessage: lightning.BaseMessage{EventID: msg.ID, ChannelID: msg.ChannelID, Time: &msg.Timestamp}, 25 | Attachments: getLightningAttachments(msg.Attachments, msg.StickerItems), 26 | Author: getLightningAuthor(p.discord, msg), 27 | Content: getLightningForward(p.discord, msg) + getLightningContent(p.discord, msg), 28 | Embeds: getLightningEmbeds(msg.Embeds), 29 | RepliedTo: getLightningReplies(msg), 30 | } 31 | 32 | message.Content = replaceIncomingEmoji(message) 33 | 34 | return message 35 | } 36 | 37 | func getLightningAttachments( 38 | attachments []*discordgo.MessageAttachment, 39 | stickers []*discordgo.StickerItem, 40 | ) []lightning.Attachment { 41 | result := make([]lightning.Attachment, 0, len(attachments)+len(stickers)) 42 | for _, a := range attachments { 43 | result = append(result, lightning.Attachment{URL: a.URL, Name: a.Filename, Size: int64(a.Size)}) 44 | } 45 | 46 | for _, sticker := range stickers { 47 | stickerURL := "https://cdn.discordapp.com/stickers/" + sticker.ID 48 | 49 | switch sticker.FormatType { 50 | case discordgo.StickerFormatTypePNG, discordgo.StickerFormatTypeAPNG: 51 | stickerURL += ".png" 52 | case discordgo.StickerFormatTypeLottie: 53 | stickerURL += ".json" 54 | case discordgo.StickerFormatTypeGIF: 55 | stickerURL += ".gif" 56 | default: 57 | } 58 | 59 | result = append(result, lightning.Attachment{URL: stickerURL + "?size=160", Name: sticker.Name, Size: 0}) 60 | } 61 | 62 | return result 63 | } 64 | 65 | func getLightningAuthor(session *discordgo.Session, message *discordgo.Message) *lightning.MessageAuthor { 66 | profilePicture := message.Author.AvatarURL("") 67 | author := lightning.MessageAuthor{ 68 | ID: message.Author.ID, 69 | Nickname: message.Author.DisplayName(), 70 | Username: message.Author.Username, 71 | Color: "#5865F2", 72 | ProfilePicture: &profilePicture, 73 | } 74 | 75 | if message.GuildID == "" { 76 | return &author 77 | } 78 | 79 | if message.Member == nil { 80 | member, err := session.State.Member(message.GuildID, message.Author.ID) 81 | if err != nil { 82 | return &author 83 | } 84 | 85 | message.Member = member 86 | } 87 | 88 | if message.Member.GuildID == "" { 89 | message.Member.GuildID = message.GuildID 90 | } 91 | 92 | message.Member.User = message.Author 93 | author.Nickname = message.Member.DisplayName() 94 | profilePicture = message.Member.AvatarURL("") 95 | author.ProfilePicture = &profilePicture 96 | 97 | return &author 98 | } 99 | 100 | var ( 101 | tenorURL = regexp.MustCompile(`https://tenor\.com/view/[^/]+-(\d+).*`) 102 | userMention = regexp.MustCompile(`<@!?(\d+)>`) 103 | channelMention = regexp.MustCompile(`<#(\d+)>`) 104 | roleMention = regexp.MustCompile(`<@&(\d+)>`) 105 | emojiMention = regexp.MustCompile(``) 106 | ) 107 | 108 | func getLightningContent(session *discordgo.Session, message *discordgo.Message) string { 109 | content := tenorURL.ReplaceAllStringFunc(message.Content, func(match string) string { 110 | return "https://tenor.com/view/" + tenorURL.FindStringSubmatch(match)[1] + ".gif" 111 | }) 112 | 113 | content = userMention.ReplaceAllStringFunc(content, func(match string) string { 114 | userID := userMention.FindStringSubmatch(match)[1] 115 | 116 | if message.GuildID != "" { 117 | if member, err := session.State.Member(message.GuildID, userID); err == nil { 118 | return "@" + member.DisplayName() 119 | } 120 | } 121 | 122 | if user, err := session.User(userID); err == nil { 123 | return "@" + user.DisplayName() 124 | } 125 | 126 | return "@" + userID 127 | }) 128 | 129 | content = channelMention.ReplaceAllStringFunc(content, func(match string) string { 130 | channelID := channelMention.FindStringSubmatch(match)[1] 131 | if channel, err := session.State.Channel(channelID); err == nil { 132 | return "#" + channel.Name 133 | } 134 | 135 | return "#" + channelID 136 | }) 137 | 138 | return roleMention.ReplaceAllStringFunc(content, func(match string) string { 139 | roleID := roleMention.FindStringSubmatch(match)[1] 140 | 141 | if guild, err := session.State.Guild(message.GuildID); err == nil { 142 | for _, role := range guild.Roles { 143 | if role.ID == roleID { 144 | return "@" + role.Name 145 | } 146 | } 147 | } 148 | 149 | return "@&" + roleID 150 | }) 151 | } 152 | 153 | func replaceIncomingEmoji(msg *lightning.Message) string { 154 | return emojiMention.ReplaceAllStringFunc(msg.Content, func(match string) string { 155 | split := strings.Split(match, ":") 156 | url := "https://cdn.discordapp.com/emojis/" + split[2] 157 | 158 | if strings.Contains(match, " ") 275 | } 276 | 277 | return snapshot 278 | } 279 | -------------------------------------------------------------------------------- /pkg/platforms/discord/plugin.go: -------------------------------------------------------------------------------- 1 | // Package discord provides a [lightning.Plugin] implementation for Discord. 2 | // To use Discord support with lightning, see [New] 3 | // 4 | // bot := lightning.NewBot(lightning.BotOptions{ 5 | // // ... 6 | // } 7 | // 8 | // bot.AddPluginType("discord", discord.New) 9 | // 10 | // bot.UsePluginType("discord", "", map[string]string{ 11 | // // ... 12 | // }) 13 | package discord 14 | 15 | import ( 16 | "fmt" 17 | "log" 18 | "time" 19 | 20 | "github.com/bwmarrin/discordgo" 21 | "github.com/williamhorning/lightning/internal/cache" 22 | "github.com/williamhorning/lightning/pkg/lightning" 23 | ) 24 | 25 | // New creates a new [lightning.Plugin] that provides Discord support for Lightning 26 | // 27 | // It only takes in a map with the following structure: 28 | // 29 | // map[string]string{ 30 | // "token": "", // a string with your Discord bot token 31 | // } 32 | // 33 | // Note that you MUST enable the Message Content intent for the plugin to work. 34 | func New(cfg map[string]string) (lightning.Plugin, error) { 35 | discord, err := discordgo.New("Bot " + cfg["token"]) 36 | if err != nil { 37 | return nil, fmt.Errorf("discord: failed to create session: %w", err) 38 | } 39 | 40 | discord.Identify.Intents = discordgo.IntentGuilds | discordgo.IntentGuildMessages | 41 | discordgo.IntentDirectMessages | discordgo.IntentMessageContent | discordgo.IntentGuildMessagePolls 42 | discord.StateEnabled = true 43 | discord.ShouldReconnectOnError = true 44 | discord.LogLevel = discordgo.LogError 45 | discord.UserAgent = "lightning/" + lightning.VERSION + " DiscordGo/" + discordgo.VERSION 46 | 47 | if err = discord.Open(); err != nil { 48 | return nil, fmt.Errorf("discord: failed to open session: %w", err) 49 | } 50 | 51 | app, err := discord.Application("@me") 52 | if err != nil { 53 | return nil, fmt.Errorf("discord: failed to get application info: %w", err) 54 | } 55 | 56 | log.Printf("discord: ready as %s in %d servers\n", app.Name, len(discord.State.Guilds)) 57 | log.Printf("discord: https://discord.com/oauth2/authorize?client_id=%s&scope=bot&permissions=8\n", app.ID) 58 | 59 | return &discordPlugin{discord: discord}, nil 60 | } 61 | 62 | type discordPlugin struct { 63 | discord *discordgo.Session 64 | webhookCache cache.Expiring[string, bool] 65 | } 66 | 67 | func (p *discordPlugin) SetupChannel(channel string) (any, error) { 68 | wh, err := p.discord.WebhookCreate(channel, channel, "") 69 | if err != nil { 70 | return nil, getError(err, map[string]any{"channel": channel}, "Failed to create webhook for channel") 71 | } 72 | 73 | return map[string]string{"id": wh.ID, "token": wh.Token}, nil 74 | } 75 | 76 | func (p *discordPlugin) SendCommandResponse( 77 | message *lightning.Message, 78 | opts *lightning.SendOptions, 79 | user string, 80 | ) ([]string, error) { 81 | channel, err := p.discord.UserChannelCreate(user) 82 | if err != nil { 83 | return nil, getError(err, map[string]any{"user": user}, "Failed to create DM channel for command response") 84 | } 85 | 86 | message.ChannelID = channel.ID 87 | 88 | return p.SendMessage(message, opts) 89 | } 90 | 91 | func (p *discordPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) { 92 | msg := getOutgoingMessage(p.discord, message, opts) 93 | 94 | if opts != nil { 95 | webhook, err := p.getWebhookFromChannel(message.ChannelID, opts) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | res, err := p.discord.WebhookExecute(webhook.ID, webhook.Token, true, &discordgo.WebhookParams{ 101 | AllowedMentions: msg.allowedMentions, AvatarURL: msg.avatarURL, Components: msg.components, 102 | Content: msg.content, Embeds: msg.embeds, Files: msg.files, Username: msg.username, 103 | }) 104 | if err != nil { 105 | return nil, getError(err, map[string]any{"msg": msg}, "Failed to send message to Discord via webhook") 106 | } 107 | 108 | return []string{res.ID}, nil 109 | } 110 | 111 | res, err := p.discord.ChannelMessageSendComplex(message.ChannelID, &discordgo.MessageSend{ 112 | AllowedMentions: msg.allowedMentions, Components: msg.components, Content: msg.content, Embeds: msg.embeds, 113 | Files: msg.files, Reference: msg.reference, 114 | }) 115 | if err == nil { 116 | return []string{res.ID}, nil 117 | } 118 | 119 | return nil, getError(err, map[string]any{"msg": msg}, "Failed to send message to Discord") 120 | } 121 | 122 | func (p *discordPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error { 123 | webhook, err := p.getWebhookFromChannel(message.ChannelID, opts) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | if len(ids) == 0 { 129 | return nil 130 | } 131 | 132 | msg := getOutgoingMessage(p.discord, message, opts) 133 | 134 | _, err = p.discord.WebhookMessageEdit(webhook.ID, webhook.Token, ids[0], &discordgo.WebhookEdit{ 135 | AllowedMentions: msg.allowedMentions, Content: &msg.content, Components: &msg.components, Embeds: &msg.embeds, 136 | Files: msg.files, 137 | }) 138 | if err == nil { 139 | return nil 140 | } 141 | 142 | err = getError(err, map[string]any{"ids": ids, "msg": message}, "Failed to edit message in Discord via webhook") 143 | if err != nil { 144 | return err 145 | } 146 | 147 | return nil 148 | } 149 | 150 | func (p *discordPlugin) DeleteMessage(channel string, ids []string) error { 151 | if err := p.discord.ChannelMessagesBulkDelete(channel, ids); err != nil { 152 | if err = getError(err, map[string]any{"ids": ids}, "Failed to delete messages in Discord"); err != nil { 153 | return err 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | 160 | func (p *discordPlugin) SetupCommands(command map[string]*lightning.Command) error { 161 | app, err := p.discord.Application("@me") 162 | if err != nil { 163 | return getError(err, nil, "failed to get application info for Discord commands") 164 | } 165 | 166 | _, err = p.discord.ApplicationCommandBulkOverwrite(app.ID, "", getDiscordCommand(command)) 167 | if err != nil { 168 | return getError(err, nil, "failed to setup Discord commands") 169 | } 170 | 171 | return nil 172 | } 173 | 174 | func (p *discordPlugin) ListenMessages() <-chan *lightning.Message { 175 | channel := make(chan *lightning.Message, 1000) 176 | 177 | p.discord.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) { 178 | if msg := p.getLightningMessage(m.Message); msg != nil { 179 | channel <- msg 180 | } 181 | }) 182 | 183 | return channel 184 | } 185 | 186 | func (p *discordPlugin) ListenEdits() <-chan *lightning.EditedMessage { 187 | channel := make(chan *lightning.EditedMessage, 1000) 188 | 189 | p.discord.AddHandler(func(_ *discordgo.Session, message *discordgo.MessageUpdate) { 190 | if msg := p.getLightningMessage(message.Message); msg != nil { 191 | if message.EditedTimestamp == nil { 192 | now := time.Now() 193 | message.EditedTimestamp = &now 194 | } 195 | 196 | channel <- &lightning.EditedMessage{Message: msg, Edited: message.EditedTimestamp} 197 | } 198 | }) 199 | 200 | return channel 201 | } 202 | 203 | func (p *discordPlugin) ListenDeletes() <-chan *lightning.BaseMessage { 204 | channel := make(chan *lightning.BaseMessage, 1000) 205 | 206 | p.discord.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageDelete) { 207 | channel <- &lightning.BaseMessage{EventID: m.ID, ChannelID: m.ChannelID, Time: &m.Timestamp} 208 | }) 209 | 210 | return channel 211 | } 212 | 213 | func (p *discordPlugin) ListenCommands() <-chan *lightning.CommandEvent { 214 | channel := make(chan *lightning.CommandEvent, 1000) 215 | 216 | p.discord.AddHandler(func(s *discordgo.Session, m *discordgo.InteractionCreate) { 217 | if cmd := getLightningCommand(s, m); cmd != nil { 218 | channel <- cmd 219 | } 220 | }) 221 | 222 | return channel 223 | } 224 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/api.go: -------------------------------------------------------------------------------- 1 | package guilded 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "sync/atomic" 11 | "time" 12 | 13 | "github.com/gorilla/websocket" 14 | "github.com/williamhorning/lightning/pkg/lightning" 15 | ) 16 | 17 | func guildedMakeRequest(token, method, endpoint string, body io.Reader) (*http.Response, error) { 18 | url := "https://www.guilded.gg/api/v1" + endpoint 19 | 20 | req, err := http.NewRequestWithContext(context.Background(), method, url, body) 21 | if err != nil { 22 | return nil, fmt.Errorf("guilded: creating request: %w\n\tendpoint: %s\n\tmethod: %s", err, endpoint, method) 23 | } 24 | 25 | req.Header["Authorization"] = []string{"Bearer " + token} 26 | req.Header["Content-Type"] = []string{"application/json"} 27 | req.Header["User-Agent"] = []string{"lightning/" + lightning.VERSION} 28 | req.Header["x-guilded-bot-api-use-official-markdown"] = []string{"true"} 29 | 30 | resp, err := http.DefaultClient.Do(req) 31 | if err != nil { 32 | return nil, fmt.Errorf("guilded: making request: %w\n\tendpoint: %s\n\tmethod: %s", err, endpoint, method) 33 | } 34 | 35 | if resp.StatusCode == http.StatusTooManyRequests { 36 | retryAfter := resp.Header["Retry-After"] 37 | 38 | if len(retryAfter) == 0 { 39 | retryAfter = append(retryAfter, "1000") 40 | } 41 | 42 | if retryAfter[0] == "" { 43 | retryAfter[0] = "1000" 44 | } 45 | 46 | retryAfterDuration, err := time.ParseDuration(retryAfter[0] + "ms") 47 | if err != nil { 48 | retryAfterDuration = time.Second 49 | } 50 | 51 | time.Sleep(retryAfterDuration) 52 | 53 | return guildedMakeRequest(token, method, endpoint, body) 54 | } 55 | 56 | return resp, nil 57 | } 58 | 59 | type session struct { 60 | conn *websocket.Conn 61 | ready chan *guildedWelcomeMessage 62 | messageDeleted chan *guildedChatMessageDeleted 63 | messageCreated chan *guildedChatMessageCreated 64 | messageUpdated chan *guildedChatMessageUpdated 65 | token string 66 | connected atomic.Bool 67 | } 68 | 69 | func (s *session) connect() error { 70 | if s.connected.Load() { 71 | return nil 72 | } 73 | 74 | conn, resp, err := websocket.DefaultDialer.Dial( 75 | "wss://www.guilded.gg/websocket/v1", 76 | map[string][]string{ 77 | "Authorization": {"Bearer " + s.token}, 78 | "User-Agent": {"lightning" + lightning.VERSION}, 79 | "x-guilded-bot-api-use-official-markdown": {"true"}, 80 | }, 81 | ) 82 | if err != nil { 83 | return fmt.Errorf("guilded: failed to dial: %w", err) 84 | } 85 | 86 | if err = resp.Body.Close(); err != nil { 87 | log.Printf("guilded: failed to close body: %v\n", err) 88 | } 89 | 90 | s.conn = conn 91 | s.connected.Swap(true) 92 | 93 | go readMessages(s) 94 | 95 | return nil 96 | } 97 | 98 | func readMessages(session *session) { 99 | for session.connected.Load() && session.conn != nil { 100 | _, message, err := session.conn.ReadMessage() 101 | if err != nil { 102 | if !websocket.IsCloseError(err, websocket.CloseNormalClosure) { 103 | log.Printf("guilded: error reading socket: %v\n", err) 104 | } 105 | 106 | break 107 | } 108 | 109 | handleEvent(session, message) 110 | } 111 | 112 | session.connected.Store(false) 113 | 114 | if session.conn != nil { 115 | if err := session.conn.Close(); err != nil { 116 | log.Printf("guilded: failed to close connection: %v\n", err) 117 | } 118 | 119 | session.conn = nil 120 | } 121 | 122 | go handleReconnect(session.connect) 123 | } 124 | 125 | func handleReconnect(connect func() error) { 126 | attempt := 0 127 | backoff := 100 * time.Millisecond 128 | 129 | for { 130 | attempt++ 131 | 132 | time.Sleep(backoff) 133 | 134 | if connect() == nil { 135 | return 136 | } 137 | 138 | backoff = min(time.Duration(float64(backoff)*1.5), time.Second) 139 | 140 | log.Printf("guilded: attempting reconnect #%d after %s\n", attempt, backoff.String()) 141 | } 142 | } 143 | 144 | func handleEvent(session *session, message []byte) { 145 | var data guildedSocketEventEnvelope 146 | if err := json.Unmarshal(message, &data); err != nil { 147 | log.Printf("guilded: failed unmarshaling event wrapper: %v\n\tdata: %s\n", err, string(message)) 148 | 149 | return 150 | } 151 | 152 | if data.Op == 1 { 153 | handleGenericEvent(&data, session.ready) 154 | 155 | return 156 | } 157 | 158 | if data.T == nil { 159 | return 160 | } 161 | 162 | switch *data.T { 163 | case "ChatMessageCreated": 164 | handleGenericEvent(&data, session.messageCreated) 165 | case "ChatMessageUpdated": 166 | handleGenericEvent(&data, session.messageUpdated) 167 | case "ChatMessageDeleted": 168 | handleGenericEvent(&data, session.messageDeleted) 169 | default: 170 | } 171 | } 172 | 173 | func handleGenericEvent[T any](data *guildedSocketEventEnvelope, channel chan *T) { 174 | var decoded T 175 | if err := json.Unmarshal(data.D, &decoded); err != nil { 176 | log.Printf("guilded: failed unmarshaling event: %v\n\tdata: %s\n", err, string(data.D)) 177 | 178 | return 179 | } 180 | 181 | channel <- &decoded 182 | } 183 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/errors.go: -------------------------------------------------------------------------------- 1 | package guilded 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/williamhorning/lightning/pkg/lightning" 7 | ) 8 | 9 | type guildedWebhookDataError struct{} 10 | 11 | func (guildedWebhookDataError) Disable() *lightning.ChannelDisabled { 12 | return &lightning.ChannelDisabled{Read: false, Write: true} 13 | } 14 | 15 | func (guildedWebhookDataError) Error() string { 16 | return "invalid webhook data for Guilded channel" 17 | } 18 | 19 | type guildedStatusError struct { 20 | msg string 21 | data string 22 | code int 23 | disableWrite bool 24 | } 25 | 26 | func (e guildedStatusError) Disable() *lightning.ChannelDisabled { 27 | return &lightning.ChannelDisabled{Read: false, Write: e.disableWrite} 28 | } 29 | 30 | func (e guildedStatusError) Error() string { 31 | return strconv.FormatInt(int64(e.code), 10) + ": " + e.msg + "\n\tdata: " + e.data 32 | } 33 | 34 | type guildedWebhookTokenNilError struct { 35 | channel string 36 | } 37 | 38 | func (e guildedWebhookTokenNilError) Error() string { 39 | return "guilded: " + e.channel + " has a nil webhook token, probably due to a Guilded bug" 40 | } 41 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/guilded.go: -------------------------------------------------------------------------------- 1 | package guilded 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | type guildedChatEmbedAuthor struct { 9 | IconURL *string `json:"icon_url,omitempty"` 10 | Name *string `json:"name,omitempty"` 11 | URL *string `json:"url,omitempty"` 12 | } 13 | 14 | type guildedChatEmbedField struct { 15 | Inline *bool `json:"inline,omitempty"` 16 | Name string `json:"name"` 17 | Value string `json:"value"` 18 | } 19 | 20 | type guildedChatEmbedFooter struct { 21 | IconURL *string `json:"icon_url,omitempty"` 22 | Text string `json:"text"` 23 | } 24 | 25 | type guildedChatEmbedMedia struct { 26 | URL *string `json:"url,omitempty"` 27 | } 28 | 29 | type guildedChatEmbed struct { 30 | Author *guildedChatEmbedAuthor `json:"author,omitempty"` 31 | Color *int `json:"color,omitempty"` 32 | Description *string `json:"description,omitempty"` 33 | Fields *[]guildedChatEmbedField `json:"fields,omitempty"` 34 | Footer *guildedChatEmbedFooter `json:"footer,omitempty"` 35 | Image *guildedChatEmbedMedia `json:"image,omitempty"` 36 | Thumbnail *guildedChatEmbedMedia `json:"thumbnail,omitempty"` 37 | Timestamp *string `json:"timestamp,omitempty"` 38 | Title *string `json:"title,omitempty"` 39 | URL *string `json:"url,omitempty"` 40 | } 41 | 42 | type guildedChatMessage struct { 43 | CreatedAt time.Time `json:"createdAt"` 44 | Content *string `json:"content,omitempty"` 45 | CreatedByWebhookID *string `json:"createdByWebhookId,omitempty"` 46 | Embeds *[]guildedChatEmbed `json:"embeds,omitempty"` 47 | ReplyMessageIDs *[]string `json:"replyMessageIds,omitempty"` 48 | ServerID *string `json:"serverId,omitempty"` 49 | UpdatedAt *time.Time `json:"updatedAt,omitempty"` 50 | ChannelID string `json:"channelId"` 51 | CreatedBy string `json:"createdBy"` 52 | ID string `json:"id"` 53 | } 54 | 55 | type guildedChatMessageResponse struct { 56 | Message guildedChatMessage `json:"message"` 57 | } 58 | 59 | type guildedChatMessageCreated struct { 60 | Message guildedChatMessage `json:"message"` 61 | } 62 | 63 | type guildedChatMessageDeleted struct { 64 | DeletedAt time.Time `json:"deletedAt"` 65 | Message guildedChatMessage `json:"message"` 66 | } 67 | 68 | type guildedChatMessageUpdated struct { 69 | Message guildedChatMessage `json:"message"` 70 | } 71 | 72 | type guildedPayload struct { 73 | Content string `json:"content,omitempty"` 74 | AvatarURL string `json:"avatar_url,omitempty"` 75 | Username string `json:"username,omitempty"` 76 | Embeds []guildedChatEmbed `json:"embeds,omitempty"` 77 | ReplyMessageIDs []string `json:"replyMessageIds,omitempty"` 78 | } 79 | 80 | type guildedServerChannel struct { 81 | ServerID string `json:"serverId"` 82 | } 83 | 84 | type guildedServerChannelResponse struct { 85 | Channel guildedServerChannel `json:"channel"` 86 | } 87 | 88 | type guildedServerMember struct { 89 | Nickname *string `json:"nickname,omitempty"` 90 | User guildedUser `json:"user"` 91 | } 92 | 93 | type guildedServerMemberResponse struct { 94 | Member guildedServerMember `json:"member"` 95 | } 96 | 97 | type guildedSocketEventEnvelope struct { 98 | T *string `json:"t,omitempty"` 99 | D json.RawMessage `json:"d,omitempty"` 100 | Op int `json:"op"` 101 | } 102 | 103 | type guildedURLSignature struct { 104 | RetryAfter *int `json:"retryAfter,omitempty"` 105 | Signature *string `json:"signature,omitempty"` 106 | } 107 | 108 | type guildedURLSignatureResponse struct { 109 | URLSignatures []guildedURLSignature `json:"urlSignatures"` 110 | } 111 | 112 | type guildedUser struct { 113 | Avatar *string `json:"avatar,omitempty"` 114 | ID string `json:"id"` 115 | Name string `json:"name"` 116 | } 117 | 118 | type guildedWebhook struct { 119 | Avatar *string `json:"avatar,omitempty"` 120 | Token *string `json:"token,omitempty"` 121 | ID string `json:"id"` 122 | Name string `json:"name"` 123 | } 124 | 125 | type guildedWebhookResponse struct { 126 | Webhook guildedWebhook `json:"webhook"` 127 | } 128 | 129 | type guildedWebhookExecuteResponse struct { 130 | ID string `json:"id"` 131 | } 132 | 133 | type guildedWelcomeMessage struct { 134 | User guildedUser `json:"user"` 135 | } 136 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/outgoing.go: -------------------------------------------------------------------------------- 1 | package guilded 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/williamhorning/lightning/pkg/lightning" 11 | ) 12 | 13 | var usernameRegex = regexp.MustCompile(`(?ms)^[a-zA-Z0-9_ ()-]{1,25}$`) 14 | 15 | func (p *guildedPlugin) getOutgoingMessage(message *lightning.Message, opts *lightning.SendOptions) *guildedPayload { 16 | base := &guildedPayload{ 17 | Content: message.Content, 18 | ReplyMessageIDs: message.RepliedTo, 19 | Embeds: p.getOutgoingEmbeds(message, opts), 20 | } 21 | 22 | if message.Author != nil { 23 | if message.Author.ProfilePicture != nil { 24 | base.AvatarURL = *message.Author.ProfilePicture 25 | } 26 | 27 | base.Username = message.Author.ID 28 | 29 | if usernameRegex.MatchString(message.Author.Username) { 30 | base.Username = message.Author.Username 31 | } 32 | 33 | if usernameRegex.MatchString(message.Author.Nickname) { 34 | base.Username = message.Author.Nickname 35 | } 36 | } 37 | 38 | if len(base.Content) == 0 && len(base.Embeds) == 0 { 39 | base.Content = "\u2800" 40 | } 41 | 42 | if opts != nil && !opts.AllowEveryonePings { 43 | base.Content = strings.ReplaceAll(base.Content, "@everyone", "@\u2800everyone") 44 | base.Content = strings.ReplaceAll(base.Content, "@here", "@\u2800here") 45 | } 46 | 47 | return base 48 | } 49 | 50 | func (p *guildedPlugin) getOutgoingEmbeds(message *lightning.Message, opts *lightning.SendOptions) []guildedChatEmbed { 51 | guildedEmbeds := make([]guildedChatEmbed, 0) 52 | 53 | for _, embed := range message.Embeds { 54 | guildedEmbeds = append(guildedEmbeds, guildedChatEmbed{ 55 | Title: embed.Title, 56 | Description: embed.Description, 57 | Color: embed.Color, 58 | Image: getEmbedImage(&embed), 59 | Thumbnail: getEmbedThumbnail(&embed), 60 | Footer: getEmbedFooter(&embed), 61 | Author: getEmbedAuthor(&embed), 62 | Fields: getEmbedFields(&embed), 63 | Timestamp: embed.Timestamp, 64 | URL: embed.URL, 65 | }) 66 | } 67 | 68 | if len(message.Attachments) > 0 { 69 | description := "" 70 | 71 | for _, attachment := range message.Attachments { 72 | description += "[" + attachment.Name + "](" + attachment.URL + ")\n" 73 | } 74 | 75 | guildedEmbeds = append(guildedEmbeds, guildedChatEmbed{ 76 | Description: &description, 77 | }) 78 | } 79 | 80 | if opts != nil && len(message.RepliedTo) > 0 { 81 | guildedEmbeds = p.appendReplyEmbed(guildedEmbeds, message) 82 | } 83 | 84 | return guildedEmbeds 85 | } 86 | 87 | func getEmbedImage(embed *lightning.Embed) *guildedChatEmbedMedia { 88 | if embed.Image != nil && embed.Image.URL != "" { 89 | return &guildedChatEmbedMedia{ 90 | URL: &embed.Image.URL, 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | 97 | func getEmbedThumbnail(embed *lightning.Embed) *guildedChatEmbedMedia { 98 | if embed.Thumbnail != nil && embed.Thumbnail.URL != "" { 99 | return &guildedChatEmbedMedia{ 100 | URL: &embed.Thumbnail.URL, 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func getEmbedFooter(embed *lightning.Embed) *guildedChatEmbedFooter { 108 | if embed.Footer != nil { 109 | footer := &guildedChatEmbedFooter{ 110 | Text: embed.Footer.Text, 111 | } 112 | if embed.Footer.IconURL != nil { 113 | footer.IconURL = embed.Footer.IconURL 114 | } 115 | 116 | return footer 117 | } 118 | 119 | return nil 120 | } 121 | 122 | func getEmbedAuthor(embed *lightning.Embed) *guildedChatEmbedAuthor { 123 | if embed.Author != nil { 124 | author := &guildedChatEmbedAuthor{ 125 | Name: &embed.Author.Name, 126 | URL: embed.Author.URL, 127 | } 128 | if embed.Author.IconURL != nil { 129 | author.IconURL = embed.Author.IconURL 130 | } 131 | 132 | return author 133 | } 134 | 135 | return nil 136 | } 137 | 138 | func getEmbedFields(embed *lightning.Embed) *[]guildedChatEmbedField { 139 | if len(embed.Fields) > 0 { 140 | convertedFields := make([]guildedChatEmbedField, len(embed.Fields)) 141 | for i, field := range embed.Fields { 142 | convertedFields[i] = guildedChatEmbedField{ 143 | Inline: &field.Inline, 144 | Name: field.Name, 145 | Value: field.Value, 146 | } 147 | } 148 | 149 | return &convertedFields 150 | } 151 | 152 | return nil 153 | } 154 | 155 | func (p *guildedPlugin) appendReplyEmbed(embeds []guildedChatEmbed, message *lightning.Message) []guildedChatEmbed { 156 | resp, err := guildedMakeRequest(p.token, "GET", 157 | "/channels/"+message.ChannelID+"/messages/"+message.RepliedTo[0], nil) 158 | if err != nil { 159 | return embeds 160 | } 161 | 162 | body, err := io.ReadAll(resp.Body) 163 | if err != nil { 164 | return embeds 165 | } 166 | 167 | if resp.Body.Close() != nil { 168 | log.Println("guilded: failed to close request body when getting reply embed") 169 | } 170 | 171 | var messageResp guildedChatMessageResponse 172 | if json.Unmarshal(body, &messageResp) != nil { 173 | return embeds 174 | } 175 | 176 | author := p.getIncomingAuthor(&messageResp.Message) 177 | title := "reply to " + author.Nickname 178 | 179 | return append(embeds, guildedChatEmbed{ 180 | Author: &guildedChatEmbedAuthor{ 181 | Name: &title, 182 | IconURL: author.ProfilePicture, 183 | }, 184 | Description: messageResp.Message.Content, 185 | }) 186 | } 187 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/plugin.go: -------------------------------------------------------------------------------- 1 | // Package guilded provides a [lightning.Plugin] implementation for Guilded. 2 | // To use Guilded support with lightning, see [New] 3 | // 4 | // bot := lightning.NewBot(lightning.BotOptions{ 5 | // // ... 6 | // } 7 | // 8 | // bot.AddPluginType("guilded", guilded.New) 9 | // 10 | // bot.UsePluginType("guilded", "", map[string]string{ 11 | // // ... 12 | // }) 13 | package guilded 14 | 15 | import ( 16 | "fmt" 17 | "log" 18 | 19 | "github.com/williamhorning/lightning/internal/cache" 20 | "github.com/williamhorning/lightning/pkg/lightning" 21 | ) 22 | 23 | // New creates a new [lightning.Plugin] that provides Guilded support for Lightning 24 | // 25 | // It only takes in a map with the following structure: 26 | // 27 | // map[string]string{ 28 | // "token": "", // a string with your Guilded bot token 29 | // } 30 | func New(cfg map[string]string) (lightning.Plugin, error) { 31 | plugin := &guildedPlugin{socket: &session{ 32 | ready: make(chan *guildedWelcomeMessage, 100), 33 | messageDeleted: make(chan *guildedChatMessageDeleted, 1000), 34 | messageCreated: make(chan *guildedChatMessageCreated, 1000), 35 | messageUpdated: make(chan *guildedChatMessageUpdated, 1000), 36 | token: cfg["token"], 37 | }, token: cfg["token"]} 38 | 39 | plugin.assetsCache.TTL = assetCacheTTL 40 | 41 | go func() { 42 | for msg := range plugin.socket.ready { 43 | log.Printf("guilded: ready as %s!\n", msg.User.Name) 44 | } 45 | }() 46 | 47 | if err := plugin.socket.connect(); err != nil { 48 | return nil, fmt.Errorf("guilded: failed to connect to socket: %w", err) 49 | } 50 | 51 | return plugin, nil 52 | } 53 | 54 | type guildedPlugin struct { 55 | socket *session 56 | token string 57 | assetsCache cache.Expiring[string, lightning.Attachment] 58 | membersCache cache.Expiring[string, guildedServerMember] 59 | webhooksCache cache.Expiring[string, guildedWebhook] 60 | webhookIDsCache cache.Expiring[string, bool] 61 | } 62 | 63 | func (*guildedPlugin) EditMessage(_ *lightning.Message, _ []string, _ *lightning.SendOptions) error { 64 | return nil 65 | } 66 | 67 | func (p *guildedPlugin) DeleteMessage(channel string, ids []string) error { 68 | for _, msgID := range ids { 69 | resp, err := guildedMakeRequest(p.token, "DELETE", "/channels/"+channel+"/messages/"+msgID, nil) 70 | 71 | if resp.Body.Close() != nil { 72 | log.Println("guilded: failed to close request body when deleting message") 73 | } 74 | 75 | if err != nil { 76 | return fmt.Errorf("guilded: failed to delete message: %w\n\tchannel %s\n\tmessage: %s", err, channel, msgID) 77 | } 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (*guildedPlugin) SetupCommands(_ map[string]*lightning.Command) error { 84 | return nil 85 | } 86 | 87 | func (p *guildedPlugin) ListenMessages() <-chan *lightning.Message { 88 | channel := make(chan *lightning.Message, 1000) 89 | 90 | go func() { 91 | for msg := range p.socket.messageCreated { 92 | if message := p.getIncomingMessage(&msg.Message); message != nil { 93 | channel <- message 94 | } 95 | } 96 | }() 97 | 98 | return channel 99 | } 100 | 101 | func (p *guildedPlugin) ListenEdits() <-chan *lightning.EditedMessage { 102 | channel := make(chan *lightning.EditedMessage, 1000) 103 | 104 | go func() { 105 | for msg := range p.socket.messageUpdated { 106 | if message := p.getIncomingMessage(&msg.Message); message != nil { 107 | channel <- &lightning.EditedMessage{Message: message, Edited: msg.Message.UpdatedAt} 108 | } 109 | } 110 | }() 111 | 112 | return channel 113 | } 114 | 115 | func (p *guildedPlugin) ListenDeletes() <-chan *lightning.BaseMessage { 116 | channel := make(chan *lightning.BaseMessage, 1000) 117 | 118 | go func() { 119 | for msg := range p.socket.messageDeleted { 120 | channel <- &lightning.BaseMessage{ 121 | EventID: msg.Message.ID, ChannelID: msg.Message.ChannelID, Time: &msg.DeletedAt, 122 | } 123 | } 124 | }() 125 | 126 | return channel 127 | } 128 | 129 | func (*guildedPlugin) ListenCommands() <-chan *lightning.CommandEvent { 130 | return nil 131 | } 132 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/send.go: -------------------------------------------------------------------------------- 1 | package guilded 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | 12 | "github.com/williamhorning/lightning/pkg/lightning" 13 | ) 14 | 15 | func (p *guildedPlugin) SendCommandResponse( 16 | message *lightning.Message, 17 | opts *lightning.SendOptions, 18 | _ string, 19 | ) ([]string, error) { 20 | return p.SendMessage(message, opts) 21 | } 22 | 23 | func (p *guildedPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) { 24 | msg := p.getOutgoingMessage(message, opts) 25 | 26 | jsonMsg, err := json.Marshal(msg) 27 | if err != nil { 28 | return nil, fmt.Errorf("guilded: failed to marshal message: %w\n\tdata: %#+v", err, message) 29 | } 30 | 31 | reader := bytes.NewReader(jsonMsg) 32 | 33 | if opts == nil { 34 | return p.apiSendMessage(message, reader) 35 | } 36 | 37 | return p.sendWebhookMessage(message, opts, reader) 38 | } 39 | 40 | func (p *guildedPlugin) apiSendMessage(message *lightning.Message, reader io.Reader) ([]string, error) { 41 | resp, err := guildedMakeRequest(p.token, "POST", "/channels/"+message.ChannelID+"/messages", reader) 42 | if err != nil { 43 | return nil, fmt.Errorf("guilded: failed to send message: %w\n\tdata: %#+v", err, message) 44 | } 45 | 46 | if err := checkStatusCode(resp, message.ChannelID); err != nil { 47 | return nil, err 48 | } 49 | 50 | var msg guildedChatMessageResponse 51 | if err := readResponse(resp, &msg, message.ChannelID); err != nil { 52 | return nil, err 53 | } 54 | 55 | if resp.Body.Close() != nil { 56 | log.Println("guilded: failed to close request body when sending message") 57 | } 58 | 59 | return []string{msg.Message.ID}, nil 60 | } 61 | 62 | func (p *guildedPlugin) getWebhookInfo(data any) (guildedWebhook, error) { 63 | webhookData, ok := data.(map[string]any) 64 | if !ok { 65 | return guildedWebhook{}, &guildedWebhookDataError{} 66 | } 67 | 68 | whID, idOk := webhookData["id"].(string) 69 | token, tokenOk := webhookData["token"].(string) 70 | 71 | if !idOk || !tokenOk { 72 | return guildedWebhook{}, &guildedWebhookDataError{} 73 | } 74 | 75 | p.webhookIDsCache.Set(whID, true) 76 | 77 | return guildedWebhook{ID: whID, Token: &token}, nil 78 | } 79 | 80 | func (p *guildedPlugin) sendWebhookMessage( 81 | message *lightning.Message, 82 | opts *lightning.SendOptions, 83 | reader io.Reader, 84 | ) ([]string, error) { 85 | webhook, err := p.getWebhookInfo(opts.ChannelData) 86 | if err != nil { 87 | return nil, err 88 | } 89 | 90 | url := "https://media.guilded.gg/webhooks/" + webhook.ID + "/" + *webhook.Token 91 | 92 | req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, reader) 93 | if err != nil { 94 | return nil, fmt.Errorf("guilded: failed to create webhook message: %w\n\tchannel: %s\n\tmessage: %#+v", 95 | err, message.ChannelID, message) 96 | } 97 | 98 | req.Header["Content-Type"] = []string{"application/json"} 99 | 100 | resp, err := http.DefaultClient.Do(req) 101 | if err != nil { 102 | return nil, fmt.Errorf("guilded: failed to send webhook message: %w\n\tchannel: %s\n\tmessage: %#+v", 103 | err, message.ChannelID, message) 104 | } 105 | 106 | if err := checkStatusCode(resp, message.ChannelID); err != nil { 107 | return nil, err 108 | } 109 | 110 | var response guildedWebhookExecuteResponse 111 | if err := readResponse(resp, &response, message.ChannelID); err != nil { 112 | return nil, err 113 | } 114 | 115 | if resp.Body.Close() != nil { 116 | log.Println("guilded: failed to close request body when sending webhook message") 117 | } 118 | 119 | return []string{response.ID}, nil 120 | } 121 | 122 | func checkStatusCode(resp *http.Response, channelID string) error { 123 | if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { 124 | return nil 125 | } 126 | 127 | var ( 128 | errMsg string 129 | disable bool 130 | ) 131 | 132 | switch resp.StatusCode { 133 | case http.StatusNotFound: 134 | errMsg = "not found! this might be a Guilded problem" 135 | disable = true 136 | case http.StatusForbidden: 137 | errMsg = "the bot lacks some permissions, please check them" 138 | disable = true 139 | default: 140 | errMsg = "unexpected status code: " + resp.Status 141 | disable = false 142 | } 143 | 144 | return &guildedStatusError{"failed to send message to " + channelID + ": " + errMsg, "", resp.StatusCode, disable} 145 | } 146 | 147 | func readResponse(resp *http.Response, target any, channelID string) error { 148 | bodyBytes, err := io.ReadAll(resp.Body) 149 | if err != nil { 150 | return fmt.Errorf("guilded: failed to read response body: %w\n\tchannel: %s\n\tstatus: %d", 151 | err, channelID, resp.StatusCode) 152 | } 153 | 154 | if err := json.Unmarshal(bodyBytes, target); err != nil { 155 | return fmt.Errorf("guilded: failed to unmarshal response body: %w\n\tchannel: %s\n\tstatus: %d", 156 | err, channelID, resp.StatusCode) 157 | } 158 | 159 | return nil 160 | } 161 | -------------------------------------------------------------------------------- /pkg/platforms/guilded/setup.go: -------------------------------------------------------------------------------- 1 | package guilded 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | func (p *guildedPlugin) SetupChannel(channel string) (any, error) { 13 | channelData, err := getChannel(p.token, channel) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | body, err := json.Marshal(map[string]string{"channelId": channel, "name": channel}) 19 | if err != nil { 20 | return nil, fmt.Errorf("guilded: failed to marshal webhook creation body: %w\n\tchannel: %s", err, channel) 21 | } 22 | 23 | var reader io.Reader = bytes.NewReader(body) 24 | 25 | resp, err := guildedMakeRequest(p.token, "POST", "/servers/"+channelData.ServerID+"/webhooks", reader) 26 | if err != nil { 27 | return nil, fmt.Errorf("guilded: failed to create webhook for channel: %w\n\tchannel: %s", err, channel) 28 | } 29 | 30 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 31 | bodyBytes, err := io.ReadAll(resp.Body) 32 | if err == nil { 33 | body = []byte(string(body) + "\n\tdata: " + string(bodyBytes)) 34 | } 35 | 36 | if resp.Body.Close() != nil { 37 | log.Println("guilded: failed to close request body when making webhook") 38 | } 39 | 40 | return nil, &guildedStatusError{"failed to create webhook", string(body), resp.StatusCode, false} 41 | } 42 | 43 | var webhookData guildedWebhookResponse 44 | 45 | if err := json.NewDecoder(resp.Body).Decode(&webhookData); err != nil { 46 | return nil, fmt.Errorf("guilded: failed to decode webhook data: %w\n\tchannel: %s", err, channel) 47 | } 48 | 49 | if webhookData.Webhook.Token == nil { 50 | return nil, &guildedWebhookTokenNilError{channel} 51 | } 52 | 53 | p.webhookIDsCache.Set(webhookData.Webhook.ID, true) 54 | 55 | return map[string]string{"id": webhookData.Webhook.ID, "token": *webhookData.Webhook.Token}, nil 56 | } 57 | 58 | func getChannel(token, channel string) (*guildedServerChannel, error) { 59 | resp, err := guildedMakeRequest(token, "GET", "/channels/"+channel, nil) 60 | if err != nil { 61 | return nil, fmt.Errorf("guilded: failed to get channel for setup: %w\n\tchannel: %s", err, channel) 62 | } 63 | 64 | bodyBytes, err := io.ReadAll(resp.Body) 65 | if err != nil { 66 | return nil, fmt.Errorf("guilded: failed to read body when getting channel: %w\n\tchannel: %s", err, channel) 67 | } 68 | 69 | if resp.Body.Close() != nil { 70 | log.Println("guilded: failed to close request body when getting channel") 71 | } 72 | 73 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 74 | return nil, &guildedStatusError{"failed to get channel", string(bodyBytes), resp.StatusCode, false} 75 | } 76 | 77 | var channelData guildedServerChannelResponse 78 | if err := json.Unmarshal(bodyBytes, &channelData); err != nil { 79 | return nil, fmt.Errorf("guilded: failed to unmarshal channel data: %w\n\tbody: %s", err, bodyBytes) 80 | } 81 | 82 | return &channelData.Channel, nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/platforms/matrix/errors.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/williamhorning/lightning/pkg/lightning" 10 | "maunium.net/go/mautrix" 11 | ) 12 | 13 | type matrixError struct { 14 | err error 15 | msg string 16 | disableWrite bool 17 | } 18 | 19 | // Disable implements lightning.ChannelDisabler. 20 | func (e matrixError) Disable() *lightning.ChannelDisabled { 21 | return &lightning.ChannelDisabled{Write: e.disableWrite} 22 | } 23 | 24 | func (e matrixError) Error() string { 25 | return e.msg 26 | } 27 | 28 | func (e matrixError) Unwrap() error { 29 | return e.err 30 | } 31 | 32 | func handleError(err error, msg string, extra map[string]any) error { 33 | log.Printf("matrix: %s: %v\n\textra: %#+v\n", msg, err, extra) 34 | 35 | var httpErr *mautrix.HTTPError 36 | if !errors.As(err, &httpErr) { 37 | return fmt.Errorf("matrix error: %w", err) 38 | } 39 | 40 | extra["err_msg"] = httpErr.Message 41 | 42 | extra["status_code"] = httpErr.Response.StatusCode 43 | if httpErr.RespError == nil { 44 | return fmt.Errorf("matrix error: %w", err) 45 | } 46 | 47 | extra["err_code"] = httpErr.RespError.ErrCode 48 | 49 | disable := false 50 | 51 | switch httpErr.RespError.StatusCode { 52 | case http.StatusForbidden, http.StatusNotFound: 53 | disable = true 54 | default: 55 | } 56 | 57 | return &matrixError{err, msg, disable} 58 | } 59 | -------------------------------------------------------------------------------- /pkg/platforms/matrix/events.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | "maunium.net/go/mautrix" 10 | "maunium.net/go/mautrix/event" 11 | "maunium.net/go/mautrix/format" 12 | ) 13 | 14 | func setupEvents( 15 | syncer *mautrix.DefaultSyncer, 16 | client *mautrix.Client, 17 | msgChannel chan<- *lightning.Message, 18 | editChannel chan<- *lightning.EditedMessage, 19 | ) { 20 | syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { 21 | if evt.Content.AsMember().Membership == event.MembershipInvite { 22 | _, err := client.JoinRoomByID(ctx, evt.RoomID) 23 | if err != nil { 24 | log.Printf("matrix: failed to join room: %v\n", err) 25 | } 26 | } 27 | }) 28 | 29 | syncer.OnSync(func(ctx context.Context, resp *mautrix.RespSync, since string) bool { 30 | if since != "" { 31 | return true 32 | } 33 | 34 | return client.DontProcessOldEvents(ctx, resp, since) 35 | }) 36 | 37 | syncer.OnEventType(event.EventMessage, onMessageHandler(client, msgChannel, editChannel)) 38 | 39 | go func() { 40 | if err := client.Sync(); err != nil { 41 | log.Printf("matrix: failed to sync: %v\n", err) 42 | 43 | return 44 | } 45 | }() 46 | } 47 | 48 | func onMessageHandler( 49 | client *mautrix.Client, 50 | msgChannel chan<- *lightning.Message, 51 | editChannel chan<- *lightning.EditedMessage, 52 | ) mautrix.EventHandler { 53 | return func(ctx context.Context, evt *event.Event) { 54 | msg := evt.Content.AsMessage() 55 | 56 | if string(evt.Sender) == string(client.UserID) && msg.BeeperPerMessageProfile != nil { 57 | return 58 | } 59 | 60 | if msg.FormattedBody == "" { 61 | msg.FormattedBody = msg.Body 62 | } 63 | 64 | attachments := make([]lightning.Attachment, 0) 65 | content := "" 66 | timestamp := time.UnixMilli(evt.Timestamp) 67 | 68 | if msg.FileName == msg.Body { 69 | url := getMXC(client, string(msg.URL)) 70 | 71 | attachments = append(attachments, lightning.Attachment{ 72 | Name: msg.FileName, 73 | URL: url, 74 | Size: 0, 75 | }) 76 | } else { 77 | msg.RemovePerMessageProfileFallback() 78 | 79 | content, _ = format.HTMLToMarkdownFull(nil, msg.FormattedBody) 80 | } 81 | 82 | newMessage := lightning.Message{ 83 | BaseMessage: lightning.BaseMessage{ 84 | Time: ×tamp, 85 | EventID: string(evt.ID), 86 | ChannelID: string(evt.RoomID), 87 | }, 88 | Attachments: attachments, 89 | Author: getAuthor(ctx, client, evt, msg), 90 | Content: content, 91 | RepliedTo: getRepliedTo(msg), 92 | } 93 | 94 | if msg.NewContent != nil { 95 | if msg.NewContent.FormattedBody == "" { 96 | msg.NewContent.FormattedBody = msg.NewContent.Body 97 | } 98 | 99 | newContent, _ := format.HTMLToMarkdownFull(nil, msg.NewContent.FormattedBody) 100 | newMessage.Content = newContent 101 | 102 | editChannel <- &lightning.EditedMessage{Edited: &evt.Mautrix.EditedAt, Message: &newMessage} 103 | } else { 104 | msgChannel <- &newMessage 105 | } 106 | } 107 | } 108 | 109 | func getAuthor( 110 | ctx context.Context, 111 | client *mautrix.Client, 112 | evt *event.Event, 113 | msg *event.MessageEventContent, 114 | ) *lightning.MessageAuthor { 115 | defaultProfile, err := client.GetProfile(ctx, evt.Sender) 116 | if err != nil { 117 | log.Printf("matrix: failed to get default message profile: %v\n", err) 118 | 119 | if msg.BeeperPerMessageProfile == nil { 120 | return &lightning.MessageAuthor{ 121 | ID: string(evt.Sender), 122 | Nickname: string(evt.Sender), 123 | Username: string(evt.Sender), 124 | ProfilePicture: nil, 125 | Color: "#ffffff", 126 | } 127 | } 128 | } 129 | 130 | var defaultPic *string 131 | 132 | if err == nil { 133 | if !defaultProfile.AvatarURL.IsEmpty() { 134 | url := getMXC(client, "mxc://"+defaultProfile.AvatarURL.Homeserver+"/"+defaultProfile.AvatarURL.FileID) 135 | defaultPic = &url 136 | } 137 | } 138 | 139 | if msg.BeeperPerMessageProfile != nil { 140 | var profile *string 141 | 142 | if msg.BeeperPerMessageProfile.AvatarURL != nil && *msg.BeeperPerMessageProfile.AvatarURL != "" { 143 | url := getMXC(client, string(*msg.BeeperPerMessageProfile.AvatarURL)) 144 | profile = &url 145 | } else if *msg.BeeperPerMessageProfile.AvatarURL == "" && !defaultProfile.AvatarURL.IsEmpty() { 146 | profile = defaultPic 147 | } 148 | 149 | return &lightning.MessageAuthor{ 150 | ID: string(evt.Sender), 151 | Nickname: msg.BeeperPerMessageProfile.Displayname, 152 | Username: defaultProfile.DisplayName, 153 | ProfilePicture: profile, 154 | Color: "#ffffff", 155 | } 156 | } 157 | 158 | return &lightning.MessageAuthor{ 159 | ID: string(evt.Sender), 160 | Nickname: defaultProfile.DisplayName, 161 | Username: defaultProfile.DisplayName, 162 | ProfilePicture: defaultPic, 163 | Color: "#ffffff", 164 | } 165 | } 166 | 167 | func getRepliedTo(msg *event.MessageEventContent) []string { 168 | replyIDs := []string{} 169 | 170 | if msg.RelatesTo != nil && msg.RelatesTo.InReplyTo != nil { 171 | replyIDs = append(replyIDs, string(msg.RelatesTo.InReplyTo.EventID)) 172 | } 173 | 174 | return replyIDs 175 | } 176 | 177 | func getMXC(client *mautrix.Client, file string) string { 178 | return client.HomeserverURL.JoinPath( 179 | "_matrix/media/r0/download", 180 | file[5:], 181 | ).String() 182 | } 183 | -------------------------------------------------------------------------------- /pkg/platforms/matrix/login.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | "maunium.net/go/mautrix" 10 | "maunium.net/go/mautrix/crypto" 11 | "maunium.net/go/mautrix/crypto/cryptohelper" 12 | "maunium.net/go/mautrix/id" 13 | ) 14 | 15 | func setupClient(cfg map[string]string) (*mautrix.Client, error) { 16 | client, err := mautrix.NewClient(cfg["homeserver"], id.UserID(cfg["mxid"]), cfg["access_token"]) 17 | if err != nil { 18 | return nil, fmt.Errorf("matrix: failed to create client: %w", err) 19 | } 20 | 21 | client.UserAgent = "lightning/" + lightning.VERSION 22 | 23 | if cfg["access_token"] == "" || cfg["device_id"] == "" || cfg["mxid"] == "" { 24 | _, err = client.Login(context.Background(), &mautrix.ReqLogin{ 25 | Type: mautrix.AuthTypePassword, 26 | Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: cfg["username"]}, 27 | Password: cfg["password"], 28 | StoreCredentials: true, 29 | }) 30 | if err != nil { 31 | return nil, fmt.Errorf("matrix: failed to login: %w", err) 32 | } 33 | 34 | cfg["device_id"] = string(client.DeviceID) 35 | cfg["access_token"] = client.AccessToken 36 | cfg["mxid"] = string(client.UserID) 37 | 38 | log.Printf("matrix: please set the following in your config: %#+v\n", cfg) 39 | } 40 | 41 | helper, err := cryptohelper.NewCryptoHelper( 42 | client, 43 | []byte(cfg["random"]), 44 | crypto.NewMemoryStore(func() error { return nil }), 45 | ) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to setup crypto helper: %w", err) 48 | } 49 | 50 | err = helper.Init(context.Background()) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to init crypto helper: %w", err) 53 | } 54 | 55 | client.Crypto = helper 56 | 57 | if err = setupKeys(cfg, helper); err != nil { 58 | return nil, err 59 | } 60 | 61 | return client, nil 62 | } 63 | 64 | func setupKeys(cfg map[string]string, helper *cryptohelper.CryptoHelper) error { 65 | keyID, keyData, err := helper.Machine().SSSS.GetDefaultKeyData(context.Background()) 66 | if err != nil { 67 | return fmt.Errorf("failed to get default key: %w", err) 68 | } 69 | 70 | key, err := keyData.VerifyRecoveryKey(keyID, cfg["recovery_key"]) 71 | if err != nil { 72 | return fmt.Errorf("failed to verify recovery key: %w", err) 73 | } 74 | 75 | err = helper.Machine().FetchCrossSigningKeysFromSSSS(context.Background(), key) 76 | if err != nil { 77 | return fmt.Errorf("failed to fetch cross signing keys: %w", err) 78 | } 79 | 80 | err = helper.Machine().SignOwnDevice(context.Background(), helper.Machine().OwnIdentity()) 81 | if err != nil { 82 | return fmt.Errorf("failed to sign own device: %w", err) 83 | } 84 | 85 | err = helper.Machine().SignOwnMasterKey(context.Background()) 86 | if err != nil { 87 | return fmt.Errorf("failed to sign own master key: %w", err) 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/platforms/matrix/outgoing.go: -------------------------------------------------------------------------------- 1 | package matrix 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/williamhorning/lightning/pkg/lightning" 8 | "maunium.net/go/mautrix/event" 9 | "maunium.net/go/mautrix/format" 10 | "maunium.net/go/mautrix/id" 11 | ) 12 | 13 | func (p *matrixPlugin) getOutgoing( 14 | msg *lightning.Message, 15 | ids []string, 16 | opts *lightning.SendOptions, 17 | ) []*event.MessageEventContent { 18 | for _, embed := range msg.Embeds { 19 | msg.Content += "\n\n" + embed.ToMarkdown() 20 | } 21 | 22 | message := format.RenderMarkdown(msg.Content, true, false) 23 | 24 | var url *id.ContentURIString 25 | 26 | if msg.Author.ProfilePicture != nil { 27 | url = p.uploadFile(*msg.Author.ProfilePicture) 28 | } 29 | 30 | message.BeeperPerMessageProfile = &event.BeeperPerMessageProfile{ 31 | ID: msg.Author.ID, 32 | Displayname: msg.Author.Nickname, 33 | AvatarURL: url, 34 | HasFallback: false, 35 | } 36 | 37 | if opts != nil && !opts.AllowEveryonePings { 38 | message.Body = strings.ReplaceAll(message.Body, "@room", "@\u200Broom") 39 | message.FormattedBody = strings.ReplaceAll(message.FormattedBody, "@room", "@\u200Broom") 40 | } 41 | 42 | message.AddPerMessageProfileFallback() 43 | 44 | if len(msg.Attachments) == 0 || len(ids) != 0 { 45 | return []*event.MessageEventContent{&message} 46 | } 47 | 48 | messages := make([]*event.MessageEventContent, 0, len(msg.Attachments)+1) 49 | 50 | for _, attachment := range msg.Attachments { 51 | if mxc := p.uploadFile(attachment.URL); mxc != nil { 52 | messages = append(messages, &event.MessageEventContent{ 53 | MsgType: event.MsgFile, 54 | URL: *mxc, 55 | }) 56 | } 57 | } 58 | 59 | return messages 60 | } 61 | 62 | func (p *matrixPlugin) uploadFile(url string) *id.ContentURIString { 63 | if cached, ok := p.mxcCache.Get(url); ok { 64 | return &cached 65 | } 66 | 67 | resp, err := p.client.UploadLink(context.Background(), url) 68 | if err == nil { 69 | curl := id.ContentURIString("mxc://" + resp.ContentURI.Homeserver + "/" + resp.ContentURI.FileID) 70 | 71 | return &curl 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /pkg/platforms/matrix/plugin.go: -------------------------------------------------------------------------------- 1 | // Package matrix provides a [lightning.Plugin] implementation for Matrix. 2 | // To use Matrix support with lightning, see [New] 3 | // 4 | // bot := lightning.NewBot(lightning.BotOptions{ 5 | // // ... 6 | // } 7 | // 8 | // bot.AddPluginType("matrix", matrix.New) 9 | // 10 | // bot.UsePluginType("matrix", map[string]string{ 11 | // // ... 12 | // }) 13 | package matrix 14 | 15 | import ( 16 | "context" 17 | "time" 18 | 19 | "github.com/williamhorning/lightning/internal/cache" 20 | "github.com/williamhorning/lightning/pkg/lightning" 21 | "maunium.net/go/mautrix" 22 | "maunium.net/go/mautrix/event" 23 | "maunium.net/go/mautrix/id" 24 | ) 25 | 26 | // New creates a new [lightning.Plugin] that provides Matrix support for Lightning 27 | // 28 | // It only takes in a map with the following structure: 29 | // 30 | // map[string]string{ 31 | // "access_token": "", // a string with your Matrix bot's token. 32 | // // note: this should be set after initial login 33 | // "device_id": "", // a string with your Matrix bot's device ID. 34 | // // note: this should be set after initial login 35 | // "homeserver": "", // a string with your Matrix homeserver URL. 36 | // // note: this MUST be set 37 | // "mxid": "", // a string with your Matrix homeserver URL. 38 | // // note: this should be set after initial login 39 | // "password": "", // a string with your Matrix bot password 40 | // // note: this MUST be set 41 | // "random": "", // a random encryption key which MUST be set 42 | // "recovery_key": "", // a string with your Matrix bot recovery key 43 | // // note: this MUST be set 44 | // "username": "", // a string with your Matrix bot username 45 | // // note: this MUST be set 46 | // } 47 | func New(config map[string]string) (lightning.Plugin, error) { 48 | client, err := setupClient(config) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | syncer, ok := client.Syncer.(*mautrix.DefaultSyncer) 54 | if !ok { 55 | client.Syncer = mautrix.NewDefaultSyncer() 56 | } 57 | 58 | msgChannel := make(chan *lightning.Message, 1000) 59 | editChannel := make(chan *lightning.EditedMessage, 1000) 60 | 61 | setupEvents(syncer, client, msgChannel, editChannel) 62 | 63 | return &matrixPlugin{client: client, syncer: syncer, msgChannel: msgChannel, editChannel: editChannel}, nil 64 | } 65 | 66 | type matrixPlugin struct { 67 | client *mautrix.Client 68 | syncer *mautrix.DefaultSyncer 69 | msgChannel chan *lightning.Message 70 | editChannel chan *lightning.EditedMessage 71 | mxcCache cache.Expiring[string, id.ContentURIString] 72 | } 73 | 74 | func (*matrixPlugin) SetupChannel(_ string) (any, error) { 75 | return nil, nil //nolint:nilnil // we don't need a value for ChannelData later 76 | } 77 | 78 | func (p *matrixPlugin) SendCommandResponse( 79 | message *lightning.Message, 80 | opts *lightning.SendOptions, 81 | _ string, 82 | ) ([]string, error) { 83 | return p.SendMessage(message, opts) 84 | } 85 | 86 | func (p *matrixPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) { 87 | ids := make([]string, 0, len(message.Attachments)+1) 88 | 89 | for _, msg := range p.getOutgoing(message, nil, opts) { 90 | resp, err := p.client.SendMessageEvent( 91 | context.Background(), id.RoomID(message.ChannelID), event.EventMessage, msg, mautrix.ReqSendEvent{}, 92 | ) 93 | if err != nil { 94 | return nil, handleError(err, "failed to send matrix message", 95 | map[string]any{"channel": message.ChannelID, "content": message.Content}) 96 | } 97 | 98 | ids = append(ids, string(resp.EventID)) 99 | } 100 | 101 | return ids, nil 102 | } 103 | 104 | func (p *matrixPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error { 105 | for idx, msg := range p.getOutgoing(message, ids, opts) { 106 | msg.RelatesTo.Type = "m.replace" 107 | msg.RelatesTo.EventID = id.EventID(ids[idx]) 108 | 109 | _, err := p.client.SendMessageEvent( 110 | context.Background(), id.RoomID(message.ChannelID), event.EventMessage, msg, mautrix.ReqSendEvent{}, 111 | ) 112 | if err != nil { 113 | return handleError(err, "failed to edit matrix message", 114 | map[string]any{"channel": message.ChannelID, "content": message.Content}) 115 | } 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (p *matrixPlugin) DeleteMessage(channel string, ids []string) error { 122 | for _, msgID := range ids { 123 | if _, err := p.client.RedactEvent( 124 | context.Background(), id.RoomID(channel), id.EventID(msgID), mautrix.ReqRedact{Reason: "deleted in bridge"}, 125 | ); err != nil { 126 | return handleError(err, "Failed to redact Matrix message", 127 | map[string]any{"channel": channel, "message_id": msgID}) 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (*matrixPlugin) SetupCommands(_ map[string]*lightning.Command) error { 135 | return nil 136 | } 137 | 138 | func (p *matrixPlugin) ListenMessages() <-chan *lightning.Message { 139 | return p.msgChannel 140 | } 141 | 142 | func (p *matrixPlugin) ListenEdits() <-chan *lightning.EditedMessage { 143 | return p.editChannel 144 | } 145 | 146 | func (p *matrixPlugin) ListenDeletes() <-chan *lightning.BaseMessage { 147 | channel := make(chan *lightning.BaseMessage, 1000) 148 | 149 | p.syncer.OnEventType(event.EventRedaction, func(_ context.Context, evt *event.Event) { 150 | timestamp := time.UnixMilli(evt.Timestamp) 151 | 152 | channel <- &lightning.BaseMessage{ 153 | Time: ×tamp, 154 | EventID: string(evt.Content.AsRedaction().Redacts), 155 | ChannelID: string(evt.RoomID), 156 | } 157 | }) 158 | 159 | return channel 160 | } 161 | 162 | func (*matrixPlugin) ListenCommands() <-chan *lightning.CommandEvent { 163 | return nil 164 | } 165 | -------------------------------------------------------------------------------- /pkg/platforms/stoat/errors.go: -------------------------------------------------------------------------------- 1 | package stoat 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/williamhorning/lightning/internal/rvapi" 7 | "github.com/williamhorning/lightning/pkg/lightning" 8 | ) 9 | 10 | type stoatPermissionsError struct { 11 | permissions rvapi.Permission 12 | expected rvapi.Permission 13 | } 14 | 15 | func (*stoatPermissionsError) Disable() *lightning.ChannelDisabled { 16 | return &lightning.ChannelDisabled{Read: false, Write: true} 17 | } 18 | 19 | func (e *stoatPermissionsError) Error() string { 20 | return "insufficient permissions (have " + 21 | strconv.FormatUint(uint64(e.permissions), 10) + ", want " + 22 | strconv.FormatUint(uint64(e.expected), 10) + "), please check them" 23 | } 24 | 25 | type stoatStatusError struct { 26 | msg string 27 | resp []byte 28 | code int 29 | edit bool 30 | } 31 | 32 | func (e *stoatStatusError) Disable() *lightning.ChannelDisabled { 33 | return &lightning.ChannelDisabled{Read: false, Write: e.code == 401 || e.code == 403 || (e.code == 404 && !e.edit)} 34 | } 35 | 36 | func (e *stoatStatusError) Error() string { 37 | return "stoat status code " + strconv.FormatInt(int64(e.code), 10) + ": " + e.msg + ": " + string(e.resp) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/platforms/stoat/incoming.go: -------------------------------------------------------------------------------- 1 | package stoat 2 | 3 | import ( 4 | "regexp" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/oklog/ulid/v2" 9 | "github.com/williamhorning/lightning/internal/rvapi" 10 | "github.com/williamhorning/lightning/pkg/lightning" 11 | ) 12 | 13 | func (p *stoatPlugin) getIncomingMessage(message rvapi.Message) *lightning.Message { 14 | if message.Author == p.self.ID && message.Masquerade != nil { 15 | return nil 16 | } 17 | 18 | msg := &lightning.Message{ 19 | BaseMessage: lightning.BaseMessage{ 20 | EventID: message.ID, 21 | ChannelID: message.Channel, 22 | Time: getLightningTime(message), 23 | }, 24 | Attachments: getLightningAttachment(message.Attachments), 25 | Author: p.getLightningAuthor(message.Author, message.Channel, message.Masquerade), 26 | Embeds: getLightningEmbeds(message.Embeds), 27 | RepliedTo: message.Replies, 28 | } 29 | 30 | msg.Content = replaceSpoilers(message.Content) 31 | msg.Content = p.replaceEmojis(msg) 32 | msg.Content = p.replaceMentions(msg.ChannelID, msg.Content) 33 | msg.Content = p.replaceChannels(msg.Content) 34 | 35 | return msg 36 | } 37 | 38 | func getLightningTime(message rvapi.Message) *time.Time { 39 | if !message.Edited.IsZero() { 40 | return &message.Edited 41 | } 42 | 43 | msgID, err := ulid.Parse(message.ID) 44 | if err != nil { 45 | timestamp := time.Now() 46 | 47 | return ×tamp 48 | } 49 | 50 | timestamp := msgID.Timestamp() 51 | 52 | return ×tamp 53 | } 54 | 55 | func getLightningAttachment(attachments []rvapi.File) []lightning.Attachment { 56 | result := make([]lightning.Attachment, len(attachments)) 57 | for i, att := range attachments { 58 | result[i] = lightning.Attachment{ 59 | URL: getURL(&att), 60 | Name: att.Filename, 61 | Size: int64(att.Size), 62 | } 63 | } 64 | 65 | return result 66 | } 67 | 68 | func (p *stoatPlugin) getLightningAuthor( 69 | authorID string, 70 | channelID string, 71 | masquerade *rvapi.Masquerade, 72 | ) *lightning.MessageAuthor { 73 | author := lightning.MessageAuthor{ 74 | ID: authorID, 75 | Username: "StoatUser", 76 | Nickname: "Stoat User", 77 | Color: "#8C24EC", 78 | } 79 | 80 | user := p.session.User(authorID) 81 | if user == nil { 82 | return applyMasquerade(author, masquerade) 83 | } 84 | 85 | author.Username = user.Username 86 | author.Nickname = user.Username 87 | 88 | if user.Avatar != nil { 89 | profilePic := getURL(user.Avatar) 90 | author.ProfilePicture = &profilePic 91 | } 92 | 93 | p.setServerMember(&author, authorID, channelID) 94 | 95 | return applyMasquerade(author, masquerade) 96 | } 97 | 98 | func (p *stoatPlugin) setServerMember(author *lightning.MessageAuthor, authorID, channelID string) { 99 | channel := p.session.Channel(channelID) 100 | if channel == nil || channel.ChannelType != "TextChannel" || channel.Server == nil { 101 | return 102 | } 103 | 104 | member := p.session.Member(*channel.Server, authorID) 105 | if member == nil { 106 | return 107 | } 108 | 109 | if member.Nickname != nil { 110 | author.Nickname = *member.Nickname 111 | } 112 | 113 | if member.Avatar != nil { 114 | memberAvatar := getURL(member.Avatar) 115 | author.ProfilePicture = &memberAvatar 116 | } 117 | } 118 | 119 | func getURL(file *rvapi.File) string { 120 | return "https://cdn.stoatusercontent.com/" + file.Tag + "/" + file.ID 121 | } 122 | 123 | func applyMasquerade(author lightning.MessageAuthor, masquerade *rvapi.Masquerade) *lightning.MessageAuthor { 124 | if masquerade == nil { 125 | return &author 126 | } 127 | 128 | if masquerade.Name != "" { 129 | author.Nickname = masquerade.Name 130 | } 131 | 132 | if masquerade.Colour != "" { 133 | author.Color = masquerade.Colour 134 | } 135 | 136 | if masquerade.Avatar != "" { 137 | author.ProfilePicture = &masquerade.Avatar 138 | } 139 | 140 | return &author 141 | } 142 | 143 | var ( 144 | stoatSpoilerRegex = regexp.MustCompile(`!!(.+?)!!`) 145 | spoilerRegex = regexp.MustCompile(`\|\|(.+?)\|\|`) 146 | emojiRegex = regexp.MustCompile(":([0-7][0-9A-HJKMNP-TV-Z]{25}):") 147 | mentionRegex = regexp.MustCompile("<@([0-7][0-9A-HJKMNP-TV-Z]{25})>") 148 | channelRegex = regexp.MustCompile("<#([0-7][0-9A-HJKMNP-TV-Z]{25})>") 149 | ) 150 | 151 | func replaceSpoilers(content string) string { 152 | return stoatSpoilerRegex.ReplaceAllStringFunc(content, func(match string) string { 153 | return "||" + match[2:len(match)-2] + "||" 154 | }) 155 | } 156 | 157 | func (p *stoatPlugin) replaceEmojis(message *lightning.Message) string { 158 | return emojiRegex.ReplaceAllStringFunc(message.Content, func(match string) string { 159 | if emojiID := extractID(match, emojiRegex); emojiID != "" { 160 | emoji := p.session.Emoji(emojiID) 161 | 162 | if emoji == nil { 163 | return match 164 | } 165 | 166 | url := "https://cdn.stoatusercontent.com/emojis/" + emoji.ID 167 | 168 | message.Emoji = append(message.Emoji, lightning.Emoji{ 169 | URL: &url, 170 | ID: emoji.ID, 171 | Name: emoji.Name, 172 | }) 173 | 174 | return ":" + emoji.Name + ":" 175 | } 176 | 177 | return match 178 | }) 179 | } 180 | 181 | func (p *stoatPlugin) replaceMentions(channelID string, content string) string { 182 | return mentionRegex.ReplaceAllStringFunc(content, func(match string) string { 183 | userID := extractID(match, mentionRegex) 184 | if userID == "" { 185 | return match 186 | } 187 | 188 | user := p.session.User(userID) 189 | if user == nil { 190 | return "@" + userID 191 | } 192 | 193 | channel := p.session.Channel(channelID) 194 | if channel != nil && channel.Server != nil { 195 | member := p.session.Member(*channel.Server, userID) 196 | if member != nil && member.Nickname != nil { 197 | return "@" + *member.Nickname 198 | } 199 | } 200 | 201 | return "@" + user.Username 202 | }) 203 | } 204 | 205 | func (p *stoatPlugin) replaceChannels(content string) string { 206 | return channelRegex.ReplaceAllStringFunc(content, func(match string) string { 207 | chanID := extractID(match, channelRegex) 208 | if chanID == "" { 209 | return match 210 | } 211 | 212 | channel := p.session.Channel(chanID) 213 | if channel == nil { 214 | return "#" + chanID 215 | } 216 | 217 | return "#" + channel.Name 218 | }) 219 | } 220 | 221 | func extractID(match string, re *regexp.Regexp) string { 222 | matches := re.FindStringSubmatch(match) 223 | if len(matches) < 2 { 224 | return "" 225 | } 226 | 227 | return matches[1] 228 | } 229 | 230 | func getLightningEmbeds(embeds []rvapi.Embed) []lightning.Embed { 231 | result := make([]lightning.Embed, 0) 232 | for _, embed := range embeds { 233 | lightningEmbed := lightning.Embed{ 234 | Title: embed.Title, 235 | Description: embed.Description, 236 | URL: embed.URL, 237 | Image: getEmbedImage(&embed), 238 | Video: getEmbedVideo(&embed), 239 | } 240 | 241 | if embed.Colour != nil { 242 | if colorInt, err := strconv.ParseInt((*embed.Colour)[1:], 16, 32); err == nil { 243 | colorVal := int(colorInt) 244 | lightningEmbed.Color = &colorVal 245 | } 246 | } 247 | 248 | if embed.IconURL != nil { 249 | lightningEmbed.Thumbnail = &lightning.Media{URL: *embed.IconURL} 250 | } 251 | 252 | result = append(result, lightningEmbed) 253 | } 254 | 255 | return result 256 | } 257 | 258 | func getEmbedImage(embed *rvapi.Embed) *lightning.Media { 259 | if embed.Image != nil && embed.Image.URL != "" { 260 | return &lightning.Media{URL: embed.Image.URL, Width: embed.Image.Width, Height: embed.Image.Height} 261 | } 262 | 263 | return nil 264 | } 265 | 266 | func getEmbedVideo(embed *rvapi.Embed) *lightning.Media { 267 | if embed.Video != nil && embed.Video.URL != "" { 268 | return &lightning.Media{URL: embed.Video.URL, Width: embed.Video.Width, Height: embed.Video.Height} 269 | } 270 | 271 | return nil 272 | } 273 | -------------------------------------------------------------------------------- /pkg/platforms/stoat/methods.go: -------------------------------------------------------------------------------- 1 | package stoat 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/williamhorning/lightning/internal/rvapi" 12 | "github.com/williamhorning/lightning/pkg/lightning" 13 | ) 14 | 15 | func (p *stoatPlugin) stoatSendMessage(channel string, message rvapi.DataMessageSend) (string, error) { 16 | if shouldClearMasqueradeColour(p.session.Channel(channel), message) { 17 | message.Masquerade.Colour = "" 18 | } 19 | 20 | payload, err := json.Marshal(message) 21 | if err != nil { 22 | return "", fmt.Errorf("rvapi: failed to marshal send: %w\n\tbody: %#+v", err, message) 23 | } 24 | 25 | resp, code, err := p.session.Fetch(http.MethodPost, "/channels/"+channel+"/messages", bytes.NewBuffer(payload)) 26 | if err != nil { 27 | return "", fmt.Errorf("stoat: error making send message request: %w", err) 28 | } 29 | 30 | defer func() { 31 | if err = resp.Close(); err != nil { 32 | log.Printf("stoat: failed to close send body: %v\n", err) 33 | } 34 | }() 35 | 36 | body, err := io.ReadAll(resp) 37 | if err != nil { 38 | return "", fmt.Errorf("stoat: failed to read send body: %w", err) 39 | } 40 | 41 | if code != http.StatusOK { 42 | return "", &stoatStatusError{"failed to send stoat message", body, code, false} 43 | } 44 | 45 | var response rvapi.Message 46 | if err := json.Unmarshal(body, &response); err != nil { 47 | return "", fmt.Errorf("stoat: failed to decode %d response: %w", code, err) 48 | } 49 | 50 | return response.ID, nil 51 | } 52 | 53 | func shouldClearMasqueradeColour(ch *rvapi.Channel, msg rvapi.DataMessageSend) bool { 54 | if ch == nil || msg.Masquerade == nil { 55 | return false 56 | } 57 | 58 | return ch.ChannelType != rvapi.ChannelTypeText && ch.ChannelType != rvapi.ChannelTypeVoice 59 | } 60 | 61 | func (p *stoatPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error { 62 | message.Attachments = nil 63 | outgoing := p.getOutgoing(message, opts) 64 | data := rvapi.DataEditMessage{Content: outgoing.Content, Embeds: outgoing.Embeds} 65 | 66 | payload, err := json.Marshal(data) 67 | if err != nil { 68 | return fmt.Errorf("stoat: failed to marshal edit: %w\n\tbody: %#+v", err, data) 69 | } 70 | 71 | resp, code, err := p.session.Fetch( 72 | http.MethodPatch, "/channels/"+message.ChannelID+"/messages/"+ids[0], bytes.NewBuffer(payload), 73 | ) 74 | if err != nil { 75 | return fmt.Errorf("stoat: error making edit request: %w", err) 76 | } 77 | 78 | if err := resp.Close(); err != nil { 79 | log.Printf("stoat: failed to close edit body: %v\n", err) 80 | } 81 | 82 | if code != http.StatusOK { 83 | body, err := io.ReadAll(resp) 84 | if err != nil { 85 | body = []byte(err.Error()) 86 | } 87 | 88 | return &stoatStatusError{"failed to edit stoat message", body, code, true} 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func (p *stoatPlugin) DeleteMessage(channel string, ids []string) error { 95 | payload, err := json.Marshal(&rvapi.OptionsBulkDelete{IDs: ids}) 96 | if err != nil { 97 | return fmt.Errorf("stoat: failed to marshal deletion: %w", err) 98 | } 99 | 100 | resp, code, err := p.session.Fetch( 101 | http.MethodDelete, "/channels/"+channel+"/messages/bulk", bytes.NewBuffer(payload), 102 | ) 103 | if err != nil { 104 | return fmt.Errorf("stoat: error making deletion request: %w", err) 105 | } 106 | 107 | defer func() { 108 | if err := resp.Close(); err != nil { 109 | log.Printf("stoat: failed to close deletion body: %v\n", err) 110 | } 111 | }() 112 | 113 | if code != http.StatusNoContent { 114 | body, err := io.ReadAll(resp) 115 | if err != nil { 116 | body = []byte(err.Error()) 117 | } 118 | 119 | return &stoatStatusError{"failed to delete stoat messages\n\tbody: " + string(payload), body, code, true} 120 | } 121 | 122 | return nil 123 | } 124 | -------------------------------------------------------------------------------- /pkg/platforms/stoat/outgoing.go: -------------------------------------------------------------------------------- 1 | package stoat 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/williamhorning/lightning/internal/emoji" 13 | "github.com/williamhorning/lightning/internal/rvapi" 14 | "github.com/williamhorning/lightning/internal/workaround" 15 | "github.com/williamhorning/lightning/pkg/lightning" 16 | ) 17 | 18 | func (p *stoatPlugin) getOutgoing( 19 | message *lightning.Message, 20 | opts *lightning.SendOptions, 21 | ) rvapi.DataMessageSend { 22 | content := spoilerRegex.ReplaceAllStringFunc( 23 | emojiSendRegex.ReplaceAllStringFunc(message.Content, p.replaceOutgoingEmoji(message)), 24 | func(match string) string { return "!!" + match[2:len(match)-2] + "!!" }, 25 | ) 26 | 27 | if opts != nil && !opts.AllowEveryonePings { 28 | content = strings.ReplaceAll(content, "@everyone", "@\u2800everyone") 29 | content = strings.ReplaceAll(content, "@online", "@\u2800online") 30 | } 31 | 32 | if len([]rune(content)) > 2000 { 33 | content = string([]rune(content)[:1997]) + "..." // split the message? 34 | } 35 | 36 | msg := rvapi.DataMessageSend{ 37 | Attachments: p.getOutgoingAttachments(message.Attachments), 38 | Content: content, 39 | Embeds: getOutgoingEmbeds(message.Embeds), 40 | Replies: getOutgoingReplies(message.RepliedTo), 41 | } 42 | 43 | if len(content) == 0 && len(msg.Embeds) == 0 && len(msg.Attachments) == 0 { 44 | msg.Content = "\u200B" 45 | } 46 | 47 | if opts != nil { 48 | msg.Masquerade = getOutgoingMasquerade(message.Author) 49 | } 50 | 51 | return msg 52 | } 53 | 54 | var emojiSendRegex = regexp.MustCompile(`:\w+:`) 55 | 56 | func (p *stoatPlugin) replaceOutgoingEmoji(message *lightning.Message) func(string) string { 57 | return func(match string) string { 58 | if emoji.IsEmoji(match) { 59 | return match 60 | } 61 | 62 | name := strings.ReplaceAll(match, ":", "") 63 | 64 | channel := p.session.Channel(message.ChannelID) 65 | 66 | if channel != nil && channel.ChannelType == rvapi.ChannelTypeText && channel.Server != nil { 67 | for _, emoji := range p.session.ServerEmoji(*channel.Server) { 68 | if emoji.Name == name { 69 | return ":" + emoji.ID + ":" 70 | } 71 | } 72 | } 73 | 74 | for _, emoji := range message.Emoji { 75 | if emoji.Name == name && emoji.URL != nil { 76 | return "[" + emoji.Name + "](" + *emoji.URL + ")" 77 | } 78 | } 79 | 80 | return match 81 | } 82 | } 83 | 84 | func (p *stoatPlugin) getOutgoingAttachments(attachments []lightning.Attachment) []string { 85 | if len(attachments) == 0 { 86 | return nil 87 | } 88 | 89 | attachmentIDs := make([]string, 0, len(attachments)) 90 | 91 | for _, attachment := range attachments { 92 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 93 | 94 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, attachment.URL, nil) 95 | if err != nil { 96 | cancel() 97 | 98 | continue 99 | } 100 | 101 | resp, err := workaround.Client.Do(req) 102 | if err != nil { 103 | cancel() 104 | 105 | continue 106 | } 107 | 108 | file, err := p.session.UploadFile("attachments", attachment.Name, resp.Body) 109 | if err == nil { 110 | attachmentIDs = append(attachmentIDs, file.ID) 111 | } else { 112 | log.Printf("%v\n", err) 113 | } 114 | 115 | err = resp.Body.Close() 116 | if err != nil { 117 | log.Printf("stoat: failed to close upload body: %v\n", err) 118 | } 119 | 120 | cancel() 121 | } 122 | 123 | return attachmentIDs 124 | } 125 | 126 | func getOutgoingEmbeds(embeds []lightning.Embed) []rvapi.SendableEmbed { 127 | result := make([]rvapi.SendableEmbed, 0, len(embeds)) 128 | 129 | for _, embed := range embeds { 130 | result = append(result, convertOutgoingEmbed(embed)) 131 | } 132 | 133 | return result 134 | } 135 | 136 | func convertOutgoingEmbed(embed lightning.Embed) rvapi.SendableEmbed { 137 | stoatEmbed := rvapi.SendableEmbed{ 138 | Title: embed.Title, 139 | Description: getEmbedDescription(embed), 140 | URL: embed.URL, 141 | } 142 | 143 | if embed.Color != nil { 144 | color := "#" + strconv.FormatInt(int64(*embed.Color), 16) 145 | 146 | stoatEmbed.Colour = &color 147 | } 148 | 149 | setEmbedMedia(&stoatEmbed, embed) 150 | 151 | return stoatEmbed 152 | } 153 | 154 | func getEmbedDescription(embed lightning.Embed) *string { 155 | description := "" 156 | if embed.Description != nil { 157 | description = *embed.Description 158 | } 159 | 160 | if len(embed.Fields) == 0 { 161 | return &description 162 | } 163 | 164 | for _, field := range embed.Fields { 165 | if description != "" { 166 | description += "\n\n" 167 | } 168 | 169 | description += "**" + field.Name + "**\n" + field.Value 170 | } 171 | 172 | return &description 173 | } 174 | 175 | func setEmbedMedia(stoatEmbed *rvapi.SendableEmbed, embed lightning.Embed) { 176 | if embed.Image != nil { 177 | stoatEmbed.Media = &embed.Image.URL 178 | } 179 | 180 | if embed.Video != nil { 181 | stoatEmbed.Media = &embed.Video.URL 182 | } 183 | 184 | if embed.Thumbnail != nil && len([]rune(embed.Thumbnail.URL)) > 0 && len([]rune(embed.Thumbnail.URL)) <= 128 { 185 | stoatEmbed.IconURL = &embed.Thumbnail.URL 186 | } 187 | } 188 | 189 | func getOutgoingReplies(replyIDs []string) []rvapi.ReplyIntent { 190 | replies := make([]rvapi.ReplyIntent, len(replyIDs)) 191 | for i, id := range replyIDs { 192 | replies[i] = rvapi.ReplyIntent{ 193 | ID: id, 194 | Mention: false, 195 | FailIfNotExists: false, 196 | } 197 | } 198 | 199 | return replies 200 | } 201 | 202 | func getOutgoingMasquerade(author *lightning.MessageAuthor) *rvapi.Masquerade { 203 | avatar := "" 204 | if author.ProfilePicture != nil && len([]rune(*author.ProfilePicture)) > 1 && 205 | len([]rune(*author.ProfilePicture)) <= 256 { 206 | avatar = *author.ProfilePicture 207 | } 208 | 209 | nickname := author.Nickname 210 | 211 | if len([]rune(nickname)) > 32 { 212 | nickname = string([]rune(nickname))[:32] 213 | } 214 | 215 | return &rvapi.Masquerade{ 216 | Colour: author.Color, 217 | Name: nickname, 218 | Avatar: avatar, 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /pkg/platforms/stoat/plugin.go: -------------------------------------------------------------------------------- 1 | // Package stoat provides a [lightning.Plugin] implementation for Stoat. 2 | // To use Stoat support with lightning, see [New] 3 | // 4 | // bot := lightning.NewBot(lightning.BotOptions{ 5 | // // ... 6 | // } 7 | // 8 | // bot.AddPluginType("stoat", stoat.New) 9 | // 10 | // bot.UsePluginType("stoat", "", map[string]string{ 11 | // // ... 12 | // }) 13 | package stoat 14 | 15 | import ( 16 | "fmt" 17 | "log" 18 | "math" 19 | "time" 20 | 21 | "github.com/williamhorning/lightning/internal/rvapi" 22 | "github.com/williamhorning/lightning/pkg/lightning" 23 | ) 24 | 25 | // New creates a new [lightning.Plugin] that provides Stoat support for Lightning 26 | // 27 | // It only takes in a map with the following structure: 28 | // 29 | // map[string]string{ 30 | // "token": "", // a string with your Stoat bot token 31 | // } 32 | func New(cfg map[string]string) (lightning.Plugin, error) { 33 | plugin := &stoatPlugin{session: &rvapi.Session{ 34 | MessageDeleted: make(chan *rvapi.MessageDeleteEvent, 1000), 35 | MessageCreated: make(chan *rvapi.MessageEvent, 1000), 36 | MessageUpdated: make(chan *rvapi.MessageUpdateEvent, 1000), 37 | Ready: make(chan *rvapi.ReadyEvent, 100), 38 | Token: cfg["token"], 39 | }} 40 | plugin.self = plugin.session.User("@me") 41 | 42 | if plugin.self == nil { 43 | return nil, lightning.PluginConfigError{Plugin: "stoat", Message: "failed to get self user"} 44 | } 45 | 46 | go func() { 47 | for ready := range plugin.session.Ready { 48 | log.Printf("stoat: ready as %s in %d servers!\n", plugin.self.Username, len(ready.Servers)) 49 | log.Printf("stoat: https://app.stoat.chat/invite/%s\n", plugin.self.ID) 50 | } 51 | }() 52 | 53 | if err := plugin.session.Connect(); err != nil { 54 | return nil, fmt.Errorf("stoat: failed to connect to socket: %w", err) 55 | } 56 | 57 | return plugin, nil 58 | } 59 | 60 | type stoatPlugin struct { 61 | self *rvapi.User 62 | session *rvapi.Session 63 | } 64 | 65 | const correctPermissionValue = rvapi.PermissionManageCustomization | rvapi.PermissionManageRole | 66 | rvapi.PermissionChangeNickname | rvapi.PermissionChangeAvatar | rvapi.PermissionViewChannel | 67 | rvapi.PermissionReadMessageHistory | rvapi.PermissionSendMessage | rvapi.PermissionManageMessages | 68 | rvapi.PermissionInviteOthers | rvapi.PermissionSendEmbeds | rvapi.PermissionUploadFiles | 69 | rvapi.PermissionMasquerade | rvapi.PermissionReact 70 | 71 | func (p *stoatPlugin) SetupChannel(channel string) (any, error) { 72 | channelData := p.session.Channel(channel) 73 | needed := correctPermissionValue 74 | 75 | if channelData.ChannelType == rvapi.ChannelTypeGroup { 76 | needed &= ^rvapi.PermissionManageCustomization 77 | needed &= ^rvapi.PermissionManageRole 78 | needed &= ^rvapi.PermissionChangeNickname 79 | needed &= ^rvapi.PermissionChangeAvatar 80 | } 81 | 82 | permissions := p.session.GetPermissions(p.self, channelData) 83 | 84 | if permissions&needed == needed { 85 | return nil, nil //nolint:nilnil // we don't need a value for ChannelData later 86 | } 87 | 88 | return nil, &stoatPermissionsError{permissions, needed} 89 | } 90 | 91 | func (p *stoatPlugin) SendCommandResponse( 92 | message *lightning.Message, 93 | opts *lightning.SendOptions, 94 | user string, 95 | ) ([]string, error) { 96 | var channel rvapi.Channel 97 | 98 | if err := rvapi.Get(p.session, "/users/"+user+"/dm", &channel); err != nil { 99 | return nil, fmt.Errorf("stoat: failed to get dm channel: %w", err) 100 | } 101 | 102 | message.ChannelID = channel.ID 103 | 104 | return p.SendMessage(message, opts) 105 | } 106 | 107 | func (p *stoatPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) { 108 | msg := p.getOutgoing(message, opts) 109 | leftover := make([]string, 0) 110 | 111 | if len(msg.Attachments) > 5 { 112 | leftover = msg.Attachments[5:] 113 | msg.Attachments = msg.Attachments[:5] 114 | } 115 | 116 | res, err := p.stoatSendMessage(message.ChannelID, msg) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | ids := []string{res} 122 | 123 | if len(leftover) == 0 { 124 | return ids, nil 125 | } 126 | 127 | chunks := make([][]string, 0, int(math.Ceil(float64(len(leftover))/5))) 128 | 129 | for i := 0; i < len(leftover); i += 5 { 130 | end := min(i+5, len(leftover)) 131 | 132 | chunks = append(chunks, leftover[i:end]) 133 | } 134 | 135 | for _, chunk := range chunks { 136 | res, err := p.stoatSendMessage(message.ChannelID, rvapi.DataMessageSend{ 137 | Attachments: chunk, 138 | Masquerade: msg.Masquerade, 139 | Replies: msg.Replies, 140 | }) 141 | if err != nil { 142 | log.Printf("failed to send leftover attachments: %v\n\tleftover: %#+v\n", err, leftover) 143 | } else { 144 | ids = append(ids, res) 145 | } 146 | } 147 | 148 | return ids, nil 149 | } 150 | 151 | func (*stoatPlugin) SetupCommands(_ map[string]*lightning.Command) error { 152 | return nil 153 | } 154 | 155 | func (p *stoatPlugin) ListenMessages() <-chan *lightning.Message { 156 | channel := make(chan *lightning.Message, 1000) 157 | 158 | go func() { 159 | for m := range p.session.MessageCreated { 160 | if msg := p.getIncomingMessage(m.Message); msg != nil { 161 | channel <- msg 162 | } 163 | } 164 | }() 165 | 166 | return channel 167 | } 168 | 169 | func (p *stoatPlugin) ListenEdits() <-chan *lightning.EditedMessage { 170 | channel := make(chan *lightning.EditedMessage, 1000) 171 | 172 | go func() { 173 | for m := range p.session.MessageUpdated { 174 | if msg := p.getIncomingMessage(m.Data); msg != nil { 175 | channel <- &lightning.EditedMessage{ 176 | Message: msg, 177 | Edited: &m.Data.Edited, 178 | } 179 | } 180 | } 181 | }() 182 | 183 | return channel 184 | } 185 | 186 | func (p *stoatPlugin) ListenDeletes() <-chan *lightning.BaseMessage { 187 | channel := make(chan *lightning.BaseMessage, 1000) 188 | 189 | go func() { 190 | for m := range p.session.MessageDeleted { 191 | timestamp := time.Now() 192 | channel <- &lightning.BaseMessage{ 193 | EventID: m.ID, 194 | ChannelID: m.Channel, 195 | Time: ×tamp, 196 | } 197 | } 198 | }() 199 | 200 | return channel 201 | } 202 | 203 | func (*stoatPlugin) ListenCommands() <-chan *lightning.CommandEvent { 204 | return nil 205 | } 206 | -------------------------------------------------------------------------------- /pkg/platforms/telegram/incoming.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "strconv" 5 | "time" 6 | 7 | "github.com/PaulSonOfLars/gotgbot/v2" 8 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 9 | "github.com/williamhorning/lightning/pkg/lightning" 10 | ) 11 | 12 | func getMessage(bot *gotgbot.Bot, ctx *ext.Context, proxyPath string) lightning.Message { 13 | timestamp := time.UnixMilli(ctx.EffectiveMessage.Date * 1000) 14 | 15 | msg := lightning.Message{ 16 | Author: &lightning.MessageAuthor{ 17 | ID: strconv.FormatInt(ctx.EffectiveSender.Id(), 10), 18 | Nickname: ctx.EffectiveSender.Name(), 19 | Username: ctx.EffectiveSender.Username(), 20 | ProfilePicture: getProfilePicture(bot, ctx, proxyPath), 21 | Color: "#24A1DE", 22 | }, 23 | BaseMessage: lightning.BaseMessage{ 24 | EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10), 25 | ChannelID: strconv.FormatInt(ctx.EffectiveChat.Id, 10), 26 | Time: ×tamp, 27 | }, 28 | } 29 | 30 | if ctx.EffectiveMessage.ReplyToMessage != nil { 31 | msg.RepliedTo = append(msg.RepliedTo, strconv.FormatInt(ctx.EffectiveMessage.ReplyToMessage.GetMessageId(), 10)) 32 | } 33 | 34 | switch { 35 | case ctx.EffectiveMessage.Text != "": 36 | msg.Content = ctx.EffectiveMessage.Text 37 | case ctx.EffectiveMessage.Dice != nil: 38 | msg.Content = ctx.EffectiveMessage.Dice.Emoji + " " + strconv.FormatInt(ctx.EffectiveMessage.Dice.Value, 10) 39 | case ctx.EffectiveMessage.Location != nil: 40 | msg.Content = "https://www.openstreetmap.org/#map=18/" + 41 | strconv.FormatFloat(ctx.EffectiveMessage.Location.Latitude, 'f', 6, 64) + "/" + 42 | strconv.FormatFloat(ctx.EffectiveMessage.Location.Longitude, 'f', 6, 64) 43 | case ctx.EffectiveMessage.Caption != "" || len(ctx.EffectiveMessage.NewChatPhoto) != 0: 44 | msg.Content = ctx.EffectiveMessage.Caption 45 | 46 | fileID, fileName := getFileDetails(ctx) 47 | 48 | if f, err := bot.GetFile(fileID, nil); err == nil { 49 | msg.Attachments = append(msg.Attachments, lightning.Attachment{ 50 | URL: proxyPath + f.FilePath, 51 | Name: fileName, 52 | Size: f.FileSize, 53 | }) 54 | } 55 | default: 56 | } 57 | 58 | return msg 59 | } 60 | 61 | func getProfilePicture(bot *gotgbot.Bot, ctx *ext.Context, proxyPath string) *string { 62 | if ctx.EffectiveUser == nil { 63 | return nil 64 | } 65 | 66 | pics, err := ctx.EffectiveUser.GetProfilePhotos(bot, nil) 67 | if err != nil || pics.TotalCount <= 0 { 68 | return nil 69 | } 70 | 71 | bestPhoto := getBestPhoto(pics.Photos[0]) 72 | if bestPhoto == nil { 73 | return nil 74 | } 75 | 76 | if f, err := bot.GetFile(bestPhoto.FileId, nil); err == nil { 77 | url := proxyPath + f.FilePath 78 | 79 | return &url 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func getBestPhoto(size []gotgbot.PhotoSize) *gotgbot.PhotoSize { 86 | var bestPhoto *gotgbot.PhotoSize 87 | 88 | for _, photo := range size { 89 | if bestPhoto == nil || photo.Width > bestPhoto.Width { 90 | bestPhoto = &photo 91 | } 92 | } 93 | 94 | return bestPhoto 95 | } 96 | 97 | func getFileDetails(ctx *ext.Context) (string, string) { //nolint:revive,cyclop 98 | switch { 99 | case len(ctx.EffectiveMessage.NewChatPhoto) != 0: 100 | return getBestPhoto(ctx.EffectiveMessage.NewChatPhoto).FileId, "photo.jpg" 101 | case len(ctx.EffectiveMessage.Photo) != 0: 102 | return getBestPhoto(ctx.EffectiveMessage.Photo).FileId, "photo.jpg" 103 | case ctx.EffectiveMessage.Document != nil: 104 | return ctx.EffectiveMessage.Document.FileId, ctx.EffectiveMessage.Document.FileName 105 | case ctx.EffectiveMessage.Animation != nil: 106 | return ctx.EffectiveMessage.Animation.FileId, ctx.EffectiveMessage.Animation.FileName 107 | case ctx.EffectiveMessage.Audio != nil: 108 | return ctx.EffectiveMessage.Audio.FileId, ctx.EffectiveMessage.Audio.FileName 109 | case ctx.EffectiveMessage.Sticker != nil: 110 | return ctx.EffectiveMessage.Sticker.FileId, ctx.ChannelPost.Sticker.SetName + 111 | getStickerExtension(ctx.ChannelPost.Sticker) 112 | case ctx.EffectiveMessage.Video != nil: 113 | return ctx.EffectiveMessage.Video.FileId, ctx.EffectiveMessage.Video.FileName 114 | case ctx.EffectiveMessage.VideoNote != nil: 115 | return ctx.EffectiveMessage.VideoNote.FileId, ctx.EffectiveMessage.VideoNote.FileId + ".mp4" 116 | case ctx.EffectiveMessage.Voice != nil: 117 | return ctx.EffectiveMessage.Voice.FileId, ctx.EffectiveMessage.Voice.FileId + ".ogg" 118 | default: 119 | return "", "" 120 | } 121 | } 122 | 123 | func getStickerExtension(sticker *gotgbot.Sticker) string { 124 | if sticker.IsAnimated { 125 | return ".tgs" 126 | } else if sticker.IsVideo { 127 | return ".webm" 128 | } 129 | 130 | return ".webp" 131 | } 132 | -------------------------------------------------------------------------------- /pkg/platforms/telegram/outgoing.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | 7 | "github.com/williamhorning/lightning/internal/tgmd" 8 | "github.com/williamhorning/lightning/pkg/lightning" 9 | ) 10 | 11 | type channelIDError struct { 12 | channelID string 13 | } 14 | 15 | func (channelIDError) Disable() *lightning.ChannelDisabled { 16 | return &lightning.ChannelDisabled{Read: false, Write: true} 17 | } 18 | 19 | func (e channelIDError) Error() string { 20 | return "telegram: invalid channel ID: " + e.channelID 21 | } 22 | 23 | func parseContent(message *lightning.Message, opts *lightning.SendOptions) string { 24 | content := "" 25 | 26 | if opts != nil { 27 | content += tgmd.GetMarkdownV2(message.Author.Nickname) + " » " 28 | } 29 | 30 | mdV2 := tgmd.GetMarkdownV2(strings.ReplaceAll(message.Content, "\n", "\n\n")) 31 | 32 | if len(mdV2) > 0 && 33 | slices.Contains( 34 | []string{"[", "]", "(", ")", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", "\\."}, mdV2[:1], 35 | ) { 36 | content += "\n" 37 | } 38 | 39 | content += mdV2 40 | 41 | for _, embed := range message.Embeds { 42 | content += tgmd.GetMarkdownV2(embed.ToMarkdown()) 43 | } 44 | 45 | return content 46 | } 47 | -------------------------------------------------------------------------------- /pkg/platforms/telegram/plugin.go: -------------------------------------------------------------------------------- 1 | // Package telegram provides a [lightning.Plugin] implementation for Telegram. 2 | // It additionally provides a file proxy to proxy Telegram attachments to other 3 | // platforms, as Telegram files require a token to fetch, and that shouldn't be 4 | // exposed to other platforms. 5 | // 6 | // To use Telegram support with lightning, see [New] 7 | // 8 | // bot := lightning.NewBot(lightning.BotOptions{ 9 | // // ... 10 | // } 11 | // 12 | // bot.AddPluginType("telegram", telegram.New) 13 | // 14 | // bot.UsePluginType("telegram", "", map[string]string{ 15 | // // ... 16 | // }) 17 | package telegram 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "log" 24 | "strconv" 25 | "strings" 26 | "time" 27 | 28 | "github.com/PaulSonOfLars/gotgbot/v2" 29 | "github.com/PaulSonOfLars/gotgbot/v2/ext" 30 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" 31 | "github.com/williamhorning/lightning/pkg/lightning" 32 | ) 33 | 34 | // New creates a new [lightning.Plugin] that provides Telegram support for Lightning 35 | // 36 | // It only takes in a map with the following structure: 37 | // 38 | // map[string]string{ 39 | // "token": "", // a string with your Telegram bot token. You can get this from BotFather 40 | // "proxy_port": "0", // the port to use for the built-in Telegram file proxy 41 | // "proxy_url": "", // the publicly accessible url of the Telegram file proxy 42 | // } 43 | // 44 | // Note that you must have a working file proxy at `proxy_url`, otherwise files will not 45 | // work with other plugins. 46 | func New(config map[string]string) (lightning.Plugin, error) { 47 | telegram, err := gotgbot.NewBot(config["token"], &gotgbot.BotOpts{BotClient: newRetrier()}) 48 | if err != nil { 49 | return nil, fmt.Errorf("telegram: failed to create bot: %w", err) 50 | } 51 | 52 | messages := make(chan *lightning.Message, 1000) 53 | edits := make(chan *lightning.EditedMessage, 1000) 54 | 55 | dispatch := ext.NewDispatcher(nil) 56 | 57 | dispatch.AddHandler(handlers.Message{ 58 | AllowEdited: true, 59 | AllowChannel: true, 60 | AllowBusiness: true, 61 | Filter: func(_ *gotgbot.Message) bool { 62 | return true 63 | }, 64 | Response: func(b *gotgbot.Bot, ctx *ext.Context) error { 65 | msg := getMessage(b, ctx, config["proxy_url"]) 66 | if ctx.EditedMessage != nil { 67 | time := time.UnixMilli(ctx.EditedMessage.GetDate() * 1000) 68 | edits <- &lightning.EditedMessage{ 69 | Message: &msg, 70 | Edited: &time, 71 | } 72 | } else { 73 | messages <- &msg 74 | } 75 | 76 | return nil 77 | }, 78 | }) 79 | 80 | updater := ext.NewUpdater(dispatch, &ext.UpdaterOpts{ 81 | UnhandledErrFunc: func(err error) { 82 | if err != nil && !errors.Is(err, context.DeadlineExceeded) { 83 | log.Printf("telegram: unhandled error in dispatcher: %v\n", err) 84 | } 85 | }, 86 | }) 87 | if err := updater.StartPolling(telegram, &ext.PollingOpts{ 88 | DropPendingUpdates: true, 89 | GetUpdatesOpts: &gotgbot.GetUpdatesOpts{Timeout: int64(defaultTimeout.Seconds())}, 90 | }); err != nil { 91 | return nil, fmt.Errorf("telegram: failed to start polling: %w", err) 92 | } 93 | 94 | log.Printf("telegram: ready! invite me at https://t.me/%s\n", telegram.Username) 95 | 96 | plugin := &telegramPlugin{messages, edits, dispatch, telegram, updater} 97 | 98 | go startProxy(config) 99 | 100 | return plugin, nil 101 | } 102 | 103 | type telegramPlugin struct { 104 | messageChannel chan *lightning.Message 105 | editChannel chan *lightning.EditedMessage 106 | dispatch *ext.Dispatcher 107 | telegram *gotgbot.Bot 108 | updater *ext.Updater 109 | } 110 | 111 | func (*telegramPlugin) SetupChannel(_ string) (any, error) { 112 | return nil, nil //nolint:nilnil // we don't need a value for ChannelData later 113 | } 114 | 115 | func (p *telegramPlugin) SendCommandResponse( 116 | message *lightning.Message, 117 | opts *lightning.SendOptions, 118 | user string, 119 | ) ([]string, error) { 120 | message.ChannelID = user 121 | 122 | return p.SendMessage(message, opts) 123 | } 124 | 125 | func (p *telegramPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) { 126 | channel, err := strconv.ParseInt(message.ChannelID, 10, 64) 127 | if err != nil { 128 | return nil, &channelIDError{message.ChannelID} 129 | } 130 | 131 | content := parseContent(message, opts) 132 | 133 | sendOpts := &gotgbot.SendMessageOpts{ 134 | ParseMode: gotgbot.ParseModeMarkdownV2, RequestOpts: &gotgbot.RequestOpts{Timeout: defaultTimeout}, 135 | } 136 | 137 | if len(message.RepliedTo) > 0 { 138 | var replyID int64 139 | 140 | replyID, err = strconv.ParseInt(message.RepliedTo[0], 10, 64) 141 | if err == nil && replyID > 0 { 142 | sendOpts.ReplyParameters = &gotgbot.ReplyParameters{ 143 | MessageId: replyID, 144 | AllowSendingWithoutReply: true, 145 | } 146 | } 147 | } 148 | 149 | msg, err := p.telegram.SendMessage(channel, content, sendOpts) 150 | if err != nil && strings.Contains(err.Error(), "context deadline exceeded") { 151 | return []string{}, nil 152 | } else if err != nil { 153 | return nil, fmt.Errorf("telegram: failed to send message: %w\n\tchannel: %s\n\tcontent: %s\n\treply: %#+v", 154 | err, message.ChannelID, content, sendOpts.ReplyParameters) 155 | } 156 | 157 | ids := []string{strconv.FormatInt(msg.MessageId, 10)} 158 | 159 | for _, attachment := range message.Attachments { 160 | if msg, err := p.telegram.SendDocument(channel, gotgbot.InputFileByURL(attachment.URL), nil); err == nil { 161 | ids = append(ids, strconv.FormatInt(msg.MessageId, 10)) 162 | } 163 | } 164 | 165 | return ids, nil 166 | } 167 | 168 | func (p *telegramPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error { 169 | channel, err := strconv.ParseInt(message.ChannelID, 10, 64) 170 | if err != nil { 171 | return &channelIDError{message.ChannelID} 172 | } 173 | 174 | msgID, err := strconv.ParseInt(ids[0], 10, 64) 175 | if err != nil { 176 | return fmt.Errorf("telegram: failed to parse message ID: %w\n\tchannel: %s\n\tmessage: %s", 177 | err, message.ChannelID, ids[0]) 178 | } 179 | 180 | content := parseContent(message, opts) 181 | 182 | _, _, err = p.telegram.EditMessageText( 183 | content, 184 | &gotgbot.EditMessageTextOpts{ChatId: channel, MessageId: msgID, ParseMode: gotgbot.ParseModeMarkdownV2}, 185 | ) 186 | if err != nil && 187 | strings.Contains(err.Error(), "specified new message content and reply markup are exactly the same") { 188 | return nil 189 | } 190 | 191 | if err == nil { 192 | return nil 193 | } 194 | 195 | return fmt.Errorf("telegram: failed to edit message: %w\n\tchannel: %s\n\tmessage: %s", 196 | err, message.ChannelID, ids[0]) 197 | } 198 | 199 | func (p *telegramPlugin) DeleteMessage(channelID string, ids []string) error { 200 | channel, err := strconv.ParseInt(channelID, 10, 64) 201 | if err != nil { 202 | return &channelIDError{channelID} 203 | } 204 | 205 | messageIDs := make([]int64, 0, len(ids)) 206 | for _, id := range ids { 207 | var msgID int64 208 | 209 | msgID, err = strconv.ParseInt(id, 10, 64) 210 | if err != nil { 211 | return fmt.Errorf("telegram: failed to parse message ID: %w\n\tchannel: %s\n\tmessage: %d", 212 | err, channelID, msgID) 213 | } 214 | 215 | messageIDs = append(messageIDs, msgID) 216 | } 217 | 218 | _, err = p.telegram.DeleteMessages(channel, messageIDs, nil) 219 | if err == nil { 220 | return nil 221 | } 222 | 223 | return fmt.Errorf("telegram: failed to delete message: %w\n\tchannel: %s\n\tmessage: %#+v", err, channelID, ids) 224 | } 225 | 226 | func (*telegramPlugin) SetupCommands(_ map[string]*lightning.Command) error { 227 | return nil 228 | } 229 | 230 | func (p *telegramPlugin) ListenMessages() <-chan *lightning.Message { 231 | return p.messageChannel 232 | } 233 | 234 | func (p *telegramPlugin) ListenEdits() <-chan *lightning.EditedMessage { 235 | return p.editChannel 236 | } 237 | 238 | func (*telegramPlugin) ListenDeletes() <-chan *lightning.BaseMessage { 239 | return nil 240 | } 241 | 242 | func (*telegramPlugin) ListenCommands() <-chan *lightning.CommandEvent { 243 | return nil 244 | } 245 | -------------------------------------------------------------------------------- /pkg/platforms/telegram/proxy.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | func startProxy(cfg map[string]string) { 13 | server := &http.Server{ 14 | Addr: ":" + cfg["proxy_port"], Handler: &httputil.ReverseProxy{ 15 | Director: func(req *http.Request) { 16 | req.URL = &url.URL{ 17 | Scheme: "https", Host: "api.telegram.org", 18 | Path: "/file/bot" + cfg["token"] + "/" + strings.TrimPrefix(req.URL.Path, "/telegram"), 19 | } 20 | req.Host = "api.telegram.org" 21 | }, 22 | }, ReadTimeout: defaultTimeout, WriteTimeout: defaultTimeout, 23 | } 24 | 25 | if err := server.ListenAndServe(); err != nil { 26 | panic(fmt.Errorf("telegram: failed to start file proxy: %w", err)) 27 | } 28 | 29 | log.Printf("telegram file proxy (port %s) available at %s\n", cfg["proxy_port"], cfg["proxy_url"]) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/platforms/telegram/retrier.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/PaulSonOfLars/gotgbot/v2" 13 | "github.com/williamhorning/lightning/pkg/lightning" 14 | ) 15 | 16 | const defaultTimeout = gotgbot.DefaultTimeout * 2 17 | 18 | type telegramAPIError struct { 19 | err error 20 | code int 21 | } 22 | 23 | func (e *telegramAPIError) Disable() *lightning.ChannelDisabled { 24 | return &lightning.ChannelDisabled{Read: false, Write: e.code == 401 || e.code == 403} 25 | } 26 | 27 | func (e *telegramAPIError) Error() string { 28 | return "error making telegram request (" + strconv.FormatInt(int64(e.code), 10) + "): " + e.err.Error() 29 | } 30 | 31 | func (e *telegramAPIError) Unwrap() error { 32 | return e.err 33 | } 34 | 35 | type retrier struct { 36 | baseClient *gotgbot.BaseBotClient 37 | } 38 | 39 | func newRetrier() *retrier { 40 | return &retrier{&gotgbot.BaseBotClient{DefaultRequestOpts: &gotgbot.RequestOpts{Timeout: defaultTimeout}}} 41 | } 42 | 43 | func (r *retrier) RequestWithContext( 44 | ctx context.Context, 45 | token, method string, 46 | params map[string]string, 47 | data map[string]gotgbot.FileReader, 48 | opts *gotgbot.RequestOpts, 49 | ) (json.RawMessage, error) { 50 | resp, err := r.baseClient.RequestWithContext(ctx, token, method, params, data, opts) 51 | if err == nil { 52 | return resp, nil 53 | } 54 | 55 | urlError := &url.Error{} 56 | if errors.As(err, &urlError) { 57 | urlError.URL = strings.ReplaceAll(urlError.URL, token, "") 58 | 59 | return resp, urlError 60 | } 61 | 62 | telegramError := &gotgbot.TelegramError{} 63 | if !errors.As(err, &telegramError) || telegramError.Code != 429 { 64 | return resp, &telegramAPIError{telegramError, telegramError.Code} 65 | } 66 | 67 | time.Sleep(time.Second * time.Duration(telegramError.ResponseParams.RetryAfter)) 68 | 69 | return r.RequestWithContext(ctx, token, method, params, data, opts) 70 | } 71 | 72 | func (r *retrier) GetAPIURL(opts *gotgbot.RequestOpts) string { 73 | return r.baseClient.GetAPIURL(opts) 74 | } 75 | 76 | func (r *retrier) FileURL(token, tgFilePath string, opts *gotgbot.RequestOpts) string { 77 | return r.baseClient.FileURL(token, tgFilePath, opts) 78 | } 79 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # lightning: *truly powerful* cross-platform bots 2 | 3 | ![lightning logo](./logo.svg) 4 | 5 | - [the bridge](https://williamhorning.dev/lightning/bridge) - connect 6 | Discord, Guilded, Stoat, and Telegram 7 | - [the framework](https://williamhorning.dev/lightning/framework) - build 8 | your own cross-platform bots 9 | 10 | Lightning is a project developing *truly powerful* cross-platform bots, with 11 | the underlying *Lightning framework* being used for *Lightning bridge*, 12 | which is what runs *Bolt*, the hosted bridge bot. The goal is to also make the 13 | framework itself usable by other developers, to create their own bots, and to 14 | make the bridge easy to self-host, while also supporting the principles of: 15 | 16 | - **Connecting communities**: the Lightning bridge connects Discord, Guilded, 17 | Stoat, and Telegram, allowing communities to connect, wherever they are 18 | - **Extensibility**: the Lightning framework uses plugins to make it easy to 19 | add new features, while keeping the core simple. The bridge is also designed to 20 | be flexible, with options to disable pings, setup subscribe-only channels, and 21 | more. 22 | - **Ease of use**: the Lightning framework is designed to be easy to use, 23 | with a simple API, and the bridge is designed to be easy to set up and use, 24 | with easy-to-understand documentation. 25 | - **Strength**: Lightning is built on Go, making it easy to build, run, and 26 | configure, all while being performant and reliable. 27 | 28 | ## the bridge bot 29 | 30 | If you've ever had a community, chances are you talk to them in many places, 31 | whether that's Discord, Guilded, Stoat, or Telegram. Over time, you end up 32 | with fragmentation as your community grows and changes, with many people using 33 | multiple messaging apps. People eventually grow tired of the differences 34 | between apps, and switching between them, with things becoming a mess. 35 | 36 | You could *try* to move everyone to one app, but that might alienate people, so 37 | what do you do, what options do you have? 38 | 39 | **Bridging!** Everyone can use their favorite app, gets the same messages, and 40 | is on the same page. Lightning is an easy to use bridge bot that supports 41 | Discord, Guilded, Stoat, and Telegram. To get started, check out the 42 | [getting started guide](https://williamhorning.dev/lightning/bridge/users), 43 | which will walk you through using Bolt, the hosted version of the Lightning bot. 44 | If you want to self-host, read the 45 | [self-hosting guide](https://williamhorning.dev/lightning/bridge/hosting) to 46 | get started. 47 | 48 | ## the framework 49 | 50 | Lightning is a framework for building cross-platform bots, allowing you to make 51 | bots that support multiple platforms without having to worry about 52 | platform-specific code. The framework is built in Go, making it easy to work 53 | with, and is designed to handle things like commands, events, rate-limits, 54 | attachments, and more, all while being battle-tested in Bolt, which has handled 55 | over half-a-million messages during just the summer of 2025. 56 | 57 | The framework consists of the core library, which is platform-agnostic, and 58 | plugins, which add support for specific platforms, such as Discord, Guilded, 59 | Stoat, and Telegram. The only platform-specific code is in the plugins, making 60 | it possible to support new platforms without modifying your bot's core logic. 61 | 62 | To see a simple example of how to use the framework, check out the 63 | [framework: hello world](https://williamhorning.dev/lightning/framework/hello-world) 64 | guide, which will walk you through creating a simple bot that responds to 65 | messages and commands. For the full documentation, check out the 66 | [framework documentation](https://williamhorning.dev/lightning/framework). 67 | 68 | ## what's the difference between Bolt and Lightning? 69 | 70 | **Lightning** is both the open-source framework and bridge bot that are used to 71 | run **Bolt**, the hosted version of the bridge bot. Bolt is a specific instance 72 | of the Lightning bridge bot, which is hosted by 73 | [William Horning](https://williamhorning.dev/) and is free to use. You can 74 | also self-host your own instance of the Lightning bridge 75 | 76 | ## what about matrix? 77 | 78 | The [Matrix protocol](https://matrix.org) is great, and adding support for it 79 | to the Lightning framework would be amazing, but non-trivial. Implementing all 80 | the features necessary to make a smooth and reliable experience, on par with 81 | other platforms, is something that is being worked on in 82 | [#98](https://github.com/williamhorning/lightning/pull/98), but isn't complete. 83 | [MSC4144](https://github.com/matrix-org/matrix-spec-proposals/pull/4144) is a 84 | new proposal which makes things easier for the bridge use-case, but that still 85 | needs a lot of other work to happen first. Please feel free to contribute if 86 | you'd like! 87 | 88 | ## licensing 89 | 90 | Lightning, the framework and bridge bot, is licensed under the MIT license. The 91 | framework and plugins will always remain under the MIT license, though the 92 | bridge bot may have a different license in the future, but will always be free 93 | to use. Bolt is also free to use, but is also subject to the 94 | [terms of service](https://williamhorning.dev/bolt/legal). 95 | --------------------------------------------------------------------------------