// 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 HonkPage 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" } switch c.HonkPage { case "atme", "longago", "home", "myhonks": default: return fmt.Errorf("bad page type: %s", c.HonkPage) } if what != "" { return fmt.Errorf("'%s' shouldn't be empty", what) } return nil } var config = &Config{} // A Honk is a post from honk. type Honk struct { ID int // unique id of a post What string // type of an action (post, repost, reply) Oondle string // e-mail style handle of the original author of a post Oonker string // url of the original author of a post XID string // url of a post, also unique RID string // url of a post that current post is replying to Date time.Time // datetime of a post Precis string // post summary Noise string // contents of a post Onts []string // a slice of tags in a post Donks []*Donk // a slice of attachments to a post MessID int // telegram message_id ReplyToID int // telegram message_id of a message to reply to Action HonkAction } // A HonkAction tells what to do with the saved honk. type HonkAction int const ( HonkNotSet HonkAction = iota HonkIgnore HonkSend HonkEdit ) // A Donk stores metadata for media files. type Donk struct { URL string Media string // mime-type of an attachment 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("check: checking honk #", h.ID) // info 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.MessID 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 } // Decide sets the Action of a Honk. // // It sets HonkIgnore to those honks that are: 1) old; 2) already sent and not edits. func (h *Honk) Decide() { oldhonk, ok := honkMap[h.XID] if ok { if oldhonk.MessID == 0 || h.Date.Equal(oldhonk.Date) { h.Action = HonkIgnore h.save(oldhonk.MessID) return } log.Print("decide: honk #", h.XID, " is to be edited") h.Action = HonkEdit h.MessID = oldhonk.MessID return } if h.Date.Before(now) { h.Action = HonkIgnore h.save(0) return } log.Print("decide: honk #", h.ID, " is to be sent") h.Action = HonkSend } // save records a Honk to the honkMap func (h *Honk) save(messID int) { h.MessID = messID honkMap[h.XID] = h } // forget unchecks a Honk from the honkMap func (h *Honk) forget() { oldhonk, ok := honkMap[h.XID] if !ok { return } oldhonk.MessID = 0 oldhonk.ReplyToID = 0 honkMap[oldhonk.XID] = oldhonk } // A Mess holds data for a message to be sent to Telegram. type Mess struct { Text string `json:"text"` ChatID string `json:"chat_id"` ParseMode string `json:"parse_mode,omitempty"` MessageID int `json:"message_id,omitempty"` ReplyToMessageID int `json:"reply_to_message_id,omitempty"` Document string `json:"document,omitempty"` Photo string `json:"photo,omitempty"` Caption string `json:"caption,omitempty"` kind messKind } // A TelegramResponse is a response from Telegram API. type TelegramResponse struct { Ok bool Description string Result 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) []*Mess { var truncateWith = "...\n\nfull honk: " + honk.XID // hardcoded == bad // 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("") // donks don't contain html donkMess.Caption = TruncateNoise(donk.Desc, truncateWith, 1024) 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 honk.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 text = CalmNoise(honk.Noise) text = TruncateNoise(text, truncateWith, 4096) // 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("%s:", honk.Oonker, honk.Oondle) text = oonker + "\n" + text } // danger zone handling if strings.HasPrefix(honk.Precis, "DZ:") { text = strings.Join([]string{"", ""}, text) text = honk.Precis + "\n" + text } messes[0].Text = text 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 } // Telegram Bot API methods const ( tgGetMe = "getMe" tgSendMessage = "sendMessage" tgEditMessageText = "editMessageText" tgSendPhoto = "sendPhoto" tgSendDocument = "sendDocument" ) // getHonks receives and unmarshals some honks from a Honk instance. func getHonks(page string, after int) ([]*Honk, error) { query := url.Values{} query.Set("action", "gethonks") query.Set("page", page) query.Set("after", strconv.Itoa(after)) apiurl := config.HonkURL + "/api?" + query.Encode() req, err := http.NewRequest("GET", apiurl, nil) if err != nil { return nil, err } req.Header.Add("Authorization", "Bearer " + config.HonkAuthToken) resp, err := client.Do(req) 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 { // FIXME: honk tokens last for a week or so. when one expires, shouldn't this say something meaningful instead of `unexpected v in blah-blah'? log.Print("gethonks: ", resp.Status) return nil, err } honks := honkJunk["honks"] // honk.ID monotonically increases, so it can be used to sort honks 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(`
`) reImgTags = regexp.MustCompile(``) reUlTags = regexp.MustCompile(`<\/?ul>`) reTbTags = regexp.MustCompile(`.*
`) reLiTags = regexp.MustCompile(`
  • ([^<>\/]*)<\/li>`) reHnTags = regexp.MustCompile(`(.*)`) reHrTags = regexp.MustCompile(`
    .*<\/hr>`) reBqTags = regexp.MustCompile(`
    (.*)<\/blockquote>`) ) const emptyNoise = "

    \n" // CalmNoise erases and rewrites html tags that are not supported by Telegram. func CalmNoise(s string) string { // delete these s = rePTags.ReplaceAllString(s, "") s = reHlTags.ReplaceAllString(s, "") s = reBrTags.ReplaceAllString(s, "") s = reImgTags.ReplaceAllString(s, "") s = reUlTags.ReplaceAllString(s, "") s = reTbTags.ReplaceAllString(s, "") // these can be repurposed s = reHrTags.ReplaceAllString(s, "---\n") s = reHnTags.ReplaceAllString(s, "$1\n\n") s = reBqTags.ReplaceAllString(s, "| $1") s = reLiTags.ReplaceAllString(s, "* $1\n") return strings.TrimSpace(s) } // TruncateNoise truncates a string up to `length - len(with)` characters long and adds `with` to the end. func TruncateNoise(s, with string, length int) string { // telegram can handle posts no longer than 4096 (or 1024) characters _after_ the parsing of entities. // we could be clever and calculate the true length of text, but let's keep it simple and stupid. if len(s) <= length { return s } var b strings.Builder b.Grow(length) var end = length - 1 - len(with) for i, r := range s { if i >= end { break } b.WriteRune(r) } b.WriteString(with) return b.String() } 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.HonkPage, "honk_page", "myhonks", "Page to get honks from. Should be one of [atme, longago, home, myhonks]") flag.StringVar(&config.HonkURL, "honk_url", "", "URL of a Honk instance") flag.Parse() if err := config.Check(); err != nil { log.Fatal("config:", err) // fail } config.TgApiURL = strings.TrimRight(config.TgApiURL, "/") if err := checkTgAPI(); err != nil { log.Fatal("tgAPI:", err) // fail } } var client = http.DefaultClient var honkMap = make(map[string]*Honk) // FIXME: not safe for use by multiple goroutines! var now = time.Now() func main() { var retry = 5 log.Print("starting telebonk") // info for { honks, err := getHonks(config.HonkPage, 0) if err != nil { log.Print("gethonks:", err) // error retry-- if retry == 0 { log.Fatal("gethonks: giving up") // fail } time.Sleep(5 * time.Second) continue } HonkLoop: for _, honk := range honks { honk.Decide() switch honk.Action { case HonkIgnore: continue case HonkSend, HonkEdit: if err := honk.Check(); err != nil { log.Print("honk check:", err) // error continue } } messes := NewMessFromHonk(honk) // messes[0] is a honk or a donk to be sent resp, err := messes[0].Send() if err != nil { log.Print("mess send", err) // error honk.forget() // retry continue } // remember only the first mess' response honk.save(resp.Result.MessageID) for _, mess := range messes[1:] { if _, err := mess.Send(); err != nil { log.Print("mess send", err) // error continue HonkLoop } } } time.Sleep(30 * time.Second) } }