diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..668679613084ec650caff6f7b2ba959184f74fa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bridgething +bridge.conf + +*.swp +.idea/ \ No newline at end of file diff --git a/cleanup.go b/cleanup.go new file mode 100644 index 0000000000000000000000000000000000000000..2ebb497637f149ca8e160ac6dace23a4b12d616c --- /dev/null +++ b/cleanup.go @@ -0,0 +1,15 @@ +package main + +import "log" + +func cleanup() { + if botAPI != nil { + botAPI.StopReceivingUpdates() + } + + if session != nil { + if err := session.Close(); err != nil { + log.Printf("error closing discord session: %s", err) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000000000000000000000000000000000000..e252e848d9961e155daec5001c9c7aaef50a9bc8 --- /dev/null +++ b/config.go @@ -0,0 +1,157 @@ +package main + +import ( + "flag" + "github.com/pelletier/go-toml/v2" + "log" + "os" + "strconv" +) + +var ( + config conf + confPath string + parsed = false + + discordBridge = make(map[string]bridgeConf) + telegramBridge = make(map[int64]bridgeConf) +) + +func init() { + flag.StringVar(&confPath, "c", "bridge.conf", "specify location of configuration file") +} + +type conf struct { + System systemConf + Telegram telegramConf + Discord discordConf + Bridges struct{ Item []bridgeConf } +} + +type systemConf struct { + Verbose bool + DisplaceHeader bool +} + +type telegramConf struct { + Token string + NameFormat string + StickerEmoji bool + BypassBacklog bool +} + +type discordConf struct { + Token string + NameFormat string + StickerText bool +} + +type bridgeConf struct { + Label string + Discord bridgePlatformConf + Telegram bridgePlatformConf +} + +type bridgePlatformConf struct { + ID int + Enable bool + Join bool + Leave bool + Delete bool +} + +func setupConfig() { + if parsed { + panic("config already parsed") + } + defer func() { parsed = true }() + + if file, err := os.Open(confPath); err != nil { + if !os.IsNotExist(err) { + log.Fatalf("error opening configuration file: %s", err) + } + if file, err = os.Create(confPath); err != nil { + log.Fatalf("error creating configuration file: %s", err) + } else { + encoder := toml.NewEncoder(file) + encoder.SetIndentSymbol(" ") + encoder.SetIndentTables(true) + + if err = encoder.Encode(defConf); err != nil { + log.Fatalf("error generating default configuration: %s", err) + } else { + log.Print("default configuration generated") + os.Exit(1) + } + } + } else if err = toml.NewDecoder(file).Decode(&config); err != nil { + log.Fatalf("error parsing configuration: %s", err) + } else { + if err = file.Close(); err != nil { + log.Fatalf("error closing configuration: %s", err) + } + } + + for _, c := range config.Bridges.Item { + if c.Discord.Enable { + discordBridge[strconv.Itoa(c.Discord.ID)] = c + } + if c.Telegram.Enable { + telegramBridge[int64(c.Telegram.ID)] = c + } + } +} + +var defConf = conf{ + System: systemConf{ + Verbose: false, + DisplaceHeader: true, + }, + Telegram: telegramConf{ + Token: "INSERT_TOKEN_HERE", + NameFormat: "**$FIRST$SPACE$LAST (@$USER)**", + StickerEmoji: true, + BypassBacklog: true, + }, + Discord: discordConf{ + Token: "INSERT_TOKEN_HERE", + NameFormat: "$USER#$DISCRIMINATOR", + StickerText: true, + }, + Bridges: struct{ Item []bridgeConf }{Item: []bridgeConf{ + { + Label: "Default", + Discord: bridgePlatformConf{ + ID: 0, + Enable: true, + Join: true, + Leave: true, + Delete: true, + }, + Telegram: bridgePlatformConf{ + ID: 0, + Enable: true, + Join: true, + Leave: true, + Delete: true, + }, + }, + { + Label: "Disabled", + Discord: bridgePlatformConf{ + ID: 0, + Enable: false, + Join: true, + Leave: true, + Delete: true, + }, + Telegram: bridgePlatformConf{ + ID: 0, + Enable: false, + Join: true, + Leave: true, + Delete: true, + }, + }, + }}, +} diff --git a/discord.go b/discord.go new file mode 100644 index 0000000000000000000000000000000000000000..db59670b90836599e969c511164b3bf048b7ce63 --- /dev/null +++ b/discord.go @@ -0,0 +1,53 @@ +package main + +import ( + "github.com/bwmarrin/discordgo" + "log" +) + +var session *discordgo.Session + +func init() { + discordgo.Logger = func(msgL, _ int, format string, a ...interface{}) { + prefix := "discord: " + switch msgL { + case discordgo.LogError: + prefix += "[error] " + case discordgo.LogWarning: + prefix += "[warning] " + case discordgo.LogInformational: + prefix += "[informational] " + case discordgo.LogDebug: + if !config.System.Verbose { + return + } + prefix += "[debug] " + } + log.Printf(prefix+format, a...) + } +} + +func openDiscord() { + if s, err := discordgo.New(); err != nil { + // this should never happen + log.Fatalf("error creating discord session: %s", err) + } else { + session = s + } + + session.Identify.Intents = discordgo.IntentsGuildMessages + session.Token = "Bot " + config.Discord.Token + + if err := session.Open(); err != nil { + log.Fatalf("error connecting to discord: %s", err) + } else { + log.Printf("connected to discord as %s#%s (%s)", + session.State.User.Username, session.State.User.Discriminator, session.State.User.ID) + } + + ready <- struct{}{} +} + +func handleDiscord() { + // TODO +} diff --git a/go.mod b/go.mod index 59452587e5d2132c8a269da942cf3bf069c842f2..954d4352cd7ad7cfb2bec04a0665aaf0fc7f413a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module random.chars.jp/git/bridgething go 1.17 + +require ( + github.com/bwmarrin/discordgo v0.23.2 + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible + github.com/pelletier/go-toml/v2 v2.0.0-beta.3 +) + +require ( + github.com/gorilla/websocket v1.4.0 // indirect + github.com/technoweenie/multipartstreamer v1.0.1 // indirect + golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..b92f49b5f2533b4a4f6248a9fa307447502e1f7f --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/bwmarrin/discordgo v0.23.2 h1:BzrtTktixGHIu9Tt7dEE6diysEF9HWnXeHuoJEt2fH4= +github.com/bwmarrin/discordgo v0.23.2/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/pelletier/go-toml/v2 v2.0.0-beta.3 h1:PNCTU4naEJ8mKal97P3A2qDU74QRQGlv4FXiL1XDqi4= +github.com/pelletier/go-toml/v2 v2.0.0-beta.3/go.mod h1:aNseLYu/uKskg0zpr/kbr2z8yGuWtotWf/0BpGIAL2Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942 h1:t0lM6y/M5IiUZyvbBTcngso8SZEZICH7is9B6g/obVU= +github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA= +golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..415593f1620800baf1b818605b25170cdfa07cdf --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "syscall" +) + +var ( + exec string + rest bool + ready = make(chan struct{}) +) + +func main() { + flag.Parse() + + setupConfig() + go openTelegram() + go openDiscord() + + go func() { + <-ready + <-ready + log.Print("sessions ready, starting handlers") + go handleDiscord() + go handleTelegram() + }() + + s := make(chan os.Signal, 1) + signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + func() { + defer func() { cleanup() }() + for { + sig := <-s + switch sig { + case syscall.SIGINT: + println() + log.Print("shutting down") + return + default: + log.Print("shutting down") + return + } + } + }() + + if rest { + restart() + } +} diff --git a/restart.go b/restart.go new file mode 100644 index 0000000000000000000000000000000000000000..7442e19bd5c4813d0dba400708737de1bb3e4ed5 --- /dev/null +++ b/restart.go @@ -0,0 +1,26 @@ +//go:build !windows + +package main + +import ( + "log" + "os" + "syscall" +) + +func restart() { + var err error + if exec == "" { + return + } + + if _, err = os.Stat(exec); err != nil { + log.Fatalf("error stat exec: %s", err) + } + + log.Printf("execve %s", exec) + err = syscall.Exec(exec, os.Args, os.Environ()) + if err != nil { + log.Fatalf("error execve: %s", err) + } +} diff --git a/restart_windows.go b/restart_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..fa2e49c13383b1337fce156199a8ebc155eadc58 --- /dev/null +++ b/restart_windows.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "os" +) + +func restart() { + if exec == "" { + return + } + + if _, err := os.Stat(executable); err != nil { + log.Fatalf("error stat exec: %s", err) + } + wd, err := os.Getwd() + if err != nil { + log.Fatalf("error getwd: %s", err) + } + log.Printf("pwd is %s", wd) + + if _, err = os.StartProcess(executable, []string{}, &os.ProcAttr{ + Dir: wd, + Env: nil, + Files: []*os.File{os.Stderr, os.Stdin, os.Stdout}, + Sys: nil, + }); err != nil { + log.Fatalf("error start process: %s", err) + } +} diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000000000000000000000000000000000000..3e8ac1b64eea8d47e906088194831915ba0f7f5c --- /dev/null +++ b/telegram.go @@ -0,0 +1,213 @@ +package main + +import ( + "github.com/bwmarrin/discordgo" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +var botAPI *tgbotapi.BotAPI + +type botLogger struct{} + +func (botLogger) Printf(format string, v ...interface{}) { + log.Printf("telegram: "+format, v...) +} +func (botLogger) Println(v ...interface{}) { + log.Println(append([]interface{}{"telegram:"}, v...)...) +} + +func init() { + if err := tgbotapi.SetLogger(botLogger{}); err != nil { + panic(err) + } +} + +func openTelegram() { + if bot, err := tgbotapi.NewBotAPI(config.Telegram.Token); err != nil { + log.Fatalf("error connecting to telegram: %s", err) + } else { + botAPI = bot + } + log.Printf("connected to telegram as @%s (%v)", botAPI.Self.UserName, botAPI.Self.ID) + botAPI.Debug = config.System.Verbose + + ready <- struct{}{} +} + +func handleTelegram() { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + if updates, err := botAPI.GetUpdatesChan(u); err != nil { + log.Fatalf("error getting updates: %s", err) + } else { + if config.Telegram.BypassBacklog { + time.Sleep(time.Millisecond * 500) + updates.Clear() + } + + for update := range updates { + respondTelegram(update) + } + } +} + +var previousCaller = make(map[int64]int) + +func setPreviousCaller(cid int64, uid int) bool { + if previousCaller[cid] == uid { + return true + } + previousCaller[cid] = uid + return false +} + +func respondTelegram(update tgbotapi.Update) { + // TODO: cross reply + // TODO: sticker object storage + + if update.Message == nil || update.Message.Chat == nil { + if update.EditedMessage != nil { + // TODO: handle edit + return + } + if config.System.Verbose { + log.Printf("got irrelevant update %v", update.UpdateID) + } + return + } + + if update.Message.IsCommand() { + message := tgbotapi.NewMessage(update.Message.Chat.ID, "") + switch update.Message.Command() { + case "id": + message.Text = strconv.Itoa(int(update.Message.Chat.ID)) + default: + } + if message.Text != "" { + message.ReplyToMessageID = update.Message.MessageID + if _, err := botAPI.Send(message); err != nil { + log.Printf("error responding to command %v, %s", update.Message.Chat.ID, err) + } + return + } + } + + var ( + dc bridgePlatformConf + tc bridgePlatformConf + ) + if c, ok := telegramBridge[update.Message.Chat.ID]; !ok { + return + } else { + dc = c.Discord + tc = c.Telegram + } + + var ( + username = "unknown" + id = -1 + dMsgID = "-1" + ) + if update.Message.From != nil { + username = update.Message.From.UserName + id = update.Message.From.ID + } + + space := "" + if update.Message.From.LastName != "" { + space = " " + } + name := "" + if !config.System.DisplaceHeader || !setPreviousCaller(update.Message.Chat.ID, update.Message.From.ID) { + // TODO: check the discord side for header displace + name = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(config.Telegram.NameFormat, + "$FIRST", update.Message.From.FirstName), + "$LAST", update.Message.From.LastName), + "$USER", update.Message.From.UserName), + "$SPACE", space) + name += "\n" + } + + if update.Message.Sticker != nil { + var url string + if u, err := botAPI.GetFileDirectURL(update.Message.Sticker.FileID); err != nil { + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error getting URL") + msg.ReplyToMessageID = update.Message.MessageID + log.Printf("error getting URL of %s: %s", update.Message.Sticker.FileID, err) + _, _ = botAPI.Send(msg) + return + } else { + url = u + } + + var ( + file *discordgo.File + body io.ReadCloser + ) + if resp, err := http.Get(url); err != nil { + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error getting file") + msg.ReplyToMessageID = update.Message.MessageID + log.Printf("error getting file %s: %s", update.Message.Sticker.FileID, err) + _, _ = botAPI.Send(msg) + return + } else { + file = &discordgo.File{ + Name: "sticker.webp", + ContentType: "image/webp", + Reader: resp.Body, + } + body = resp.Body + } + + content := name + if config.Telegram.StickerEmoji { + content += update.Message.Sticker.Emoji + } + if message, err := session.ChannelMessageSendComplex(strconv.Itoa(dc.ID), &discordgo.MessageSend{ + Content: content, + File: file, + }); err != nil { + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error relaying message") + msg.ReplyToMessageID = update.Message.MessageID + log.Printf("error relaying message %v: %s", update.Message.MessageID, err) + _, _ = botAPI.Send(msg) + return + } else { + dMsgID = message.ID + } + if err := body.Close(); err != nil { + log.Printf("error closing request body: %s", err) + } + log.Printf("T%vM%v -> D%vM%s @%s (%v): %s", + tc.ID, update.Message.MessageID, dc.ID, dMsgID, username, id, "Sticker: "+update.Message.Sticker.FileID) + return + } + + if update.Message.Contact != nil { + // TODO: handle contact + return + } + + if update.Message.Text == "" { + return + } + + if message, err := session.ChannelMessageSend(strconv.Itoa(dc.ID), name+update.Message.Text); err != nil { + msg := tgbotapi.NewMessage(update.Message.Chat.ID, "error relaying message") + msg.ReplyToMessageID = update.Message.MessageID + log.Printf("error relaying message %v: %s", update.Message.MessageID, err.Error()) + _, _ = botAPI.Send(msg) + } else { + dMsgID = message.ID + } + + log.Printf("T%vM%v -> D%vM%s @%s (%v): %s", + tc.ID, update.Message.MessageID, dc.ID, dMsgID, username, id, update.Message.Text) +}