├── .gitignore
├── .github
├── dependabot.yml
├── workflows
│ ├── ci.yml
│ └── publish.yml
└── logo.svg
├── internal
├── workaround
│ └── tls.go
├── app
│ ├── config.go
│ ├── logging.go
│ ├── bridge.go
│ └── commands.go
├── data
│ ├── database.go
│ ├── types.go
│ ├── queries.go
│ └── postgres.go
├── cache
│ └── expiring.go
├── stoat
│ ├── upload.go
│ ├── api.go
│ ├── socket.go
│ ├── permissions.go
│ └── types.go
└── telegram
│ └── markdown.go
├── pkg
├── platforms
│ ├── matrix
│ │ ├── errors.go
│ │ ├── events.go
│ │ ├── outgoing.go
│ │ ├── store.go
│ │ ├── login.go
│ │ ├── incoming.go
│ │ └── plugin.go
│ ├── guilded
│ │ ├── errors.go
│ │ ├── guilded.go
│ │ ├── send.go
│ │ ├── api.go
│ │ ├── plugin.go
│ │ └── incoming.go
│ ├── telegram
│ │ ├── proxy.go
│ │ ├── outgoing.go
│ │ ├── retrier.go
│ │ ├── incoming.go
│ │ └── plugin.go
│ ├── stoat
│ │ ├── errors.go
│ │ ├── methods.go
│ │ ├── outgoing.go
│ │ ├── plugin.go
│ │ └── incoming.go
│ └── discord
│ │ ├── errors.go
│ │ ├── files.go
│ │ ├── endpoints.go
│ │ ├── command.go
│ │ ├── plugin.go
│ │ ├── outgoing.go
│ │ └── incoming.go
└── lightning
│ ├── errors.go
│ ├── addhandler.go
│ ├── embed.go
│ ├── commands.go
│ ├── bot.go
│ ├── methods.go
│ ├── plugin.go
│ └── types.go
├── .golangci.toml
├── containerfile
├── license
├── go.mod
├── cmd
└── lightning
│ └── main.go
├── readme.md
└── go.sum
/.gitignore:
--------------------------------------------------------------------------------
1 | /lightning.toml
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - { package-ecosystem: gomod, directory: /, schedule: { interval: daily } }
4 | - { package-ecosystem: docker, directory: /, schedule: { interval: daily } }
5 | - {
6 | package-ecosystem: github-actions,
7 | directory: /,
8 | schedule: { interval: daily },
9 | }
10 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | pull_request:
4 | push: { branches: [develop] }
5 | permissions: { contents: read, pull-requests: read }
6 | jobs:
7 | ci:
8 | name: lint
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v6
12 | - uses: actions/setup-go@v6
13 | with: { go-version: stable }
14 | - name: golangci-lint
15 | uses: https://github.com/golangci/golangci-lint-action@v9
16 | with: { version: v2.4.0 }
17 |
--------------------------------------------------------------------------------
/internal/workaround/tls.go:
--------------------------------------------------------------------------------
1 | // Package workaround exists to avoid TLS errors from Stoat
2 | package workaround
3 |
4 | import "net/http"
5 |
6 | // Do is a workaround for cloudflare issuing invalid certificates... pls fix >:(.
7 | func Do(req *http.Request) (*http.Response, error) {
8 | transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert
9 | transport.TLSClientConfig.InsecureSkipVerify = req.Host == "cdn.stoatusercontent.com"
10 |
11 | return (&http.Client{Transport: transport}).Do(req) //nolint:wrapcheck
12 | }
13 |
--------------------------------------------------------------------------------
/pkg/platforms/matrix/errors.go:
--------------------------------------------------------------------------------
1 | package matrix
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 |
7 | "codeberg.org/jersey/lightning/pkg/lightning"
8 | "maunium.net/go/mautrix"
9 | )
10 |
11 | type matrixError struct {
12 | msg string
13 | code int
14 | }
15 |
16 | func (e matrixError) Disable() *lightning.ChannelDisabled {
17 | return &lightning.ChannelDisabled{Read: false, Write: e.code == 403 || e.code == 404}
18 | }
19 |
20 | func (e matrixError) Error() string {
21 | return e.msg
22 | }
23 |
24 | func handleError(err error, msg string) error {
25 | var httpErr *mautrix.HTTPError
26 | if !errors.As(err, &httpErr) || httpErr.RespError == nil {
27 | return fmt.Errorf("matrix error: %w", err)
28 | }
29 |
30 | return &matrixError{msg, httpErr.RespError.StatusCode}
31 | }
32 |
--------------------------------------------------------------------------------
/.golangci.toml:
--------------------------------------------------------------------------------
1 | version = "2"
2 |
3 | [run]
4 | build-tags = ["goolm"]
5 |
6 | [linters]
7 | default = "all"
8 | disable = ["depguard", "exhaustruct", "noctx", "noinlineerr", "mnd", "tagliatelle", "wsl"]
9 | exclusions = { presets = ["std-error-handling"] }
10 | settings.ireturn = { allow = ["error", "stdlib", "Plugin", "Database"] }
11 | settings.lll = { tab-width = 4 }
12 | settings.revive.enable-all-rules = true
13 | settings.revive.rules = [
14 | { name = "add-constant", disabled = true },
15 | { name = "cognitive-complexity", disabled = true },
16 | { name = "line-length-limit", disabled = true },
17 | { name = "max-public-structs", disabled = true }
18 | ]
19 |
20 | [formatters]
21 | enable = ["gci", "gofmt", "gofumpt", "goimports", "golines", "swaggo"]
22 | settings = { golines = { max-len = 120, tab-len = 4 } }
23 |
--------------------------------------------------------------------------------
/pkg/platforms/guilded/errors.go:
--------------------------------------------------------------------------------
1 | package guilded
2 |
3 | import (
4 | "strconv"
5 |
6 | "codeberg.org/jersey/lightning/pkg/lightning"
7 | )
8 |
9 | type guildedStatusError struct {
10 | code int
11 | }
12 |
13 | func (e guildedStatusError) Disable() *lightning.ChannelDisabled {
14 | return &lightning.ChannelDisabled{Read: false, Write: e.code >= 400 && e.code < 500}
15 | }
16 |
17 | func (e guildedStatusError) Error() string {
18 | return "failed to send Guilded message: " + strconv.FormatInt(int64(e.code), 10)
19 | }
20 |
21 | type guildedShuttingDownError struct{}
22 |
23 | func (*guildedShuttingDownError) Error() string {
24 | return "Guilded is shutting down on December 19th, so you'll no longer able to setup new channels with Guilded." +
25 | "Please look at moving your server elsewhere. See https://www.guilded.gg/blog/guilded-shut-down-12-19-25"
26 | }
27 |
--------------------------------------------------------------------------------
/containerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.25.4-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.8"
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.8"
14 | LABEL org.opencontainers.image.source="https://codeberg.org/jersey/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 |
--------------------------------------------------------------------------------
/pkg/platforms/telegram/proxy.go:
--------------------------------------------------------------------------------
1 | package telegram
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "net/http"
7 | "net/http/httputil"
8 | "strings"
9 | )
10 |
11 | func startProxy(cfg map[string]string) {
12 | server := &http.Server{
13 | Addr: ":" + cfg["proxy_port"], Handler: &httputil.ReverseProxy{
14 | Director: func(req *http.Request) {
15 | req.URL.Scheme = "https"
16 | req.URL.Host = "api.telegram.com"
17 | req.URL.Path = "/file/bot" + cfg["token"] + "/" + strings.TrimPrefix(req.URL.Path, "/telegram")
18 | req.Host = "api.telegram.org"
19 | },
20 | }, ReadTimeout: defaultTimeout, WriteTimeout: defaultTimeout,
21 | }
22 |
23 | if err := server.ListenAndServe(); err != nil {
24 | panic(fmt.Errorf("telegram: failed to start file proxy: %w", err))
25 | }
26 |
27 | log.Printf("telegram file proxy (port %s) available at %s\n", cfg["proxy_port"], cfg["proxy_url"])
28 | }
29 |
--------------------------------------------------------------------------------
/internal/app/config.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 |
6 | "codeberg.org/jersey/lightning/internal/data"
7 | "github.com/BurntSushi/toml"
8 | )
9 |
10 | // Config is the configuration for the bridge bot.
11 | type Config struct {
12 | DatabaseConfig data.DatabaseConfig `toml:"database"`
13 | Plugins map[string]map[string]string `toml:"plugins"`
14 | CommandPrefix string `toml:"prefix"`
15 | ErrorURL string `toml:"error_url"`
16 | Username string `toml:"username"`
17 | }
18 |
19 | // GetConfig loads the configuration from the given file.
20 | func GetConfig(file string) (Config, error) {
21 | var config Config
22 |
23 | if _, err := toml.DecodeFile(file, &config); err != nil {
24 | return config, fmt.Errorf("failed loading config: %w", err)
25 | }
26 |
27 | if config.Username == "" {
28 | config.Username = "lightning"
29 | }
30 |
31 | return config, nil
32 | }
33 |
--------------------------------------------------------------------------------
/internal/app/logging.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "io"
7 | "log"
8 | "net/http"
9 | "os"
10 | )
11 |
12 | // SetupLogging creates a logger that deals with color and webhooks.
13 | func SetupLogging(url string) {
14 | log.SetFlags(log.Ltime | log.Lshortfile)
15 | log.SetPrefix("")
16 | log.SetOutput(io.MultiWriter(os.Stderr, &webhookLogger{url}))
17 | }
18 |
19 | type webhookLogger struct {
20 | url string
21 | }
22 |
23 | func (l *webhookLogger) Write(output []byte) (int, error) {
24 | go func() {
25 | if l.url == "" {
26 | return
27 | }
28 |
29 | data, err := json.Marshal(map[string]string{"content": "```ansi\n" + string(output) + "\n```"})
30 | if err != nil {
31 | return
32 | }
33 |
34 | req, err := http.NewRequest(http.MethodPost, l.url, bytes.NewBuffer(data))
35 | if err != nil {
36 | return
37 | }
38 |
39 | req.Header["Content-Type"] = []string{"application/json"}
40 |
41 | resp, err := http.DefaultClient.Do(req)
42 | if err != nil {
43 | return
44 | }
45 |
46 | defer resp.Body.Close()
47 | }()
48 |
49 | return len(output), nil
50 | }
51 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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@v6
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: codeberg.org
23 | username: jersey
24 | password: ${{ secrets.CODEBERG_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 | codeberg.org/jersey/lightning:latest
34 | codeberg.org/jersey/lightning:${{ github.ref_name }}
35 |
--------------------------------------------------------------------------------
/pkg/platforms/telegram/outgoing.go:
--------------------------------------------------------------------------------
1 | package telegram
2 |
3 | import (
4 | "slices"
5 | "strings"
6 |
7 | "codeberg.org/jersey/lightning/internal/telegram"
8 | "codeberg.org/jersey/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 lightningToTelegramMessage(message *lightning.Message, opts *lightning.SendOptions) string {
24 | content := ""
25 |
26 | if opts != nil && message.Author != nil {
27 | content += telegram.GetMarkdownV2(message.Author.Nickname) + " » "
28 | }
29 |
30 | mdV2 := telegram.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 += telegram.GetMarkdownV2(embed.ToMarkdown())
43 | }
44 |
45 | return content
46 | }
47 |
--------------------------------------------------------------------------------
/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 is already registered and can't be registered again.
9 | type PluginRegisteredError struct{}
10 |
11 | func (PluginRegisteredError) Error() string {
12 | return "plugin already registered: this is a bug or misconfiguration"
13 | }
14 |
15 | // MissingPluginError only occurs when a plugin/type is not found.
16 | type MissingPluginError struct{}
17 |
18 | func (MissingPluginError) Error() string {
19 | return "plugin not found internally: this is a bug or misconfiguration"
20 | }
21 |
22 | // PluginMethodError is a wrapped error that occurs when a plugin method fails.
23 | type PluginMethodError struct {
24 | ID string
25 | Method string
26 | Message string
27 | err []error
28 | }
29 |
30 | func (p PluginMethodError) Error() string {
31 | str := "plugin " + p.ID + " method " + p.Method + " failed: " + p.Message + ": "
32 |
33 | for _, err := range p.err {
34 | str += "\n\t" + err.Error()
35 | }
36 |
37 | return str
38 | }
39 |
40 | func (p PluginMethodError) Unwrap() []error {
41 | return p.err
42 | }
43 |
--------------------------------------------------------------------------------
/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 GetDatabase(config DatabaseConfig) (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 |
--------------------------------------------------------------------------------
/pkg/platforms/stoat/errors.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import (
4 | "strconv"
5 |
6 | "codeberg.org/jersey/lightning/internal/stoat"
7 | "codeberg.org/jersey/lightning/pkg/lightning"
8 | )
9 |
10 | type stoatPermissionsError struct {
11 | permissions stoat.Permission
12 | expected stoat.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 | err := "Missing the following permissions, please ensure these are granted: `"
21 |
22 | for permission, name := range stoat.PermissionNames {
23 | if (e.expected&permission == permission) && (e.permissions&permission != permission) {
24 | err += name + " "
25 | }
26 | }
27 |
28 | return err + strconv.FormatUint(uint64(e.permissions), 10) + "&" + strconv.FormatUint(uint64(e.expected), 10) + "`"
29 | }
30 |
31 | type stoatStatusError struct {
32 | msg string
33 | code int
34 | edit bool
35 | }
36 |
37 | func (e *stoatStatusError) Disable() *lightning.ChannelDisabled {
38 | return &lightning.ChannelDisabled{Read: false, Write: e.code == 401 || e.code == 403 || (e.code == 404 && !e.edit)}
39 | }
40 |
41 | func (e *stoatStatusError) Error() string {
42 | return "stoat status code " + strconv.FormatInt(int64(e.code), 10) + ": " + e.msg
43 | }
44 |
--------------------------------------------------------------------------------
/pkg/platforms/discord/errors.go:
--------------------------------------------------------------------------------
1 | package discord
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "strconv"
7 |
8 | "codeberg.org/jersey/lightning/pkg/lightning"
9 | "github.com/bwmarrin/discordgo"
10 | )
11 |
12 | type discordAPIError struct {
13 | message string
14 | code int
15 | }
16 |
17 | func (e discordAPIError) Disable() *lightning.ChannelDisabled {
18 | switch e.code {
19 | case discordgo.ErrCodeUnknownChannel:
20 | return &lightning.ChannelDisabled{Read: true, Write: true}
21 | case discordgo.ErrCodeMaximumNumberOfWebhooksReached,
22 | discordgo.ErrCodeMissingPermissions,
23 | discordgo.ErrCodeUnknownWebhook,
24 | discordgo.ErrCodeInvalidWebhookTokenProvided:
25 | return &lightning.ChannelDisabled{Read: false, Write: true}
26 | default:
27 | return &lightning.ChannelDisabled{Read: false, Write: false}
28 | }
29 | }
30 |
31 | func (e discordAPIError) Error() string {
32 | return "Discord API Error " + strconv.FormatInt(int64(e.code), 10) + ": " + e.message
33 | }
34 |
35 | func getError(err error, message string) error {
36 | var restErr *discordgo.RESTError
37 | if errors.As(err, &restErr) {
38 | if restErr.Message.Code == discordgo.ErrCodeUnknownMessage {
39 | return nil
40 | }
41 |
42 | return &discordAPIError{message + ": " + restErr.Message.Message, restErr.Message.Code}
43 | }
44 |
45 | return fmt.Errorf("%s: %w", message, err)
46 | }
47 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module codeberg.org/jersey/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 | go.mau.fi/util v0.9.3
14 | maunium.net/go/mautrix v0.26.0
15 | )
16 |
17 | require (
18 | filippo.io/edwards25519 v1.1.0 // indirect
19 | github.com/jackc/pgpassfile v1.0.0 // indirect
20 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
21 | github.com/jackc/puddle/v2 v2.2.2 // indirect
22 | github.com/mattn/go-colorable v0.1.14 // indirect
23 | github.com/mattn/go-isatty v0.0.20 // indirect
24 | github.com/mattn/go-sqlite3 v1.14.32 // indirect
25 | github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a // indirect
26 | github.com/rs/zerolog v1.34.0 // indirect
27 | github.com/tidwall/gjson v1.18.0 // indirect
28 | github.com/tidwall/match v1.2.0 // indirect
29 | github.com/tidwall/pretty v1.2.1 // indirect
30 | github.com/tidwall/sjson v1.2.5 // indirect
31 | golang.org/x/crypto v0.45.0 // indirect
32 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
33 | golang.org/x/net v0.47.0 // indirect
34 | golang.org/x/sync v0.18.0 // indirect
35 | golang.org/x/sys v0.38.0 // indirect
36 | golang.org/x/text v0.31.0 // indirect
37 | )
38 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/internal/cache/expiring.go:
--------------------------------------------------------------------------------
1 | // Package cache provides a simple expiring cache
2 | package cache
3 |
4 | import (
5 | "sync"
6 | "time"
7 | )
8 |
9 | type cacheItem[T any] struct {
10 | Value T
11 | ExpiresAt time.Time
12 | }
13 |
14 | // An Expiring cache that automatically removes items, when given a TTL.
15 | type Expiring[K comparable, V any] struct {
16 | items map[K]cacheItem[V]
17 | mu sync.RWMutex
18 | TTL time.Duration
19 | }
20 |
21 | // Get a key from the cache, returning its value and whether it exists.
22 | func (c *Expiring[K, V]) Get(key K) (V, bool) { //nolint:all
23 | c.mu.RLock()
24 |
25 | if c.items == nil {
26 | c.mu.RUnlock()
27 |
28 | var zero V
29 |
30 | return zero, false
31 | }
32 |
33 | item, exists := c.items[key]
34 | c.mu.RUnlock()
35 |
36 | if !exists {
37 | var zero V
38 |
39 | return zero, false
40 | }
41 |
42 | if time.Now().After(item.ExpiresAt) {
43 | c.mu.Lock()
44 | delete(c.items, key)
45 | c.mu.Unlock()
46 |
47 | var zero V
48 |
49 | return zero, false
50 | }
51 |
52 | return item.Value, true
53 | }
54 |
55 | // Set a key in the cache, replacing any existing value.
56 | func (c *Expiring[K, V]) Set(key K, value V) {
57 | c.mu.Lock()
58 | defer c.mu.Unlock()
59 |
60 | if c.TTL == 0 {
61 | c.TTL = 30 * time.Second
62 | }
63 |
64 | if c.items == nil {
65 | c.items = make(map[K]cacheItem[V])
66 | }
67 |
68 | c.items[key] = cacheItem[V]{
69 | Value: value,
70 | ExpiresAt: time.Now().Add(c.TTL),
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/pkg/platforms/matrix/events.go:
--------------------------------------------------------------------------------
1 | package matrix
2 |
3 | import (
4 | "context"
5 | "log"
6 | "time"
7 |
8 | "codeberg.org/jersey/lightning/pkg/lightning"
9 | "maunium.net/go/mautrix"
10 | "maunium.net/go/mautrix/event"
11 | "maunium.net/go/mautrix/format"
12 | )
13 |
14 | func listenForEvents(
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 | if _, err := client.JoinRoomByID(ctx, evt.RoomID); err != nil {
23 | log.Printf("matrix: failed to join room: %v\n", err)
24 | }
25 | }
26 | })
27 |
28 | syncer.OnSync(func(ctx context.Context, resp *mautrix.RespSync, since string) bool {
29 | return since != "" || client.DontProcessOldEvents(ctx, resp, since)
30 | })
31 |
32 | syncer.OnEventType(event.EventMessage, mautrix.EventHandler(func(ctx context.Context, evt *event.Event) {
33 | msg := matrixToLightningMessage(ctx, evt, client)
34 |
35 | if msg == nil {
36 | return
37 | }
38 |
39 | edit := evt.Content.AsMessage().NewContent
40 |
41 | if edit == nil {
42 | msgChannel <- msg
43 |
44 | return
45 | }
46 |
47 | if edit.FormattedBody == "" {
48 | edit.FormattedBody = edit.Body
49 | }
50 |
51 | newContent, _ := format.HTMLToMarkdownFull(nil, edit.FormattedBody)
52 | msg.Content = newContent
53 |
54 | editChannel <- &lightning.EditedMessage{Edited: time.UnixMilli(evt.Timestamp), Message: msg}
55 | }))
56 | }
57 |
--------------------------------------------------------------------------------
/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 != "" && embed.URL != "" {
14 | str += "[" + embed.Title + "](" + embed.URL + ")"
15 | } else if embed.Title != "" {
16 | str += embed.Title
17 | }
18 |
19 | if embed.Timestamp != "" {
20 | str += " (" + embed.Timestamp + ")"
21 | }
22 |
23 | str += "\n\n"
24 |
25 | if embed.Author != nil && embed.Author.URL != "" {
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 != "" {
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 != "" {
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 "\n\n"
62 | }
63 |
--------------------------------------------------------------------------------
/internal/stoat/upload.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "mime/multipart"
10 | "net/http"
11 | "time"
12 |
13 | "codeberg.org/jersey/lightning/internal/workaround"
14 | )
15 |
16 | // UploadFile uploads a file to Autumn.
17 | func (session *Session) UploadFile(tag, srcURL, filename string) (*CDNFile, error) {
18 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
19 |
20 | defer cancel()
21 |
22 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, srcURL, nil)
23 | if err != nil {
24 | return nil, fmt.Errorf("failed to create download request: %w", err)
25 | }
26 |
27 | resp, err := workaround.Do(req)
28 | if err != nil {
29 | return nil, fmt.Errorf("failed to download file: %w", err)
30 | }
31 |
32 | defer resp.Body.Close()
33 |
34 | var buf bytes.Buffer
35 |
36 | writer := multipart.NewWriter(&buf)
37 |
38 | part, err := writer.CreateFormFile("file", filename)
39 | if err != nil {
40 | return nil, fmt.Errorf("failed to create form file: %w", err)
41 | }
42 |
43 | if _, err = io.Copy(part, resp.Body); err != nil {
44 | return nil, fmt.Errorf("failed to copy downloaded data: %w", err)
45 | }
46 |
47 | if err = writer.Close(); err != nil {
48 | return nil, fmt.Errorf("failed to finalize multipart payload: %w", err)
49 | }
50 |
51 | base := "https://cdn.stoatusercontent.com/"
52 |
53 | body, _, err := session.Fetch(http.MethodPost, tag, &buf, &base,
54 | map[string][]string{"Content-Type": {writer.FormDataContentType()}})
55 | if err != nil {
56 | return nil, fmt.Errorf("upload failed: %w", err)
57 | }
58 |
59 | defer body.Close()
60 |
61 | var uploaded CDNFile
62 | if err := json.NewDecoder(body).Decode(&uploaded); err != nil {
63 | return nil, fmt.Errorf("failed to decode upload response: %w", err)
64 | }
65 |
66 | return &uploaded, nil
67 | }
68 |
--------------------------------------------------------------------------------
/cmd/lightning/main.go:
--------------------------------------------------------------------------------
1 | // Package main is the entrypoint for Lightning, the bridge bot thing.
2 | package main
3 |
4 | import (
5 | "flag"
6 | "log"
7 | "os"
8 | "os/signal"
9 | "syscall"
10 |
11 | "codeberg.org/jersey/lightning/internal/app"
12 | "codeberg.org/jersey/lightning/internal/data"
13 | "codeberg.org/jersey/lightning/pkg/lightning"
14 | "codeberg.org/jersey/lightning/pkg/platforms/discord"
15 | "codeberg.org/jersey/lightning/pkg/platforms/guilded"
16 | "codeberg.org/jersey/lightning/pkg/platforms/matrix"
17 | "codeberg.org/jersey/lightning/pkg/platforms/stoat"
18 | "codeberg.org/jersey/lightning/pkg/platforms/telegram"
19 | )
20 |
21 | func main() {
22 | cfgPath := flag.String("config", "lightning.toml", "path to the configuration file")
23 | flag.Parse()
24 |
25 | config, err := app.GetConfig(*cfgPath)
26 | if err != nil {
27 | log.Fatalf("failed to get config: %v\n", err)
28 | }
29 |
30 | app.SetupLogging(config.ErrorURL)
31 |
32 | bot := lightning.NewBot(lightning.BotOptions{Prefix: config.CommandPrefix})
33 |
34 | database, err := data.GetDatabase(config.DatabaseConfig)
35 | if err != nil {
36 | log.Fatalf("failed to setup database: %v\n", err)
37 | }
38 |
39 | app.RegisterCommands(bot, database, config.Username)
40 |
41 | bot.AddPluginType("discord", discord.New)
42 | bot.AddPluginType("guilded", guilded.New)
43 | bot.AddPluginType("revolt", stoat.New)
44 | bot.AddPluginType("telegram", telegram.New)
45 | bot.AddPluginType("matrix", matrix.New)
46 |
47 | bot.AddHandler(app.Create(database))
48 | bot.AddHandler(app.Edit(database))
49 | bot.AddHandler(app.Delete(database))
50 |
51 | for plugin, cfg := range config.Plugins {
52 | if err := bot.UsePluginType(plugin, "", cfg); err != nil {
53 | log.Fatalf("failed to setup plugin for %s: %v\n", plugin, err)
54 | }
55 | }
56 |
57 | quitChannel := make(chan os.Signal, 1)
58 | signal.Notify(quitChannel, os.Interrupt, syscall.SIGTERM)
59 | <-quitChannel
60 |
61 | log.Println("bot stopped")
62 | }
63 |
--------------------------------------------------------------------------------
/internal/data/types.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import "codeberg.org/jersey/lightning/pkg/lightning"
4 |
5 | // BridgeSettings are used to configure the bridge.
6 | type BridgeSettings struct {
7 | AllowEveryone bool `json:"allow_everyone"`
8 | }
9 |
10 | // BridgeChannel represents a channel in a bridge.
11 | type BridgeChannel struct {
12 | Data map[string]string `json:"data,omitempty"`
13 | ID string `json:"id"`
14 | Disabled lightning.ChannelDisabled `json:"disabled"`
15 | }
16 |
17 | // Bridge represents a collection of channels to send and receive messages between.
18 | type Bridge struct {
19 | ID string `json:"id"`
20 | Channels []BridgeChannel `json:"channels"`
21 | Settings BridgeSettings `json:"settings"`
22 | }
23 |
24 | // ChannelMessage represents a collection of message IDs for a specific channel.
25 | type ChannelMessage struct {
26 | ChannelID string `json:"channel_id"`
27 | MessageIDs []string `json:"message_ids"`
28 | }
29 |
30 | // BridgeMessageCollection represents a collection of messages for a specific bridge.
31 | type BridgeMessageCollection struct {
32 | ID string `json:"id"`
33 | BridgeID string `json:"bridge_id"`
34 | Messages []ChannelMessage `json:"messages"`
35 | }
36 |
37 | // GetChannelMessageIDs returns the message IDs for a specific channel in the bridge message collection.
38 | func (m *BridgeMessageCollection) GetChannelMessageIDs(channelID string) []string {
39 | if m == nil {
40 | return nil
41 | }
42 |
43 | for _, msg := range m.Messages {
44 | if msg.ChannelID == channelID {
45 | return msg.MessageIDs
46 | }
47 | }
48 |
49 | return nil
50 | }
51 |
52 | // GetChannelDisabled returns the disabled status for a specific channel in the bridge.
53 | func (b *Bridge) GetChannelDisabled(channelID string) lightning.ChannelDisabled {
54 | for _, channel := range b.Channels {
55 | if channel.ID == channelID {
56 | return channel.Disabled
57 | }
58 | }
59 |
60 | return lightning.ChannelDisabled{Read: false, Write: false}
61 | }
62 |
--------------------------------------------------------------------------------
/pkg/platforms/discord/files.go:
--------------------------------------------------------------------------------
1 | package discord
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "time"
9 |
10 | "codeberg.org/jersey/lightning/internal/workaround"
11 | "codeberg.org/jersey/lightning/pkg/lightning"
12 | "github.com/bwmarrin/discordgo"
13 | )
14 |
15 | func getMaxFileSize(session *discordgo.Session, channel string) int64 {
16 | maxFileSize := int64(10485760)
17 |
18 | if ch, err := session.State.Channel(channel); err == nil && ch.GuildID != "" {
19 | if guild, err := session.State.Guild(ch.GuildID); err == nil {
20 | switch guild.PremiumTier {
21 | case discordgo.PremiumTier2:
22 | maxFileSize = 52428800
23 | case discordgo.PremiumTier3:
24 | maxFileSize = 104857600
25 | case discordgo.PremiumTier1, discordgo.PremiumTierNone:
26 | default:
27 | }
28 | }
29 | }
30 |
31 | return maxFileSize
32 | }
33 |
34 | type cancelableReadCloser struct {
35 | io.ReadCloser
36 |
37 | cancel context.CancelFunc
38 | }
39 |
40 | func (c *cancelableReadCloser) Close() error {
41 | err := c.ReadCloser.Close()
42 | c.cancel()
43 |
44 | if err != nil {
45 | return fmt.Errorf("discord: failed closing cancelable read closer: %w", err)
46 | }
47 |
48 | return nil
49 | }
50 |
51 | func lightningToDiscordFiles(session *discordgo.Session, msg *lightning.Message) []*discordgo.File {
52 | files := make([]*discordgo.File, 0, len(msg.Attachments))
53 |
54 | maxSize := getMaxFileSize(session, msg.ChannelID)
55 |
56 | for _, file := range msg.Attachments {
57 | if file.Size > maxSize {
58 | continue
59 | }
60 |
61 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
62 |
63 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, file.URL, nil)
64 | if err != nil {
65 | cancel()
66 |
67 | continue
68 | }
69 |
70 | resp, err := workaround.Do(req) //nolint:bodyclose // see cancelableReadCloser
71 | if err != nil {
72 | cancel()
73 |
74 | continue
75 | }
76 |
77 | files = append(files, &discordgo.File{
78 | Name: file.Name, ContentType: resp.Header.Get("Content-Type"),
79 | Reader: cancelableReadCloser{resp.Body, cancel},
80 | })
81 | }
82 |
83 | return files
84 | }
85 |
--------------------------------------------------------------------------------
/pkg/platforms/discord/endpoints.go:
--------------------------------------------------------------------------------
1 | package discord
2 |
3 | import "github.com/bwmarrin/discordgo"
4 |
5 | func setBaseURL(base string) {
6 | discordgo.EndpointDiscord = base
7 | discordgo.EndpointAPI = discordgo.EndpointDiscord + "api/v" + discordgo.APIVersion + "/"
8 | discordgo.EndpointGuilds = discordgo.EndpointAPI + "guilds/"
9 | discordgo.EndpointChannels = discordgo.EndpointAPI + "channels/"
10 | discordgo.EndpointUsers = discordgo.EndpointAPI + "users/"
11 | discordgo.EndpointGateway = discordgo.EndpointAPI + "gateway"
12 | discordgo.EndpointGatewayBot = discordgo.EndpointGateway + "/bot"
13 | discordgo.EndpointWebhooks = discordgo.EndpointAPI + "webhooks/"
14 | discordgo.EndpointStickers = discordgo.EndpointAPI + "stickers/"
15 | discordgo.EndpointStageInstances = discordgo.EndpointAPI + "stage-instances"
16 | discordgo.EndpointSKUs = discordgo.EndpointAPI + "skus"
17 | discordgo.EndpointVoice = discordgo.EndpointAPI + "/voice/"
18 | discordgo.EndpointVoiceRegions = discordgo.EndpointVoice + "regions"
19 | discordgo.EndpointNitroStickersPacks = discordgo.EndpointAPI + "/sticker-packs"
20 | discordgo.EndpointGuildCreate = discordgo.EndpointAPI + "guilds"
21 | discordgo.EndpointApplications = discordgo.EndpointAPI + "applications"
22 | discordgo.EndpointOAuth2 = discordgo.EndpointAPI + "oauth2/"
23 | discordgo.EndpointOAuth2Applications = discordgo.EndpointOAuth2 + "applications"
24 | discordgo.EndpointOauth2 = discordgo.EndpointOAuth2
25 | discordgo.EndpointOauth2Applications = discordgo.EndpointOAuth2Applications
26 | }
27 |
28 | func setCDNURL(cdn string) {
29 | discordgo.EndpointCDN = cdn
30 | discordgo.EndpointCDNAttachments = discordgo.EndpointCDN + "attachments/"
31 | discordgo.EndpointCDNAvatars = discordgo.EndpointCDN + "avatars/"
32 | discordgo.EndpointCDNIcons = discordgo.EndpointCDN + "icons/"
33 | discordgo.EndpointCDNSplashes = discordgo.EndpointCDN + "splashes/"
34 | discordgo.EndpointCDNChannelIcons = discordgo.EndpointCDN + "channel-icons/"
35 | discordgo.EndpointCDNBanners = discordgo.EndpointCDN + "banners/"
36 | discordgo.EndpointCDNGuilds = discordgo.EndpointCDN + "guilds/"
37 | discordgo.EndpointCDNRoleIcons = discordgo.EndpointCDN + "role-icons/"
38 | }
39 |
--------------------------------------------------------------------------------
/pkg/platforms/matrix/outgoing.go:
--------------------------------------------------------------------------------
1 | package matrix
2 |
3 | import (
4 | "context"
5 | "log"
6 | "strings"
7 |
8 | "codeberg.org/jersey/lightning/pkg/lightning"
9 | "maunium.net/go/mautrix/event"
10 | "maunium.net/go/mautrix/format"
11 | "maunium.net/go/mautrix/id"
12 | )
13 |
14 | func (p *matrixPlugin) lightningToMatrixMessage(
15 | msg *lightning.Message,
16 | ids []string,
17 | opts *lightning.SendOptions,
18 | ) []*event.MessageEventContent {
19 | for _, embed := range msg.Embeds {
20 | msg.Content += "\n\n" + embed.ToMarkdown()
21 | }
22 |
23 | message := format.RenderMarkdown(msg.Content, true, false)
24 |
25 | var url *id.ContentURIString
26 |
27 | if msg.Author != nil {
28 | if msg.Author.ProfilePicture != "" {
29 | url = p.uploadFile(msg.Author.ProfilePicture)
30 | }
31 |
32 | message.BeeperPerMessageProfile = &event.BeeperPerMessageProfile{
33 | ID: msg.Author.ID,
34 | Displayname: msg.Author.Nickname,
35 | AvatarURL: url,
36 | }
37 | }
38 |
39 | if opts != nil && !opts.AllowEveryonePings {
40 | message.Body = strings.ReplaceAll(message.Body, "@room", "@\u200Broom")
41 | message.FormattedBody = strings.ReplaceAll(message.FormattedBody, "@room", "@\u200Broom")
42 | }
43 |
44 | message.AddPerMessageProfileFallback()
45 |
46 | if len(msg.Attachments) == 0 || len(ids) != 0 {
47 | return []*event.MessageEventContent{&message}
48 | }
49 |
50 | messages := make([]*event.MessageEventContent, 0, len(msg.Attachments)+1)
51 |
52 | for _, attachment := range msg.Attachments {
53 | if mxc := p.uploadFile(attachment.URL); mxc != nil {
54 | messages = append(messages, &event.MessageEventContent{
55 | MsgType: event.MsgFile,
56 | URL: *mxc,
57 | })
58 | }
59 | }
60 |
61 | return messages
62 | }
63 |
64 | func (p *matrixPlugin) uploadFile(url string) *id.ContentURIString {
65 | if cached, ok := p.mxcCache.Get(url); ok {
66 | return &cached
67 | }
68 |
69 | resp, err := p.client.UploadLink(context.Background(), url)
70 | if err == nil {
71 | curl := id.ContentURIString("mxc://" + resp.ContentURI.Homeserver + "/" + resp.ContentURI.FileID)
72 |
73 | return &curl
74 | }
75 |
76 | log.Printf("matrix: upload failed for %s: %v", url, err)
77 |
78 | return nil
79 | }
80 |
--------------------------------------------------------------------------------
/pkg/platforms/guilded/guilded.go:
--------------------------------------------------------------------------------
1 | package guilded
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 |
7 | "codeberg.org/jersey/lightning/pkg/lightning"
8 | )
9 |
10 | type guildedChatMessage struct {
11 | CreatedAt time.Time `json:"createdAt"`
12 | Content string `json:"content,omitempty"`
13 | CreatedByWebhookID string `json:"createdByWebhookId,omitempty"`
14 | ReplyMessageIDs []string `json:"replyMessageIds,omitempty"`
15 | ServerID *string `json:"serverId,omitempty"`
16 | UpdatedAt time.Time `json:"updatedAt,omitempty"`
17 | ChannelID string `json:"channelId"`
18 | CreatedBy string `json:"createdBy"`
19 | ID string `json:"id"`
20 | Embeds []lightning.Embed `json:"embeds,omitempty"`
21 | }
22 |
23 | type guildedChatMessageWrapper struct {
24 | Message guildedChatMessage `json:"message"`
25 | }
26 |
27 | type guildedChatMessageDeleted struct {
28 | DeletedAt time.Time `json:"deletedAt"`
29 | Message guildedChatMessage `json:"message"`
30 | }
31 |
32 | type guildedPayload struct {
33 | Content string `json:"content,omitempty"`
34 | AvatarURL string `json:"avatar_url,omitempty"`
35 | Username string `json:"username,omitempty"`
36 | Embeds []lightning.Embed `json:"embeds,omitempty"`
37 | ReplyMessageIDs []string `json:"replyMessageIds,omitempty"`
38 | }
39 |
40 | type guildedServerMember struct {
41 | Nickname *string `json:"nickname,omitempty"`
42 | User guildedUser `json:"user"`
43 | }
44 |
45 | type guildedSocketEventEnvelope struct {
46 | T string `json:"t"`
47 | D json.RawMessage `json:"d"`
48 | Op int `json:"op"`
49 | }
50 |
51 | type guildedURLSignature struct {
52 | RetryAfter *int `json:"retryAfter,omitempty"`
53 | Signature *string `json:"signature,omitempty"`
54 | }
55 |
56 | type guildedURLSignatureResponse struct {
57 | URLSignatures []guildedURLSignature `json:"urlSignatures"`
58 | }
59 |
60 | type guildedUser struct {
61 | Avatar string `json:"avatar,omitempty"`
62 | ID string `json:"id"`
63 | Name string `json:"name"`
64 | }
65 |
--------------------------------------------------------------------------------
/pkg/platforms/matrix/store.go:
--------------------------------------------------------------------------------
1 | package matrix
2 |
3 | import (
4 | "encoding/gob"
5 | "fmt"
6 | "os"
7 |
8 | "go.mau.fi/util/random"
9 | "maunium.net/go/mautrix"
10 | "maunium.net/go/mautrix/crypto"
11 | "maunium.net/go/mautrix/event"
12 | "maunium.net/go/mautrix/id"
13 | )
14 |
15 | func newCryptoStore(accessToken, deviceID, mxid, path string) *cryptoStore {
16 | store := &cryptoStore{
17 | AccessToken: accessToken,
18 | DeviceID: deviceID,
19 | UserID: mxid,
20 | Pickle: random.String(32),
21 |
22 | Path: path,
23 |
24 | MemoryStateStore: mautrix.MemoryStateStore{
25 | Registrations: make(map[id.UserID]bool),
26 | Members: make(map[id.RoomID]map[id.UserID]*event.MemberEventContent),
27 | MembersFetched: make(map[id.RoomID]bool),
28 | PowerLevels: make(map[id.RoomID]*event.PowerLevelsEventContent),
29 | Encryption: make(map[id.RoomID]*event.EncryptionEventContent),
30 | Create: make(map[id.RoomID]*event.Event),
31 | },
32 | }
33 |
34 | store.MemoryStore = crypto.NewMemoryStore(store.saveCallback)
35 |
36 | return store
37 | }
38 |
39 | func openCryptoStore(path string) (*cryptoStore, error) {
40 | file, err := os.Open(path) //nolint:gosec
41 | if err != nil {
42 | return nil, fmt.Errorf("failed to open file: %w", err)
43 | }
44 |
45 | defer file.Close()
46 |
47 | var store *cryptoStore
48 |
49 | if err = gob.NewDecoder(file).Decode(store); err != nil {
50 | return nil, fmt.Errorf("failed to decode file: %w", err)
51 | }
52 |
53 | return store, nil
54 | }
55 |
56 | type cryptoStore struct {
57 | AccessToken string
58 | DeviceID string
59 | UserID string
60 | Pickle string
61 | Path string
62 |
63 | *crypto.MemoryStore //nolint:embeddedstructfieldcheck
64 | mautrix.MemoryStateStore
65 | }
66 |
67 | func (s *cryptoStore) saveCallback() error {
68 | file, err := os.OpenFile(s.Path, 0o600, 0o600)
69 | if err != nil {
70 | return fmt.Errorf("failed to open file: %w", err)
71 | }
72 |
73 | if err = gob.NewEncoder(file).Encode(s); err != nil {
74 | return fmt.Errorf("failed to marshal self: %w", err)
75 | }
76 |
77 | if err = file.Close(); err != nil {
78 | return fmt.Errorf("failed to close file: %w", err)
79 | }
80 |
81 | return nil
82 | }
83 |
--------------------------------------------------------------------------------
/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 | "codeberg.org/jersey/lightning/pkg/lightning"
13 | "github.com/PaulSonOfLars/gotgbot/v2"
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 |
--------------------------------------------------------------------------------
/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 platform-specific command systems.
7 | func (b *Bot) AddCommand(commands ...Command) {
8 | for _, command := range commands {
9 | b.commands[command.Name] = &command
10 | }
11 |
12 | for _, plugin := range b.plugins {
13 | _ = plugin.SetupCommands(b.commands)
14 | }
15 | }
16 |
17 | func handleMessageCommand(bot *Bot, event *Message) {
18 | if len(event.Content) <= len(bot.prefix) || event.Content[:len(bot.prefix)] != bot.prefix {
19 | return
20 | }
21 |
22 | args := strings.Fields(event.Content[len(bot.prefix):])
23 | if len(args) == 0 {
24 | args = []string{"help"}
25 | }
26 |
27 | commandName := args[0]
28 | options := args[1:]
29 |
30 | reply := func(msg *Message, sensitive bool) {
31 | plugin, channel, ok := bot.getPluginFromChannel(event.ChannelID)
32 | if !ok {
33 | return
34 | }
35 |
36 | msg.ChannelID = channel
37 | msg.RepliedTo = append(msg.RepliedTo, event.EventID)
38 |
39 | if sensitive {
40 | _, _ = plugin.SendCommandResponse(msg, nil, event.Author.ID)
41 | } else {
42 | _, _ = plugin.SendMessage(msg, nil)
43 | }
44 | }
45 |
46 | handleCommandEvent(bot, &CommandEvent{
47 | CommandOptions: &CommandOptions{&event.BaseMessage, make(map[string]string), bot, reply, bot.prefix},
48 | Command: commandName,
49 | Options: options,
50 | })
51 | }
52 |
53 | func handleCommandEvent(bot *Bot, event *CommandEvent) {
54 | event.Bot = bot
55 |
56 | command, exists := bot.commands[event.Command]
57 | if !exists {
58 | command = bot.commands["help"]
59 | }
60 |
61 | if len(command.Subcommands) != 0 && len(event.Options) != 0 && event.Subcommand == nil {
62 | event.Subcommand = &event.Options[0]
63 | event.Options = event.Options[1:]
64 | }
65 |
66 | if event.Subcommand != nil {
67 | if cmd, ok := command.Subcommands[*event.Subcommand]; ok {
68 | command = &cmd
69 | }
70 | }
71 |
72 | for _, arg := range command.Arguments {
73 | if event.Arguments[arg.Name] == "" && len(event.Options) > 0 {
74 | event.Arguments[arg.Name] = event.Options[0]
75 | event.Options = event.Options[1:]
76 | }
77 | }
78 |
79 | command.Executor(event.CommandOptions)
80 | }
81 |
--------------------------------------------------------------------------------
/internal/telegram/markdown.go:
--------------------------------------------------------------------------------
1 | // Package telegram handles Telegram-specific transformations
2 | package telegram
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 syntax and turns it into Telegram's MarkdownV2.
14 | func GetMarkdownV2(input string) string {
15 | reader := text.NewReader([]byte(input))
16 | root := goldmark.DefaultParser().Parse(reader)
17 |
18 | return strings.TrimSpace(nodeToTelegram(root, reader.Source()))
19 | }
20 |
21 | func nodeToTelegram(astNode ast.Node, source []byte) string {
22 | switch node := astNode.(type) {
23 | case *ast.Text:
24 | return escapeTelegramText(string(node.Segment.Value(source)))
25 | case *ast.Emphasis:
26 | return getEmphasis(node) + handleNode(node, source) + getEmphasis(node)
27 | case *ast.Link:
28 | return "[" + handleNode(node, source) + "](" + escapeTelegramText(string(node.Destination)) + ")"
29 | case *ast.Paragraph:
30 | return handleNode(node, source) + "\n\n"
31 | case *ast.CodeSpan:
32 | return "`" + handleNode(node, source) + "`"
33 | case *ast.Blockquote:
34 | return ">" + handleNode(node, source) + "\n"
35 | case *ast.FencedCodeBlock:
36 | return "```" + string(node.Language(source)) + "\n" + escapeTelegramText(string(node.Lines().Value(source))) +
37 | "\n```\n"
38 | default:
39 | return handleNode(node, source)
40 | }
41 | }
42 |
43 | func escapeTelegramText(input string) string {
44 | specialChars := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}
45 | for _, ch := range specialChars {
46 | input = strings.ReplaceAll(input, ch, "\\"+ch)
47 | }
48 |
49 | return input
50 | }
51 |
52 | func getEmphasis(node *ast.Emphasis) string {
53 | if node.Level == 1 {
54 | return "_"
55 | }
56 |
57 | return "*"
58 | }
59 |
60 | func handleNode(node ast.Node, source []byte) string {
61 | res := ""
62 |
63 | for i, child := 1, node.FirstChild(); child != nil; child, i = child.NextSibling(), i+1 {
64 | if list, ok := node.(*ast.List); ok {
65 | prefix := "\\- "
66 |
67 | if list.IsOrdered() {
68 | prefix = strconv.FormatInt(int64(i), 10) + "\\. "
69 | }
70 |
71 | res += prefix + nodeToTelegram(child, source) + "\n"
72 | } else {
73 | res += nodeToTelegram(child, source)
74 | }
75 | }
76 |
77 | return res
78 | }
79 |
--------------------------------------------------------------------------------
/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.8"
11 |
12 | // BotOptions allows you to configure the prefix used by the bot for registered
13 | // commands, in addition to 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/platforms/stoat/methods.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 |
7 | "codeberg.org/jersey/lightning/internal/stoat"
8 | "codeberg.org/jersey/lightning/pkg/lightning"
9 | )
10 |
11 | func (p *stoatPlugin) stoatSendMessage(channel string, message stoat.DataMessageSend) (string, error) {
12 | ch, err := stoat.Get(p.session, "/channels/"+channel, channel, &p.session.ChannelCache)
13 |
14 | if err == nil && message.Masquerade != nil &&
15 | (ch.ChannelType != stoat.ChannelTypeText && ch.ChannelType != stoat.ChannelTypeVoice) {
16 | message.Masquerade.Colour = ""
17 | }
18 |
19 | resp, code, err := p.session.Fetch("POST", "/channels/"+channel+"/messages", message, nil,
20 | map[string][]string{"Content-Type": {"application/json"}})
21 | if err != nil {
22 | return "", fmt.Errorf("stoat: error making send message request: %w", err)
23 | }
24 |
25 | defer resp.Close()
26 |
27 | if code != 200 {
28 | return "", &stoatStatusError{"failed to send stoat message", code, true}
29 | }
30 |
31 | var response stoat.Message
32 | if err := json.NewDecoder(resp).Decode(&response); err != nil {
33 | return "", fmt.Errorf("stoat: failed to decode %d response: %w", code, err)
34 | }
35 |
36 | return response.ID, nil
37 | }
38 |
39 | func (p *stoatPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error {
40 | message.Attachments = nil
41 | outgoing := lightningToStoatMessage(p.session, message, opts)
42 | data := stoat.DataEditMessage{Content: outgoing.Content, Embeds: outgoing.Embeds}
43 |
44 | resp, code, err := p.session.Fetch("PATCH", "/channels/"+message.ChannelID+"/messages/"+ids[0], data, nil,
45 | map[string][]string{"Content-Type": {"application/json"}})
46 | if err != nil {
47 | return fmt.Errorf("stoat: error making edit request: %w", err)
48 | }
49 |
50 | defer resp.Close()
51 |
52 | if code != 200 {
53 | return &stoatStatusError{"failed to edit stoat message", code, true}
54 | }
55 |
56 | return nil
57 | }
58 |
59 | func (p *stoatPlugin) DeleteMessage(channel string, ids []string) error {
60 | resp, code, err := p.session.Fetch(
61 | "DELETE", "/channels/"+channel+"/messages/bulk",
62 | stoat.OptionsBulkDelete{IDs: ids},
63 | nil, map[string][]string{"Content-Type": {"application/json"}},
64 | )
65 | if err != nil {
66 | return fmt.Errorf("stoat: error making deletion request: %w", err)
67 | }
68 |
69 | defer resp.Close()
70 |
71 | if code != 204 {
72 | return &stoatStatusError{"failed to delete stoat messages", code, true}
73 | }
74 |
75 | return nil
76 | }
77 |
--------------------------------------------------------------------------------
/pkg/platforms/matrix/login.go:
--------------------------------------------------------------------------------
1 | package matrix
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "os"
7 |
8 | "codeberg.org/jersey/lightning/pkg/lightning"
9 | "maunium.net/go/mautrix"
10 | "maunium.net/go/mautrix/crypto/cryptohelper"
11 | )
12 |
13 | func setupClient(cfg map[string]string) (*mautrix.Client, error) {
14 | client, err := mautrix.NewClient(cfg["homeserver"], "", "")
15 | if err != nil {
16 | return nil, fmt.Errorf("matrix: failed to create client: %w", err)
17 | }
18 |
19 | client.UserAgent = "lightning/" + lightning.VERSION
20 |
21 | _, err = os.Stat(cfg["path"])
22 |
23 | var store *cryptoStore
24 |
25 | if os.IsNotExist(err) {
26 | _, err = client.Login(context.Background(), &mautrix.ReqLogin{
27 | Type: mautrix.AuthTypePassword,
28 | Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: cfg["username"]},
29 | Password: cfg["password"],
30 | StoreCredentials: true,
31 | })
32 | if err != nil {
33 | return nil, fmt.Errorf("matrix: failed to login: %w", err)
34 | }
35 |
36 | store = newCryptoStore(client.AccessToken, string(client.DeviceID), string(client.UserID), cfg["store"])
37 | } else {
38 | store, err = openCryptoStore(cfg["store"])
39 | if err != nil {
40 | return nil, fmt.Errorf("matrix: failed to open store: %w", err)
41 | }
42 | }
43 |
44 | if err = setupKeys(cfg, client, store); err != nil {
45 | return nil, err
46 | }
47 |
48 | return client, nil
49 | }
50 |
51 | func setupKeys(cfg map[string]string, client *mautrix.Client, store *cryptoStore) error {
52 | client.StateStore = store
53 |
54 | helper, err := cryptohelper.NewCryptoHelper(client, []byte(store.Pickle), &store)
55 | if err != nil {
56 | return fmt.Errorf("failed to setup crypto helper: %w", err)
57 | }
58 |
59 | err = helper.Init(context.Background())
60 | if err != nil {
61 | return fmt.Errorf("failed to init crypto helper: %w", err)
62 | }
63 |
64 | client.Crypto = helper
65 |
66 | keyID, keyData, err := helper.Machine().SSSS.GetDefaultKeyData(context.Background())
67 | if err != nil {
68 | return fmt.Errorf("failed to get default key: %w", err)
69 | }
70 |
71 | key, err := keyData.VerifyRecoveryKey(keyID, cfg["recovery_key"])
72 | if err != nil {
73 | return fmt.Errorf("failed to verify recovery key: %w", err)
74 | }
75 |
76 | err = helper.Machine().FetchCrossSigningKeysFromSSSS(context.Background(), key)
77 | if err != nil {
78 | return fmt.Errorf("failed to fetch cross signing keys: %w", err)
79 | }
80 |
81 | err = helper.Machine().SignOwnDevice(context.Background(), helper.Machine().OwnIdentity())
82 | if err != nil {
83 | return fmt.Errorf("failed to sign own device: %w", err)
84 | }
85 |
86 | err = helper.Machine().SignOwnMasterKey(context.Background())
87 | if err != nil {
88 | return fmt.Errorf("failed to sign own master key: %w", err)
89 | }
90 |
91 | return nil
92 | }
93 |
--------------------------------------------------------------------------------
/pkg/platforms/guilded/send.go:
--------------------------------------------------------------------------------
1 | package guilded
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "regexp"
10 |
11 | "codeberg.org/jersey/lightning/pkg/lightning"
12 | )
13 |
14 | var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_ ()-]{1,25}$`)
15 |
16 | func (p *guildedPlugin) SendMessage(msg *lightning.Message, opts *lightning.SendOptions) ([]string, error) {
17 | payload := guildedPayload{Content: msg.Content, ReplyMessageIDs: msg.RepliedTo, Embeds: msg.Embeds}
18 |
19 | if msg.Author != nil {
20 | payload.AvatarURL = msg.Author.ProfilePicture
21 | switch {
22 | case usernameRegex.MatchString(msg.Author.Nickname):
23 | payload.Username = msg.Author.Nickname
24 | case usernameRegex.MatchString(msg.Author.Username):
25 | payload.Username = msg.Author.Username
26 | default:
27 | payload.Username = msg.Author.ID
28 | }
29 | }
30 |
31 | if payload.Content == "" && len(payload.Embeds) == 0 {
32 | payload.Content = "\u2800"
33 | }
34 |
35 | body, err := json.Marshal(payload)
36 | if err != nil {
37 | return nil, fmt.Errorf("guilded: marshal message: %w", err)
38 | }
39 |
40 | if opts == nil {
41 | return p.sendAPI(msg, bytes.NewReader(body))
42 | }
43 |
44 | return p.sendWebhook(opts, bytes.NewReader(body))
45 | }
46 |
47 | func (p *guildedPlugin) sendAPI(msg *lightning.Message, body io.Reader) ([]string, error) {
48 | resp, err := guildedMakeRequest(p.token, "POST", "/channels/"+msg.ChannelID+"/messages", body)
49 | if err != nil {
50 | return nil, err
51 | }
52 |
53 | defer resp.Body.Close()
54 |
55 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
56 | return nil, &guildedStatusError{resp.StatusCode}
57 | }
58 |
59 | var r guildedChatMessageWrapper
60 | if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
61 | return nil, fmt.Errorf("failed to decode send: %w", err)
62 | }
63 |
64 | return []string{r.Message.ID}, nil
65 | }
66 |
67 | func (p *guildedPlugin) sendWebhook(opts *lightning.SendOptions, payload io.Reader) ([]string, error) {
68 | id, token := opts.ChannelData["id"], opts.ChannelData["token"]
69 | p.webhookIDsCache.Set(id, true)
70 | url := "https://media.guilded.gg/webhooks/" + id + "/" + token
71 |
72 | req, err := http.NewRequest(http.MethodPost, url, payload)
73 | if err != nil {
74 | return nil, fmt.Errorf("failed to make webhook request: %w", err)
75 | }
76 |
77 | req.Header["Content-Type"] = []string{"application/json"}
78 |
79 | resp, err := http.DefaultClient.Do(req)
80 | if err != nil {
81 | return nil, fmt.Errorf("failed to send webhook request: %w", err)
82 | }
83 |
84 | defer resp.Body.Close()
85 |
86 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
87 | return nil, &guildedStatusError{resp.StatusCode}
88 | }
89 |
90 | var body struct {
91 | ID string `json:"id"`
92 | }
93 | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
94 | return nil, fmt.Errorf("failed to decode webhook send: %w", err)
95 | }
96 |
97 | return []string{body.ID}, nil
98 | }
99 |
--------------------------------------------------------------------------------
/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) (map[string]string, 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 | err := plugin.EditMessage(message, ids, opts)
55 | if err != nil {
56 | return &PluginMethodError{oldID, "EditMessage", "failed to edit message", []error{err}}
57 | }
58 |
59 | return nil
60 | }
61 |
62 | // DeleteMessages allows you to delete messages in the channel and plugin specified.
63 | // The 'ids' parameter should contain the IDs of the messages to be edited, as
64 | // returned by SendMessage.
65 | func (b *Bot) DeleteMessages(channelID string, ids []string) error {
66 | plugin, channel, ok := b.getPluginFromChannel(channelID)
67 | if !ok {
68 | return MissingPluginError{}
69 | }
70 |
71 | err := plugin.DeleteMessage(channel, ids)
72 | if err != nil {
73 | return &PluginMethodError{channelID, "DeleteMessages", "failed to delete messages", []error{err}}
74 | }
75 |
76 | return nil
77 | }
78 |
79 | func (b *Bot) getPluginFromChannel(channel string) (Plugin, string, bool) {
80 | pluginName, channelName, found := strings.Cut(channel, "::")
81 | if !found {
82 | return nil, "", false
83 | }
84 |
85 | b.pluginMutex.RLock()
86 | plugin, found := b.plugins[pluginName]
87 | b.pluginMutex.RUnlock()
88 |
89 | return plugin, channelName, found
90 | }
91 |
--------------------------------------------------------------------------------
/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.3');`
70 |
71 | zeroEightThreeMigrationQuery = `UPDATE bridge_channels SET data = CASE WHEN jsonb_typeof(data) = 'object' THEN
72 | (SELECT jsonb_object_agg(key, value) FROM jsonb_each_text(data)) ELSE NULL END;`
73 | )
74 |
--------------------------------------------------------------------------------
/pkg/platforms/discord/command.go:
--------------------------------------------------------------------------------
1 | package discord
2 |
3 | import (
4 | "log"
5 | "time"
6 |
7 | "codeberg.org/jersey/lightning/pkg/lightning"
8 | "github.com/bwmarrin/discordgo"
9 | )
10 |
11 | func lightningToDiscordCommands(original map[string]*lightning.Command) []*discordgo.ApplicationCommand {
12 | cmds := make([]*discordgo.ApplicationCommand, 0, len(original))
13 |
14 | for _, cmd := range original {
15 | cmds = append(cmds, &discordgo.ApplicationCommand{
16 | Name: cmd.Name,
17 | Type: discordgo.ChatApplicationCommand,
18 | Description: cmd.Description,
19 | Options: lightningToDiscordCommandOptions(cmd),
20 | })
21 | }
22 |
23 | return cmds
24 | }
25 |
26 | func lightningToDiscordCommandOptions(cmd *lightning.Command) []*discordgo.ApplicationCommandOption {
27 | options := make([]*discordgo.ApplicationCommandOption, 0, len(cmd.Arguments)+len(cmd.Subcommands))
28 |
29 | for _, arg := range cmd.Arguments {
30 | options = append(options, &discordgo.ApplicationCommandOption{
31 | Name: arg.Name,
32 | Description: arg.Description,
33 | Required: arg.Required,
34 | Type: discordgo.ApplicationCommandOptionString,
35 | })
36 | }
37 |
38 | for _, sub := range cmd.Subcommands {
39 | options = append(options, &discordgo.ApplicationCommandOption{
40 | Name: sub.Name,
41 | Description: sub.Description,
42 | Type: discordgo.ApplicationCommandOptionSubCommand,
43 | Options: lightningToDiscordCommandOptions(&sub),
44 | })
45 | }
46 |
47 | return options
48 | }
49 |
50 | func discordToLightningCommand(
51 | session *discordgo.Session,
52 | interaction *discordgo.InteractionCreate,
53 | ) *lightning.CommandEvent {
54 | if interaction.Type != discordgo.InteractionApplicationCommand {
55 | return nil
56 | }
57 |
58 | args := make(map[string]string)
59 | data := interaction.ApplicationCommandData()
60 |
61 | var subcommand *string
62 |
63 | for _, option := range data.Options {
64 | if option.Type == discordgo.ApplicationCommandOptionSubCommand {
65 | subcommand = &option.Name
66 |
67 | for _, subOption := range option.Options {
68 | if subOption.Type == discordgo.ApplicationCommandOptionString {
69 | args[subOption.Name] = subOption.StringValue()
70 | }
71 | }
72 | } else {
73 | args[option.Name] = option.StringValue()
74 | }
75 | }
76 |
77 | timestamp, err := discordgo.SnowflakeTimestamp(interaction.ID)
78 | if err != nil {
79 | timestamp = time.Now()
80 | }
81 |
82 | return &lightning.CommandEvent{
83 | CommandOptions: &lightning.CommandOptions{
84 | Arguments: args,
85 | BaseMessage: &lightning.BaseMessage{
86 | EventID: interaction.ID, ChannelID: interaction.ChannelID, Time: timestamp,
87 | },
88 | Prefix: "/",
89 | Reply: func(message *lightning.Message, sensitive bool) {
90 | msgs := lightningToDiscordSendable(session, message, nil)
91 |
92 | data := msgs[0].toInteractionResponseData()
93 |
94 | if sensitive {
95 | data.Flags = discordgo.MessageFlagsEphemeral
96 | }
97 |
98 | if err := session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{
99 | Type: discordgo.InteractionResponseChannelMessageWithSource, Data: data,
100 | }); err != nil {
101 | log.Printf("discord: failed to respond to command: %v\n", err)
102 | }
103 | },
104 | },
105 | Command: data.Name,
106 | Subcommand: subcommand,
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/.github/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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) (map[string]string, 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 overwrites existing plugin types if the name is a duplicate.
23 | func (b *Bot) AddPluginType(name string, constructor PluginConstructor) {
24 | b.typesMutex.Lock()
25 | defer b.typesMutex.Unlock()
26 |
27 | b.types[name] = constructor
28 | }
29 |
30 | // UsePluginType takes in a plugin name and config to use a plugin with your bot.
31 | // It only returns an error if a plugin already exists *or* if the plugin type is
32 | // not found. If you pass an empty string to instanceName, it will default to
33 | // typeName, but that value must be unique.
34 | func (b *Bot) UsePluginType(typeName, instanceName string, config map[string]string) error {
35 | if instanceName == "" {
36 | instanceName = typeName
37 | }
38 |
39 | b.pluginMutex.RLock()
40 |
41 | if _, exists := b.plugins[instanceName]; exists {
42 | return PluginRegisteredError{}
43 | }
44 |
45 | b.pluginMutex.RUnlock()
46 |
47 | b.typesMutex.RLock()
48 |
49 | constructor, ok := b.types[typeName]
50 |
51 | b.typesMutex.RUnlock()
52 |
53 | if !ok {
54 | return MissingPluginError{}
55 | }
56 |
57 | instance, err := constructor(config)
58 | if err != nil {
59 | return err
60 | }
61 |
62 | b.pluginMutex.Lock()
63 |
64 | b.plugins[instanceName] = instance
65 |
66 | b.pluginMutex.Unlock()
67 |
68 | go processEventHandlers(nil, b.editChannel, &b.editHandlers, &b.editProcessorActive, b)
69 | go processEventHandlers(nil, b.messageChannel, &b.messageHandlers, &b.messageProcessorActive, b)
70 | go processEventHandlers(nil, b.delChannel, &b.delHandlers, &b.delProcessorActive, b)
71 | go processEventHandlers(nil, b.commandChannel, &b.commandHandlers, &b.commandProcessorActive, b)
72 |
73 | b.startPluginListeners(instanceName, instance)
74 |
75 | return nil
76 | }
77 |
78 | // startPluginListeners listens for events from a plugin and forwards them.
79 | // do NOT rely on the ChannelID format, treat it as an opaque string.
80 | func (b *Bot) startPluginListeners(name string, instance Plugin) {
81 | go func() {
82 | for msg := range instance.ListenMessages() {
83 | msg.ChannelID = name + "::" + msg.ChannelID
84 | b.messageChannel <- msg
85 | }
86 | }()
87 | go func() {
88 | for edit := range instance.ListenEdits() {
89 | edit.Message.ChannelID = name + "::" + edit.Message.ChannelID
90 | b.editChannel <- edit
91 | }
92 | }()
93 | go func() {
94 | for del := range instance.ListenDeletes() {
95 | del.ChannelID = name + "::" + del.ChannelID
96 | b.delChannel <- del
97 | }
98 | }()
99 | go func() {
100 | for cmd := range instance.ListenCommands() {
101 | cmd.ChannelID = name + "::" + cmd.ChannelID
102 | b.commandChannel <- cmd
103 | }
104 | }()
105 | }
106 |
--------------------------------------------------------------------------------
/pkg/platforms/guilded/api.go:
--------------------------------------------------------------------------------
1 | package guilded
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "log"
8 | "net/http"
9 | "sync/atomic"
10 | "time"
11 |
12 | "codeberg.org/jersey/lightning/pkg/lightning"
13 | "github.com/gorilla/websocket"
14 | )
15 |
16 | func guildedMakeRequest(token, method, endpoint string, body io.Reader) (*http.Response, error) {
17 | req, err := http.NewRequest(method, "https://www.guilded.gg/api/v1"+endpoint, body)
18 | if err != nil {
19 | return nil, fmt.Errorf("guilded: creating request: %w\n\tendpoint: %s\n\tmethod: %s", err, endpoint, method)
20 | }
21 |
22 | req.Header = http.Header{
23 | "Authorization": {"Bearer " + token},
24 | "Content-Type": {"application/json"},
25 | "User-Agent": {"lightning/" + lightning.VERSION},
26 | "x-guilded-bot-api-use-official-markdown": {"true"},
27 | }
28 |
29 | resp, err := http.DefaultClient.Do(req)
30 | if err != nil {
31 | return nil, fmt.Errorf("guilded: making request: %w\n\tendpoint: %s\n\tmethod: %s", err, endpoint, method)
32 | }
33 |
34 | if resp.StatusCode != http.StatusTooManyRequests {
35 | return resp, nil
36 | }
37 |
38 | retry := resp.Header.Get("Retry-After")
39 | if retry == "" {
40 | retry = "1000"
41 | }
42 |
43 | dur, _ := time.ParseDuration(retry + "ms")
44 | if dur == 0 {
45 | dur = time.Second
46 | }
47 |
48 | time.Sleep(dur)
49 |
50 | return guildedMakeRequest(token, method, endpoint, body)
51 | }
52 |
53 | type session struct {
54 | conn *websocket.Conn
55 | messageDeleted chan *guildedChatMessageDeleted
56 | messageCreated chan *guildedChatMessageWrapper
57 | messageUpdated chan *guildedChatMessageWrapper
58 | token string
59 | connected atomic.Bool
60 | }
61 |
62 | func (s *session) connect() error {
63 | if s.connected.Load() {
64 | return nil
65 | }
66 |
67 | conn, resp, err := websocket.DefaultDialer.Dial(
68 | "wss://www.guilded.gg/websocket/v1",
69 | http.Header{
70 | "Authorization": {"Bearer " + s.token},
71 | "User-Agent": {"lightning" + lightning.VERSION},
72 | "x-guilded-bot-api-use-official-markdown": {"true"},
73 | },
74 | )
75 | if err != nil {
76 | return fmt.Errorf("guilded: failed to dial: %w", err)
77 | }
78 |
79 | defer resp.Body.Close()
80 |
81 | s.conn = conn
82 | s.connected.Store(true)
83 |
84 | go readMessages(s)
85 |
86 | return nil
87 | }
88 |
89 | func readMessages(session *session) {
90 | for session.connected.Load() && session.conn != nil {
91 | _, reader, err := session.conn.NextReader()
92 | if err != nil {
93 | break
94 | }
95 |
96 | var data guildedSocketEventEnvelope
97 | if json.NewDecoder(reader).Decode(&data) != nil {
98 | return
99 | }
100 |
101 | switch data.T {
102 | case "ChatMessageCreated":
103 | handleGenericEvent(data.D, session.messageCreated)
104 | case "ChatMessageUpdated":
105 | handleGenericEvent(data.D, session.messageUpdated)
106 | case "ChatMessageDeleted":
107 | handleGenericEvent(data.D, session.messageDeleted)
108 | default:
109 | }
110 | }
111 |
112 | go handleReconnect(session)
113 | }
114 |
115 | func handleReconnect(session *session) {
116 | session.connected.Store(false)
117 |
118 | if session.conn != nil {
119 | defer session.conn.Close()
120 |
121 | session.conn = nil
122 | }
123 |
124 | for attempt, backoff := 1, 100*time.Millisecond; ; attempt++ {
125 | time.Sleep(backoff)
126 |
127 | if session.connect() == nil {
128 | return
129 | }
130 |
131 | backoff = min(time.Duration(float64(backoff)*1.5), time.Second)
132 |
133 | log.Printf("guilded: reconnect #%d after %s\n", attempt, backoff)
134 | }
135 | }
136 |
137 | func handleGenericEvent[T any](bytes json.RawMessage, events chan *T) {
138 | var d T
139 | if json.Unmarshal(bytes, &d) != nil {
140 | return
141 | }
142 |
143 | events <- &d
144 | }
145 |
--------------------------------------------------------------------------------
/pkg/platforms/matrix/incoming.go:
--------------------------------------------------------------------------------
1 | package matrix
2 |
3 | import (
4 | "context"
5 | "log"
6 | "time"
7 |
8 | "codeberg.org/jersey/lightning/pkg/lightning"
9 | "maunium.net/go/mautrix"
10 | "maunium.net/go/mautrix/event"
11 | "maunium.net/go/mautrix/format"
12 | )
13 |
14 | func matrixToLightningMessage(
15 | ctx context.Context,
16 | evt *event.Event,
17 | client *mautrix.Client,
18 | ) *lightning.Message {
19 | msg := evt.Content.AsMessage()
20 |
21 | if string(evt.Sender) == string(client.UserID) && msg.BeeperPerMessageProfile != nil {
22 | return nil
23 | }
24 |
25 | if msg.FormattedBody == "" {
26 | msg.FormattedBody = msg.Body
27 | }
28 |
29 | attachments := make([]lightning.Attachment, 0)
30 | content := ""
31 |
32 | if msg.FileName == msg.Body {
33 | url := getFile(client, string(msg.URL))
34 |
35 | attachments = append(attachments, lightning.Attachment{
36 | Name: msg.FileName,
37 | URL: url,
38 | Size: 0,
39 | })
40 | } else {
41 | msg.RemovePerMessageProfileFallback()
42 |
43 | content, _ = format.HTMLToMarkdownFull(nil, msg.FormattedBody)
44 | }
45 |
46 | return &lightning.Message{
47 | BaseMessage: lightning.BaseMessage{
48 | Time: time.UnixMilli(evt.Timestamp),
49 | EventID: string(evt.ID),
50 | ChannelID: string(evt.RoomID),
51 | },
52 | Attachments: attachments,
53 | Author: matrixToLightningAuthor(ctx, client, evt, msg),
54 | Content: content,
55 | RepliedTo: matrixToLightningReplies(msg),
56 | }
57 | }
58 |
59 | func matrixToLightningAuthor(
60 | ctx context.Context,
61 | client *mautrix.Client,
62 | evt *event.Event,
63 | msg *event.MessageEventContent,
64 | ) *lightning.MessageAuthor {
65 | defaultProfile, err := client.GetProfile(ctx, evt.Sender)
66 | if err != nil {
67 | log.Printf("matrix: failed to get default message profile: %v\n", err)
68 |
69 | if msg.BeeperPerMessageProfile == nil {
70 | return &lightning.MessageAuthor{
71 | ID: string(evt.Sender),
72 | Nickname: string(evt.Sender),
73 | Username: string(evt.Sender),
74 | ProfilePicture: "",
75 | Color: "#ffffff",
76 | }
77 | }
78 | }
79 |
80 | var profile string
81 |
82 | if err == nil {
83 | if !defaultProfile.AvatarURL.IsEmpty() {
84 | profile = getFile(client, "mxc://"+defaultProfile.AvatarURL.Homeserver+"/"+defaultProfile.AvatarURL.FileID)
85 | }
86 | }
87 |
88 | if msg.BeeperPerMessageProfile != nil {
89 | if msg.BeeperPerMessageProfile.AvatarURL != nil && *msg.BeeperPerMessageProfile.AvatarURL != "" {
90 | profile = getFile(client, string(*msg.BeeperPerMessageProfile.AvatarURL))
91 | }
92 |
93 | return &lightning.MessageAuthor{
94 | ID: string(evt.Sender),
95 | Nickname: msg.BeeperPerMessageProfile.Displayname,
96 | Username: defaultProfile.DisplayName,
97 | ProfilePicture: profile,
98 | Color: "#ffffff",
99 | }
100 | }
101 |
102 | return &lightning.MessageAuthor{
103 | ID: string(evt.Sender),
104 | Nickname: defaultProfile.DisplayName,
105 | Username: defaultProfile.DisplayName,
106 | ProfilePicture: profile,
107 | Color: "#ffffff",
108 | }
109 | }
110 |
111 | func matrixToLightningReplies(msg *event.MessageEventContent) []string {
112 | replyIDs := []string{}
113 |
114 | if msg.RelatesTo != nil && msg.RelatesTo.InReplyTo != nil {
115 | replyIDs = append(replyIDs, string(msg.RelatesTo.InReplyTo.EventID))
116 | }
117 |
118 | return replyIDs
119 | }
120 |
121 | func getFile(client *mautrix.Client, file string) string {
122 | if len(file) < 6 || file[:6] != "mxc://" {
123 | log.Printf("matrix: invalid MXC URL: %q", file)
124 |
125 | return ""
126 | }
127 |
128 | return client.HomeserverURL.JoinPath(
129 | "_matrix/media/r0/download",
130 | file[5:],
131 | ).String()
132 | }
133 |
--------------------------------------------------------------------------------
/internal/stoat/api.go:
--------------------------------------------------------------------------------
1 | // Package stoat provides functionality to deal with the Stoat API.
2 | package stoat
3 |
4 | import (
5 | "bytes"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "sync"
11 | "sync/atomic"
12 | "time"
13 |
14 | "codeberg.org/jersey/lightning/internal/cache"
15 | "codeberg.org/jersey/lightning/internal/workaround"
16 | "github.com/gorilla/websocket"
17 | )
18 |
19 | // Session represents a bot session on Stoat.
20 | type Session struct {
21 | MessageDeleted chan *MessageDeleteEvent
22 | conn *websocket.Conn
23 | Ready chan *ReadyEvent
24 | MessageCreated chan *Message
25 | MessageUpdated chan *MessageUpdateEvent
26 | Token string
27 | ChannelCache cache.Expiring[string, Channel]
28 | MemberCache cache.Expiring[string, Member]
29 | UserCache cache.Expiring[string, User]
30 | ServerEmojiCache cache.Expiring[string, []Emoji]
31 | EmojiCache cache.Expiring[string, Emoji]
32 | ServerCache cache.Expiring[string, Server]
33 | connected atomic.Bool
34 | lock sync.Mutex
35 | }
36 |
37 | // Get makes a request against the Stoat API.
38 | func Get[T any](session *Session, endpoint string, key string, cacher *cache.Expiring[string, T]) (*T, error) {
39 | if key != "" {
40 | if val, ok := cacher.Get(key); ok {
41 | return &val, nil
42 | }
43 | }
44 |
45 | body, code, err := session.Fetch(http.MethodGet, endpoint, nil, nil, map[string][]string{})
46 | if err != nil || code != 200 {
47 | return nil, fmt.Errorf("failed to fetch (%d): %w", code, err)
48 | }
49 |
50 | defer body.Close()
51 |
52 | var val T
53 |
54 | if err = json.NewDecoder(body).Decode(&val); err != nil {
55 | return nil, fmt.Errorf("failed to decode: %w", err)
56 | }
57 |
58 | if key != "" {
59 | cacher.Set(key, val)
60 | }
61 |
62 | return &val, nil
63 | }
64 |
65 | // Fetch returns a request body, status code, and/or possible error from the Stoat API.
66 | func (session *Session) Fetch(
67 | method, endpoint string, data any, base *string, headers map[string][]string,
68 | ) (io.ReadCloser, int, error) {
69 | if base == nil {
70 | defaultURL := "https://api.stoat.chat/0.8"
71 | base = &defaultURL
72 | }
73 |
74 | var body io.Reader
75 |
76 | if data != nil && headers["Content-Type"][0] == "application/json" {
77 | payload, err := json.Marshal(data)
78 | if err != nil {
79 | return nil, 0, fmt.Errorf("failed to marshal body: %w", err)
80 | }
81 |
82 | body = bytes.NewBuffer(payload)
83 | } else if reader, ok := data.(io.Reader); ok {
84 | body = reader
85 | }
86 |
87 | req, err := http.NewRequest(method, *base+endpoint, body)
88 | if err != nil {
89 | return nil, 0, fmt.Errorf("failed to create %s request for %s: %w", method, endpoint, err)
90 | }
91 |
92 | req.Header = headers
93 | req.Header["X-Bot-Token"] = []string{session.Token}
94 | req.Header["User-Agent"] = []string{"rvapi/0.8.0-rc.8"}
95 |
96 | resp, err := workaround.Do(req)
97 | if err != nil {
98 | return nil, 0, fmt.Errorf("failed to make %s request to %s: %w", method, endpoint, err)
99 | }
100 |
101 | if method != http.MethodGet && resp.StatusCode == http.StatusTooManyRequests {
102 | return handleRatelimiting(session, resp, method, endpoint, body)
103 | }
104 |
105 | return resp.Body, resp.StatusCode, nil
106 | }
107 |
108 | func handleRatelimiting(
109 | session *Session,
110 | resp *http.Response,
111 | method, endpoint string,
112 | body io.Reader,
113 | ) (io.ReadCloser, int, error) {
114 | retryAfter, ok := resp.Header["X-Ratelimit-Retry-After"]
115 |
116 | if !ok || len(retryAfter) == 0 {
117 | retryAfter = []string{"1000"}
118 | }
119 |
120 | retryAfterDuration, err := time.ParseDuration(retryAfter[0] + "ms")
121 | if err != nil {
122 | retryAfterDuration = time.Second
123 | }
124 |
125 | time.Sleep(retryAfterDuration)
126 |
127 | return session.Fetch(method, endpoint, body, nil, map[string][]string{})
128 | }
129 |
--------------------------------------------------------------------------------
/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 | "codeberg.org/jersey/lightning/internal/cache"
20 | "codeberg.org/jersey/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 | messageDeleted: make(chan *guildedChatMessageDeleted, 1000),
33 | messageCreated: make(chan *guildedChatMessageWrapper, 1000),
34 | messageUpdated: make(chan *guildedChatMessageWrapper, 1000),
35 | token: cfg["token"],
36 | }, token: cfg["token"]}
37 |
38 | plugin.assetsCache.TTL = assetCacheTTL
39 |
40 | if err := plugin.socket.connect(); err != nil {
41 | return nil, fmt.Errorf("guilded: failed to connect to socket: %w", err)
42 | }
43 |
44 | log.Println("guilded: ready")
45 |
46 | return plugin, nil
47 | }
48 |
49 | type guildedPlugin struct {
50 | socket *session
51 | token string
52 | assetsCache cache.Expiring[string, lightning.Attachment]
53 | membersCache cache.Expiring[string, guildedServerMember]
54 | webhookIDsCache cache.Expiring[string, bool]
55 | }
56 |
57 | func (*guildedPlugin) SetupChannel(_ string) (map[string]string, error) {
58 | return nil, &guildedShuttingDownError{}
59 | }
60 |
61 | func (p *guildedPlugin) SendCommandResponse(
62 | message *lightning.Message, opts *lightning.SendOptions, _ string,
63 | ) ([]string, error) {
64 | return p.SendMessage(message, opts)
65 | }
66 |
67 | func (*guildedPlugin) EditMessage(_ *lightning.Message, _ []string, _ *lightning.SendOptions) error {
68 | return nil
69 | }
70 |
71 | func (p *guildedPlugin) DeleteMessage(channel string, ids []string) error {
72 | for _, msgID := range ids {
73 | resp, err := guildedMakeRequest(p.token, "DELETE", "/channels/"+channel+"/messages/"+msgID, nil)
74 | if err != nil {
75 | return fmt.Errorf("guilded: failed to delete message: %w\n\tchannel %s\n\tmessage: %s", err, channel, msgID)
76 | }
77 |
78 | _ = resp.Body.Close()
79 | }
80 |
81 | return nil
82 | }
83 |
84 | func (*guildedPlugin) SetupCommands(_ map[string]*lightning.Command) error {
85 | return nil
86 | }
87 |
88 | func (p *guildedPlugin) ListenMessages() <-chan *lightning.Message {
89 | channel := make(chan *lightning.Message, 1000)
90 |
91 | go func() {
92 | for msg := range p.socket.messageCreated {
93 | if message := guildedToLightning(p, &msg.Message); message != nil {
94 | channel <- message
95 | }
96 | }
97 | }()
98 |
99 | return channel
100 | }
101 |
102 | func (p *guildedPlugin) ListenEdits() <-chan *lightning.EditedMessage {
103 | channel := make(chan *lightning.EditedMessage, 1000)
104 |
105 | go func() {
106 | for msg := range p.socket.messageUpdated {
107 | if message := guildedToLightning(p, &msg.Message); message != nil {
108 | channel <- &lightning.EditedMessage{Message: message, Edited: msg.Message.UpdatedAt}
109 | }
110 | }
111 | }()
112 |
113 | return channel
114 | }
115 |
116 | func (p *guildedPlugin) ListenDeletes() <-chan *lightning.BaseMessage {
117 | channel := make(chan *lightning.BaseMessage, 1000)
118 |
119 | go func() {
120 | for msg := range p.socket.messageDeleted {
121 | channel <- &lightning.BaseMessage{
122 | EventID: msg.Message.ID, ChannelID: msg.Message.ChannelID, Time: msg.DeletedAt,
123 | }
124 | }
125 | }()
126 |
127 | return channel
128 | }
129 |
130 | func (*guildedPlugin) ListenCommands() <-chan *lightning.CommandEvent {
131 | return nil
132 | }
133 |
--------------------------------------------------------------------------------
/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)
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 `json:"icon_url,omitempty"`
72 | IconURL string `json:"name,omitempty"`
73 | Name string `json:"url,omitempty"`
74 | }
75 |
76 | // EmbedField is a field on an [Embed].
77 | type EmbedField struct {
78 | Name string `json:"name"`
79 | Value string `json:"value"`
80 | Inline bool `json:"inline"`
81 | }
82 |
83 | // EmbedFooter is a footer on an [Embed].
84 | type EmbedFooter struct {
85 | IconURL string `json:"icon_url,omitempty"`
86 | Text string `json:"text"`
87 | }
88 |
89 | // Embed is a Discord-style embed.
90 | type Embed struct {
91 | Author *EmbedAuthor `json:"author,omitempty"`
92 | Footer *EmbedFooter `json:"footer,omitempty"`
93 | Image *Media `json:"image,omitempty"`
94 | Thumbnail *Media `json:"thumbnail,omitempty"`
95 | Video *Media `json:"video,omitempty"`
96 | Timestamp string `json:"timestamp,omitempty"`
97 | Title string `json:"title,omitempty"`
98 | URL string `json:"url,omitempty"`
99 | Description string `json:"description,omitempty"`
100 | Fields []EmbedField `json:"fields,omitempty"`
101 | Color int `json:"color,omitempty"`
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 `json:"url"`
114 | Height int `json:"height"`
115 | Width int `json:"width"`
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 map[string]string
142 | AllowEveryonePings bool
143 | }
144 |
--------------------------------------------------------------------------------
/internal/stoat/socket.go:
--------------------------------------------------------------------------------
1 | package stoat
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 (session *Session) Connect() error {
14 | if session.connected.Load() {
15 | return nil
16 | }
17 |
18 | conn, resp, err := websocket.DefaultDialer.Dial(
19 | "wss://events.stoat.chat/?version=1&format=json&token="+session.Token,
20 | map[string][]string{"User-Agent": {"rvapi/0.8.0-rc.8"}},
21 | )
22 | if err != nil {
23 | return fmt.Errorf("failed to dial: %w", err)
24 | }
25 |
26 | defer resp.Body.Close()
27 |
28 | session.conn = conn
29 | session.connected.Store(true)
30 |
31 | go ping(session)
32 | go readMessages(session)
33 |
34 | return nil
35 | }
36 |
37 | func ping(session *Session) {
38 | for session.connected.Load() && session.conn != nil {
39 | time.Sleep(10 * time.Second)
40 |
41 | session.lock.Lock()
42 |
43 | err := session.conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"Ping"}`))
44 | if err != nil {
45 | log.Printf("internal/stoat: error pinging: %v\n", err)
46 | }
47 |
48 | session.lock.Unlock()
49 | }
50 | }
51 |
52 | func readMessages(session *Session) {
53 | for session.connected.Load() && session.conn != nil {
54 | _, message, err := session.conn.ReadMessage()
55 | if err != nil {
56 | break
57 | }
58 |
59 | handleEvent(session, message)
60 | }
61 |
62 | session.connected.Store(false)
63 |
64 | if session.conn != nil {
65 | if err := session.conn.Close(); err != nil {
66 | log.Printf("internal/stoat: failed to close connection: %v\n", err)
67 | }
68 |
69 | session.conn = nil
70 | }
71 |
72 | go handleReconnect(session.Connect)
73 | }
74 |
75 | func handleReconnect(connect func() error) {
76 | attempt := 0
77 | backoff := 100 * time.Millisecond
78 |
79 | for {
80 | attempt++
81 |
82 | time.Sleep(backoff)
83 |
84 | if connect() == nil {
85 | return
86 | }
87 |
88 | backoff = min(time.Duration(float64(backoff)*1.5), time.Second)
89 |
90 | log.Printf("internal/stoat: trying reconnect #%d after %s\n", attempt, backoff.String())
91 | }
92 | }
93 |
94 | func handleEvent(session *Session, message []byte) {
95 | var data BaseEvent
96 | if err := json.Unmarshal(message, &data); err != nil {
97 | return
98 | }
99 |
100 | switch data.Type {
101 | case "Bulk":
102 | handleBulkEvent(session, message)
103 | case "Ready":
104 | handleReadyEvent(session, message)
105 | case "Message":
106 | handleGenericEvent(message, session.MessageCreated)
107 | case "MessageUpdate":
108 | handleGenericEvent(message, session.MessageUpdated)
109 | case "MessageDelete":
110 | handleGenericEvent(message, session.MessageDeleted)
111 | default:
112 | }
113 | }
114 |
115 | func handleBulkEvent(session *Session, message []byte) {
116 | var bulk BulkEvent
117 | if err := json.Unmarshal(message, &bulk); err != nil {
118 | return
119 | }
120 |
121 | for _, event := range bulk.V {
122 | handleEvent(session, event)
123 | }
124 | }
125 |
126 | func handleReadyEvent(session *Session, message []byte) {
127 | var ready ReadyEvent
128 | if err := json.Unmarshal(message, &ready); err != nil {
129 | return
130 | }
131 |
132 | session.Ready <- &ready
133 |
134 | for _, channel := range ready.Channels {
135 | session.ChannelCache.Set(channel.ID, channel)
136 | }
137 |
138 | for _, server := range ready.Servers {
139 | session.ServerCache.Set(server.ID, server)
140 | session.ServerEmojiCache.Set(server.ID, []Emoji{})
141 | }
142 |
143 | for _, user := range ready.Users {
144 | session.UserCache.Set(user.ID, user)
145 | }
146 |
147 | for _, member := range ready.Members {
148 | session.MemberCache.Set(member.ID.Server+"-"+member.ID.User, member)
149 | }
150 |
151 | for _, emoji := range ready.Emojis {
152 | session.EmojiCache.Set(emoji.ID, emoji)
153 |
154 | emojis, _ := session.ServerEmojiCache.Get(emoji.Parent.ID)
155 | session.ServerEmojiCache.Set(emoji.Parent.ID, append(emojis, emoji))
156 | }
157 | }
158 |
159 | func handleGenericEvent[T any](message json.RawMessage, channel chan *T) {
160 | var decoded T
161 | if err := json.Unmarshal(message, &decoded); err != nil {
162 | return
163 | }
164 |
165 | channel <- &decoded
166 | }
167 |
--------------------------------------------------------------------------------
/pkg/platforms/telegram/incoming.go:
--------------------------------------------------------------------------------
1 | package telegram
2 |
3 | import (
4 | "strconv"
5 | "time"
6 |
7 | "codeberg.org/jersey/lightning/pkg/lightning"
8 | "github.com/PaulSonOfLars/gotgbot/v2"
9 | "github.com/PaulSonOfLars/gotgbot/v2/ext"
10 | )
11 |
12 | func telegramToLightningMessage(bot *gotgbot.Bot, ctx *ext.Context, proxyPath string) lightning.Message {
13 | msg := lightning.Message{
14 | Author: &lightning.MessageAuthor{
15 | ID: strconv.FormatInt(ctx.EffectiveSender.Id(), 10),
16 | Nickname: ctx.EffectiveSender.Name(),
17 | Username: ctx.EffectiveSender.Username(),
18 | ProfilePicture: telegramToLightningProfilePicture(bot, ctx, proxyPath),
19 | Color: "#24A1DE",
20 | },
21 | BaseMessage: lightning.BaseMessage{
22 | EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10),
23 | ChannelID: strconv.FormatInt(ctx.EffectiveChat.Id, 10),
24 | Time: time.UnixMilli(ctx.EffectiveMessage.Date * 1000),
25 | },
26 | }
27 |
28 | if ctx.EffectiveMessage.ReplyToMessage != nil {
29 | msg.RepliedTo = append(msg.RepliedTo, strconv.FormatInt(ctx.EffectiveMessage.ReplyToMessage.GetMessageId(), 10))
30 | }
31 |
32 | switch {
33 | case ctx.EffectiveMessage.Text != "":
34 | msg.Content = ctx.EffectiveMessage.Text
35 | case ctx.EffectiveMessage.Dice != nil:
36 | msg.Content = ctx.EffectiveMessage.Dice.Emoji + " " + strconv.FormatInt(ctx.EffectiveMessage.Dice.Value, 10)
37 | case ctx.EffectiveMessage.Location != nil:
38 | msg.Content = "https://www.openstreetmap.org/#map=18/" +
39 | strconv.FormatFloat(ctx.EffectiveMessage.Location.Latitude, 'f', 6, 64) + "/" +
40 | strconv.FormatFloat(ctx.EffectiveMessage.Location.Longitude, 'f', 6, 64)
41 | case ctx.EffectiveMessage.Caption != "" || len(ctx.EffectiveMessage.NewChatPhoto) != 0:
42 | msg.Content = ctx.EffectiveMessage.Caption
43 |
44 | fileID, fileName := getFileDetails(ctx)
45 |
46 | if f, err := bot.GetFile(fileID, nil); err == nil {
47 | msg.Attachments = append(msg.Attachments, lightning.Attachment{
48 | URL: proxyPath + f.FilePath,
49 | Name: fileName,
50 | Size: f.FileSize,
51 | })
52 | }
53 | default:
54 | }
55 |
56 | return msg
57 | }
58 |
59 | func telegramToLightningProfilePicture(bot *gotgbot.Bot, ctx *ext.Context, proxyPath string) string {
60 | var fileID string
61 |
62 | switch {
63 | case ctx.EffectiveChat != nil:
64 | chat, err := ctx.EffectiveChat.Get(bot, nil)
65 | if err != nil || chat.Photo == nil {
66 | return ""
67 | }
68 |
69 | fileID = chat.Photo.BigFileId
70 | case ctx.EffectiveUser != nil:
71 | pics, err := ctx.EffectiveUser.GetProfilePhotos(bot, nil)
72 | if err != nil || pics.TotalCount <= 0 {
73 | return ""
74 | }
75 |
76 | fileID = getBestPhoto(pics.Photos[0])
77 | default:
78 | return ""
79 | }
80 |
81 | if f, err := bot.GetFile(fileID, nil); err == nil {
82 | return proxyPath + f.FilePath
83 | }
84 |
85 | return ""
86 | }
87 |
88 | func getFileDetails(ctx *ext.Context) (string, string) { //nolint:revive,cyclop
89 | switch {
90 | case len(ctx.EffectiveMessage.NewChatPhoto) != 0:
91 | return getBestPhoto(ctx.EffectiveMessage.NewChatPhoto), "photo.jpg"
92 | case len(ctx.EffectiveMessage.Photo) != 0:
93 | return getBestPhoto(ctx.EffectiveMessage.Photo), "photo.jpg"
94 | case ctx.EffectiveMessage.Document != nil:
95 | return ctx.EffectiveMessage.Document.FileId, ctx.EffectiveMessage.Document.FileName
96 | case ctx.EffectiveMessage.Animation != nil:
97 | return ctx.EffectiveMessage.Animation.FileId, ctx.EffectiveMessage.Animation.FileName
98 | case ctx.EffectiveMessage.Audio != nil:
99 | return ctx.EffectiveMessage.Audio.FileId, ctx.EffectiveMessage.Audio.FileName
100 | case ctx.EffectiveMessage.Sticker != nil:
101 | return ctx.EffectiveMessage.Sticker.FileId, ctx.ChannelPost.Sticker.SetName +
102 | getStickerExtension(ctx.ChannelPost.Sticker)
103 | case ctx.EffectiveMessage.Video != nil:
104 | return ctx.EffectiveMessage.Video.FileId, ctx.EffectiveMessage.Video.FileName
105 | case ctx.EffectiveMessage.VideoNote != nil:
106 | return ctx.EffectiveMessage.VideoNote.FileId, ctx.EffectiveMessage.VideoNote.FileId + ".mp4"
107 | case ctx.EffectiveMessage.Voice != nil:
108 | return ctx.EffectiveMessage.Voice.FileId, ctx.EffectiveMessage.Voice.FileId + ".ogg"
109 | default:
110 | return "", ""
111 | }
112 | }
113 |
114 | func getBestPhoto(size []gotgbot.PhotoSize) string {
115 | var bestPhoto *gotgbot.PhotoSize
116 |
117 | for _, photo := range size {
118 | if bestPhoto == nil || photo.Width > bestPhoto.Width {
119 | bestPhoto = &photo
120 | }
121 | }
122 |
123 | return bestPhoto.FileId
124 | }
125 |
126 | func getStickerExtension(sticker *gotgbot.Sticker) string {
127 | if sticker.IsAnimated {
128 | return ".tgs"
129 | } else if sticker.IsVideo {
130 | return ".webm"
131 | }
132 |
133 | return ".webp"
134 | }
135 |
--------------------------------------------------------------------------------
/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 | "log"
18 | "time"
19 |
20 | "codeberg.org/jersey/lightning/internal/cache"
21 | "codeberg.org/jersey/lightning/pkg/lightning"
22 | "maunium.net/go/mautrix"
23 | "maunium.net/go/mautrix/event"
24 | "maunium.net/go/mautrix/id"
25 | )
26 |
27 | // New creates a new [lightning.Plugin] that provides Matrix support for Lightning
28 | //
29 | // It only takes in a map with the following structure:
30 | //
31 | // map[string]string{
32 | // "homeserver": "", // a string with your Matrix homeserver URL
33 | // "password": "", // a string with your Matrix bot password
34 | // "recovery_key": "", // a string with your Matrix bot recovery key
35 | // "username": "", // a string with your Matrix bot username
36 | // }
37 | func New(config map[string]string) (lightning.Plugin, error) {
38 | client, err := setupClient(config)
39 | if err != nil {
40 | return nil, err
41 | }
42 |
43 | syncer, ok := client.Syncer.(*mautrix.DefaultSyncer)
44 | if !ok {
45 | syncer = mautrix.NewDefaultSyncer()
46 | client.Syncer = syncer
47 | }
48 |
49 | msgChannel := make(chan *lightning.Message, 1000)
50 | editChannel := make(chan *lightning.EditedMessage, 1000)
51 |
52 | listenForEvents(syncer, client, msgChannel, editChannel)
53 |
54 | go func() {
55 | for {
56 | if err := client.Sync(); err != nil {
57 | log.Printf("matrix: sync stopped: %v, retrying...", err)
58 | }
59 | }
60 | }()
61 |
62 | return &matrixPlugin{client: client, syncer: syncer, msgChannel: msgChannel, editChannel: editChannel}, nil
63 | }
64 |
65 | type matrixPlugin struct {
66 | client *mautrix.Client
67 | syncer *mautrix.DefaultSyncer
68 | msgChannel chan *lightning.Message
69 | editChannel chan *lightning.EditedMessage
70 | mxcCache cache.Expiring[string, id.ContentURIString]
71 | }
72 |
73 | func (*matrixPlugin) SetupChannel(_ string) (map[string]string, error) {
74 | return nil, nil //nolint:nilnil // we don't need a value for ChannelData later
75 | }
76 |
77 | func (p *matrixPlugin) SendCommandResponse(
78 | message *lightning.Message,
79 | opts *lightning.SendOptions,
80 | _ string,
81 | ) ([]string, error) {
82 | return p.SendMessage(message, opts)
83 | }
84 |
85 | func (p *matrixPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) {
86 | ids := make([]string, 0, len(message.Attachments)+1)
87 |
88 | for _, msg := range p.lightningToMatrixMessage(message, nil, opts) {
89 | resp, err := p.client.SendMessageEvent(
90 | context.Background(), id.RoomID(message.ChannelID), event.EventMessage, msg, mautrix.ReqSendEvent{},
91 | )
92 | if err != nil {
93 | return nil, handleError(err, "failed to send matrix message")
94 | }
95 |
96 | ids = append(ids, string(resp.EventID))
97 | }
98 |
99 | return ids, nil
100 | }
101 |
102 | func (p *matrixPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error {
103 | for idx, msg := range p.lightningToMatrixMessage(message, ids, opts) {
104 | msg.RelatesTo.Type = "m.replace"
105 | msg.RelatesTo.EventID = id.EventID(ids[idx])
106 |
107 | _, err := p.client.SendMessageEvent(
108 | context.Background(), id.RoomID(message.ChannelID), event.EventMessage, msg, mautrix.ReqSendEvent{},
109 | )
110 | if err != nil {
111 | return handleError(err, "failed to edit matrix message")
112 | }
113 | }
114 |
115 | return nil
116 | }
117 |
118 | func (p *matrixPlugin) DeleteMessage(channel string, ids []string) error {
119 | for _, msgID := range ids {
120 | if _, err := p.client.RedactEvent(
121 | context.Background(), id.RoomID(channel), id.EventID(msgID), mautrix.ReqRedact{Reason: "deleted in bridge"},
122 | ); err != nil {
123 | return handleError(err, "Failed to redact Matrix message")
124 | }
125 | }
126 |
127 | return nil
128 | }
129 |
130 | func (*matrixPlugin) SetupCommands(_ map[string]*lightning.Command) error {
131 | return nil
132 | }
133 |
134 | func (p *matrixPlugin) ListenMessages() <-chan *lightning.Message {
135 | return p.msgChannel
136 | }
137 |
138 | func (p *matrixPlugin) ListenEdits() <-chan *lightning.EditedMessage {
139 | return p.editChannel
140 | }
141 |
142 | func (p *matrixPlugin) ListenDeletes() <-chan *lightning.BaseMessage {
143 | channel := make(chan *lightning.BaseMessage, 1000)
144 |
145 | p.syncer.OnEventType(event.EventRedaction, func(_ context.Context, evt *event.Event) {
146 | channel <- &lightning.BaseMessage{
147 | Time: time.UnixMilli(evt.Timestamp),
148 | EventID: string(evt.Content.AsRedaction().Redacts),
149 | ChannelID: string(evt.RoomID),
150 | }
151 | })
152 |
153 | return channel
154 | }
155 |
156 | func (*matrixPlugin) ListenCommands() <-chan *lightning.CommandEvent {
157 | return nil
158 | }
159 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # lightning: _truly powerful_ cross-platform bots
2 |
3 | 
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://codeberg.org/jersey/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 |
--------------------------------------------------------------------------------
/pkg/platforms/stoat/outgoing.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import (
4 | "log"
5 | "regexp"
6 | "strconv"
7 | "strings"
8 |
9 | "codeberg.org/jersey/lightning/internal/emoji"
10 | "codeberg.org/jersey/lightning/internal/stoat"
11 | "codeberg.org/jersey/lightning/pkg/lightning"
12 | )
13 |
14 | func lightningToStoatMessage(
15 | session *stoat.Session,
16 | message *lightning.Message,
17 | opts *lightning.SendOptions,
18 | ) stoat.DataMessageSend {
19 | content := stoatOutgoingSpoilerRegex.ReplaceAllStringFunc(stoatOutgoingEmojiRegex.ReplaceAllStringFunc(
20 | message.Content, func(match string) string { return replaceStoatOutgoingEmoji(session, message, match) }),
21 | func(match string) string { return "!!" + match[2:len(match)-2] + "!!" },
22 | )
23 |
24 | if opts != nil && !opts.AllowEveryonePings {
25 | content = strings.ReplaceAll(content, "@everyone", "@\u2800everyone")
26 | content = strings.ReplaceAll(content, "@online", "@\u2800online")
27 | }
28 |
29 | if len(content) > 2000 {
30 | content = content[:1997] + "..."
31 | }
32 |
33 | msg := stoat.DataMessageSend{
34 | Attachments: lightningToStoatAttachments(session, message.Attachments),
35 | Content: content,
36 | Embeds: lightningToStoatEmbeds(message.Embeds),
37 | Replies: lightningToStoatReplies(message.RepliedTo),
38 | }
39 |
40 | if len(content) == 0 && len(msg.Embeds) == 0 && len(msg.Attachments) == 0 {
41 | msg.Content = "\u200B"
42 | }
43 |
44 | if opts != nil && message.Author != nil {
45 | msg.Masquerade = lightningToStoatMasquerade(*message.Author)
46 | }
47 |
48 | return msg
49 | }
50 |
51 | var (
52 | stoatOutgoingEmojiRegex = regexp.MustCompile(`:\w+:`)
53 | stoatOutgoingSpoilerRegex = regexp.MustCompile(`\|\|(.+?)\|\|`)
54 | )
55 |
56 | func replaceStoatOutgoingEmoji(session *stoat.Session, message *lightning.Message, match string) string {
57 | if str, ok := emoji.GetEmoji(match); ok {
58 | return str
59 | }
60 |
61 | name := strings.ReplaceAll(match, ":", "")
62 |
63 | channel, err := stoat.Get(session, "/channels/"+message.ChannelID, message.ChannelID, &session.ChannelCache)
64 | if err == nil && channel.ChannelType == stoat.ChannelTypeText && channel.Server != nil {
65 | serverEmojis, err := stoat.Get(session, "/servers/"+*channel.Server+"/emojis", *channel.Server,
66 | &session.ServerEmojiCache)
67 | if err == nil {
68 | for _, instance := range *serverEmojis {
69 | if instance.Name == name {
70 | return ":" + instance.ID + ":"
71 | }
72 | }
73 | }
74 | }
75 |
76 | for _, e := range message.Emoji {
77 | if e.Name == name {
78 | return "[" + e.Name + "](" + e.URL + ")"
79 | }
80 | }
81 |
82 | return match
83 | }
84 |
85 | func lightningToStoatAttachments(session *stoat.Session, attachments []lightning.Attachment) []string {
86 | out := make([]string, 0, len(attachments))
87 | for _, att := range attachments {
88 | file, err := session.UploadFile("attachments", att.URL, att.Name)
89 | if err == nil {
90 | out = append(out, file.ID)
91 | } else {
92 | log.Printf("stoat: %v\n", err)
93 | }
94 | }
95 |
96 | return out
97 | }
98 |
99 | func lightningToStoatEmbeds(embeds []lightning.Embed) []stoat.SendableEmbed {
100 | out := make([]stoat.SendableEmbed, 0, len(embeds))
101 |
102 | for _, e := range embeds {
103 | out = append(out, lightningToStoatEmbed(e))
104 | }
105 |
106 | return out
107 | }
108 |
109 | func lightningToStoatEmbed(embed lightning.Embed) stoat.SendableEmbed {
110 | newEmbed := stoat.SendableEmbed{
111 | Title: embed.Title,
112 | Description: *stoatEmbedDescription(embed),
113 | }
114 |
115 | if embed.URL != "" {
116 | if len(embed.URL) > 256 {
117 | embed.URL = embed.URL[:256]
118 | }
119 |
120 | newEmbed.URL = embed.URL
121 | }
122 |
123 | if embed.Color != 0 {
124 | newEmbed.Colour = "#" + strconv.FormatInt(int64(embed.Color), 16)
125 | }
126 |
127 | setStoatEmbedMedia(&newEmbed, embed)
128 |
129 | return newEmbed
130 | }
131 |
132 | func stoatEmbedDescription(embed lightning.Embed) *string {
133 | if len(embed.Fields) == 0 {
134 | return &embed.Description
135 | }
136 |
137 | for _, field := range embed.Fields {
138 | if embed.Description != "" {
139 | embed.Description += "\n\n"
140 | }
141 |
142 | embed.Description += "**" + field.Name + "**\n" + field.Value
143 | }
144 |
145 | if embed.Description == "" {
146 | return nil
147 | }
148 |
149 | return &embed.Description
150 | }
151 |
152 | func setStoatEmbedMedia(sEmbed *stoat.SendableEmbed, embed lightning.Embed) {
153 | if embed.Image != nil {
154 | sEmbed.Media = embed.Image.URL
155 | }
156 |
157 | if embed.Video != nil {
158 | sEmbed.Media = embed.Video.URL
159 | }
160 |
161 | if embed.Thumbnail != nil && len(embed.Thumbnail.URL) > 0 && len(embed.Thumbnail.URL) <= 128 {
162 | sEmbed.IconURL = embed.Thumbnail.URL
163 | }
164 | }
165 |
166 | func lightningToStoatReplies(replyIDs []string) []stoat.ReplyIntent {
167 | replies := make([]stoat.ReplyIntent, len(replyIDs))
168 |
169 | for i, id := range replyIDs {
170 | replies[i] = stoat.ReplyIntent{
171 | ID: id,
172 | Mention: false,
173 | FailIfNotExists: false,
174 | }
175 | }
176 |
177 | return replies
178 | }
179 |
180 | func lightningToStoatMasquerade(author lightning.MessageAuthor) *stoat.Masquerade {
181 | if len(author.ProfilePicture) >= 256 {
182 | author.ProfilePicture = author.ProfilePicture[:256]
183 | }
184 |
185 | if len(author.Nickname) > 32 {
186 | author.Nickname = author.Nickname[:32]
187 | }
188 |
189 | return &stoat.Masquerade{
190 | Colour: author.Color,
191 | Name: author.Nickname,
192 | Avatar: author.ProfilePicture,
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/pkg/platforms/guilded/incoming.go:
--------------------------------------------------------------------------------
1 | package guilded
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "path"
10 | "regexp"
11 | "strconv"
12 | "strings"
13 | "time"
14 |
15 | "codeberg.org/jersey/lightning/pkg/lightning"
16 | )
17 |
18 | const assetCacheTTL = 24 * time.Hour
19 |
20 | var attachmentRegex = regexp.MustCompile(
21 | `!\[.*?\]\(https:\/\/cdn\.gldcdn\.com\/ContentMedia(?:GenericFiles)?\/.*\)`,
22 | )
23 |
24 | func guildedToLightning(plugin *guildedPlugin, msg *guildedChatMessage) *lightning.Message {
25 | if msg.ServerID == nil {
26 | return nil
27 | }
28 |
29 | if found, _ := plugin.webhookIDsCache.Get(msg.CreatedByWebhookID); found {
30 | return nil
31 | }
32 |
33 | attachmentMarkdown := attachmentRegex.FindAllString(msg.Content, -1)
34 |
35 | return &lightning.Message{
36 | BaseMessage: lightning.BaseMessage{
37 | EventID: msg.ID,
38 | ChannelID: msg.ChannelID,
39 | Time: msg.CreatedAt,
40 | },
41 | Author: guildedToLightningAuthor(plugin, msg),
42 | Content: attachmentRegex.ReplaceAllString(msg.Content, ""),
43 | Embeds: msg.Embeds,
44 | Attachments: guildedToLightningAttachments(plugin, attachmentMarkdown),
45 | RepliedTo: msg.ReplyMessageIDs,
46 | }
47 | }
48 |
49 | func guildedToLightningAuthor(plugin *guildedPlugin, msg *guildedChatMessage) *lightning.MessageAuthor {
50 | var cacheKey string
51 |
52 | var endpoint string
53 |
54 | if msg.CreatedByWebhookID == "" {
55 | cacheKey = *msg.ServerID + "/" + msg.CreatedBy
56 | endpoint = "/servers/" + *msg.ServerID + "/members/" + msg.CreatedBy
57 | } else {
58 | cacheKey = *msg.ServerID + "/" + msg.CreatedByWebhookID
59 | endpoint = "/servers/" + *msg.ServerID + "/webhooks/" + msg.CreatedByWebhookID
60 | }
61 |
62 | if cachedMember, ok := plugin.membersCache.Get(cacheKey); ok {
63 | return toAuthor(cachedMember.User, cachedMember.Nickname)
64 | }
65 |
66 | var responseBody struct {
67 | Member *guildedServerMember `json:"member,omitempty"`
68 | Webhook *guildedUser `json:"webhook,omitempty"`
69 | }
70 |
71 | if err := doJSONRequest(plugin.token, http.MethodGet, endpoint, nil, &responseBody); err != nil {
72 | return &lightning.MessageAuthor{
73 | Nickname: "Guilded User",
74 | Username: "GuildedUser",
75 | ID: msg.CreatedBy,
76 | Color: "#f8d64c",
77 | }
78 | }
79 |
80 | if responseBody.Member != nil {
81 | plugin.membersCache.Set(cacheKey, *responseBody.Member)
82 |
83 | return toAuthor(responseBody.Member.User, responseBody.Member.Nickname)
84 | }
85 |
86 | plugin.membersCache.Set(cacheKey, guildedServerMember{User: *responseBody.Webhook})
87 |
88 | return toAuthor(*responseBody.Webhook, nil)
89 | }
90 |
91 | func toAuthor(user guildedUser, nickname *string) *lightning.MessageAuthor {
92 | displayName := user.Name
93 | if nickname != nil {
94 | displayName = *nickname
95 | }
96 |
97 | return &lightning.MessageAuthor{
98 | Nickname: displayName,
99 | Username: user.Name,
100 | ID: user.ID,
101 | ProfilePicture: user.Avatar,
102 | Color: "#f8d64c",
103 | }
104 | }
105 |
106 | func guildedToLightningAttachments(plugin *guildedPlugin, markdownLinks []string) []lightning.Attachment {
107 | attachments := make([]lightning.Attachment, 0, len(markdownLinks))
108 |
109 | for _, mdLink := range markdownLinks {
110 | url := extractURLFromMarkdown(mdLink)
111 | if url == "" {
112 | continue
113 | }
114 |
115 | if cachedAttachment, ok := plugin.assetsCache.Get(url); ok {
116 | attachments = append(attachments, cachedAttachment)
117 |
118 | continue
119 | }
120 |
121 | sig := getSignature(url, plugin.token)
122 | if sig == nil || len(sig.URLSignatures) == 0 || sig.URLSignatures[0].Signature == nil {
123 | continue
124 | }
125 |
126 | urlSignature := *sig.URLSignatures[0].Signature
127 | attachment := lightning.Attachment{
128 | Name: path.Base(strings.SplitN(urlSignature, "?", 2)[0]),
129 | URL: urlSignature,
130 | Size: getContentLength(urlSignature),
131 | }
132 |
133 | plugin.assetsCache.Set(url, attachment)
134 | attachments = append(attachments, attachment)
135 | }
136 |
137 | return attachments
138 | }
139 |
140 | func extractURLFromMarkdown(markdown string) string {
141 | start, end := strings.LastIndex(markdown, "("), strings.LastIndex(markdown, ")")
142 | if start >= 0 && end > start {
143 | return markdown[start+1 : end]
144 | }
145 |
146 | return ""
147 | }
148 |
149 | func getContentLength(url string) int64 {
150 | req, err := http.NewRequest(http.MethodHead, url, nil)
151 | if err != nil {
152 | return 0
153 | }
154 |
155 | resp, err := http.DefaultClient.Do(req)
156 | if err != nil {
157 | return 0
158 | }
159 | defer resp.Body.Close()
160 |
161 | contentLength, _ := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
162 |
163 | return contentLength
164 | }
165 |
166 | func getSignature(url, token string) *guildedURLSignatureResponse {
167 | var signatureResponse guildedURLSignatureResponse
168 | if err := doJSONRequest(token, http.MethodPost, "/url-signatures",
169 | map[string][]string{"urls": {url}}, &signatureResponse); err != nil {
170 | return nil
171 | }
172 |
173 | return &signatureResponse
174 | }
175 |
176 | func doJSONRequest(token, method, endpoint string, body, out any) error {
177 | var buf io.Reader
178 |
179 | if body != nil {
180 | b, err := json.Marshal(body)
181 | if err != nil {
182 | return fmt.Errorf("failed to marshal: %w", err)
183 | }
184 |
185 | buf = bytes.NewReader(b)
186 | }
187 |
188 | resp, err := guildedMakeRequest(token, method, endpoint, buf)
189 | if err != nil {
190 | return err
191 | }
192 |
193 | defer resp.Body.Close()
194 |
195 | if err = json.NewDecoder(resp.Body).Decode(out); err != nil {
196 | return fmt.Errorf("failed to decode: %w", err)
197 | }
198 |
199 | return nil
200 | }
201 |
--------------------------------------------------------------------------------
/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 | "slices"
19 | "time"
20 |
21 | "codeberg.org/jersey/lightning/internal/stoat"
22 | "codeberg.org/jersey/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: &stoat.Session{
34 | MessageDeleted: make(chan *stoat.MessageDeleteEvent, 1000),
35 | MessageCreated: make(chan *stoat.Message, 1000),
36 | MessageUpdated: make(chan *stoat.MessageUpdateEvent, 1000),
37 | Ready: make(chan *stoat.ReadyEvent, 100),
38 | Token: cfg["token"],
39 | }}
40 |
41 | var err error
42 |
43 | plugin.self, err = stoat.Get(plugin.session, "/users/@me", "@me", &plugin.session.UserCache)
44 | if err != nil {
45 | return nil, fmt.Errorf("failed to get self: %w", err)
46 | }
47 |
48 | go func() {
49 | for ready := range plugin.session.Ready {
50 | log.Printf("stoat: ready as %s in %d servers!\n", plugin.self.Username, len(ready.Servers))
51 | }
52 | }()
53 |
54 | if err = plugin.session.Connect(); err != nil {
55 | return nil, fmt.Errorf("stoat: failed to connect to socket: %w", err)
56 | }
57 |
58 | return plugin, nil
59 | }
60 |
61 | type stoatPlugin struct {
62 | self *stoat.User
63 | session *stoat.Session
64 | }
65 |
66 | const correctPermissionValue = stoat.PermissionManageCustomization | stoat.PermissionManageRole |
67 | stoat.PermissionChangeNickname | stoat.PermissionChangeAvatar | stoat.PermissionViewChannel |
68 | stoat.PermissionReadMessageHistory | stoat.PermissionSendMessage | stoat.PermissionManageMessages |
69 | stoat.PermissionInviteOthers | stoat.PermissionSendEmbeds | stoat.PermissionUploadFiles |
70 | stoat.PermissionMasquerade | stoat.PermissionReact
71 |
72 | func (p *stoatPlugin) SetupChannel(channel string) (map[string]string, error) {
73 | channelData, err := stoat.Get(p.session, "/channels/"+channel, channel, &p.session.ChannelCache)
74 | if err != nil {
75 | return nil, fmt.Errorf("failed to get channel: %w", err)
76 | }
77 |
78 | needed := correctPermissionValue
79 |
80 | if channelData.ChannelType == stoat.ChannelTypeGroup {
81 | needed &= ^stoat.PermissionManageCustomization
82 | needed &= ^stoat.PermissionManageRole
83 | needed &= ^stoat.PermissionChangeNickname
84 | needed &= ^stoat.PermissionChangeAvatar
85 | }
86 |
87 | permissions := p.session.GetPermissions(p.self, channelData)
88 |
89 | if permissions&needed == needed {
90 | return nil, nil //nolint:nilnil // we don't need a value for ChannelData later
91 | }
92 |
93 | return nil, &stoatPermissionsError{permissions, needed}
94 | }
95 |
96 | func (p *stoatPlugin) SendCommandResponse(
97 | message *lightning.Message,
98 | opts *lightning.SendOptions,
99 | user string,
100 | ) ([]string, error) {
101 | channel, err := stoat.Get(p.session, "/users/"+user+"/dm", "", &p.session.ChannelCache)
102 | if err != nil {
103 | return nil, fmt.Errorf("failed to get dm channel: %w", err)
104 | }
105 |
106 | message.ChannelID = channel.ID
107 |
108 | return p.SendMessage(message, opts)
109 | }
110 |
111 | func (p *stoatPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) {
112 | msg := lightningToStoatMessage(p.session, message, opts)
113 | leftover := make([]string, 0)
114 |
115 | if len(msg.Attachments) > 5 {
116 | leftover = msg.Attachments[5:]
117 | msg.Attachments = msg.Attachments[:5]
118 | }
119 |
120 | res, err := p.stoatSendMessage(message.ChannelID, msg)
121 | if err != nil {
122 | return nil, err
123 | }
124 |
125 | ids := []string{res}
126 |
127 | if len(leftover) == 0 {
128 | return ids, nil
129 | }
130 |
131 | for chunk := range slices.Chunk(leftover, 5) {
132 | res, err := p.stoatSendMessage(message.ChannelID, stoat.DataMessageSend{
133 | Attachments: chunk,
134 | Masquerade: msg.Masquerade,
135 | Replies: msg.Replies,
136 | })
137 | if err != nil {
138 | log.Printf("failed to send leftover attachments: %v\n\tleftover: %#+v\n", err, leftover)
139 | } else {
140 | ids = append(ids, res)
141 | }
142 | }
143 |
144 | return ids, nil
145 | }
146 |
147 | func (*stoatPlugin) SetupCommands(_ map[string]*lightning.Command) error {
148 | return nil
149 | }
150 |
151 | func (p *stoatPlugin) ListenMessages() <-chan *lightning.Message {
152 | channel := make(chan *lightning.Message, 1000)
153 |
154 | go func() {
155 | for m := range p.session.MessageCreated {
156 | if msg := stoatToLightningMessage(p.session, p.self.ID, m); msg != nil {
157 | channel <- msg
158 | }
159 | }
160 | }()
161 |
162 | return channel
163 | }
164 |
165 | func (p *stoatPlugin) ListenEdits() <-chan *lightning.EditedMessage {
166 | channel := make(chan *lightning.EditedMessage, 1000)
167 |
168 | go func() {
169 | for m := range p.session.MessageUpdated {
170 | if msg := stoatToLightningMessage(p.session, p.self.ID, &m.Data); msg != nil {
171 | channel <- &lightning.EditedMessage{Message: msg, Edited: m.Data.Edited}
172 | }
173 | }
174 | }()
175 |
176 | return channel
177 | }
178 |
179 | func (p *stoatPlugin) ListenDeletes() <-chan *lightning.BaseMessage {
180 | channel := make(chan *lightning.BaseMessage, 1000)
181 |
182 | go func() {
183 | for m := range p.session.MessageDeleted {
184 | channel <- &lightning.BaseMessage{EventID: m.ID, ChannelID: m.Channel, Time: time.Now()}
185 | }
186 | }()
187 |
188 | return channel
189 | }
190 |
191 | func (*stoatPlugin) ListenCommands() <-chan *lightning.CommandEvent {
192 | return nil
193 | }
194 |
--------------------------------------------------------------------------------
/internal/stoat/permissions.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import "time"
4 |
5 | // GetPermissions returns the permissions for the user in the given channel.
6 | func (session *Session) GetPermissions(user *User, channel *Channel) Permission {
7 | switch channel.ChannelType {
8 | case ChannelTypeDM:
9 | return session.calculateUserPermissions(user, channel)
10 | case ChannelTypeGroup:
11 | if channel.Owner != nil && *channel.Owner == user.ID {
12 | return PermissionAll
13 | }
14 |
15 | if channel.Permissions == nil {
16 | return PermissionSet1
17 | }
18 |
19 | return *channel.Permissions
20 | case ChannelTypeSavedMessages:
21 | return PermissionAll
22 | case ChannelTypeText, ChannelTypeVoice:
23 | return session.calculateServerPermissions(channel, user)
24 | default:
25 | return 0
26 | }
27 | }
28 |
29 | func (session *Session) calculateUserPermissions(self *User, channel *Channel) Permission {
30 | userID := ""
31 |
32 | for _, recipient := range channel.Recipients {
33 | if recipient != self.ID {
34 | userID = recipient
35 |
36 | break
37 | }
38 | }
39 |
40 | if userID == "" {
41 | return PermissionSet3
42 | }
43 |
44 | recipient, err := Get(session, "/users/"+userID, userID, &session.UserCache)
45 | if err != nil {
46 | return PermissionSet3
47 | }
48 |
49 | if recipient.Relationship == RelationshipFriend || recipient.Relationship == RelationshipUser {
50 | return PermissionSet1
51 | }
52 |
53 | return PermissionSet3
54 | }
55 |
56 | func (session *Session) calculateServerPermissions(channel *Channel, user *User) Permission {
57 | if channel.Server == nil {
58 | return 0
59 | }
60 |
61 | server, err := Get(session, "/servers/"+*channel.Server, *channel.Server, &session.ServerCache)
62 | if err != nil {
63 | return 0
64 | }
65 |
66 | if server.Owner == user.ID {
67 | return PermissionAll
68 | }
69 |
70 | member, err := Get(session, "/servers/"+server.ID+"/members/"+user.ID, server.ID+"-"+user.ID, &session.MemberCache)
71 | if err != nil {
72 | return 0
73 | }
74 |
75 | return getMemberPermissions(member, server, channel)
76 | }
77 |
78 | func getMemberPermissions(member *Member, server *Server, channel *Channel) Permission {
79 | if server.Owner == member.ID.User {
80 | return PermissionAll
81 | }
82 |
83 | permissions := server.DefaultPermissions
84 |
85 | for _, roleID := range member.Roles {
86 | role, ok := server.Roles[roleID]
87 | if ok {
88 | permissions |= role.Permissions.Allow
89 | permissions &^= role.Permissions.Deny
90 | }
91 | }
92 |
93 | if channel.DefaultPerms != nil {
94 | permissions |= channel.DefaultPerms.Allow
95 | permissions &^= channel.DefaultPerms.Deny
96 | }
97 |
98 | for _, roleID := range member.Roles {
99 | role, ok := channel.RolePermissions[roleID]
100 | if ok {
101 | permissions |= role.Allow
102 | permissions &^= role.Deny
103 | }
104 | }
105 |
106 | if member.Timeout.After(time.Now()) {
107 | permissions &= PermissionSet3
108 | }
109 |
110 | return permissions
111 | }
112 |
113 | // Permission type.
114 | type Permission uint64
115 |
116 | // Individual permission flags.
117 | const (
118 | PermissionManageChannel Permission = 1 << iota
119 | PermissionManageServer
120 | PermissionManagePermissions
121 | PermissionManageRole
122 | PermissionManageCustomization
123 | PermissionKickMembers Permission = 1 << (iota + 1)
124 | PermissionBanMembers
125 | PermissionTimeoutMembers
126 | PermissionAssignRoles
127 | PermissionChangeNickname
128 | PermissionManageNicknames
129 | PermissionChangeAvatar
130 | PermissionRemoveAvatars
131 | PermissionViewChannel Permission = 1 << (iota + 7)
132 | PermissionReadMessageHistory
133 | PermissionSendMessage
134 | PermissionManageMessages
135 | PermissionManageWebhooks
136 | PermissionInviteOthers
137 | PermissionSendEmbeds
138 | PermissionUploadFiles
139 | PermissionMasquerade
140 | PermissionReact
141 | PermissionConnect
142 | PermissionSpeak
143 | PermissionVideo
144 | PermissionMuteMembers
145 | PermissionDeafenMembers
146 | PermissionMoveMembers
147 | PermissionListen
148 | PermissionMentionEveryone
149 | PermissionMentionRoles
150 |
151 | PermissionAll Permission = 0x000F_FFFF_FFFF_FFFF
152 | PermissionSet1 Permission = PermissionSet3 | PermissionSendMessage |
153 | PermissionManageChannel | PermissionConnect | PermissionSendEmbeds | PermissionInviteOthers |
154 | PermissionUploadFiles
155 | PermissionSet2 Permission = PermissionSet1 | PermissionChangeNickname | PermissionChangeAvatar
156 | PermissionSet3 Permission = PermissionViewChannel | PermissionReadMessageHistory
157 | )
158 |
159 | // PermissionNames is a map of individual permission names.
160 | var PermissionNames = map[Permission]string{ //nolint:gochecknoglobals
161 | PermissionManageChannel: "Manage Channel",
162 | PermissionManageServer: "Manage Server",
163 | PermissionManagePermissions: "Manage Permissions",
164 | PermissionManageRole: "Manage Role",
165 | PermissionManageCustomization: "Manage Customization",
166 | PermissionKickMembers: "Kick Members",
167 | PermissionBanMembers: "Ban Members",
168 | PermissionTimeoutMembers: "Timeout Members",
169 | PermissionAssignRoles: "Assign Roles",
170 | PermissionChangeNickname: "Change Nickname",
171 | PermissionManageNicknames: "Manage Nicknames",
172 | PermissionChangeAvatar: "Change Avatar",
173 | PermissionRemoveAvatars: "Remove Avatars",
174 | PermissionViewChannel: "View Channel",
175 | PermissionReadMessageHistory: "Read Message History",
176 | PermissionSendMessage: "Send Message",
177 | PermissionManageMessages: "Manage Messages",
178 | PermissionManageWebhooks: "Manage Webhooks",
179 | PermissionInviteOthers: "Invite Others",
180 | PermissionSendEmbeds: "Send Embeds",
181 | PermissionUploadFiles: "Upload Files",
182 | PermissionMasquerade: "Masquerade",
183 | PermissionReact: "React",
184 | PermissionConnect: "Connect",
185 | PermissionSpeak: "Speak",
186 | PermissionVideo: "Video",
187 | PermissionMuteMembers: "Mute Members",
188 | PermissionDeafenMembers: "Deafen Members",
189 | PermissionMoveMembers: "Move Members",
190 | }
191 |
--------------------------------------------------------------------------------
/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 | "codeberg.org/jersey/lightning/internal/cache"
21 | "codeberg.org/jersey/lightning/pkg/lightning"
22 | "github.com/bwmarrin/discordgo"
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 | // "base_url": "", // optional, allows you to specify a non-Discord API implementation
31 | // "cdn_url": "", // optional, allows you to specify a non-Discord CDN implementation
32 | // "token": "", // required, a string with your Discord bot token
33 | // }
34 | //
35 | // Note that you MUST enable the Message Content intent for the plugin to work.
36 | func New(cfg map[string]string) (lightning.Plugin, error) {
37 | if base, ok := cfg["base_url"]; ok {
38 | setBaseURL(base)
39 | }
40 |
41 | if cdn, ok := cfg["cdn_url"]; ok {
42 | setCDNURL(cdn)
43 | }
44 |
45 | session, err := discordgo.New("Bot " + cfg["token"])
46 | if err != nil {
47 | return nil, fmt.Errorf("failed to create Discord session: %w", err)
48 | }
49 |
50 | session.Identify.Intents |= discordgo.IntentMessageContent
51 | session.UserAgent += " lightning/" + lightning.VERSION
52 |
53 | if err = session.Open(); err != nil {
54 | return nil, fmt.Errorf("failed to open Discord session: %w", err)
55 | }
56 |
57 | log.Printf("discord: ready as %s in %d servers\n", session.State.User.DisplayName(), len(session.State.Guilds))
58 |
59 | return &discordPlugin{session: session}, nil
60 | }
61 |
62 | type discordPlugin struct {
63 | session *discordgo.Session
64 | webhooks cache.Expiring[string, bool]
65 | }
66 |
67 | func (p *discordPlugin) SetupChannel(channel string) (map[string]string, error) {
68 | wh, err := p.session.WebhookCreate(channel, channel, "")
69 | if err != nil {
70 | return nil, getError(err, "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.session.UserChannelCreate(user)
82 | if err != nil {
83 | return nil, getError(err, "Failed to create DM channel for "+user+" in command response")
84 | }
85 |
86 | message.ChannelID = channel.ID
87 | message.RepliedTo = nil
88 |
89 | return p.SendMessage(message, opts)
90 | }
91 |
92 | func (p *discordPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) {
93 | msgs := lightningToDiscordSendable(p.session, message, opts)
94 |
95 | if opts != nil {
96 | p.webhooks.Set(opts.ChannelData["id"], true)
97 |
98 | res, err := p.session.WebhookExecute(
99 | opts.ChannelData["id"], opts.ChannelData["token"], true, msgs[0].toWebhook(),
100 | )
101 | if err != nil {
102 | return nil, getError(err, "Failed to send message to Discord via webhook")
103 | }
104 |
105 | return []string{res.ID}, nil
106 | }
107 |
108 | res, err := p.session.ChannelMessageSendComplex(message.ChannelID, &msgs[0].MessageSend)
109 | if err == nil {
110 | return []string{res.ID}, nil
111 | }
112 |
113 | return nil, getError(err, "Failed to send message to Discord")
114 | }
115 |
116 | func (p *discordPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error {
117 | p.webhooks.Set(opts.ChannelData["id"], true)
118 |
119 | if len(ids) == 0 {
120 | return nil
121 | }
122 |
123 | msgs := lightningToDiscordSendable(p.session, message, opts)
124 |
125 | _, err := p.session.WebhookMessageEdit(opts.ChannelData["id"], opts.ChannelData["id"], ids[0],
126 | msgs[0].toWebhookEdit())
127 | if err == nil {
128 | return nil
129 | }
130 |
131 | err = getError(err, "Failed to edit message in Discord via webhook")
132 | if err != nil {
133 | return err
134 | }
135 |
136 | return nil
137 | }
138 |
139 | func (p *discordPlugin) DeleteMessage(channel string, ids []string) error {
140 | if err := p.session.ChannelMessagesBulkDelete(channel, ids); err != nil {
141 | if err = getError(err, "Failed to delete messages in Discord"); err != nil {
142 | return err
143 | }
144 | }
145 |
146 | return nil
147 | }
148 |
149 | func (p *discordPlugin) SetupCommands(command map[string]*lightning.Command) error {
150 | app, err := p.session.Application("@me")
151 | if err != nil {
152 | return getError(err, "failed to get application info for Discord commands")
153 | }
154 |
155 | _, err = p.session.ApplicationCommandBulkOverwrite(app.ID, "", lightningToDiscordCommands(command))
156 | if err != nil {
157 | return getError(err, "failed to setup Discord commands")
158 | }
159 |
160 | return nil
161 | }
162 |
163 | func (p *discordPlugin) ListenMessages() <-chan *lightning.Message {
164 | channel := make(chan *lightning.Message, 1000)
165 |
166 | p.session.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageCreate) {
167 | if msg := discordToLightning(&p.webhooks, p.session, m.Message); msg != nil {
168 | channel <- msg
169 | }
170 | })
171 |
172 | return channel
173 | }
174 |
175 | func (p *discordPlugin) ListenEdits() <-chan *lightning.EditedMessage {
176 | channel := make(chan *lightning.EditedMessage, 1000)
177 |
178 | p.session.AddHandler(func(_ *discordgo.Session, message *discordgo.MessageUpdate) {
179 | if msg := discordToLightning(&p.webhooks, p.session, message.Message); msg != nil {
180 | if message.EditedTimestamp == nil {
181 | now := time.Now()
182 | message.EditedTimestamp = &now
183 | }
184 |
185 | channel <- &lightning.EditedMessage{Message: msg, Edited: *message.EditedTimestamp}
186 | }
187 | })
188 |
189 | return channel
190 | }
191 |
192 | func (p *discordPlugin) ListenDeletes() <-chan *lightning.BaseMessage {
193 | channel := make(chan *lightning.BaseMessage, 1000)
194 |
195 | p.session.AddHandler(func(_ *discordgo.Session, m *discordgo.MessageDelete) {
196 | channel <- &lightning.BaseMessage{EventID: m.ID, ChannelID: m.ChannelID, Time: m.Timestamp}
197 | })
198 |
199 | return channel
200 | }
201 |
202 | func (p *discordPlugin) ListenCommands() <-chan *lightning.CommandEvent {
203 | channel := make(chan *lightning.CommandEvent, 1000)
204 |
205 | p.session.AddHandler(func(s *discordgo.Session, m *discordgo.InteractionCreate) {
206 | if cmd := discordToLightningCommand(s, m); cmd != nil {
207 | channel <- cmd
208 | }
209 | })
210 |
211 | return channel
212 | }
213 |
--------------------------------------------------------------------------------
/pkg/platforms/discord/outgoing.go:
--------------------------------------------------------------------------------
1 | package discord
2 |
3 | import (
4 | "regexp"
5 | "slices"
6 | "strings"
7 |
8 | "codeberg.org/jersey/lightning/internal/emoji"
9 | "codeberg.org/jersey/lightning/pkg/lightning"
10 | "github.com/bwmarrin/discordgo"
11 | )
12 |
13 | type discordSendable struct {
14 | discordgo.MessageSend
15 |
16 | Username string
17 | AvatarURL string
18 | }
19 |
20 | func lightningToDiscordSendable(
21 | session *discordgo.Session,
22 | msg *lightning.Message,
23 | opts *lightning.SendOptions,
24 | ) []discordSendable {
25 | toSend := []discordSendable{{
26 | MessageSend: discordgo.MessageSend{
27 | Content: lightningToDiscordContent(session, msg),
28 | Embeds: lightningToDiscordEmbeds(msg.Embeds),
29 | AllowedMentions: lightningToDiscordAllowedMentions(opts),
30 | Components: lightningToDiscordComponents(session, msg),
31 | Reference: lightningToDiscordReference(msg),
32 | Files: lightningToDiscordFiles(session, msg),
33 | },
34 | }}
35 |
36 | if msg.Author != nil {
37 | toSend[0].AvatarURL = msg.Author.ProfilePicture
38 | toSend[0].Username = msg.Author.Nickname
39 | }
40 |
41 | if len(toSend[0].Content) > 2000 {
42 | leftover := toSend[0].Content[2000:]
43 |
44 | toSend[0].Content = toSend[0].Content[:2000]
45 |
46 | for chunk := range slices.Chunk([]byte(leftover), 2000) {
47 | toSend = append(toSend, discordSendable{
48 | AvatarURL: toSend[0].AvatarURL,
49 | Username: toSend[0].Username,
50 | MessageSend: discordgo.MessageSend{Content: string(chunk)},
51 | })
52 | }
53 | }
54 |
55 | for i := range toSend {
56 | if toSend[i].Content == "" && len(toSend[i].Embeds) == 0 && len(toSend[i].Files) == 0 {
57 | toSend[i].Content = "_ _"
58 | }
59 | }
60 |
61 | return toSend
62 | }
63 |
64 | func (msg *discordSendable) toWebhook() *discordgo.WebhookParams {
65 | return &discordgo.WebhookParams{
66 | Content: msg.Content, Username: msg.Username, AvatarURL: msg.AvatarURL, TTS: msg.TTS, Files: msg.Files,
67 | Components: msg.Components, Embeds: msg.Embeds, AllowedMentions: msg.AllowedMentions, Flags: msg.Flags,
68 | }
69 | }
70 |
71 | func (msg *discordSendable) toWebhookEdit() *discordgo.WebhookEdit {
72 | return &discordgo.WebhookEdit{
73 | Content: &msg.Content, Components: &msg.Components, Embeds: &msg.Embeds,
74 | Files: msg.Files, AllowedMentions: msg.AllowedMentions,
75 | }
76 | }
77 |
78 | func (msg *discordSendable) toInteractionResponseData() *discordgo.InteractionResponseData {
79 | return &discordgo.InteractionResponseData{
80 | Content: msg.Content, TTS: msg.TTS, Files: msg.Files, Components: msg.Components, Embeds: msg.Embeds,
81 | AllowedMentions: msg.AllowedMentions, Flags: msg.Flags,
82 | }
83 | }
84 |
85 | var sendableEmojiRegex = regexp.MustCompile(`:\w+:`)
86 |
87 | func lightningToDiscordContent(session *discordgo.Session, msg *lightning.Message) string {
88 | return sendableEmojiRegex.ReplaceAllStringFunc(msg.Content, func(match string) string {
89 | if emoji, ok := emoji.GetEmoji(match); ok {
90 | return emoji
91 | }
92 |
93 | match = strings.ReplaceAll(match, ":", "")
94 |
95 | channel, err := session.State.Channel(msg.ChannelID)
96 | if err == nil {
97 | serverEmoji, err := session.GuildEmojis(channel.GuildID)
98 | if err == nil {
99 | for _, emoji := range serverEmoji {
100 | if emoji.Name == match {
101 | return emoji.MessageFormat()
102 | }
103 | }
104 | }
105 | }
106 |
107 | for _, emoji := range msg.Emoji {
108 | if emoji.Name == match {
109 | return "[" + emoji.Name + "](" + emoji.URL + ")"
110 | }
111 | }
112 |
113 | return match
114 | })
115 | }
116 |
117 | func lightningToDiscordEmbeds(src []lightning.Embed) []*discordgo.MessageEmbed {
118 | toImage := func(media *lightning.Media) *discordgo.MessageEmbedImage {
119 | if media == nil {
120 | return nil
121 | }
122 |
123 | return &discordgo.MessageEmbedImage{URL: media.URL, Width: media.Width, Height: media.Height}
124 | }
125 |
126 | toThumbnail := func(media *lightning.Media) *discordgo.MessageEmbedThumbnail {
127 | if media == nil {
128 | return nil
129 | }
130 |
131 | return &discordgo.MessageEmbedThumbnail{URL: media.URL, Width: media.Width, Height: media.Height}
132 | }
133 |
134 | embeds := make([]*discordgo.MessageEmbed, len(src))
135 | for idx, embed := range src {
136 | embeds[idx] = &discordgo.MessageEmbed{
137 | URL: embed.URL, Title: embed.Title, Description: embed.Description, Timestamp: embed.Timestamp,
138 | Color: embed.Color, Image: toImage(embed.Image), Thumbnail: toThumbnail(embed.Image),
139 | Video: func() *discordgo.MessageEmbedVideo {
140 | if embed.Video == nil {
141 | return nil
142 | }
143 |
144 | return &discordgo.MessageEmbedVideo{
145 | URL: embed.Video.URL, Width: embed.Video.Width, Height: embed.Video.Height,
146 | }
147 | }(),
148 | Footer: func() *discordgo.MessageEmbedFooter {
149 | if embed.Footer == nil {
150 | return nil
151 | }
152 |
153 | return &discordgo.MessageEmbedFooter{Text: embed.Footer.Text, IconURL: embed.Footer.IconURL}
154 | }(),
155 | Author: func() *discordgo.MessageEmbedAuthor {
156 | if embed.Author == nil {
157 | return nil
158 | }
159 |
160 | return &discordgo.MessageEmbedAuthor{
161 | Name: embed.Author.Name, URL: embed.Author.URL, IconURL: embed.Author.IconURL,
162 | }
163 | }(),
164 | Fields: func() []*discordgo.MessageEmbedField {
165 | out := make([]*discordgo.MessageEmbedField, len(embed.Fields))
166 |
167 | for i, f := range embed.Fields {
168 | out[i] = &discordgo.MessageEmbedField{Name: f.Name, Value: f.Value, Inline: f.Inline}
169 | }
170 |
171 | return out
172 | }(),
173 | }
174 | }
175 |
176 | return embeds
177 | }
178 |
179 | func lightningToDiscordAllowedMentions(opts *lightning.SendOptions) *discordgo.MessageAllowedMentions {
180 | if opts == nil || opts.AllowEveryonePings {
181 | return nil
182 | }
183 |
184 | return &discordgo.MessageAllowedMentions{
185 | Parse: []discordgo.AllowedMentionType{discordgo.AllowedMentionTypeRoles, discordgo.AllowedMentionTypeUsers},
186 | }
187 | }
188 |
189 | func lightningToDiscordComponents(session *discordgo.Session, msg *lightning.Message) []discordgo.MessageComponent {
190 | if len(msg.RepliedTo) == 0 {
191 | return nil
192 | }
193 |
194 | replyMessage, err := session.State.Message(msg.ChannelID, msg.RepliedTo[0])
195 | if err != nil {
196 | return nil
197 | }
198 |
199 | return []discordgo.MessageComponent{discordgo.Button{
200 | Label: "reply to " + replyMessage.Member.DisplayName(),
201 | Style: discordgo.LinkButton,
202 | URL: "https://discord.com/channels/" + replyMessage.GuildID + "/" + replyMessage.ChannelID +
203 | "/" + replyMessage.ID,
204 | }}
205 | }
206 |
207 | func lightningToDiscordReference(msg *lightning.Message) *discordgo.MessageReference {
208 | if len(msg.RepliedTo) == 0 {
209 | return nil
210 | }
211 |
212 | return &discordgo.MessageReference{
213 | Type: discordgo.MessageReferenceTypeDefault,
214 | MessageID: msg.RepliedTo[0],
215 | ChannelID: msg.ChannelID,
216 | }
217 | }
218 |
--------------------------------------------------------------------------------
/internal/app/bridge.go:
--------------------------------------------------------------------------------
1 | // Package app defines the lightning bridge application
2 | package app
3 |
4 | import (
5 | "errors"
6 | "log"
7 | "sync"
8 |
9 | "codeberg.org/jersey/lightning/internal/data"
10 | "codeberg.org/jersey/lightning/pkg/lightning"
11 | )
12 |
13 | // Create handles and bridges new messages.
14 | func Create(database data.Database) func(*lightning.Bot, *lightning.Message) { //nolint:cyclop,revive
15 | return func(bot *lightning.Bot, message *lightning.Message) {
16 | bridge, err := database.GetBridgeByChannel(message.ChannelID)
17 | if err != nil {
18 | log.Printf("failed to get bridge from database on create: %v\n", err)
19 |
20 | return
21 | }
22 |
23 | if bridge.ID == "" || bridge.GetChannelDisabled(message.ChannelID).Read {
24 | return
25 | }
26 |
27 | repliedTo := getRepliedToMessage(database, message)
28 | messages := make([]data.ChannelMessage, 0, len(bridge.Channels)+1)
29 | results := make(chan data.ChannelMessage, len(bridge.Channels))
30 | wait := sync.WaitGroup{}
31 |
32 | for _, channel := range bridge.Channels {
33 | if channel.ID == message.ChannelID || channel.Disabled.Write {
34 | continue
35 | }
36 |
37 | wait.Go(func() {
38 | defer func() {
39 | if r := recover(); r != nil {
40 | log.Printf("bridge: panic on create in channel %s: %#+v", channel.ID, r)
41 | }
42 | }()
43 |
44 | msg := *message
45 | msg.ChannelID = channel.ID
46 | msg.RepliedTo = repliedTo.GetChannelMessageIDs(channel.ID)
47 |
48 | resultIDs, err := bot.SendMessage(&msg, &lightning.SendOptions{
49 | AllowEveryonePings: bridge.Settings.AllowEveryone, ChannelData: channel.Data,
50 | })
51 | if err == nil {
52 | results <- data.ChannelMessage{ChannelID: channel.ID, MessageIDs: resultIDs}
53 | } else {
54 | handleError(database, &bridge, channel.ID, "create", err)
55 | }
56 | })
57 | }
58 |
59 | wait.Wait()
60 | close(results)
61 |
62 | for msg := range results {
63 | messages = append(messages, msg)
64 | }
65 |
66 | if err = database.CreateMessage(data.BridgeMessageCollection{
67 | ID: message.EventID, BridgeID: bridge.ID, Messages: append(messages, data.ChannelMessage{
68 | ChannelID: message.ChannelID, MessageIDs: []string{message.EventID},
69 | }),
70 | }); err != nil {
71 | log.Printf("failed to set message collection in bridge_messages on create: %v\n", err)
72 | }
73 | }
74 | }
75 |
76 | // Edit handles and bridges message edits.
77 | func Edit(database data.Database) func(*lightning.Bot, *lightning.EditedMessage) {
78 | return func(bot *lightning.Bot, message *lightning.EditedMessage) {
79 | bridge, prior, found := getPriorMessage(database, &message.Message.BaseMessage)
80 | if !found {
81 | return
82 | }
83 |
84 | repliedTo := getRepliedToMessage(database, message.Message)
85 | wait := sync.WaitGroup{}
86 |
87 | for _, channel := range bridge.Channels {
88 | if channel.ID == message.Message.ChannelID || channel.Disabled.Write {
89 | continue
90 | }
91 |
92 | wait.Go(func() {
93 | defer func() {
94 | if r := recover(); r != nil {
95 | log.Printf("bridge: panic on edit in channel %s: %#+v", channel.ID, r)
96 | }
97 | }()
98 |
99 | msg := *message.Message
100 |
101 | msg.ChannelID = channel.ID
102 | msg.RepliedTo = repliedTo.GetChannelMessageIDs(channel.ID)
103 |
104 | if err := bot.EditMessage(&msg, prior.GetChannelMessageIDs(channel.ID),
105 | &lightning.SendOptions{
106 | AllowEveryonePings: bridge.Settings.AllowEveryone, ChannelData: channel.Data,
107 | }); err != nil {
108 | handleError(database, bridge, channel.ID, "edit", err)
109 | }
110 | })
111 | }
112 |
113 | wait.Wait()
114 | }
115 | }
116 |
117 | // Delete handles and bridges message deletion.
118 | func Delete(database data.Database) func(*lightning.Bot, *lightning.BaseMessage) {
119 | return func(bot *lightning.Bot, message *lightning.BaseMessage) {
120 | bridge, prior, found := getPriorMessage(database, message)
121 | if !found {
122 | return
123 | }
124 |
125 | wait := sync.WaitGroup{}
126 |
127 | for _, channel := range prior.Messages {
128 | if bridge.GetChannelDisabled(channel.ChannelID).Write || len(channel.MessageIDs) == 0 {
129 | continue
130 | }
131 |
132 | wait.Go(func() {
133 | defer func() {
134 | if r := recover(); r != nil {
135 | log.Printf("bridge: panic on delete in channel %s: %#+v", channel.ChannelID, r)
136 | }
137 | }()
138 |
139 | if err := bot.DeleteMessages(channel.ChannelID, channel.MessageIDs); err != nil {
140 | handleError(database, bridge, channel.ChannelID, "delete", err)
141 | }
142 | })
143 | }
144 |
145 | wait.Wait()
146 |
147 | if err := database.DeleteMessage(message.EventID); err != nil {
148 | log.Printf("failed to set delete collection in bridge_messages on delete: %v\n", err)
149 | }
150 | }
151 | }
152 |
153 | func getPriorMessage(
154 | database data.Database, base *lightning.BaseMessage,
155 | ) (*data.Bridge, *data.BridgeMessageCollection, bool) {
156 | bridge, err := database.GetBridgeByChannel(base.ChannelID)
157 | if err != nil {
158 | log.Printf("failed to get bridge from database on delete: %v\n", err)
159 |
160 | return nil, nil, false
161 | }
162 |
163 | if bridge.ID == "" || bridge.GetChannelDisabled(base.ChannelID).Read {
164 | return nil, nil, false
165 | }
166 |
167 | prior, err := database.GetMessage(base.ChannelID)
168 | if err != nil {
169 | log.Printf("failed to get prior from database on delete: %v\n", err)
170 |
171 | return nil, nil, false
172 | }
173 |
174 | if prior.ID == "" || len(prior.Messages) < 2 {
175 | return nil, nil, false
176 | }
177 |
178 | return &bridge, &prior, true
179 | }
180 |
181 | func getRepliedToMessage(database data.Database, msg *lightning.Message) *data.BridgeMessageCollection {
182 | if msg == nil || len(msg.RepliedTo) == 0 {
183 | return nil
184 | }
185 |
186 | repliedTo, err := database.GetMessage(msg.RepliedTo[0])
187 | if err != nil {
188 | log.Printf("bridge: failed to get replied_to for %s: %v\n", msg.RepliedTo[0], err)
189 |
190 | return nil
191 | }
192 |
193 | return &repliedTo
194 | }
195 |
196 | func handleError(database data.Database, bridge *data.Bridge, channelID, event string, err error) {
197 | var disabled lightning.ChannelDisabled
198 |
199 | disabler := new(lightning.ChannelDisabler)
200 | if errors.As(err, disabler) {
201 | if result := (*disabler).Disable(); result != nil {
202 | disabled = *result
203 | }
204 | }
205 |
206 | log.Printf("bridge: in bridge %s on %s: %v\n", bridge.ID, event, err)
207 |
208 | if !disabled.Read && !disabled.Write {
209 | return
210 | }
211 |
212 | for i, ch := range bridge.Channels {
213 | if ch.ID == channelID {
214 | bridge.Channels[i].Disabled = disabled
215 |
216 | break
217 | }
218 | }
219 |
220 | log.Printf("bridge: disabling channel %s in bridge %s on %s: %#+v\n", bridge.ID, channelID, event, disabled)
221 |
222 | if err := database.CreateBridge(*bridge); err != nil {
223 | log.Printf("bridge: failed to disable %s in bridge %s: %v\n", channelID, bridge.ID, err)
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/pkg/platforms/stoat/incoming.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import (
4 | "regexp"
5 | "strconv"
6 | "time"
7 |
8 | "codeberg.org/jersey/lightning/internal/stoat"
9 | "codeberg.org/jersey/lightning/pkg/lightning"
10 | "github.com/oklog/ulid/v2"
11 | )
12 |
13 | func stoatToLightningMessage(
14 | session *stoat.Session,
15 | selfID string,
16 | message *stoat.Message,
17 | ) *lightning.Message {
18 | if message.Author == selfID && message.Masquerade.Name != "" {
19 | return nil
20 | }
21 |
22 | msg := &lightning.Message{
23 | BaseMessage: lightning.BaseMessage{
24 | EventID: message.ID,
25 | ChannelID: message.Channel,
26 | Time: stoatToLightningTime(message),
27 | },
28 | Author: stoatToLightningAuthor(session, message),
29 | Content: stoatToLightningContent(session, message),
30 | Embeds: stoatToLightningEmbeds(message.Embeds),
31 | Attachments: stoatToLightningAttachments(message.Attachments),
32 | RepliedTo: message.Replies,
33 | }
34 |
35 | msg.Content = stoatToLightningEmoji(session, msg)
36 |
37 | return msg
38 | }
39 |
40 | func stoatToLightningTime(message *stoat.Message) time.Time {
41 | if !message.Edited.IsZero() {
42 | return message.Edited
43 | }
44 |
45 | id, err := ulid.Parse(message.ID)
46 | if err != nil {
47 | return time.Now()
48 | }
49 |
50 | return id.Timestamp()
51 | }
52 |
53 | func stoatToLightningAuthor(session *stoat.Session, msg *stoat.Message) *lightning.MessageAuthor {
54 | author := &lightning.MessageAuthor{ID: msg.Author, Username: "StoatUser", Nickname: "Stoat User", Color: "#8C24EC"}
55 |
56 | if u, err := stoat.Get(session, "/users/"+msg.Author, msg.Author, &session.UserCache); err == nil {
57 | author.Username, author.Nickname = u.Username, u.Username
58 | if u.Avatar != nil {
59 | author.ProfilePicture = getStoatFileURL(u.Avatar)
60 | }
61 | }
62 |
63 | if mem := getStoatMember(session, msg); mem != nil {
64 | if mem.Nickname != nil {
65 | author.Nickname = *mem.Nickname
66 | }
67 |
68 | if mem.Avatar != nil {
69 | author.ProfilePicture = getStoatFileURL(mem.Avatar)
70 | }
71 | }
72 |
73 | if msg.Masquerade.Name != "" {
74 | author.Nickname = msg.Masquerade.Name
75 | }
76 |
77 | if msg.Masquerade.Colour != "" {
78 | author.Color = msg.Masquerade.Colour
79 | }
80 |
81 | if msg.Masquerade.Avatar != "" {
82 | author.ProfilePicture = msg.Masquerade.Avatar
83 | }
84 |
85 | return author
86 | }
87 |
88 | func getStoatMember(session *stoat.Session, msg *stoat.Message) *stoat.Member {
89 | channel, err := stoat.Get(session, "/channels/"+msg.Channel, msg.Channel, &session.ChannelCache)
90 | if err != nil || channel.Server == nil {
91 | return nil
92 | }
93 |
94 | mem, err := stoat.Get(
95 | session,
96 | "/servers/"+*channel.Server+"/members/"+msg.Author,
97 | *channel.Server+"-"+msg.Author,
98 | &session.MemberCache,
99 | )
100 | if err != nil {
101 | return nil
102 | }
103 |
104 | return mem
105 | }
106 |
107 | func stoatToLightningAttachments(attachments []stoat.File) []lightning.Attachment {
108 | out := make([]lightning.Attachment, len(attachments))
109 |
110 | for i, att := range attachments {
111 | out[i] = lightning.Attachment{
112 | URL: getStoatFileURL(&att),
113 | Name: att.Filename,
114 | Size: int64(att.Size),
115 | }
116 | }
117 |
118 | return out
119 | }
120 |
121 | var (
122 | stoatSpoilerRegex = regexp.MustCompile(`!!(.+?)!!`)
123 | stoatEmojiRegex = regexp.MustCompile(":([0-7][0-9A-HJKMNP-TV-Z]{25}):")
124 | stoatMentionRegex = regexp.MustCompile("<@([0-7][0-9A-HJKMNP-TV-Z]{25})>")
125 | stoatChannelRegex = regexp.MustCompile("<#([0-7][0-9A-HJKMNP-TV-Z]{25})>")
126 | )
127 |
128 | func stoatToLightningContent(session *stoat.Session, message *stoat.Message) string {
129 | content := stoatSpoilerRegex.ReplaceAllStringFunc(message.Content, func(match string) string {
130 | return "||" + match[2:len(match)-2] + "||"
131 | })
132 |
133 | content = stoatEmojiRegex.ReplaceAllStringFunc(content, func(match string) string {
134 | if emojiID := extractStoatID(match, stoatEmojiRegex); emojiID != "" {
135 | e, err := stoat.Get(session, "/custom/emoji/"+emojiID, emojiID, &session.EmojiCache)
136 | if err == nil {
137 | return ":" + e.Name + ":"
138 | }
139 | }
140 |
141 | return match
142 | })
143 |
144 | content = stoatMentionRegex.ReplaceAllStringFunc(content, func(match string) string {
145 | userID := extractStoatID(match, stoatMentionRegex)
146 | if userID == "" {
147 | return match
148 | }
149 |
150 | user, err := stoat.Get(session, "/users/"+userID, userID, &session.UserCache)
151 | if err != nil {
152 | return "@" + userID
153 | }
154 |
155 | if member := getStoatMember(session, message); member != nil && member.Nickname != nil {
156 | return "@" + *member.Nickname
157 | }
158 |
159 | return "@" + user.Username
160 | })
161 |
162 | content = stoatChannelRegex.ReplaceAllStringFunc(content, func(match string) string {
163 | channelID := extractStoatID(match, stoatChannelRegex)
164 | if channelID == "" {
165 | return match
166 | }
167 |
168 | ch, err := stoat.Get(session, "/channels/"+channelID, channelID, &session.ChannelCache)
169 | if err != nil {
170 | return "#" + channelID
171 | }
172 |
173 | return "#" + ch.Name
174 | })
175 |
176 | return content
177 | }
178 |
179 | func stoatToLightningEmbeds(embeds []stoat.Embed) []lightning.Embed {
180 | out := make([]lightning.Embed, 0, len(embeds))
181 |
182 | for _, embed := range embeds {
183 | newEmbed := lightning.Embed{
184 | Title: embed.Title, Description: embed.Description, URL: embed.URL,
185 | Image: getStoatEmbedMedia(embed.Image), Video: getStoatEmbedMedia(embed.Video),
186 | }
187 |
188 | if embed.Colour != "" {
189 | if c, err := strconv.ParseInt(embed.Colour[1:], 16, 32); err == nil {
190 | newEmbed.Color = int(c)
191 | }
192 | }
193 |
194 | if embed.IconURL != nil {
195 | newEmbed.Thumbnail = &lightning.Media{URL: *embed.IconURL}
196 | }
197 |
198 | out = append(out, newEmbed)
199 | }
200 |
201 | return out
202 | }
203 |
204 | func stoatToLightningEmoji(session *stoat.Session, msg *lightning.Message) string {
205 | return stoatEmojiRegex.ReplaceAllStringFunc(msg.Content, func(match string) string {
206 | if emojiID := extractStoatID(match, stoatEmojiRegex); emojiID != "" {
207 | emoji, err := stoat.Get(session, "/custom/emoji/"+emojiID, emojiID, &session.EmojiCache)
208 | if err == nil {
209 | msg.Emoji = append(msg.Emoji, lightning.Emoji{
210 | URL: "https://cdn.stoatusercontent.com/emojis/" + emoji.ID,
211 | ID: emoji.ID,
212 | Name: emoji.Name,
213 | })
214 |
215 | return ":" + emoji.Name + ":"
216 | }
217 | }
218 |
219 | return match
220 | })
221 | }
222 |
223 | func getStoatFileURL(file *stoat.File) string {
224 | return "https://cdn.stoatusercontent.com/" + file.Tag + "/" + file.ID
225 | }
226 |
227 | func getStoatEmbedMedia(media *stoat.Media) *lightning.Media {
228 | if media != nil && media.URL != "" {
229 | return &lightning.Media{
230 | URL: media.URL,
231 | Width: media.Width,
232 | Height: media.Height,
233 | }
234 | }
235 |
236 | return nil
237 | }
238 |
239 | func extractStoatID(match string, re *regexp.Regexp) string {
240 | matches := re.FindStringSubmatch(match)
241 | if len(matches) < 2 {
242 | return ""
243 | }
244 |
245 | return matches[1]
246 | }
247 |
--------------------------------------------------------------------------------
/internal/data/postgres.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "log"
9 |
10 | "github.com/jackc/pgx/v5"
11 | "github.com/jackc/pgx/v5/pgxpool"
12 | )
13 |
14 | type postgresDatabase struct {
15 | pool *pgxpool.Pool
16 | }
17 |
18 | func newPostgresDatabase(conn string) (Database, error) {
19 | pool, err := pgxpool.New(context.Background(), conn)
20 | if err != nil {
21 | return nil, fmt.Errorf("failed to make connection pool: %w", err)
22 | }
23 |
24 | database := &postgresDatabase{pool}
25 |
26 | if err = database.setupDatabase(); err != nil {
27 | pool.Close()
28 |
29 | return nil, fmt.Errorf("failed to setup schema: %w", err)
30 | }
31 |
32 | return database, nil
33 | }
34 |
35 | func (p *postgresDatabase) CreateBridge(bridge Bridge) error {
36 | txn, err := p.pool.BeginTx(context.Background(), pgx.TxOptions{})
37 | if err != nil {
38 | return fmt.Errorf("begin tx: %w", err)
39 | }
40 |
41 | defer func() {
42 | if err := txn.Rollback(context.Background()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
43 | log.Printf("txn rollback failed: %v", err)
44 | }
45 | }()
46 |
47 | settings, err := json.Marshal(bridge.Settings)
48 | if err != nil {
49 | return fmt.Errorf("marshal settings: %w", err)
50 | }
51 |
52 | if _, err = txn.Exec(context.Background(), insertBridge, bridge.ID, settings); err != nil {
53 | return fmt.Errorf("insert bridge: %w", err)
54 | }
55 |
56 | if _, err = txn.Exec(context.Background(), deleteBridgeChannelsQuery, bridge.ID); err != nil {
57 | return fmt.Errorf("delete old channels: %w", err)
58 | }
59 |
60 | if err = setChannels(bridge, txn); err != nil {
61 | return err
62 | }
63 |
64 | if err = txn.Commit(context.Background()); err != nil {
65 | return fmt.Errorf("failed committing txn: %w", err)
66 | }
67 |
68 | return nil
69 | }
70 |
71 | func setChannels(bridge Bridge, txn pgx.Tx) error {
72 | for _, channel := range bridge.Channels {
73 | data, err := json.Marshal(channel.Data)
74 | if err != nil {
75 | return fmt.Errorf("marshal channel data: %w", err)
76 | }
77 |
78 | disabled, err := json.Marshal(channel.Disabled)
79 | if err != nil {
80 | return fmt.Errorf("marshal channel disabled: %w", err)
81 | }
82 |
83 | if _, err := txn.Exec(context.Background(), insertChannel, bridge.ID, channel.ID, data, disabled); err != nil {
84 | return fmt.Errorf("insert channel: %w", err)
85 | }
86 | }
87 |
88 | return nil
89 | }
90 |
91 | func (p *postgresDatabase) GetBridge(bridgeID string) (Bridge, error) {
92 | var bridge Bridge
93 |
94 | bridge.ID = bridgeID
95 |
96 | var settings json.RawMessage
97 | if err := p.pool.QueryRow(context.Background(), selectBridgeSettingsByID, bridgeID).Scan(&settings); err != nil {
98 | if errors.Is(err, pgx.ErrNoRows) {
99 | return Bridge{}, nil
100 | }
101 |
102 | return Bridge{}, fmt.Errorf("query bridge settings: %w", err)
103 | }
104 |
105 | if err := json.Unmarshal(settings, &bridge.Settings); err != nil {
106 | return Bridge{}, fmt.Errorf("unmarshal settings: %w", err)
107 | }
108 |
109 | rows, err := p.pool.Query(context.Background(), selectBridgeChannelsQuery, bridgeID)
110 | if err != nil {
111 | return Bridge{}, fmt.Errorf("query channels: %w", err)
112 | }
113 | defer rows.Close()
114 |
115 | for rows.Next() {
116 | ch, err := scanChannel(rows)
117 | if err != nil {
118 | return Bridge{}, err
119 | }
120 |
121 | bridge.Channels = append(bridge.Channels, ch)
122 | }
123 |
124 | if err := rows.Err(); err != nil {
125 | return Bridge{}, fmt.Errorf("iterate channels: %w", err)
126 | }
127 |
128 | return bridge, nil
129 | }
130 |
131 | func (p *postgresDatabase) GetBridgeByChannel(channelID string) (Bridge, error) {
132 | var bridgeID string
133 |
134 | err := p.pool.QueryRow(context.Background(), selectBridgeByChannelQuery, channelID).Scan(&bridgeID)
135 | if errors.Is(err, pgx.ErrNoRows) {
136 | return Bridge{}, nil
137 | } else if err != nil {
138 | return Bridge{}, fmt.Errorf("query bridge by channel: %w", err)
139 | }
140 |
141 | return p.GetBridge(bridgeID)
142 | }
143 |
144 | func (p *postgresDatabase) CreateMessage(msg BridgeMessageCollection) error {
145 | data, err := json.Marshal(msg.Messages)
146 | if err != nil {
147 | return fmt.Errorf("marshal messages: %w", err)
148 | }
149 |
150 | return p.exec(insertMessage, msg.ID, msg.BridgeID, data)
151 | }
152 |
153 | func (p *postgresDatabase) GetMessage(msgID string) (BridgeMessageCollection, error) {
154 | var (
155 | msg BridgeMessageCollection
156 | data string
157 | )
158 |
159 | err := p.pool.QueryRow(context.Background(), selectMessageCollectionQuery, msgID).
160 | Scan(&msg.ID, &msg.BridgeID, &data)
161 | if errors.Is(err, pgx.ErrNoRows) {
162 | return BridgeMessageCollection{}, nil
163 | } else if err != nil {
164 | return BridgeMessageCollection{}, fmt.Errorf("query message: %w", err)
165 | }
166 |
167 | if err := json.Unmarshal([]byte(data), &msg.Messages); err != nil {
168 | return BridgeMessageCollection{}, fmt.Errorf("unmarshal messages: %w", err)
169 | }
170 |
171 | return msg, nil
172 | }
173 |
174 | func (p *postgresDatabase) DeleteMessage(id string) error {
175 | var realID string
176 |
177 | err := p.pool.QueryRow(context.Background(), selectMessageIDQuery, id).Scan(&realID)
178 | if errors.Is(err, pgx.ErrNoRows) {
179 | return nil
180 | } else if err != nil {
181 | return fmt.Errorf("query message ID: %w", err)
182 | }
183 |
184 | return p.exec(deleteMessageCollectionQuery, realID)
185 | }
186 |
187 | func (p *postgresDatabase) setupDatabase() error {
188 | if err := p.exec(createTables); err != nil {
189 | return fmt.Errorf("create tables: %w", err)
190 | }
191 |
192 | var version string
193 |
194 | err := p.pool.QueryRow(context.Background(), selectDatabaseVersionQuery).Scan(&version)
195 | switch {
196 | case errors.Is(err, pgx.ErrNoRows):
197 | if err = p.exec(insertDatabaseVersionQuery); err != nil {
198 | return fmt.Errorf("init version: %w", err)
199 | }
200 |
201 | return nil
202 | case err != nil:
203 | return fmt.Errorf("get db version: %w", err)
204 | case version == "0.8.3":
205 | return nil
206 | case version == "0.8.1" || version == "0.8.3":
207 | if err := p.exec(zeroEightThreeMigrationQuery); err != nil {
208 | return fmt.Errorf("db migratation d0.8.2 → d0.8.3: %w", err)
209 | }
210 |
211 | return p.exec(`UPDATE lightning SET value='0.8.3' WHERE prop='db_data_version';`)
212 | default:
213 | log.Println("migration from versions before v0.8.0-beta.8 not supported.")
214 |
215 | return UnsupportedDatabaseTypeError{}
216 | }
217 | }
218 |
219 | func (p *postgresDatabase) exec(query string, args ...any) error {
220 | _, err := p.pool.Exec(context.Background(), query, args...)
221 | if err != nil {
222 | return fmt.Errorf("exec failed: %w", err)
223 | }
224 |
225 | return nil
226 | }
227 |
228 | func scanChannel(rows pgx.Rows) (BridgeChannel, error) {
229 | var (
230 | channel BridgeChannel
231 | data, dis json.RawMessage
232 | )
233 | if err := rows.Scan(&channel.ID, &data, &dis); err != nil {
234 | return channel, fmt.Errorf("scan channel row: %w", err)
235 | }
236 |
237 | if err := json.Unmarshal(data, &channel.Data); err != nil {
238 | return channel, fmt.Errorf("unmarshal channel data: %w", err)
239 | }
240 |
241 | if err := json.Unmarshal(dis, &channel.Disabled); err != nil {
242 | return channel, fmt.Errorf("unmarshal disabled: %w", err)
243 | }
244 |
245 | return channel, nil
246 | }
247 |
--------------------------------------------------------------------------------
/internal/stoat/types.go:
--------------------------------------------------------------------------------
1 | package stoat
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | // BaseEvent provides the common "type" field.
9 | type BaseEvent struct {
10 | Type string `json:"type"`
11 | }
12 |
13 | // BulkEvent wraps multiple events into a single payload.
14 | type BulkEvent struct {
15 | V []json.RawMessage `json:"v"` // slice of other events
16 | }
17 |
18 | // CDNFile is the Autumn representation of a file.
19 | type CDNFile struct {
20 | ID string `json:"id"`
21 | }
22 |
23 | // Channel represents all channel types.
24 | type Channel struct {
25 | Name string `json:"name,omitempty"`
26 | Owner *string `json:"owner,omitempty"`
27 | Permissions *Permission `json:"permissions,omitempty"`
28 | Server *string `json:"server,omitempty"`
29 | DefaultPerms *OverrideField `json:"default_permissions,omitempty"`
30 | RolePermissions map[string]OverrideField `json:"role_permissions,omitempty"`
31 | ChannelType ChannelType `json:"channel_type"`
32 | ID string `json:"_id"`
33 | Recipients []string `json:"recipients,omitempty"`
34 | }
35 |
36 | // ChannelType is the type of a channel.
37 | type ChannelType string
38 |
39 | // Possible ChannelType values.
40 | const (
41 | ChannelTypeSavedMessages ChannelType = "SavedMessages"
42 | ChannelTypeText ChannelType = "TextChannel"
43 | ChannelTypeVoice ChannelType = "VoiceChannel"
44 | ChannelTypeDM ChannelType = "DirectMessage"
45 | ChannelTypeGroup ChannelType = "Group"
46 | )
47 |
48 | // DataEditMessage describes how to edit a message.
49 | type DataEditMessage struct {
50 | Content string `json:"content"`
51 | Embeds []SendableEmbed `json:"embeds,omitempty"`
52 | }
53 |
54 | // DataMessageSend is a message to send.
55 | type DataMessageSend struct {
56 | Masquerade *Masquerade `json:"masquerade,omitempty"`
57 | Content string `json:"content"`
58 | Attachments []string `json:"attachments,omitempty"`
59 | Replies []ReplyIntent `json:"replies,omitempty"`
60 | Embeds []SendableEmbed `json:"embeds,omitempty"`
61 | }
62 |
63 | // Embed represents embedded rich content.
64 | type Embed struct {
65 | URL string `json:"url,omitempty"`
66 | Title string `json:"title,omitempty"`
67 | Description string `json:"description,omitempty"`
68 | Image *Media `json:"image,omitempty"`
69 | Video *Media `json:"video,omitempty"`
70 | IconURL *string `json:"icon_url,omitempty"`
71 | Colour string `json:"colour,omitempty"`
72 | }
73 |
74 | // Emoji is a user-created emoji on Stoat.
75 | type Emoji struct {
76 | ID string `json:"_id"`
77 | Parent EmojiParent `json:"parent"`
78 | Name string `json:"name"`
79 | }
80 |
81 | // EmojiParent represents emoji scoping.
82 | type EmojiParent struct {
83 | ID string `json:"id,omitempty"`
84 | }
85 |
86 | // File is the representation of a file.
87 | type File struct {
88 | ID string `json:"_id"`
89 | Tag string `json:"tag"`
90 | Filename string `json:"filename"`
91 | Size int `json:"size"`
92 | }
93 |
94 | // Media is a representation of an image or video.
95 | type Media struct {
96 | URL string `json:"url"`
97 | Width int `json:"width"`
98 | Height int `json:"height"`
99 | }
100 |
101 | // Masquerade is name/avatar override information.
102 | type Masquerade struct {
103 | Name string `json:"name,omitempty"`
104 | Avatar string `json:"avatar,omitempty"`
105 | Colour string `json:"colour,omitempty"`
106 | }
107 |
108 | // Member in a server.
109 | type Member struct {
110 | ID MemberCompositeKey `json:"_id"`
111 | Timeout time.Time `json:"timeout"`
112 | Avatar *File `json:"avatar"`
113 | Nickname *string `json:"nickname"`
114 | Roles []string `json:"roles,omitempty"`
115 | }
116 |
117 | // MemberCompositeKey consists of server and user id.
118 | type MemberCompositeKey struct {
119 | Server string `json:"server"`
120 | User string `json:"user"`
121 | }
122 |
123 | // Message on Stoat.
124 | type Message struct {
125 | Edited time.Time `json:"edited"`
126 | Masquerade Masquerade `json:"masquerade"`
127 | Content string `json:"content"`
128 | Author string `json:"author"`
129 | Channel string `json:"channel"`
130 | ID string `json:"_id"`
131 | Attachments []File `json:"attachments,omitempty"`
132 | Embeds []Embed `json:"embeds,omitempty"`
133 | Replies []string `json:"replies,omitempty"`
134 | }
135 |
136 | // MessageDeleteEvent represents the deletion of a message.
137 | type MessageDeleteEvent struct {
138 | ID string `json:"id"`
139 | Channel string `json:"channel"`
140 | }
141 |
142 | // MessageUpdateEvent represents a partial message update.
143 | type MessageUpdateEvent struct {
144 | Data Message `json:"data"`
145 | }
146 |
147 | // OptionsBulkDelete are the options to delete many messages.
148 | type OptionsBulkDelete struct {
149 | IDs []string `json:"ids"`
150 | }
151 |
152 | // Override is a representation of a single permission override.
153 | type Override struct {
154 | Allow Permission `json:"allow"`
155 | Deny Permission `json:"deny"`
156 | }
157 |
158 | // OverrideField is a representation of a single permission override as it appears on models and in the database.
159 | type OverrideField struct {
160 | Allow Permission `json:"a"`
161 | Deny Permission `json:"d"`
162 | }
163 |
164 | // ReplyIntent specifies what this message should reply to and how.
165 | type ReplyIntent struct {
166 | ID string `json:"id"`
167 | Mention bool `json:"mention"`
168 | FailIfNotExists bool `json:"fail_if_not_exists"`
169 | }
170 |
171 | // RelationshipStatus is the status you have with a user.
172 | type RelationshipStatus string
173 |
174 | // Possible RelationshipStatus values.
175 | const (
176 | RelationshipNone RelationshipStatus = "None"
177 | RelationshipUser RelationshipStatus = "User"
178 | RelationshipFriend RelationshipStatus = "Friend"
179 | RelationshipOutgoing RelationshipStatus = "Outgoing"
180 | RelationshipIncoming RelationshipStatus = "Incoming"
181 | RelationshipBlocked RelationshipStatus = "Blocked"
182 | RelationshipBlockedOther RelationshipStatus = "BlockedOther"
183 | )
184 |
185 | // ReadyEvent provides an initial data snapshot.
186 | type ReadyEvent struct {
187 | Users []User `json:"users,omitempty"`
188 | Servers []Server `json:"servers,omitempty"`
189 | Channels []Channel `json:"channels,omitempty"`
190 | Members []Member `json:"members,omitempty"`
191 | Emojis []Emoji `json:"emojis,omitempty"`
192 | }
193 |
194 | // Role represents a Role in a Stoat server.
195 | type Role struct {
196 | Permissions OverrideField `json:"permissions"`
197 | }
198 |
199 | // Server on Stoat.
200 | type Server struct {
201 | Roles map[string]Role `json:"roles"`
202 | ID string `json:"_id"`
203 | Owner string `json:"owner"`
204 | DefaultPermissions Permission `json:"default_permissions"`
205 | }
206 |
207 | // SendableEmbed is a representation of a text embed before it is sent.
208 | type SendableEmbed struct {
209 | IconURL string `json:"icon_url,omitempty"`
210 | URL string `json:"url,omitempty"`
211 | Title string `json:"title,omitempty"`
212 | Description string `json:"description,omitempty"`
213 | Media string `json:"media,omitempty"`
214 | Colour string `json:"colour,omitempty"`
215 | }
216 |
217 | // User on Stoat.
218 | type User struct {
219 | Avatar *File `json:"avatar"`
220 | Relationship RelationshipStatus `json:"relationship"`
221 | ID string `json:"_id"`
222 | Username string `json:"username"`
223 | }
224 |
--------------------------------------------------------------------------------
/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 | "codeberg.org/jersey/lightning/pkg/lightning"
29 | "github.com/PaulSonOfLars/gotgbot/v2"
30 | "github.com/PaulSonOfLars/gotgbot/v2/ext"
31 | "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers"
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 := telegramToLightningMessage(b, ctx, config["proxy_url"])
66 | if ctx.EditedMessage != nil {
67 | edits <- &lightning.EditedMessage{
68 | Message: &msg, Edited: time.UnixMilli(ctx.EditedMessage.GetDate() * 1000),
69 | }
70 | } else {
71 | messages <- &msg
72 | }
73 |
74 | return nil
75 | },
76 | })
77 |
78 | updater := ext.NewUpdater(dispatch, &ext.UpdaterOpts{
79 | UnhandledErrFunc: func(err error) {
80 | if err != nil && !errors.Is(err, context.DeadlineExceeded) &&
81 | !strings.Contains(err.Error(), "connection reset") {
82 | log.Printf("telegram: unhandled error in dispatcher: %v\n", err)
83 | }
84 | },
85 | })
86 | if err := updater.StartPolling(telegram, &ext.PollingOpts{
87 | DropPendingUpdates: true,
88 | GetUpdatesOpts: &gotgbot.GetUpdatesOpts{Timeout: int64(defaultTimeout.Seconds())},
89 | }); err != nil {
90 | return nil, fmt.Errorf("telegram: failed to start polling: %w", err)
91 | }
92 |
93 | log.Printf("telegram: ready! invite me at https://t.me/%s\n", telegram.Username)
94 |
95 | plugin := &telegramPlugin{messages, edits, dispatch, telegram, updater}
96 |
97 | go startProxy(config)
98 |
99 | return plugin, nil
100 | }
101 |
102 | type telegramPlugin struct {
103 | messageChannel chan *lightning.Message
104 | editChannel chan *lightning.EditedMessage
105 | dispatch *ext.Dispatcher
106 | telegram *gotgbot.Bot
107 | updater *ext.Updater
108 | }
109 |
110 | func (*telegramPlugin) SetupChannel(_ string) (map[string]string, error) {
111 | return nil, nil //nolint:nilnil // we don't need a value for ChannelData later
112 | }
113 |
114 | func (p *telegramPlugin) SendCommandResponse(
115 | message *lightning.Message,
116 | opts *lightning.SendOptions,
117 | user string,
118 | ) ([]string, error) {
119 | message.ChannelID = user
120 |
121 | return p.SendMessage(message, opts)
122 | }
123 |
124 | func (p *telegramPlugin) SendMessage(message *lightning.Message, opts *lightning.SendOptions) ([]string, error) {
125 | channel, err := strconv.ParseInt(message.ChannelID, 10, 64)
126 | if err != nil {
127 | return nil, &channelIDError{message.ChannelID}
128 | }
129 |
130 | content := lightningToTelegramMessage(message, opts)
131 |
132 | sendOpts := &gotgbot.SendMessageOpts{
133 | ParseMode: gotgbot.ParseModeMarkdownV2, RequestOpts: &gotgbot.RequestOpts{Timeout: defaultTimeout},
134 | }
135 |
136 | if len(message.RepliedTo) > 0 {
137 | var replyID int64
138 |
139 | replyID, err = strconv.ParseInt(message.RepliedTo[0], 10, 64)
140 | if err == nil && replyID > 0 {
141 | sendOpts.ReplyParameters = &gotgbot.ReplyParameters{
142 | MessageId: replyID,
143 | AllowSendingWithoutReply: true,
144 | }
145 | }
146 | }
147 |
148 | msg, err := p.telegram.SendMessage(channel, content, sendOpts)
149 | if err != nil && strings.Contains(err.Error(), "context deadline exceeded") {
150 | return []string{}, nil
151 | } else if err != nil {
152 | return nil, fmt.Errorf("telegram: failed to send message: %w\n\tchannel: %s\n\tcontent: %s\n\treply: %#+v",
153 | err, message.ChannelID, content, sendOpts.ReplyParameters)
154 | }
155 |
156 | ids := []string{strconv.FormatInt(msg.MessageId, 10)}
157 |
158 | for _, attachment := range message.Attachments {
159 | if msg, err := p.telegram.SendDocument(channel, gotgbot.InputFileByURL(attachment.URL), nil); err == nil {
160 | ids = append(ids, strconv.FormatInt(msg.MessageId, 10))
161 | }
162 | }
163 |
164 | return ids, nil
165 | }
166 |
167 | func (p *telegramPlugin) EditMessage(message *lightning.Message, ids []string, opts *lightning.SendOptions) error {
168 | channel, err := strconv.ParseInt(message.ChannelID, 10, 64)
169 | if err != nil {
170 | return &channelIDError{message.ChannelID}
171 | }
172 |
173 | msgID, err := strconv.ParseInt(ids[0], 10, 64)
174 | if err != nil {
175 | return fmt.Errorf("telegram: failed to parse message ID: %w\n\tchannel: %s\n\tmessage: %s",
176 | err, message.ChannelID, ids[0])
177 | }
178 |
179 | content := lightningToTelegramMessage(message, opts)
180 |
181 | _, _, err = p.telegram.EditMessageText(
182 | content,
183 | &gotgbot.EditMessageTextOpts{ChatId: channel, MessageId: msgID, ParseMode: gotgbot.ParseModeMarkdownV2},
184 | )
185 | if err != nil &&
186 | strings.Contains(err.Error(), "specified new message content and reply markup are exactly the same") {
187 | return nil
188 | }
189 |
190 | if err == nil {
191 | return nil
192 | }
193 |
194 | return fmt.Errorf("telegram: failed to edit message: %w\n\tchannel: %s\n\tmessage: %s",
195 | err, message.ChannelID, ids[0])
196 | }
197 |
198 | func (p *telegramPlugin) DeleteMessage(channelID string, ids []string) error {
199 | channel, err := strconv.ParseInt(channelID, 10, 64)
200 | if err != nil {
201 | return &channelIDError{channelID}
202 | }
203 |
204 | messageIDs := make([]int64, 0, len(ids))
205 | for _, id := range ids {
206 | var msgID int64
207 |
208 | msgID, err = strconv.ParseInt(id, 10, 64)
209 | if err != nil {
210 | return fmt.Errorf("telegram: failed to parse message ID: %w\n\tchannel: %s\n\tmessage: %d",
211 | err, channelID, msgID)
212 | }
213 |
214 | messageIDs = append(messageIDs, msgID)
215 | }
216 |
217 | _, err = p.telegram.DeleteMessages(channel, messageIDs, nil)
218 | if err == nil {
219 | return nil
220 | }
221 |
222 | return fmt.Errorf("telegram: failed to delete message: %w\n\tchannel: %s\n\tmessage: %#+v", err, channelID, ids)
223 | }
224 |
225 | func (*telegramPlugin) SetupCommands(_ map[string]*lightning.Command) error {
226 | return nil
227 | }
228 |
229 | func (p *telegramPlugin) ListenMessages() <-chan *lightning.Message {
230 | return p.messageChannel
231 | }
232 |
233 | func (p *telegramPlugin) ListenEdits() <-chan *lightning.EditedMessage {
234 | return p.editChannel
235 | }
236 |
237 | func (*telegramPlugin) ListenDeletes() <-chan *lightning.BaseMessage {
238 | return nil
239 | }
240 |
241 | func (*telegramPlugin) ListenCommands() <-chan *lightning.CommandEvent {
242 | return nil
243 | }
244 |
--------------------------------------------------------------------------------
/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-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARhXfqSfRbj1vpWwYXf3eeAUyw/ndms0=
40 | github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/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/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
56 | github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
57 | github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
58 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
59 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
60 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
61 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
62 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
63 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
64 | github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
65 | go.mau.fi/util v0.9.3 h1:aqNF8KDIN8bFpFbybSk+mEBil7IHeBwlujfyTnvP0uU=
66 | go.mau.fi/util v0.9.3/go.mod h1:krWWfBM1jWTb5f8NCa2TLqWMQuM81X7TGQjhMjBeXmQ=
67 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
68 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
69 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
70 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
71 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
72 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
73 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
74 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
75 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
76 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
77 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
78 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
79 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
80 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
81 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
82 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
83 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
84 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
85 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
86 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
87 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
88 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
89 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
90 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
91 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
92 | maunium.net/go/mautrix v0.26.0 h1:valc2VmZF+oIY4bMq4Cd5H9cEKMRe8eP4FM7iiaYLxI=
93 | maunium.net/go/mautrix v0.26.0/go.mod h1:NWMv+243NX/gDrLofJ2nNXJPrG8vzoM+WUCWph85S6Q=
94 |
--------------------------------------------------------------------------------
/pkg/platforms/discord/incoming.go:
--------------------------------------------------------------------------------
1 | package discord
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 |
7 | "codeberg.org/jersey/lightning/internal/cache"
8 | "codeberg.org/jersey/lightning/internal/emoji"
9 | "codeberg.org/jersey/lightning/pkg/lightning"
10 | "github.com/bwmarrin/discordgo"
11 | )
12 |
13 | func discordToLightning(
14 | webhooks *cache.Expiring[string, bool],
15 | session *discordgo.Session,
16 | msg *discordgo.Message,
17 | ) *lightning.Message {
18 | if msg.Type != discordgo.MessageTypeDefault &&
19 | msg.Type != discordgo.MessageTypeReply &&
20 | msg.Type != discordgo.MessageTypeChatInputCommand &&
21 | msg.Type != discordgo.MessageTypeContextMenuCommand {
22 | return nil
23 | }
24 |
25 | if exists, _ := webhooks.Get(msg.WebhookID); exists {
26 | return nil
27 | }
28 |
29 | message := &lightning.Message{
30 | BaseMessage: lightning.BaseMessage{EventID: msg.ID, ChannelID: msg.ChannelID, Time: msg.Timestamp},
31 | Author: discordToLightningAuthor(session, msg),
32 | Content: discordToLightningForward(session, msg) + discordToLightningContent(session, msg),
33 | Embeds: discordToLightningEmbeds(msg.Embeds),
34 | Attachments: discordToLightningAttachments(msg.Attachments, msg.StickerItems),
35 | RepliedTo: discordToLightningReplies(msg.MessageReference),
36 | }
37 |
38 | message.Content = discordToLightningEmoji(message)
39 |
40 | return message
41 | }
42 |
43 | func discordToLightningAuthor(session *discordgo.Session, msg *discordgo.Message) *lightning.MessageAuthor {
44 | author := &lightning.MessageAuthor{
45 | ID: msg.Author.ID,
46 | Username: msg.Author.Username,
47 | Nickname: msg.Author.DisplayName(),
48 | Color: "#5865F2",
49 | ProfilePicture: msg.Author.AvatarURL(""),
50 | }
51 |
52 | if msg.GuildID == "" {
53 | return author
54 | }
55 |
56 | member := msg.Member
57 | if member == nil {
58 | if m, err := session.State.Member(msg.GuildID, msg.Author.ID); err == nil {
59 | member = m
60 | }
61 | }
62 |
63 | if member == nil {
64 | return author
65 | }
66 |
67 | member.User = msg.Author
68 | author.Nickname = member.DisplayName()
69 | author.ProfilePicture = member.AvatarURL("")
70 |
71 | return author
72 | }
73 |
74 | func discordToLightningForward(session *discordgo.Session, msg *discordgo.Message) string {
75 | if msg.MessageReference == nil || msg.MessageReference.MessageID == "" ||
76 | msg.MessageReference.Type != discordgo.MessageReferenceTypeForward || len(msg.MessageSnapshots) == 0 {
77 | return ""
78 | }
79 |
80 | out := ""
81 |
82 | for _, snapshot := range msg.MessageSnapshots {
83 | content := discordToLightningContent(session, snapshot.Message)
84 | out += "> " + strings.ReplaceAll(content, "\n", "\n> ")
85 | }
86 |
87 | return out
88 | }
89 |
90 | var (
91 | tenorURL = regexp.MustCompile(`https://tenor\.com/view/[^/]+-(\d+).*`)
92 | userMention = regexp.MustCompile(`<@!?(\d+)>`)
93 | channelMention = regexp.MustCompile(`<#(\d+)>`)
94 | roleMention = regexp.MustCompile(`<@&(\d+)>`)
95 | emojiMention = regexp.MustCompile(``)
96 | defaultEmoji = regexp.MustCompile(`(?:^|[^<])(:[^:\s]*(?:::[^:\s]*)*:)`)
97 | )
98 |
99 | func discordToLightningContent(session *discordgo.Session, msg *discordgo.Message) string {
100 | content := defaultEmoji.ReplaceAllStringFunc(msg.Content, func(match string) string {
101 | if e, ok := emoji.GetEmoji(match); ok {
102 | return e
103 | }
104 |
105 | return match
106 | })
107 |
108 | content = tenorURL.ReplaceAllStringFunc(content, func(match string) string {
109 | return "https://tenor.com/view/" + tenorURL.FindStringSubmatch(match)[1] + ".gif"
110 | })
111 |
112 | content = userMention.ReplaceAllStringFunc(content, func(match string) string {
113 | userID := userMention.FindStringSubmatch(match)[1]
114 |
115 | if msg.GuildID != "" {
116 | if m, err := session.State.Member(msg.GuildID, userID); err == nil {
117 | return "@" + m.DisplayName()
118 | }
119 | }
120 |
121 | if u, err := session.User(userID); err == nil {
122 | return "@" + u.DisplayName()
123 | }
124 |
125 | return "@" + userID
126 | })
127 |
128 | content = channelMention.ReplaceAllStringFunc(content, func(match string) string {
129 | channelID := channelMention.FindStringSubmatch(match)[1]
130 |
131 | if ch, err := session.State.Channel(channelID); err == nil {
132 | return "#" + ch.Name
133 | }
134 |
135 | return "#" + channelID
136 | })
137 |
138 | content = roleMention.ReplaceAllStringFunc(content, func(match string) string {
139 | roleID := roleMention.FindStringSubmatch(match)[1]
140 |
141 | if g, err := session.State.Guild(msg.GuildID); err == nil {
142 | for _, r := range g.Roles {
143 | if r.ID == roleID {
144 | return "@" + r.Name
145 | }
146 | }
147 | }
148 |
149 | return "@&" + roleID
150 | })
151 |
152 | return content
153 | }
154 |
155 | func discordToLightningEmbeds(embeds []*discordgo.MessageEmbed) []lightning.Embed {
156 | out := make([]lightning.Embed, 0, len(embeds))
157 | for _, original := range embeds {
158 | embed := lightning.Embed{
159 | Footer: discordToLightningEmbedFooter(original.Footer),
160 | Author: discordToLightningEmbedAuthor(original.Author),
161 | Fields: discordToLightningEmbedFields(original.Fields),
162 | Timestamp: original.Timestamp,
163 | Title: original.Title,
164 | URL: original.URL,
165 | Description: original.Description,
166 | Color: original.Color,
167 | }
168 |
169 | if original.Image != nil && original.Image.URL != "" {
170 | embed.Image = &lightning.Media{URL: original.Image.URL}
171 | }
172 |
173 | if original.Thumbnail != nil && original.Thumbnail.URL != "" {
174 | embed.Thumbnail = &lightning.Media{URL: original.Thumbnail.URL}
175 | }
176 |
177 | out = append(out, embed)
178 | }
179 |
180 | return out
181 | }
182 |
183 | func discordToLightningEmbedFooter(original *discordgo.MessageEmbedFooter) *lightning.EmbedFooter {
184 | if original == nil {
185 | return nil
186 | }
187 |
188 | return &lightning.EmbedFooter{Text: original.Text, IconURL: original.IconURL}
189 | }
190 |
191 | func discordToLightningEmbedAuthor(original *discordgo.MessageEmbedAuthor) *lightning.EmbedAuthor {
192 | if original == nil {
193 | return nil
194 | }
195 |
196 | return &lightning.EmbedAuthor{Name: original.Name, URL: original.URL, IconURL: original.URL}
197 | }
198 |
199 | func discordToLightningEmbedFields(original []*discordgo.MessageEmbedField) []lightning.EmbedField {
200 | fields := make([]lightning.EmbedField, len(original))
201 |
202 | for i, f := range original {
203 | fields[i] = lightning.EmbedField{
204 | Name: f.Name,
205 | Value: f.Value,
206 | Inline: f.Inline,
207 | }
208 | }
209 |
210 | return fields
211 | }
212 |
213 | func discordToLightningAttachments(
214 | attachments []*discordgo.MessageAttachment,
215 | stickers []*discordgo.StickerItem,
216 | ) []lightning.Attachment {
217 | result := make([]lightning.Attachment, 0, len(attachments)+len(stickers))
218 |
219 | for _, a := range attachments {
220 | result = append(result, lightning.Attachment{
221 | URL: a.URL,
222 | Name: a.Filename,
223 | Size: int64(a.Size),
224 | })
225 | }
226 |
227 | for _, sticker := range stickers {
228 | url := "https://cdn.discordapp.com/stickers/" + sticker.ID
229 |
230 | switch sticker.FormatType {
231 | case discordgo.StickerFormatTypePNG, discordgo.StickerFormatTypeAPNG:
232 | url += ".png"
233 | case discordgo.StickerFormatTypeGIF:
234 | url += ".gif"
235 | case discordgo.StickerFormatTypeLottie:
236 | url += ".json"
237 | default:
238 | }
239 |
240 | result = append(result, lightning.Attachment{
241 | URL: url + "?size=160",
242 | Name: sticker.Name,
243 | })
244 | }
245 |
246 | return result
247 | }
248 |
249 | func discordToLightningReplies(reference *discordgo.MessageReference) []string {
250 | if reference == nil || reference.MessageID == "" || reference.Type != discordgo.MessageReferenceTypeDefault {
251 | return nil
252 | }
253 |
254 | return []string{reference.MessageID}
255 | }
256 |
257 | func discordToLightningEmoji(msg *lightning.Message) string {
258 | return emojiMention.ReplaceAllStringFunc(msg.Content, func(match string) string {
259 | parts := strings.Split(match, ":")
260 | if len(parts) < 3 {
261 | return match
262 | }
263 |
264 | emojiID := parts[2]
265 | emojiName := parts[1]
266 |
267 | url := "https://cdn.discordapp.com/emojis/" + emojiID
268 | if strings.HasPrefix(match, "`: Join an existing bridge with the given ID.\n"+
39 | "- `subscribe `: Subscribe to an existing bridge with the given ID (read-only).\n"+
40 | "- `leave `: Leave the bridge that this channel is part of.\n"+
41 | "- `reset`: Tries to reset the state of channels in a bridge\n"+
42 | "- `toggle `: Toggle a setting for the bridge that this channel is part of.\n"+
43 | "- `status`: Get the status of the bridge that this channel is part of.\n\n"+
44 | "Available settings are: `allow_everyone`.",
45 | ),
46 | Subcommands: map[string]lightning.Command{
47 | "create": getCreate(database), "join": getJoin(database, "join"),
48 | "subscribe": getJoin(database, "subscribe"), "leave": getLeave(database), "reset": getReset(database),
49 | "status": getStatus(database), "toggle": getToggle(database),
50 | },
51 | }, lightning.Command{
52 | Name: "help",
53 | Description: "get help with the bot",
54 | Executor: getExecutor("help", "", false, "Hi, I'm "+username+" "+lightning.VERSION+"! available commands are:"+
55 | "\n- `about`: learn about this bot\n- `bridge`: manage bridges between channels\n- `help`: returns this "+
56 | "help message\n- `ping`: checks the one way ping of the bot\n\n"+
57 | "read the [docs](https://williamhorning.dev/lightning) for more help"),
58 | }, lightning.Command{
59 | Name: "ping",
60 | Description: "check the bot's one way ping",
61 | Executor: getExecutor("Pong! 🏓", "", false, func(opts *lightning.CommandOptions) string {
62 | return strconv.FormatInt(time.Since(opts.Time).Milliseconds(), 10) + "ms"
63 | }),
64 | })
65 | }
66 |
67 | func getExecutor[T string | func(*lightning.CommandOptions) string](
68 | title, url string, secret bool, description T,
69 | ) func(*lightning.CommandOptions) {
70 | return func(opts *lightning.CommandOptions) {
71 | opts.Reply(&lightning.Message{Embeds: []lightning.Embed{{
72 | Title: title, URL: url, Description: (func() string {
73 | if str, ok := any(description).(string); ok {
74 | return str
75 | } else if fn, ok := any(description).(func(*lightning.CommandOptions) string); ok {
76 | return fn(opts)
77 | }
78 |
79 | return ""
80 | })(), Color: 0x487c7e,
81 | Footer: &lightning.EmbedFooter{
82 | Text: "powered by lightning",
83 | IconURL: "https://williamhorning.dev/assets/lightning.png",
84 | },
85 | Timestamp: time.Now().Format(time.RFC3339),
86 | }}}, secret)
87 | }
88 | }
89 |
90 | type alreadyInBridgeError struct{}
91 |
92 | func (alreadyInBridgeError) Error() string { return "this channel is already in a bridge" }
93 |
94 | const notInBridge = "this channel is not in a bridge"
95 |
96 | func getErr(msg string, err error) string {
97 | return "uh oh! looks like you got struck by an error: " +
98 | msg + "\n\n```\n" + err.Error() + "\n```\nif you think this is a bug, or need more help, see the " +
99 | "[docs](https://williamhorning.dev/lightning/bridge)"
100 | }
101 |
102 | func prepareChannelForBridge(db data.Database, opts *lightning.CommandOptions) (*data.BridgeChannel, error) {
103 | if br, err := db.GetBridgeByChannel(opts.ChannelID); br.ID != "" || err != nil {
104 | return nil, alreadyInBridgeError{}
105 | }
106 |
107 | channelData, err := opts.Bot.SetupChannel(opts.ChannelID)
108 | if err != nil {
109 | return nil, fmt.Errorf("failed to setup %s for bridge: %w", opts.ChannelID, err)
110 | }
111 |
112 | return &data.BridgeChannel{Data: channelData, ID: opts.ChannelID}, nil
113 | }
114 |
115 | func getCreate(database data.Database) lightning.Command {
116 | return lightning.Command{
117 | Name: "create",
118 | Description: "Create a new bridge containing this channel",
119 | Executor: getExecutor("bridge create", "", true, func(opts *lightning.CommandOptions) string {
120 | channel, err := prepareChannelForBridge(database, opts)
121 | if err != nil {
122 | return getErr("failed to prepare channel data", err)
123 | }
124 |
125 | bridge := data.Bridge{
126 | ID: ulid.Make().String(),
127 | Channels: []data.BridgeChannel{*channel},
128 | Settings: data.BridgeSettings{},
129 | }
130 |
131 | if err := database.CreateBridge(bridge); err != nil {
132 | return getErr("failed to insert bridge row", err)
133 | }
134 |
135 | return "you can now join the bridge you made in other channels by using ||`" +
136 | opts.Prefix + "bridge join " + bridge.ID + "`||. Keep that command secret!"
137 | }),
138 | }
139 | }
140 |
141 | func getJoin(database data.Database, name string) lightning.Command {
142 | cmd := lightning.Command{
143 | Name: name,
144 | Description: "Join an existing bridge with the given ID",
145 | Arguments: []lightning.CommandArgument{{Name: "id", Description: "bridge ID", Required: true}},
146 | Executor: getExecutor("bridge join", "", true, func(opts *lightning.CommandOptions) string {
147 | bridge, err := database.GetBridge(opts.Arguments["id"])
148 | if err != nil || bridge.ID == "" {
149 | return "that bridge doesn't exist"
150 | }
151 |
152 | channel, err := prepareChannelForBridge(database, opts)
153 | if err != nil {
154 | return getErr("failed to prepare channel data", err)
155 | }
156 |
157 | if name == "subscribe" {
158 | channel.Disabled.Read = true
159 | }
160 |
161 | bridge.Channels = append(bridge.Channels, *channel)
162 |
163 | if err := database.CreateBridge(bridge); err != nil {
164 | return getErr("failed to update bridge row", err)
165 | }
166 |
167 | return "bridge joined (or subscribed) successfully!"
168 | }),
169 | }
170 |
171 | if name == "subscribe" {
172 | cmd.Description = "Subscribe to an existing bridge with the given ID (read-only)"
173 | }
174 |
175 | return cmd
176 | }
177 |
178 | func getLeave(database data.Database) lightning.Command {
179 | return lightning.Command{
180 | Name: "leave",
181 | Description: "Leave the bridge that this channel is part of",
182 | Arguments: []lightning.CommandArgument{{Name: "id", Description: "bridge ID", Required: true}},
183 | Executor: getExecutor("bridge leave", "", true, func(opts *lightning.CommandOptions) string {
184 | bridge, err := database.GetBridge(opts.Arguments["id"])
185 | if err != nil || bridge.ID == "" || bridge.ID != opts.Arguments["id"] {
186 | return notInBridge
187 | }
188 |
189 | for idx, channel := range bridge.Channels {
190 | if channel.ID == opts.ChannelID {
191 | bridge.Channels = slices.Delete(bridge.Channels, idx, idx+1)
192 |
193 | break
194 | }
195 | }
196 |
197 | if err := database.CreateBridge(bridge); err != nil {
198 | return getErr("failed to update bridge row", err)
199 | }
200 |
201 | return "channel removed from the bridge."
202 | }),
203 | }
204 | }
205 |
206 | func getReset(database data.Database) lightning.Command {
207 | return lightning.Command{
208 | Name: "reset",
209 | Description: "Tries to reset the state of channels in a bridge",
210 | Executor: getExecutor("bridge reset", "", false, func(opts *lightning.CommandOptions) string {
211 | bridge, err := database.GetBridgeByChannel(opts.ChannelID)
212 | if err != nil || bridge.ID == "" {
213 | return notInBridge
214 | }
215 |
216 | restored := 0
217 |
218 | for idx, ch := range bridge.Channels {
219 | if !ch.Disabled.Read && !ch.Disabled.Write {
220 | continue
221 | }
222 |
223 | data, err := opts.Bot.SetupChannel(ch.ID)
224 | if err != nil {
225 | continue
226 | }
227 |
228 | bridge.Channels[idx].Data = data
229 | bridge.Channels[idx].Disabled.Read = false
230 | bridge.Channels[idx].Disabled.Write = false
231 | restored++
232 | }
233 |
234 | if err := database.CreateBridge(bridge); err != nil {
235 | return getErr("failed to update bridge row", err)
236 | }
237 |
238 | return "finished resetting: " + strconv.FormatInt(int64(restored), 10) + " channels reset"
239 | }),
240 | }
241 | }
242 |
243 | func getStatus(database data.Database) lightning.Command {
244 | return lightning.Command{
245 | Name: "status",
246 | Description: "view channels and settings in this bridge",
247 | Executor: getExecutor("bridge status", "", false, func(opts *lightning.CommandOptions) string {
248 | bridge, err := database.GetBridgeByChannel(opts.ChannelID)
249 | if err != nil || bridge.ID == "" {
250 | return notInBridge
251 | }
252 |
253 | status := "**Channels:**\n"
254 |
255 | for _, channel := range bridge.Channels {
256 | status += "- `" + channel.ID + "`"
257 |
258 | switch {
259 | case channel.Disabled.Read && channel.Disabled.Write:
260 | status += " (disabled - try `" + opts.Prefix + "bridge reset` to fix this)"
261 | case channel.Disabled.Read:
262 | status += " (subscribed - to enable this channel, try `" + opts.Prefix + "bridge reset`)"
263 | case channel.Disabled.Write:
264 | status += " (read-only - try `" + opts.Prefix + "bridge reset` to fix this)"
265 | default:
266 | }
267 | }
268 |
269 | return "\n**Settings:**\n- AllowEveryone: " + strconv.FormatBool(bridge.Settings.AllowEveryone)
270 | }),
271 | }
272 | }
273 |
274 | func getToggle(database data.Database) lightning.Command {
275 | return lightning.Command{
276 | Name: "toggle",
277 | Description: "toggle a bridge setting",
278 | Arguments: []lightning.CommandArgument{{Name: "setting", Description: "setting name", Required: true}},
279 | Executor: getExecutor("bridge toggle", "", false, func(opts *lightning.CommandOptions) string {
280 | bridge, err := database.GetBridgeByChannel(opts.ChannelID)
281 | if err != nil || bridge.ID == "" {
282 | return notInBridge
283 | }
284 |
285 | switch strings.ToLower(opts.Arguments["setting"]) {
286 | case "alloweveryone", "allow_everyone":
287 | bridge.Settings.AllowEveryone = !bridge.Settings.AllowEveryone
288 | default:
289 | return "unknown setting: " + opts.Arguments["setting"]
290 | }
291 |
292 | if err := database.CreateBridge(bridge); err != nil {
293 | return getErr("failed to update bridge row", err)
294 | }
295 |
296 | return "setting toggled successfully."
297 | }),
298 | }
299 | }
300 |
--------------------------------------------------------------------------------