// 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 = "
\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("%s:", honk.Oonker, honk.Oondle) noise = oonker + "\n" + noise } // danger zone handling if strings.HasPrefix(honk.Precis, "DZ:") { noise = strings.Join([]string{"(.*)<\/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, "$1\n\n") noise = reBqTags.ReplaceAllString(noise, "| $1") 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) } }