// 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{"
`)
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(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) } }