// 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 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
}
// 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("checking honk #", h.ID) // info
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.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
}
func (h *Honk) Decide() honkAction {
hi, ok := honkMap[h.XID]
if ok {
if hi.messID == 0 || hi.Date.Equal(h.Date) {
h.save(hi.messID)
return honkIgnore
}
return honkEdit
}
return honkSend
}
// save records a Honk to a honkMap
func (h *Honk) save(messID int) {
h.messID = messID
honkMap[h.XID] = h
}
// forget removes a Honk from a honkMap
func (h *Honk) forget() {
delete(honkMap, h.XID)
}
type honkAction int
const (
honkIgnore honkAction = iota
honkSend
honkEdit
)
// 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"`
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 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{"
`)
reImgTags = regexp.MustCompile(``)
reUlTags = regexp.MustCompile(`<\/?ul>`)
reTbTags = regexp.MustCompile(`
(.*)<\/blockquote>`) ) const emptyNoise = "\n" // calmNoise erases and rewrites html 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() if err := config.Check(); err != nil { log.Fatal("config:", err) // fail } if err := checkTgAPI(); err != nil { log.Fatal(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 for { honks, err := getHonks(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 { action := honk.Decide() switch action { case honkIgnore: continue case honkSend, honkEdit: if err := honk.Check(); err != nil { log.Print(err) // error 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) // 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(err) // error continue HonkLoop } } } time.Sleep(30 * time.Second) } }