all repos — telebonk @ d5fb0ba220b26f5bc0e7ff593c4be35a2cf65206

reposter from honk to telegram

init
la-ninpre leobrekalini@gmail.com
Sat, 15 Oct 2022 17:45:20 +0300
commit

d5fb0ba220b26f5bc0e7ff593c4be35a2cf65206

5 files changed, 505 insertions(+), 0 deletions(-)

jump to
A .gitignore

@@ -0,0 +1,4 @@

+telebonk +acme.dump +*.json +.env
A 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.
A 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`).
A go.mod

@@ -0,0 +1,3 @@

+module git.aaoth.xyz/la-ninpre/telebonk + +go 1.19
A 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) + } +}