all repos — telebonk @ 35e839782be9df360365efb527770615965184c0

reposter from honk to telegram

main.go (view raw)

  1// Telebonk is a reposter from Honk to Telegram.
  2package main
  3
  4import (
  5	"bytes"
  6	"encoding/json"
  7	"flag"
  8	"fmt"
  9	"io"
 10	"log"
 11	"net/http"
 12	"net/url"
 13	"regexp"
 14	"sort"
 15	"strconv"
 16	"strings"
 17	"time"
 18)
 19
 20// A Config is holding configuration of telebonk.
 21type Config struct {
 22	TgBotToken    string
 23	TgChatID      string
 24	TgApiURL      string
 25	HonkAuthToken string
 26	HonkURL       string
 27}
 28
 29// Check makes sure that no Config fields are set to empty strings.
 30func (c *Config) Check() error {
 31	var what string
 32	if c.TgBotToken == "" {
 33		what = "bot_token"
 34	}
 35	if c.TgChatID == "" {
 36		what = "chat_id"
 37	}
 38	if c.TgApiURL == "" {
 39		what = "tgapi_url"
 40	}
 41	if c.HonkAuthToken == "" {
 42		what = "honk_token"
 43	}
 44	if c.HonkURL == "" {
 45		what = "honk_url"
 46	}
 47	if what != "" {
 48		return fmt.Errorf("'%s' shouldn't be empty", what)
 49	}
 50	return nil
 51}
 52
 53var config = &Config{}
 54
 55// A Honk is a post from honk.
 56type Honk struct {
 57	ID     int       // unique id of a post
 58	What   string    // type of an action (post, repost, reply)
 59	Oondle string    // e-mail style handle of the original author of a post
 60	Oonker string    // url of the original author of a post
 61	XID    string    // url of a post, also unique
 62	RID    string    // url of a post that current post is replying to
 63	Date   time.Time // datetime of a post
 64	Precis string    // post summary
 65	Noise  string    // contents of a post
 66	Onts   []string  // a slice of tags in a post
 67	Donks  []*Donk   // a slice of attachments to a post
 68
 69	messID    int // telegram message_id
 70	replyToID int // telegram message_id of a message to reply to
 71}
 72
 73// A Donk stores metadata for media files.
 74type Donk struct {
 75	URL   string
 76	Media string // mime-type of an attachment
 77	Desc  string
 78}
 79
 80// Check performs some checks on a Honk to filter out what's not going to be posted.
 81//
 82// It rejects honk if if falls into one of these categories:
 83//   - it is posted before the telebonk started
 84//   - it is replying to a honk that's not posted by telebonk (either a remote honk or old honk)
 85//   - it is of unsupported type (not a regular honk, reply or bonk)
 86//   - it contains a `#notg` tag.
 87//   - it is empty
 88func (h *Honk) Check() error {
 89	log.Print("checking honk #", h.ID) // info
 90	if h.Date.Before(now) {
 91		return fmt.Errorf("honk #%d is old", h.ID)
 92	}
 93	switch h.What {
 94	case "honked", "bonked":
 95		break
 96	case "honked back":
 97		hi, ok := honkMap[h.RID]
 98		if !ok {
 99			return fmt.Errorf("cannot reply to nonexisting telebonk")
100		}
101		h.replyToID = hi.t.Result.MessageID
102	default:
103		return fmt.Errorf("unsupported honk type: %s", h.What)
104	}
105
106	for _, ont := range h.Onts {
107		if strings.ToLower(ont) == "#notg" {
108			return fmt.Errorf("skipping #notg honk")
109		}
110	}
111
112	if h.Noise == emptyNoise && len(h.Donks) == 0 {
113		return fmt.Errorf("empty honk")
114	}
115	return nil
116}
117
118func (h *Honk) Decide() honkAction {
119	hi, ok := honkMap[h.XID]
120	if ok {
121		if hi.t == nil || hi.h.Date.Equal(h.Date) {
122			h.save(hi.t) // is this wrong?
123			return honkIgnore
124		}
125		h.messID = hi.t.Result.MessageID
126		return honkEdit
127	}
128	return honkSend
129}
130
131// save records a Honk to a honkMap
132func (h *Honk) save(t *TelegramResponse) {
133	honkMap[h.XID] = honkInfo{h: h, t: t}
134}
135
136// forget removes a Honk from a honkMap
137func (h *Honk) forget() {
138	delete(honkMap, h.XID)
139}
140
141type honkAction int
142
143const (
144	honkIgnore honkAction = iota
145	honkSend
146	honkEdit
147)
148
149// A Mess holds data for a message to be sent to Telegram.
150type Mess struct {
151	Text             string `json:"text"`
152	ParseMode        string `json:"parse_mode"`
153	ChatID           string `json:"chat_id"`
154	MessageID        int    `json:"message_id,omitempty"`
155	ReplyToMessageID int    `json:"reply_to_message_id,omitempty"`
156
157	Document string `json:"document,omitempty"`
158	Photo    string `json:"photo,omitempty"`
159	Caption  string `json:"caption,omitempty"`
160
161	kind messKind
162}
163
164// A TelegramResponse is a response from Telegram API.
165type TelegramResponse struct {
166	Ok          bool
167	Description string
168	Result      telegramResponseResult
169}
170
171type telegramResponseResult struct {
172	MessageID int `json:"message_id"`
173}
174
175// NewMess creates and populates a new Mess with default values.
176func NewMess(parseMode string) *Mess {
177	return &Mess{
178		ParseMode: parseMode,
179		ChatID:    config.TgChatID,
180		kind:      messHonk,
181	}
182}
183
184// NewMessFromHonk creates a slice of Mess objects from existing Honk.
185func NewMessFromHonk(honk *Honk, action honkAction) []*Mess {
186	// donks should be sent as a separate messages, so need to create all of 'em
187	// cap(messes) = 1 for honk + 1 for each donk
188	var messes = make([]*Mess, 0, 1+len(honk.Donks))
189
190	messes = append(messes, NewMess("html"))
191	for _, donk := range honk.Donks {
192		donkMess := NewMess("MarkdownV2") // donks don't contain html
193		donkMess.Caption = donk.Desc      // FIXME: no check for length
194		switch {
195		case strings.HasPrefix(donk.Media, "image/"):
196			donkMess.Photo = donk.URL
197			donkMess.kind = messDonkPht
198		case donk.Media == "application/pdf", donk.Media == "text/plain":
199			donkMess.Document = donk.URL
200			donkMess.kind = messDonkDoc
201		}
202		messes = append(messes, donkMess)
203	}
204	if honk.Noise == emptyNoise {
205		messes = messes[1:] // just donks
206	}
207
208	if action == honkEdit {
209		// TODO: implement editing documents and photos
210		messes[0].kind = messEdit
211		messes[0].MessageID = honk.messID
212		messes = messes[:1] // don't donk if editing
213	}
214
215	var noise = calmNoise(honk.Noise)
216	// bonk, then honk back - ok
217	// honk back, then bonk - not gonna sync, is it ok?
218	// upd: bonks work really confusing
219	switch honk.What {
220	case "honked":
221		break
222	case "honked back":
223		messes[0].ReplyToMessageID = honk.replyToID
224	case "bonked":
225		oonker := fmt.Sprintf("<a href=\"%s\">%s</a>:", honk.Oonker, honk.Oondle)
226		noise = oonker + "\n" + noise
227	}
228
229	// danger zone handling
230	if strings.HasPrefix(honk.Precis, "DZ:") {
231		noise = strings.Join([]string{"<tg-spoiler>", "</tg-spoiler>"}, noise)
232		noise = honk.Precis + "\n" + noise
233	}
234	messes[0].Text = noise
235
236	return messes
237}
238
239// Send sends a Mess to Telegram.
240func (m *Mess) Send() (*TelegramResponse, error) {
241	var apiURL = botAPIMethod(tgSendMessage)
242
243	switch m.kind {
244	case messHonk:
245		// noop
246	case messEdit:
247		apiURL = botAPIMethod(tgEditMessageText)
248	case messDonkPht:
249		apiURL = botAPIMethod(tgSendPhoto)
250	case messDonkDoc:
251		apiURL = botAPIMethod(tgSendDocument)
252	}
253
254	junk, err := json.Marshal(m)
255	if err != nil {
256		return nil, err
257	}
258	buf := bytes.NewBuffer(junk)
259	req, err := http.NewRequest("POST", apiURL, buf)
260	if err != nil {
261		return nil, err
262	}
263	req.Header.Add("Content-type", "application/json")
264	req.Header.Add("Content-length", strconv.Itoa(buf.Len()))
265
266	resp, err := client.Do(req)
267	if err != nil {
268		return nil, err
269	}
270	defer resp.Body.Close()
271
272	var res TelegramResponse
273	json.NewDecoder(resp.Body).Decode(&res)
274	if !res.Ok {
275		return nil, fmt.Errorf("mess send: %s", res.Description)
276	}
277
278	return &res, nil
279}
280
281type messKind int
282
283const (
284	messHonk messKind = iota
285	messEdit
286	messDonkPht
287	messDonkDoc
288)
289
290func botAPIMethod(method string) string {
291	return fmt.Sprintf("%s/bot%s/%s", config.TgApiURL, config.TgBotToken, method)
292}
293
294func checkTgAPI() error {
295	var apiURL = botAPIMethod(tgGetMe)
296	resp, err := client.Get(apiURL)
297	if err != nil {
298		return err
299	}
300	if resp.StatusCode != 200 {
301		status, _ := io.ReadAll(resp.Body)
302		return fmt.Errorf("status: %d: %s", resp.StatusCode, status)
303	}
304	return nil
305}
306
307// Telegram Bot API methods
308const (
309	tgGetMe           = "getMe"
310	tgSendMessage     = "sendMessage"
311	tgEditMessageText = "editMessageText"
312	tgSendPhoto       = "sendPhoto"
313	tgSendDocument    = "sendDocument"
314)
315
316// A honkInfo stores data for honk that is sent to Telegram.
317type honkInfo struct {
318	h *Honk
319	t *TelegramResponse
320}
321
322// getHonks receives and unmarshals some honks from a Honk instance.
323func getHonks(after int) ([]*Honk, error) {
324	query := url.Values{}
325	query.Set("token", config.HonkAuthToken)
326	query.Set("action", "gethonks")
327	query.Set("page", "home")
328	query.Set("after", strconv.Itoa(after))
329
330	resp, err := client.Get(fmt.Sprint(config.HonkURL, "/api?", query.Encode()))
331	if err != nil {
332		return nil, err
333	}
334	defer resp.Body.Close()
335
336	// honk outputs junk like `{ "honks": [ ... ] }`, need to get into the list
337	var honkJunk map[string][]*Honk
338	err = json.NewDecoder(resp.Body).Decode(&honkJunk)
339	if err != nil {
340		return nil, err
341	}
342
343	honks := honkJunk["honks"]
344	// honk.ID monotonically increases, so it can be used to sort honks
345	sort.Slice(honks, func(i, j int) bool { return honks[i].ID < honks[j].ID })
346
347	return honks, nil
348}
349
350var (
351	rePTags   = regexp.MustCompile(`<\/?p>`)
352	reHlTags  = regexp.MustCompile(`<\/?span( class="[a-z]{2}")?>`)
353	reBrTags  = regexp.MustCompile(`<br>`)
354	reImgTags = regexp.MustCompile(`<img .*src="(.*)">`)
355	reUlTags  = regexp.MustCompile(`<\/?ul>`)
356	reTbTags  = regexp.MustCompile(`<table>.*</table>`)
357
358	reLiTags = regexp.MustCompile(`<li>([^<>\/]*)<\/li>`)
359	reHnTags = regexp.MustCompile(`<h[1-6]>(.*)</h[1-6]>`)
360	reHrTags = regexp.MustCompile(`<hr>.*<\/hr>`)
361	reBqTags = regexp.MustCompile(`<blockquote>(.*)<\/blockquote>`)
362)
363
364const emptyNoise = "<p></p>\n"
365
366// calmNoise erases and rewrites html tags that are not supported by Telegram.
367func calmNoise(noise string) string {
368	// TODO: check length of a honk
369
370	// delete these
371	noise = rePTags.ReplaceAllString(noise, "")
372	noise = reHlTags.ReplaceAllString(noise, "")
373	noise = reBrTags.ReplaceAllString(noise, "")
374	noise = reImgTags.ReplaceAllString(noise, "")
375	noise = reUlTags.ReplaceAllString(noise, "")
376	noise = reTbTags.ReplaceAllString(noise, "")
377
378	// these can be repurposed
379	noise = reHrTags.ReplaceAllString(noise, "---\n")
380	noise = reHnTags.ReplaceAllString(noise, "<b>$1</b>\n\n")
381	noise = reBqTags.ReplaceAllString(noise, "| <i>$1</i>")
382	noise = reLiTags.ReplaceAllString(noise, "* $1\n")
383
384	return strings.TrimSpace(noise)
385}
386
387func init() {
388	flag.StringVar(&config.TgBotToken, "bot_token", "", "Telegram bot token")
389	flag.StringVar(&config.TgChatID, "chat_id", "", "Telegram chat_id")
390	flag.StringVar(&config.TgApiURL, "tgapi_url", "https://api.telegram.org", "Telegram API URL")
391	flag.StringVar(&config.HonkAuthToken, "honk_token", "", "Honk auth token")
392	flag.StringVar(&config.HonkURL, "honk_url", "", "URL of a Honk instance")
393
394	flag.Parse()
395
396	if err := config.Check(); err != nil {
397		log.Fatal("config:", err) // fail
398	}
399
400	if err := checkTgAPI(); err != nil {
401		log.Fatal(err) // fail
402	}
403}
404
405var client = http.DefaultClient
406var honkMap = make(map[string]honkInfo) // FIXME: not safe for use by multiple goroutines!
407var now = time.Now()
408
409func main() {
410	var retry = 5
411
412	for {
413		honks, err := getHonks(0)
414		if err != nil {
415			log.Print("gethonks:", err) // error
416			retry--
417			if retry == 0 {
418				log.Fatal("gethonks: giving up") // fail
419			}
420			time.Sleep(5 * time.Second)
421			continue
422		}
423
424	HonkLoop:
425		for _, honk := range honks {
426			action := honk.Decide()
427			switch action {
428			case honkIgnore:
429				continue
430			case honkSend, honkEdit:
431				if err := honk.Check(); err != nil {
432					log.Print(err) // error
433					continue
434				}
435			}
436			messes := NewMessFromHonk(honk, action)
437			// messes[0] is a honk or a donk to be sent
438			resp, err := messes[0].Send()
439			if err != nil {
440				log.Print(err) // error
441				honk.forget()  // retry
442				continue
443			}
444			// remember only the first mess' response
445			honk.save(resp)
446			for _, mess := range messes[1:] {
447				if _, err := mess.Send(); err != nil {
448					log.Print(err) // error
449					continue HonkLoop
450				}
451			}
452		}
453		time.Sleep(30 * time.Second)
454	}
455}