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.messID
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.messID == 0 || hi.Date.Equal(h.Date) {
122 h.save(hi.messID)
123 return honkIgnore
124 }
125 return honkEdit
126 }
127 return honkSend
128}
129
130// save records a Honk to a honkMap
131func (h *Honk) save(messID int) {
132 h.messID = messID
133 honkMap[h.XID] = h
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// getHonks receives and unmarshals some honks from a Honk instance.
317func getHonks(after int) ([]*Honk, error) {
318 query := url.Values{}
319 query.Set("token", config.HonkAuthToken)
320 query.Set("action", "gethonks")
321 query.Set("page", "home")
322 query.Set("after", strconv.Itoa(after))
323
324 resp, err := client.Get(fmt.Sprint(config.HonkURL, "/api?", query.Encode()))
325 if err != nil {
326 return nil, err
327 }
328 defer resp.Body.Close()
329
330 // honk outputs junk like `{ "honks": [ ... ] }`, need to get into the list
331 var honkJunk map[string][]*Honk
332 err = json.NewDecoder(resp.Body).Decode(&honkJunk)
333 if err != nil {
334 return nil, err
335 }
336
337 honks := honkJunk["honks"]
338 // honk.ID monotonically increases, so it can be used to sort honks
339 sort.Slice(honks, func(i, j int) bool { return honks[i].ID < honks[j].ID })
340
341 return honks, nil
342}
343
344var (
345 rePTags = regexp.MustCompile(`<\/?p>`)
346 reHlTags = regexp.MustCompile(`<\/?span( class="[a-z]{2}")?>`)
347 reBrTags = regexp.MustCompile(`<br>`)
348 reImgTags = regexp.MustCompile(`<img .*src="(.*)">`)
349 reUlTags = regexp.MustCompile(`<\/?ul>`)
350 reTbTags = regexp.MustCompile(`<table>.*</table>`)
351
352 reLiTags = regexp.MustCompile(`<li>([^<>\/]*)<\/li>`)
353 reHnTags = regexp.MustCompile(`<h[1-6]>(.*)</h[1-6]>`)
354 reHrTags = regexp.MustCompile(`<hr>.*<\/hr>`)
355 reBqTags = regexp.MustCompile(`<blockquote>(.*)<\/blockquote>`)
356)
357
358const emptyNoise = "<p></p>\n"
359
360// calmNoise erases and rewrites html tags that are not supported by Telegram.
361func calmNoise(noise string) string {
362 // TODO: check length of a honk
363
364 // delete these
365 noise = rePTags.ReplaceAllString(noise, "")
366 noise = reHlTags.ReplaceAllString(noise, "")
367 noise = reBrTags.ReplaceAllString(noise, "")
368 noise = reImgTags.ReplaceAllString(noise, "")
369 noise = reUlTags.ReplaceAllString(noise, "")
370 noise = reTbTags.ReplaceAllString(noise, "")
371
372 // these can be repurposed
373 noise = reHrTags.ReplaceAllString(noise, "---\n")
374 noise = reHnTags.ReplaceAllString(noise, "<b>$1</b>\n\n")
375 noise = reBqTags.ReplaceAllString(noise, "| <i>$1</i>")
376 noise = reLiTags.ReplaceAllString(noise, "* $1\n")
377
378 return strings.TrimSpace(noise)
379}
380
381func init() {
382 flag.StringVar(&config.TgBotToken, "bot_token", "", "Telegram bot token")
383 flag.StringVar(&config.TgChatID, "chat_id", "", "Telegram chat_id")
384 flag.StringVar(&config.TgApiURL, "tgapi_url", "https://api.telegram.org", "Telegram API URL")
385 flag.StringVar(&config.HonkAuthToken, "honk_token", "", "Honk auth token")
386 flag.StringVar(&config.HonkURL, "honk_url", "", "URL of a Honk instance")
387
388 flag.Parse()
389
390 if err := config.Check(); err != nil {
391 log.Fatal("config:", err) // fail
392 }
393
394 if err := checkTgAPI(); err != nil {
395 log.Fatal(err) // fail
396 }
397}
398
399var client = http.DefaultClient
400var honkMap = make(map[string]*Honk) // FIXME: not safe for use by multiple goroutines!
401var now = time.Now()
402
403func main() {
404 var retry = 5
405
406 for {
407 honks, err := getHonks(0)
408 if err != nil {
409 log.Print("gethonks:", err) // error
410 retry--
411 if retry == 0 {
412 log.Fatal("gethonks: giving up") // fail
413 }
414 time.Sleep(5 * time.Second)
415 continue
416 }
417
418 HonkLoop:
419 for _, honk := range honks {
420 action := honk.Decide()
421 switch action {
422 case honkIgnore:
423 continue
424 case honkSend, honkEdit:
425 if err := honk.Check(); err != nil {
426 log.Print(err) // error
427 continue
428 }
429 }
430 messes := NewMessFromHonk(honk, action)
431 // messes[0] is a honk or a donk to be sent
432 resp, err := messes[0].Send()
433 if err != nil {
434 log.Print(err) // error
435 honk.forget() // retry
436 continue
437 }
438 // remember only the first mess' response
439 honk.save(resp.Result.MessageID)
440 for _, mess := range messes[1:] {
441 if _, err := mess.Send(); err != nil {
442 log.Print(err) // error
443 continue HonkLoop
444 }
445 }
446 }
447 time.Sleep(30 * time.Second)
448 }
449}