aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--LICENCE15
-rw-r--r--README.md34
-rw-r--r--go.mod3
-rw-r--r--main.go449
5 files changed, 505 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ade1aab
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+telebonk
+acme.dump
+*.json
+.env
diff --git a/LICENCE b/LICENCE
new file mode 100644
index 0000000..20fa33e
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2022, la-ninpre
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5ce93f6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# telebonk
+
+(or terebonk, or テレボンク) is a reposter from [honk][1] to telegram.
+
+[1]: https://humungus.tedunangst.com/r/honk
+
+## usage
+
+create bot in telegram to obtain the token.
+note down chat_id of a chat that you want to post honks to (see chat_id section).
+obtain a honk authentication token by using it's api.
+
+install telebonk and run it (replace values in angle brackets with an actual values):
+
+```
+$ go install git.aaoth.xyz/la-ninpre/telebonk@latest
+$ telebonk -bot_token <bot token> -chat_id <chat id> -honk_token <honk token> -honk_url <honk url>
+```
+
+### chat_id
+
+telegram `chat_id` is an integer. you can get it from a web version of telegram or by
+using the [`getUpdates` method of a telegram bot][2] and sending a message to a bot.
+for channels you must add `-100` to the id you got.
+
+[2]: https://core.telegram.org/bots/api#getupdates
+
+## bugs
+
+yes.
+
+## licence
+
+telebonk is distributed under the terms of ISC licence (see `LICENCE`). \ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7d014b4
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module git.aaoth.xyz/la-ninpre/telebonk
+
+go 1.19
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..1c0bd07
--- /dev/null
+++ b/main.go
@@ -0,0 +1,449 @@
+// Telebonk is a reposter from Honk to Telegram.
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// A Config is holding configuration of telebonk.
+type Config struct {
+ TgBotToken string
+ TgChatID string
+ TgApiURL string
+ HonkAuthToken string
+ HonkURL string
+}
+
+// Check makes sure that no Config fields are set to empty strings.
+func (c *Config) Check() error {
+ var what string
+ if c.TgBotToken == "" {
+ what = "bot_token"
+ }
+ if c.TgChatID == "" {
+ what = "chat_id"
+ }
+ if c.TgApiURL == "" {
+ what = "tgapi_url"
+ }
+ if c.HonkAuthToken == "" {
+ what = "honk_token"
+ }
+ if c.HonkURL == "" {
+ what = "honk_url"
+ }
+ if what != "" {
+ return fmt.Errorf("'%s' shouldn't be empty", what)
+ }
+ return nil
+}
+
+var config = &Config{}
+
+// A Honk is a honk's honk object with most fields omitted.
+type Honk struct {
+ ID int
+ What string // to filter out honkbacks
+ Oondle string
+ Oonker string
+ XID string
+ RID string
+ Date time.Time
+ Precis string // danger zone and all that
+ Noise string
+ Onts []string // to filter out #notg
+ Donks []*Donk
+
+ messID int
+ replyToID int
+}
+
+// A Donk stores metadata for media files.
+type Donk struct {
+ URL string
+ Media string
+ Desc string
+}
+
+// Check performs some checks on a Honk to filter out what's not going to be posted.
+//
+// It rejects honk if if falls into one of these categories:
+// - it is posted before the telebonk started
+// - it is replying to a honk that's not posted by telebonk (either a remote honk or old honk)
+// - it is of unsupported type (not a regular honk, reply or bonk)
+// - it contains a `#notg` tag.
+// - it is empty
+func (h *Honk) Check() error {
+ log.Print("checking honk #", h.ID)
+ if h.Date.Before(now) {
+ return fmt.Errorf("honk #%d is old", h.ID)
+ }
+ switch h.What {
+ case "honked", "bonked":
+ break
+ case "honked back":
+ hi, ok := honkMap[h.RID]
+ if !ok {
+ return fmt.Errorf("cannot reply to nonexisting telebonk")
+ }
+ h.replyToID = hi.t.Result.MessageID
+ default:
+ return fmt.Errorf("unsupported honk type: %s", h.What)
+ }
+
+ for _, ont := range h.Onts {
+ if strings.ToLower(ont) == "#notg" {
+ return fmt.Errorf("skipping #notg honk")
+ }
+ }
+
+ if h.Noise == emptyNoise && len(h.Donks) == 0 {
+ return fmt.Errorf("empty honk")
+ }
+ return nil
+}
+
+func (h *Honk) Decide() honkAction {
+ hi, ok := honkMap[h.XID]
+ if ok {
+ if hi.t == nil || hi.h.Date.Equal(h.Date) {
+ h.save(hi.t) // is this wrong?
+ return honkIgnore
+ }
+ h.messID = hi.t.Result.MessageID
+ return honkEdit
+ }
+ return honkSend
+}
+
+// save records Honk to a honkMap
+func (h *Honk) save(t *TelegramResponse) {
+ honkMap[h.XID] = honkInfo{h: h, t: t}
+}
+
+// forget removes Honk from a honkMap
+func (h *Honk) forget() {
+ delete(honkMap, h.XID)
+}
+
+type honkAction int
+
+const (
+ honkIgnore honkAction = iota
+ honkSend
+ honkEdit
+)
+
+const emptyNoise = "<p></p>\n"
+
+// A Mess holds data for a message to be sent to Telegram.
+type Mess struct {
+ Text string `json:"text"`
+ ParseMode string `json:"parse_mode"`
+ ChatID string `json:"chat_id"`
+ MessageID int `json:"message_id,omitempty"`
+ ReplyToMessageID int `json:"reply_to_message_id,omitempty"`
+
+ // Donks
+ Document string `json:"document,omitempty"`
+ Photo string `json:"photo,omitempty"`
+ Caption string `json:"caption,omitempty"`
+ kind messKind // messHonk, messDonkPht or messDonkDoc
+}
+
+// A TelegramResponse is for handling possible errors with Telegram API.
+type TelegramResponse struct {
+ Ok bool
+ Description string
+ Result telegramResponseResult
+}
+
+type telegramResponseResult struct {
+ MessageID int `json:"message_id"`
+}
+
+// NewMess creates and populates a new Mess with default values.
+func NewMess(parseMode string) *Mess {
+ return &Mess{
+ ParseMode: parseMode,
+ ChatID: config.TgChatID,
+ kind: messHonk,
+ }
+}
+
+// NewMessFromHonk creates a slice of Mess objects from existing Honk.
+func NewMessFromHonk(honk *Honk, action honkAction) []*Mess {
+ // donks should be sent as a separate messages, so need to create all of 'em
+ // cap(messes) = 1 for honk + 1 for each donk
+ var messes = make([]*Mess, 0, 1+len(honk.Donks))
+
+ messes = append(messes, NewMess("html"))
+ for _, donk := range honk.Donks {
+ donkMess := NewMess("MarkdownV2") // donks don't contain html
+ donkMess.Caption = donk.Desc // FIXME: no check for length
+ switch {
+ case strings.HasPrefix(donk.Media, "image/"):
+ donkMess.Photo = donk.URL
+ donkMess.kind = messDonkPht
+ case donk.Media == "application/pdf", donk.Media == "text/plain":
+ donkMess.Document = donk.URL
+ donkMess.kind = messDonkDoc
+ }
+ messes = append(messes, donkMess)
+ }
+ if honk.Noise == emptyNoise {
+ messes = messes[1:] // just donks
+ }
+
+ if action == honkEdit {
+ // TODO: implement editing documents and photos
+ messes[0].kind = messEdit
+ messes[0].MessageID = honk.messID
+ messes = messes[:1] // don't donk if editing
+ }
+
+ var noise = calmNoise(honk.Noise)
+ // bonk, then honk back - ok
+ // honk back, then bonk - not gonna sync, is it ok?
+ // upd: bonks work really confusing
+ switch honk.What {
+ case "honked":
+ break
+ case "honked back":
+ messes[0].ReplyToMessageID = honk.replyToID
+ case "bonked":
+ oonker := fmt.Sprintf("<a href=\"%s\">%s</a>:", honk.Oonker, honk.Oondle)
+ noise = oonker + "\n" + noise
+ }
+
+ // danger zone handling
+ if strings.HasPrefix(honk.Precis, "DZ:") {
+ noise = strings.Join([]string{"<tg-spoiler>", "</tg-spoiler>"}, noise)
+ noise = honk.Precis + "\n" + noise
+ }
+ messes[0].Text = noise
+
+ return messes
+}
+
+// Send sends a Mess to Telegram.
+func (m *Mess) Send() (*TelegramResponse, error) {
+ var apiURL = botAPIMethod(tgSendMessage)
+
+ switch m.kind {
+ case messHonk:
+ // noop
+ case messEdit:
+ apiURL = botAPIMethod(tgEditMessageText)
+ case messDonkPht:
+ apiURL = botAPIMethod(tgSendPhoto)
+ case messDonkDoc:
+ apiURL = botAPIMethod(tgSendDocument)
+ }
+
+ junk, err := json.Marshal(m)
+ if err != nil {
+ return nil, err
+ }
+ buf := bytes.NewBuffer(junk)
+ req, err := http.NewRequest("POST", apiURL, buf)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Content-type", "application/json")
+ req.Header.Add("Content-length", strconv.Itoa(buf.Len()))
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var res TelegramResponse
+ json.NewDecoder(resp.Body).Decode(&res)
+ if !res.Ok {
+ return nil, fmt.Errorf("mess send: %s", res.Description)
+ }
+
+ return &res, nil
+}
+
+type messKind int
+
+const (
+ messHonk messKind = iota
+ messEdit
+ messDonkPht
+ messDonkDoc
+)
+
+func botAPIMethod(method string) string {
+ return fmt.Sprintf("%s/bot%s/%s", config.TgApiURL, config.TgBotToken, method)
+}
+
+func checkTgAPI() error {
+ var apiURL = botAPIMethod(tgGetMe)
+ resp, err := client.Get(apiURL)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != 200 {
+ status, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("status: %d: %s", resp.StatusCode, status)
+ }
+ return nil
+}
+
+const (
+ tgGetMe = "getMe"
+ tgSendMessage = "sendMessage"
+ tgEditMessageText = "editMessageText"
+ tgSendPhoto = "sendPhoto"
+ tgSendDocument = "sendDocument"
+)
+
+// A honkInfo stores data for honk that is sent to Telegram.
+type honkInfo struct {
+ h *Honk
+ t *TelegramResponse
+}
+
+// getHonks receives and unmarshals some honks from a Honk instance.
+func getHonks(after int) ([]*Honk, error) {
+ query := url.Values{}
+ query.Set("token", config.HonkAuthToken)
+ query.Set("action", "gethonks")
+ query.Set("page", "home")
+ query.Set("after", strconv.Itoa(after))
+
+ resp, err := client.Get(fmt.Sprint(config.HonkURL, "/api?", query.Encode()))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ // honk outputs junk like `{ "honks": [ ... ] }`, need to get into the list
+ var honkJunk map[string][]*Honk
+ err = json.NewDecoder(resp.Body).Decode(&honkJunk)
+ if err != nil {
+ return nil, err
+ }
+
+ honks := honkJunk["honks"]
+ // honk.ID monotonically increases, so it can be used to sort them
+ sort.Slice(honks, func(i, j int) bool { return honks[i].ID < honks[j].ID })
+
+ return honks, nil
+}
+
+var (
+ rePTags = regexp.MustCompile(`<\/?p>`)
+ reHlTags = regexp.MustCompile(`<\/?span( class="[a-z]{2}")?>`)
+ reBrTags = regexp.MustCompile(`<br>`)
+ reImgTags = regexp.MustCompile(`<img .*src="(.*)">`)
+ reUlTags = regexp.MustCompile(`<\/?ul>`)
+ reTbTags = regexp.MustCompile(`<table>.*</table>`)
+
+ reLiTags = regexp.MustCompile(`<li>([^<>\/]*)<\/li>`)
+ reHnTags = regexp.MustCompile(`<h[1-6]>(.*)</h[1-6]>`)
+ reHrTags = regexp.MustCompile(`<hr>.*<\/hr>`)
+ reBqTags = regexp.MustCompile(`<blockquote>(.*)<\/blockquote>`)
+)
+
+// calmNoise erases htm tags that are not supported by Telegram.
+func calmNoise(noise string) string {
+ // TODO: check length of a honk
+
+ // delete these
+ noise = rePTags.ReplaceAllString(noise, "")
+ noise = reHlTags.ReplaceAllString(noise, "")
+ noise = reBrTags.ReplaceAllString(noise, "")
+ noise = reImgTags.ReplaceAllString(noise, "")
+ noise = reUlTags.ReplaceAllString(noise, "")
+ noise = reTbTags.ReplaceAllString(noise, "")
+
+ // these can be repurposed
+ noise = reHrTags.ReplaceAllString(noise, "---\n")
+ noise = reHnTags.ReplaceAllString(noise, "<b>$1</b>\n\n")
+ noise = reBqTags.ReplaceAllString(noise, "| <i>$1</i>")
+ noise = reLiTags.ReplaceAllString(noise, "* $1\n")
+
+ return strings.TrimSpace(noise)
+}
+
+func init() {
+ flag.StringVar(&config.TgBotToken, "bot_token", "", "Telegram bot token")
+ flag.StringVar(&config.TgChatID, "chat_id", "", "Telegram chat_id")
+ flag.StringVar(&config.TgApiURL, "tgapi_url", "https://api.telegram.org", "Telegram API URL")
+ flag.StringVar(&config.HonkAuthToken, "honk_token", "", "Honk auth token")
+ flag.StringVar(&config.HonkURL, "honk_url", "", "URL of a Honk instance")
+
+ flag.Parse()
+
+ err := config.Check()
+ if err != nil {
+ log.Fatal("config:", err)
+ }
+ err = checkTgAPI()
+ if err != nil {
+ log.Fatal(err)
+ }
+}
+
+var client = http.DefaultClient
+var honkMap = make(map[string]honkInfo) // FIXME: not safe for use by multiple goroutines!
+var now = time.Now()
+
+func main() {
+
+ for {
+ honks, err := getHonks(0)
+ if err != nil {
+ log.Fatal("gethonks:", err)
+ }
+ HonkLoop:
+ for _, honk := range honks {
+ action := honk.Decide()
+ switch action {
+ case honkIgnore:
+ continue
+ case honkSend, honkEdit:
+ err := honk.Check()
+ if err != nil {
+ log.Print(err)
+ continue
+ }
+ }
+ messes := NewMessFromHonk(honk, action)
+ // messes[0] is a honk or a donk to be sent
+ resp, err := messes[0].Send()
+ if err != nil {
+ log.Print(err)
+ honk.forget() // retry
+ continue
+ }
+ // remember only the first mess' response
+ honk.save(resp)
+ for _, mess := range messes[1:] {
+ _, err := mess.Send()
+ if err != nil {
+ log.Print(err)
+ continue HonkLoop
+ }
+ }
+ }
+ time.Sleep(30 * time.Second)
+ }
+}