├── .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 "![](" + media.URL + ")\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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /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 | ![lightning logo](./.github/logo.svg) 4 | 5 | - [the bridge](https://williamhorning.dev/lightning/bridge) - connect 6 | Discord, Guilded, Stoat, and Telegram 7 | - [the framework](https://williamhorning.dev/lightning/framework) - build 8 | your own cross-platform bots 9 | 10 | Lightning is a project developing _truly powerful_ cross-platform bots, with 11 | the underlying _Lightning framework_ being used for _Lightning bridge_, 12 | which is what runs _Bolt_, the hosted bridge bot. The goal is to also make the 13 | framework itself usable by other developers, to create their own bots, and to 14 | make the bridge easy to self-host, while also supporting the principles of: 15 | 16 | - **Connecting communities**: the Lightning bridge connects Discord, Guilded, 17 | Stoat, and Telegram, allowing communities to connect, wherever they are 18 | - **Extensibility**: the Lightning framework uses plugins to make it easy to 19 | add new features, while keeping the core simple. The bridge is also designed to 20 | be flexible, with options to disable pings, setup subscribe-only channels, and 21 | more. 22 | - **Ease of use**: the Lightning framework is designed to be easy to use, 23 | with a simple API, and the bridge is designed to be easy to set up and use, 24 | with easy-to-understand documentation. 25 | - **Strength**: Lightning is built on Go, making it easy to build, run, and 26 | configure, all while being performant and reliable. 27 | 28 | ## the bridge bot 29 | 30 | If you've ever had a community, chances are you talk to them in many places, 31 | whether that's Discord, Guilded, Stoat, or Telegram. Over time, you end up 32 | with fragmentation as your community grows and changes, with many people using 33 | multiple messaging apps. People eventually grow tired of the differences 34 | between apps, and switching between them, with things becoming a mess. 35 | 36 | You could _try_ to move everyone to one app, but that might alienate people, so 37 | what do you do, what options do you have? 38 | 39 | **Bridging!** Everyone can use their favorite app, gets the same messages, and 40 | is on the same page. Lightning is an easy to use bridge bot that supports 41 | Discord, Guilded, Stoat, and Telegram. To get started, check out the 42 | [getting started guide](https://williamhorning.dev/lightning/bridge/users), 43 | which will walk you through using Bolt, the hosted version of the Lightning bot. 44 | If you want to self-host, read the 45 | [self-hosting guide](https://williamhorning.dev/lightning/bridge/hosting) to 46 | get started. 47 | 48 | ## the framework 49 | 50 | Lightning is a framework for building cross-platform bots, allowing you to make 51 | bots that support multiple platforms without having to worry about 52 | platform-specific code. The framework is built in Go, making it easy to work 53 | with, and is designed to handle things like commands, events, rate-limits, 54 | attachments, and more, all while being battle-tested in Bolt, which has handled 55 | over half-a-million messages during just the summer of 2025. 56 | 57 | The framework consists of the core library, which is platform-agnostic, and 58 | plugins, which add support for specific platforms, such as Discord, Guilded, 59 | Stoat, and Telegram. The only platform-specific code is in the plugins, making 60 | it possible to support new platforms without modifying your bot's core logic. 61 | 62 | To see a simple example of how to use the framework, check out the 63 | [framework: hello world](https://williamhorning.dev/lightning/framework/hello-world) 64 | guide, which will walk you through creating a simple bot that responds to 65 | messages and commands. For the full documentation, check out the 66 | [framework documentation](https://williamhorning.dev/lightning/framework). 67 | 68 | ## what's the difference between Bolt and Lightning? 69 | 70 | **Lightning** is both the open-source framework and bridge bot that are used to 71 | run **Bolt**, the hosted version of the bridge bot. Bolt is a specific instance 72 | of the Lightning bridge bot, which is hosted by 73 | [William Horning](https://williamhorning.dev/) and is free to use. You can 74 | also self-host your own instance of the Lightning bridge 75 | 76 | ## what about matrix? 77 | 78 | The [Matrix protocol](https://matrix.org) is great, and adding support for it 79 | to the Lightning framework would be amazing, but non-trivial. Implementing all 80 | the features necessary to make a smooth and reliable experience, on par with 81 | other platforms, is something that is being worked on in 82 | [#98](https://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 | --------------------------------------------------------------------------------