0

[WIP] Move to go project layout, remove logrus & co from vendored libs

This commit is contained in:
eleksir
2025-10-31 19:18:15 +03:00
parent d8a92b37fc
commit 490677de40
44 changed files with 1320 additions and 4501 deletions

502
internal/tg/admin.go Normal file
View File

@@ -0,0 +1,502 @@
package tg
import (
"fmt"
"regexp"
"github.com/NicoNex/echotron/v3"
"github.com/davecgh/go-spew/spew"
)
// Admin парсит команды семейства admin. Возвращает true, если команда была опознана как admin.
func Admin(msg *echotron.Update) (bool, error) {
var (
c = msg.Message.Text[len(Config.Csign):]
answer string
)
switch {
case c == "admin" || c == "админ":
answer = "```\n"
answer += fmt.Sprintf("%sadmin censor - показать список состояния типов сообщений\n", Config.Csign)
answer += fmt.Sprintf("%sадмин ценз - показать список состояния типов сообщений\n", Config.Csign)
answer += fmt.Sprintf("%sadmin censor type # - где 1 - вкл, 0 - выкл цензуры для означенного типа сообщений\n", Config.Csign)
answer += fmt.Sprintf("%sадмин ценз тип # - где 1 - вкл, 0 - выкл цензуры для означенного типа сообщений\n", Config.Csign)
answer += fmt.Sprintf("%sadmin fortune - показываем ли с утра фортунку для чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin фортунка - показываем ли с утра фортунку для чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin fortune # - где 1 - вкл, 0 - выкл фортунку с утра\n", Config.Csign)
answer += fmt.Sprintf("%sadmin фортунка # - где 1 - вкл, 0 - выкл фортунку с утра\n", Config.Csign)
answer += fmt.Sprintf("%sadmin greet - приветствуем ли новых участников чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin приветствие - приветствуем ли новых участников чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin greet # - где 1 - вкл, 0 - выкл приветствия новых участников чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin приветствие # - где 1 - вкл, 0 - выкл приветствия новых участников чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin goodbye - прощаемся ли с ушедшими участниками чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin прощание - прощаемся ли с ушедшими участниками чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin goodbye # - где 1 - вкл, 0 - выкл прощания с ушедшими участниками чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin прощание # - где 1 - вкл, 0 - выкл прощания с ушедшими участниками чата\n", Config.Csign)
answer += fmt.Sprintf("%sadmin oboobs # - где 1 - вкл, 0 - выкл плагина oboobs\n", Config.Csign)
answer += fmt.Sprintf(
"%sadmin oboobs - показываем ли сисечки по просьбе участников чата (команды %stits, %stities, %sboobs, %sboobies, %sсиси, %sсисечки)\n",
Config.Csign, Config.Csign, Config.Csign, Config.Csign, Config.Csign, Config.Csign, Config.Csign,
)
answer += fmt.Sprintf("%sadmin obutts # - где 1 - вкл, 0 - выкл плагина obutts\n", Config.Csign)
answer += fmt.Sprintf(
"%sadmin obutts - показываем ли попки по просьбе участников чата (команды %sass, %sbutt, %sbooty, %sпопа, %sпопка)\n",
Config.Csign, Config.Csign, Config.Csign, Config.Csign, Config.Csign, Config.Csign,
)
answer += fmt.Sprintf("%sadmin chan_msg - оставляем ли сообщения присланные от имени (других) каналов\n", Config.Csign)
answer += fmt.Sprintf("%sadmin chan_msg # - где 1 - оставляем, 0 - удаляем\n", Config.Csign)
answer += fmt.Sprintf(
"%sadmin ban userid sec - выдаём ban указанному user-у на указанное количество секунд (от 30 сек до 1 года), доступно только создателю чата\n",
Config.Csign,
)
answer += fmt.Sprintf(
"%sadmin mute userid sec - выдаём mute указанному user-у на указанное количество секунд (от 30 сек до 1 года), доступно только создателю чата\n",
Config.Csign,
)
answer += fmt.Sprintf(
"%sadmin admin mute - разрешено ли обычным админам мьютить участников чата через бота (если бот - админ), (создатель чата всегда может попросить бота-админа замьютить обычного участника чата)\n",
Config.Csign,
)
answer += fmt.Sprintf("%sadmin admin mute # - где 1 - разрешено, 0 - не разрешено\n", Config.Csign)
answer += "\n"
answer += "Типы сообщений:\naudio voice photo video video_note animation sticker dice game poll document\n"
answer += "```"
case regexp.MustCompile("^(admin|админ)[[:space:]](censor|ценз)$").MatchString(c):
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VoiceMsg") == "1" {
answer += "Тип сообщения voice удаляется\n"
} else {
answer += "Тип сообщения voice не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "AudioMsg") == "1" {
answer += "Тип сообщения audio удаляется\n"
} else {
answer += "Тип сообщения audio не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "PhotoMsg") == "1" {
answer += "Тип сообщения photo удаляется\n"
} else {
answer += "Тип сообщения photo не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VideoMsg") == "1" {
answer += "Тип сообщения video удаляется\n"
} else {
answer += "Тип сообщения video не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VideoNoteMsg") == "1" {
answer += "Тип сообщения video_note удаляется\n"
} else {
answer += "Тип сообщения video_note не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "AnimationMsg") == "1" {
answer += "Тип сообщения animation удаляется\n"
} else {
answer += "Тип сообщения animation не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "StickerMsg") == "1" {
answer += "Тип сообщения sticker удаляется\n"
} else {
answer += "Тип сообщения sticker не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "DiceMsg") == "1" {
answer += "Тип сообщения dice удаляется\n"
} else {
answer += "Тип сообщения dice не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GameMsg") == "1" {
answer += "Тип сообщения game удаляется\n"
} else {
answer += "Тип сообщения game не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "PollMsg") == "1" {
answer += "Тип сообщения poll удаляется\n"
} else {
answer += "Тип сообщения poll не удаляется\n"
}
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "DocumentMsg") == "1" {
answer += "Тип сообщения document удаляется\n"
} else {
answer += "Тип сообщения document не удаляется\n"
}
case regexp.MustCompile("^(admin|админ)[[:space:]](censor|ценз)[[:space:]].+$").MatchString(c):
// Снова регулярка, это не оч эффективно, но зато просто.
// Выхватываем тип сообщения и согласно типу ставим настройку.
cmdArray := regexp.MustCompile("[[:space:]]").Split(c, 4)
switch cmdArray[2] {
case "voice":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VoiceMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с voice будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VoiceMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с voice будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "audio":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "AudioMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с audio будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "AudioMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с audio будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "photo":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "PhotoMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с photo будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "PhotoMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с photo будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "video":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VideoMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с video будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VideoMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с video будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "video_note":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VideoNoteMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с video_note будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "VideoNoteMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с video_note будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "animation":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "AnimationMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с animation будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "AnimationMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с animation будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "sticker":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "StickerMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с sticker будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "StickerMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с sticker будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "dice":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "DiceMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с dice будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "diceMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с dice будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "game":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GameMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с game будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GameMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с game будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "poll":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "PollMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с poll будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "PollMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с poll будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
case "document":
switch cmdArray[3] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "DocumentMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с document будут оставаться"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "DocumentMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с document будут удаляться"
default:
answer += "Не понимаю о чём ты."
}
default:
answer += "Не понимаю о чём ты."
}
case regexp.MustCompile("^(admin|админ)[[:space:]](fortune|фортунка)$").MatchString(c):
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "FortuneMsg") == "1" {
answer += "Фортунки с утра показываются"
} else {
answer += "Фортунки с утра не показываются"
}
case regexp.MustCompile("^(admin|админ)[[:space:]](fortune|фортунка)[[:space:]].*$").MatchString(c):
// Снова регулярка, это не оч эффективно, но зато просто.
cmdArray := regexp.MustCompile("[[:space:]]").Split(c, 3)
// TODO: На самом деле надо сохранять список чятиков, в которые надо отправлять сообщения. Из этой базы
// мы не сможем вытащить список чятиков.
switch cmdArray[2] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "FortuneMsg", "1"); err != nil {
return true, err
}
answer += "Теперь сообщения с фортункой будут отправляться с утра"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "FortuneMsg", "0"); err != nil {
return true, err
}
answer += "Теперь сообщения с фортункой не будут отправляться с утра"
default:
answer += "Не понимаю о чём ты."
}
case regexp.MustCompile("^(admin|админ)[[:space:]](greet|приветствие)$").MatchString(c):
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GreetMsg") == "1" {
answer += "Приветствуем вновьприбывших участников чатика"
} else {
answer += "Не приветствуем вновьприбывших участников чатика"
}
case regexp.MustCompile("^(admin|админ)[[:space:]](greet|приветствие)[[:space:]].*$").MatchString(c):
// Снова регулярка, это не оч эффективно, но зато просто.
cmdArray := regexp.MustCompile("[[:space:]]").Split(c, 3)
// TODO: На самом деле надо сохранять список чятиков, в которые надо отправлять сообщения. Из этой базы
// мы не сможем вытащить список чятиков.
switch cmdArray[2] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GreetMsg", "1"); err != nil {
return true, err
}
answer += "Будем приветствовать вновьприбывших участников чата"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GreetMsg", "0"); err != nil {
return true, err
}
answer += "Не будем приветствовать вновьприбывших участников чата"
default:
answer += "Не понимаю, о чём ты."
}
case regexp.MustCompile("^(admin|админ)[[:space:]](goodbye|прощание)$").MatchString(c):
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GoodbyeMsg") == "1" {
answer += "Прощаемся с ушедшими участниками чатика"
} else {
answer += "Не прощаемся с ушедшими участниками чатика"
}
case regexp.MustCompile("^(admin|админ)[[:space:]](goodbye|прощание)[[:space:]].*$").MatchString(c):
// Снова регулярка, это не оч эффективно, но зато просто.
cmdArray := regexp.MustCompile("[[:space:]]").Split(c, 3)
switch cmdArray[2] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GoodbyeMsg", "1"); err != nil {
return true, err
}
answer += "Будем прощаться с ушедшими участниками чата"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GoodbyeMsg", "0"); err != nil {
return true, err
}
answer += "Не будем прощаться с ушедшими участниками чата"
default:
answer += "Не понимаю, о чём ты."
}
case regexp.MustCompile("^(admin|админ)[[:space:]](chan_msg)[[:space:]].*$").MatchString(c):
// Снова регулярка, это не оч эффективно, но зато просто.
cmdArray := regexp.MustCompile("[[:space:]]").Split(c, 3)
switch cmdArray[2] {
case "1":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "ChanMsg", "1"); err != nil {
return true, err
}
answer += "Будем удалять сообщения, написанные от имени канала"
case "0":
if err := SaveSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "ChanMsg", "0"); err != nil {
return true, err
}
answer += "Не будем удалять сообщения, написанные от имени канала"
default:
answer += "Не понимаю, о чём ты."
}
}
resp, err := tg.SendMessage(
answer,
msg.Message.Chat.ID,
&echotron.MessageOptions{ParseMode: "MarkdownV2"},
)
if err != nil || !resp.Ok {
// TODO: поддержать миграцию группы в супергруппу, поддержать вариант, когда бот замьючен.
// Красиво оформить ошибку, с полями итд, как tracedump, только ошибка.
// N.B. тут может быть сообщение о том, что группа превратилась в супергруппу, или что бот не имеет прав писать
// сообщения в чятик. Это надо бы отслеживать и хэндлить.
return true, fmt.Errorf(
"unable to send message to telegram api: %w\nResponse dump: %s",
err,
spew.Sdump(resp),
)
}
return true, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

121
internal/tg/censor.go Normal file
View File

@@ -0,0 +1,121 @@
package tg
import (
"fmt"
"github.com/NicoNex/echotron/v3"
)
// Censor парсит сообщения в поисках непотребных данных и если он их находит, то сообщение удаляется.
// Непотребными могут быть аудиосообщения, аудиофайлы, видеосообщения, сообщения от имени других каналов итп.
// Это могут настроить админы чятика через команду !admin censor.
func Censor(msg *echotron.Update) bool {
result := false
switch {
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "VoiceMsg") == "1":
result = true
// Предполагаем, что у voice-ов здесь всегда не ноль.
if msg.Message.Voice.Duration != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "AudioMsg") == "1":
// Предполагаем, что у аудио здесь всегда не ноль.
if msg.Message.Audio.Duration != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "PhotoMsg") == "1":
// Обычное сообщение не содержит фоток.
if len(msg.Message.Photo) != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "VideoMsg") == "1":
// Предполагаем, что у видео здесь всегда не ноль.
if msg.Message.Video.Duration != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "VideoNoteMsg") == "1":
// Предполагаем, что у видео-заметки здесь всегда не ноль.
if msg.Message.VideoNote.Duration != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "AnimationMsg") == "1":
// Предполагаем, что у анимации здесь всегда не ноль.
if msg.Message.Animation.Duration != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "StickerMsg") == "1":
// Предполагаем, что FileID не пустое только у стикера.
if msg.Message.Sticker.FileID != "" {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "DiceMsg") == "1":
// Предполагаем, что Value > 0 только у дайса.
if msg.Message.Dice.Value != 0 {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "GameMsg") == "1":
// Предполагаем, что title только у game-а.
if msg.Message.Game.Title != "" {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "PollMsg") == "1":
// Предполагаем, что title только у game-а.
if msg.Message.Poll.Question != "" {
delMsg(msg)
result = true
}
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "DocumentMsg") == "1":
// Предполагаем, что FileID есть только у document-а.
if msg.Message.Document.FileID != "" {
delMsg(msg)
result = true
}
// Некоторые рекламные товарищи пытаются срать своими каналами в чятик это тоже можно зацензурить ботом и это
// пидорство он будет удалять asap.
// 136817688 - это специальный id пользователя, который принимает облик канала, на него можно нажать и попасть
// на рекламируемый канал.
case GetSetting(fmt.Sprintf("%d", msg.ChatID()), "ChanMsg") == "1":
if msg.Message.From.ID == 136817688 {
delMsg(msg)
result = true
}
}
return result
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

144
internal/tg/cmdParser.go Normal file
View File

@@ -0,0 +1,144 @@
package tg
import (
"aleesa-telegram-go/internal/log"
"encoding/json"
"fmt"
"regexp"
"github.com/NicoNex/echotron/v3"
)
// cmdParser разбирает сообщение как команду и засылает его в роутер, попутно обкладывая корректными значениями
// параметров. Возвращает true если это была команда.
func cmdParser(me echotron.APIResponseUser, cmd *echotron.Update) (bool, error) {
var (
err error
smsg sMsg
bingo bool
)
smsg.From = "telegram"
smsg.Plugin = "telegram"
smsg.Chatid = fmt.Sprintf("%d", cmd.Message.Chat.ID)
smsg.Userid = fmt.Sprintf("%d", cmd.Message.From.ID)
smsg.Message = cmd.Message.Text
smsg.Misc.Answer = 1
// Тоже самое можно проставить выковырнув значение из cmd.Message.Chat.Type, где group/supergroup/channel - это
// public, а остальное - private, но по id проще/быстрее, хоть это и хак.
if cmd.Message.Chat.ID >= 0 {
smsg.Mode = "private"
} else {
smsg.Mode = "public"
}
smsg.Misc.Botnick = ConstructPartialUserUsername(me.Result)
// Предполагаем, что длина cmd.Message.Text всегда больше или равна длине Config.Csign.
if cmd.Message.Text[0:len(Config.Csign)] == Config.Csign {
// Повторно проверяем, что текст является простой командой.
command := cmd.Message.Text[len(Config.Csign):]
switch {
case command == "помощь" || command == "help":
return Help(cmd)
// Хэндлер команд admin и admin *.
case regexp.MustCompile("^(admin|админ)(.+)?$").MatchString(command):
return Admin(cmd)
}
// Команды в одно слово.
cmds := []string{
"ping", "пинг", "пинх", "pong", "понг", "понх", "coin", "монетка", "roll", "dice", "кости", "ver",
"version", "версия", "хэлп", "halp", "kde", "кде", "lat", "лат", "friday", "пятница", "proverb",
"пословица", "пословиться", "fortune", "фортунка", "f", "ф", "anek", "анек", "анекдот", "buni", "cat",
"кис", "drink", "праздник", "fox", "лис", "frog", "лягушка", "horse", "лошадь", "лошадка", "monkeyuser",
"owl", "сова", "сыч", "rabbit", "bunny", "кролик", "snail", "улитка", "tits", "boobs", "tities", "boobies",
"сиси", "сисечки", "butt", "booty", "ass", "попа", "попка", "xkcd", "dig", "копать", "fish", "fishing",
"рыба", "рыбка", "рыбалка", "karma", "карма", "fuck", "weather", "погода", "w", "п", "погодка", "погадка",
}
for _, c := range cmds {
if command == c {
bingo = true
break
}
}
// Не нашлось команды в одно слово. Поищем команды с одним аргументом.
if !bingo {
cmds = []string{
"w", "п", "weather", "погода", "погодка", "погадка", "karma", "карма",
}
for _, c := range cmds {
cRe := fmt.Sprintf("^%s ", c)
r := regexp.MustCompile(cRe)
if r.MatchString(command) {
bingo = true
break
}
}
}
// Что-то странное пришло. Залоггируем и ничего делать не будем. Просто свалим.
if !bingo {
log.Infof(
"Strange command from %s %s: %s",
ConstructFullChatName(&cmd.Message.Chat),
ConstructFullUserName(cmd.Message.From),
cmd.Message.Text,
)
return true, err
}
// Для некоторых команд надо подсвечивать имя пользователя в ответе.
cmds = []string{
"dig", "копать", "fish", "fishing", "рыба", "рыбка", "рыбалка",
}
for _, c := range cmds {
// В vscode возникает ошибка при использовании if-а.
switch {
case command == c:
smsg.Misc.Username = ConstructTelegramHighlightName(cmd.Message.From)
smsg.Misc.Msgformat = 1
break //nolint: gosimple
}
}
// Если у нас супергруппа с тредами, надо проставить thread id.
if cmd.Message.Chat.Type == "supergroup" && cmd.Message.IsTopicMessage && cmd.Message.ThreadID != 0 {
smsg.ThreadID = fmt.Sprintf("%d", cmd.Message.ThreadID)
}
// Отправляем сообщение в редиску.
data, err := json.Marshal(smsg)
if err != nil {
log.Warnf("Unable to to serialize message for redis: %s", err)
return true, err
}
// Заталкиваем наш json в редиску.
if err := RedisClient.Publish(ctx, Config.Redis.Channel, data).Err(); err != nil {
log.Warnf("Unable to send data to redis channel %s: %s", Config.Redis.Channel, err)
} else {
log.Debugf("Sent msg to redis channel %s: %s", Config.Redis.Channel, string(data))
}
return true, err
}
return false, err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

45
internal/tg/globals.go Normal file
View File

@@ -0,0 +1,45 @@
package tg
import (
"context"
"os"
"github.com/NicoNex/echotron/v3"
"github.com/cockroachdb/pebble"
"github.com/go-redis/redis/v8"
)
// Config - это у нас глобальная штука.
var Config myConfig
// To break circular message forwarding we must set some sane default, it can be overridden via config.
var forwardMax int64 = 5
// Объектики клиента-редиски.
var RedisClient *redis.Client
var Subscriber *redis.PubSub
// Main context.
var ctx = context.Background()
// Ставится в true, если мы получили сигнал на выключение.
var Shutdown = false
// Канал, в который приходят уведомления для хэндлера сигналов от траппера сигналов.
var SigChan = make(chan os.Signal, 1)
// Мапка с открытыми дескрипторами баз с настройками.
var settingsDB = make(map[string]*pebble.DB)
var tg echotron.API
// Приветсвенные фразы для новых участников чата.
var introduceGreet = [...]string{
"Дратути",
"Дарована",
"Доброе утро, день или вечер",
"Добро пожаловать в наше скромное коммунити",
"Наше вам с кисточкой тут, на канальчике",
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

69
internal/tg/help.go Normal file
View File

@@ -0,0 +1,69 @@
package tg
import (
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/davecgh/go-spew/spew"
)
// Help Выводит в чат сообщение с основными командами бота.
func Help(cmd *echotron.Update) (bool, error) {
help := "```\n"
help += fmt.Sprintf("%shelp | %sпомощь - список команд", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sanek | %sанек | %sанекдот - рандомный анекдот с anekdot.ru", Config.Csign, Config.Csign, Config.Csign)
help += fmt.Sprintf("%sbuni - рандомный стрип hapi buni", Config.Csign)
help += fmt.Sprintf("%sbunny | %srabbit | %sкролик - кролик", Config.Csign, Config.Csign, Config.Csign)
help += fmt.Sprintf("%scat | %sкис - кошечка", Config.Csign, Config.Csign)
help += fmt.Sprintf("%scoin | %sмонетка - подбросить монетку - орёл или решка?", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sdig | %sкопать - заняться археологией", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sdrink | %sпраздник - какой сегодня праздник?", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sfish | %sрыба | %sрыбка - порыбачить", Config.Csign, Config.Csign, Config.Csign)
help += fmt.Sprintf("%sfishing | %sрыбалка - порыбачить", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sf | %sф - рандомная фраза из сборника цитат fortune_mod", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sfortune | %sфортунка - рандомная фраза из сборника цитат fortune_mod", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sfox | %sлис - лисичка", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sfriday | %sпятница - а не пятница ли сегодня?", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sfrog | %sлягушка - лягушка", Config.Csign, Config.Csign)
help += fmt.Sprintf("%shorse | %sлошадка - лошадка", Config.Csign, Config.Csign)
help += fmt.Sprintf("%slat | %sлат - сгенерить фразу из крылатых латинских выражений", Config.Csign, Config.Csign)
help += fmt.Sprintf("%smonkeyuser - рандомный стрип MonkeyUser", Config.Csign)
help += fmt.Sprintf("%sowl | %sсова | %sсыч - сова", Config.Csign, Config.Csign, Config.Csign)
help += fmt.Sprintf("%sproverb | %sпословица - рандомная русская пословица", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sping | %sпинг - попинговать бота", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sroll | %sdice | %sкости - бросить кости", Config.Csign, Config.Csign, Config.Csign)
help += fmt.Sprintf("%ssnail | %sулитка - улитка", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sver | %sversion | %sверсия - что-то про версию ПО", Config.Csign, Config.Csign, Config.Csign)
help += fmt.Sprintf("%sw <город> | %sп <город> - погода в указанном городе", Config.Csign, Config.Csign)
help += fmt.Sprintf("%sweather <город> - погода в указанном городе", Config.Csign)
help += fmt.Sprintf("%sпогода <город> - погода в указанном городе", Config.Csign)
help += fmt.Sprintf("%sпогодка <город> - погода в указанном городе", Config.Csign)
help += fmt.Sprintf("%sпогадка <город> - погода в указанном городе", Config.Csign)
help += fmt.Sprintf("%sxkcd - рандомный стрип с сайта xkcd.ru", Config.Csign)
help += fmt.Sprintf("%skarma фраза | %sкарма фраза - посмотреть карму фразы", Config.Csign, Config.Csign)
help += "```\n"
help += "Но на самом деле я бот больше для общения, чем для исполнения команд.\n"
help += "Поговоришь со мной?"
resp, err := tg.SendMessage(
help,
cmd.Message.Chat.ID,
&echotron.MessageOptions{ParseMode: "MarkdownV2"},
)
if err != nil || !resp.Ok {
// TODO: поддержать миграцию группы в супергруппу, поддержать вариант, когда бот замьючен.
// Красиво оформить ошибку, с полями итд, как tracedump, только ошибка.
// N.B. тут может быть сообщение о том, что группа превратилась в супергруппу, или что бот не имеет прав писать
// сообщения в чятик. Это надо бы отслеживать и хэндлить.
return true, fmt.Errorf(
"unable to send message to telegram api: %w\nResponse dump: %s",
err,
spew.Sdump(resp),
)
}
return true, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

112
internal/tg/parseConfig.go Normal file
View File

@@ -0,0 +1,112 @@
package tg
import (
"encoding/json"
"fmt"
"os"
"aleesa-telegram-go/internal/log"
"github.com/hjson/hjson-go"
)
// ParseConfig разбирает и валидирует даденный конфиг.
func ParseConfig(path string) (myConfig, error) {
fileInfo, err := os.Stat(path)
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём.
if err != nil {
return myConfig{}, err
}
// Конфиг-файл длинноват для конфига, попробуем следующего кандидата.
if fileInfo.Size() > 65535 {
err := fmt.Errorf("config file %s is too long for config, skipping", path)
return myConfig{}, err
}
buf, err := os.ReadFile(path)
// Не удалось прочитать.
if err != nil {
return myConfig{}, err
}
// Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку.
// Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку
// сериализуем в json, а потом json превращааем в структурку. Не очень эффективно, но он и не часто требуется.
var (
sampleConfig myConfig
tmp map[string]interface{}
)
err = hjson.Unmarshal(buf, &tmp)
// Не удалось распарсить.
if err != nil {
return myConfig{}, err
}
tmpjson, err := json.Marshal(tmp)
// Не удалось преобразовать map-ку в json.
if err != nil {
return myConfig{}, err
}
if err := json.Unmarshal(tmpjson, &sampleConfig); err != nil {
return myConfig{}, err
}
// Валидируем значения из конфига.
if sampleConfig.Redis.Server == "" {
sampleConfig.Redis.Server = "localhost"
log.Infof("Redis server is not defined in config, using localhost")
}
if sampleConfig.Redis.Port == 0 {
sampleConfig.Redis.Port = 6379
}
if sampleConfig.Redis.Channel == "" {
err := fmt.Errorf("channel field in config file %s must be set", path)
return myConfig{}, err
}
if sampleConfig.Redis.MyChannel == "" {
err := fmt.Errorf("my_channel field in config file %s must be set", path)
return myConfig{}, err
}
if sampleConfig.Telegram.Token == "" {
return myConfig{}, fmt.Errorf("telegram.token field in config file %s must be set", path)
}
if sampleConfig.Loglevel == "" {
sampleConfig.Loglevel = "info"
}
// sampleConfig.Log = "" if not set
if sampleConfig.Csign == "" {
err := fmt.Errorf("csign field in config file %s must be set", path)
return myConfig{}, err
}
if sampleConfig.ForwardsMax == 0 {
sampleConfig.ForwardsMax = forwardMax
}
if sampleConfig.DataDir == "" {
return myConfig{}, fmt.Errorf("data_dir field in config file %s must be set", path)
}
return sampleConfig, err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,112 @@
package tg
import (
"aleesa-telegram-go/internal/log"
"encoding/json"
"strconv"
"github.com/NicoNex/echotron/v3"
"github.com/davecgh/go-spew/spew"
)
// RedisMsgParser парсит json-чики прилетевшие из REDIS-ки, причём json-чики должны быть относительно валидными.
func RedisMsgParser(msg string) {
if Shutdown {
// Если мы завершаем работу программы, то нам ничего обрабатывать не надо.
return
}
var j rMsg
log.Debugf("Incoming raw json: %s", msg)
if err := json.Unmarshal([]byte(msg), &j); err != nil {
log.Warnf("Unable to to parse message from redis channel: %s", err)
return
}
// Validate our j
if exist := j.From; exist == "" {
log.Warnf("Incorrect msg from redis, no from field: %s", msg)
return
}
if exist := j.Chatid; exist == "" {
log.Warnf("Incorrect msg from redis, no chatid field: %s", msg)
return
}
if exist := j.Userid; exist == "" {
log.Warnf("Incorrect msg from redis, no userid field: %s", msg)
return
}
if exist := j.Message; exist == "" {
log.Warnf("Incorrect msg from redis, no message field: %s", msg)
return
}
if exist := j.Plugin; exist == "" {
log.Warnf("Incorrect msg from redis, no plugin field: %s", msg)
return
}
if exist := j.Mode; exist == "" {
log.Warnf("Incorrect msg from redis, no mode field: %s", msg)
return
}
// j.Misc.Answer может и не быть, тогда ответа на такое сообщение не будет
if j.Misc.Answer == 0 {
log.Debug("Field Misc->Answer = 0, skipping message")
return
}
// j.Misc.BotNick тоже можно не передавать, тогда будет записана пустая строка
// j.Misc.CSign если нам его не передали, возьмём значение из конфига. (но по идее нам оно тут не нужнО.)
if exist := j.Misc.Csign; exist == "" {
j.Misc.Csign = Config.Csign
}
// j.Misc.FwdCnt если нам его не передали, то будет 0
if exist := j.Misc.Fwdcnt; exist == 0 {
j.Misc.Fwdcnt = 1
}
// j.Misc.GoodMorning может быть 1 или 0, по-умолчанию 0
// j.Misc.MsgFormat может быть 1 или 0, по-умолчанию 0
// j.Misc.Username можно не передавать, тогда будет пустая строка
// Отвалидировались, теперь вернёмся к нашим баранам.
var opts *echotron.MessageOptions
chatid, err := strconv.ParseInt(j.Chatid, 10, 64)
if err != nil {
log.Errorf("unable to parse message from redis, incorrect chatid field: %s", err)
return
}
resp, err := tg.SendMessage(j.Message, chatid, opts)
if err != nil || !resp.Ok {
// TODO: поддержать миграцию группы в супергруппу, поддержать вариант, когда бот замьючен.
// Красиво оформить ошибку, с полями итд, как tracedump, только ошибка.
// N.B. тут может быть сообщение о том, что группа превратилась в супергруппу, или что бот не имеет прав писать
// сообщения в чятик. Это надо бы отслеживать и хэндлить.
log.Errorf("Unable to send message to telegram api: %s", err)
log.Errorf("Response dump: %s", spew.Sdump(resp))
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,124 @@
package tg
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"aleesa-telegram-go/internal/log"
"github.com/cockroachdb/errors/oserror"
"github.com/cockroachdb/pebble"
)
// StoreKV сохраняет в указанной бд ключ и значение.
func StoreKV(db *pebble.DB, key string, value string) error {
var (
kArray = []byte(key)
vArray = []byte(value)
)
err := db.Set(kArray, vArray, pebble.Sync)
return err
}
// FetchV достаёт значение по ключу.
func FetchV(db *pebble.DB, key string) (string, error) {
var (
kArray = []byte(key)
vArray []byte
valueString = ""
)
vArray, closer, err := db.Get(kArray)
if err != nil {
return valueString, err
}
valueString = string(vArray)
err = closer.Close()
return valueString, err
}
// GetSetting достаёт настройку из БД с настройками.
func GetSetting(chatID string, setting string) string {
var err error
chatHash := sha256.Sum256([]byte(chatID))
database := fmt.Sprintf("settings_db/%x", chatHash)
// Если БД не открыта, откроем её
if _, ok := settingsDB[database]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений.
options.L0CompactionFileThreshold = 8
settingsDB[database], err = pebble.Open(Config.DataDir+"/"+database, &options)
if err != nil {
log.Errorf("Unable to open settings db, %s: %s\n", database, err)
return ""
}
}
value, err := FetchV(settingsDB[database], setting)
// Если из базы ничего не вынулось, по каким-то причинам, то просто вернём пустую строку.
if err != nil {
switch {
case errors.Is(err, pebble.ErrNotFound):
log.Debugf("Unable to get value for %s: no record found in db %s", setting, database)
case errors.Is(err, fs.ErrNotExist):
log.Debugf("Unable to get value for %s: db dir %s does not exist", setting, database)
case errors.Is(err, oserror.ErrNotExist):
log.Debugf("Unable to get value for %s: db dir %s does not exist", setting, database)
default:
log.Errorf("Unable to get value for %s in db dir %s: %s", setting, database, err)
}
return ""
}
return value
}
// SaveSetting сохраняет настройку в БД с настройками.
func SaveSetting(chatID string, setting string, value string) error {
var (
chatHash = sha256.Sum256([]byte(chatID))
database = fmt.Sprintf("settings_db/%x", chatHash)
err error
)
// Если БД не открыта, откроем её.
if _, ok := settingsDB[database]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений.
options.L0CompactionFileThreshold = 8
settingsDB[database], err = pebble.Open(Config.DataDir+"/"+database, &options)
if err != nil {
log.Errorf("Unable to open settings db, %s: %s\n", database, err)
return err
}
}
if err := StoreKV(settingsDB[database], setting, value); err != nil {
log.Errorf("Unable to save setting %s in database %s: %s", setting, database, err)
}
return err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

78
internal/tg/sigHandler.go Normal file
View File

@@ -0,0 +1,78 @@
package tg
import (
"os"
"syscall"
"aleesa-telegram-go/internal/log"
)
// SigHandler хэндлер сигналов закрывает все бд, все сетевые соединения и сваливает из приложения.
func SigHandler() {
var err error
for {
var s = <-SigChan
switch s {
case syscall.SIGINT:
log.Info("Got SIGINT, quitting")
case syscall.SIGTERM:
log.Info("Got SIGTERM, quitting")
case syscall.SIGQUIT:
log.Info("Got SIGQUIT, quitting")
// Заходим на новую итерацию, если у нас "неинтересный" сигнал.
default:
continue
}
// Чтобы не срать в логи ошибками от редиски, проставим shutdown state приложения в true.
Shutdown = true
// Отпишемся от всех каналов и закроем коннект к редиске.
if err = Subscriber.Unsubscribe(ctx); err != nil {
log.Errorf("Unable to unsubscribe from redis channels cleanly: %s", err)
} else {
log.Debug("Unsubscribe from all redis channels")
}
if err = Subscriber.Close(); err != nil {
log.Errorf("Unable to close redis connection cleanly: %s", err)
} else {
log.Debug("Close redis connection")
}
// close all telego stuff.
resp, err := tg.LogOut()
if err != nil {
log.Errorf("Unable to send LogOut() to Telegram Bot API server")
}
if !resp.Ok {
log.Errorf("Telegram Bot API returns an error on LogOut(): %d, %s", resp.ErrorCode, resp.Description)
}
resp, err = tg.Close()
if err != nil {
log.Errorf("Unable to send Close() to Telegram Bot API server")
}
if !resp.Ok {
log.Errorf("Telegram Bot API returns an error on Close(): %d, %s", resp.ErrorCode, resp.Description)
}
if len(settingsDB) > 0 {
log.Debug("Closing runtime bot settings db")
for _, db := range settingsDB {
_ = db.Close()
}
}
os.Exit(0)
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

161
internal/tg/telegram.go Normal file
View File

@@ -0,0 +1,161 @@
package tg
import (
"fmt"
"aleesa-telegram-go/internal/log"
"github.com/NicoNex/echotron/v3"
)
// Telega основная горутинка, реализующая бота.
func Telega(c myConfig) {
tg = echotron.NewAPI(c.Telegram.Token)
for u := range echotron.PollingUpdates(c.Telegram.Token) {
telegramMsgParser(u)
}
}
func delMsg(msg *echotron.Update) {
if res, err := tg.DeleteMessage(msg.ChatID(), msg.Message.ID); err != nil {
chat := ConstructFullChatName(msg.Message.SenderChat)
user := ConstructFullUserName(msg.Message.From)
log.Errorf("Unable to delete message %d in chat %s from %s via telegram api: %s", msg.Message.ID, chat, user, err)
} else if !res.Ok {
chat := ConstructFullChatName(msg.Message.SenderChat)
user := ConstructFullUserName(msg.Message.From)
log.Errorf("Unable to delete message %d in chat %s from %s via telegram api: %s", msg.ID, chat, user, res.Description)
}
}
// ConstructFullUserName выковыривает из сообщения полный username, в формате @username FirstName LastName (id).
func ConstructFullUserName(u *echotron.User) string {
user := fmt.Sprintf("(%d)", u.ID)
if u.LastName != "" {
user = fmt.Sprintf("%s %s", u.LastName, user)
}
if u.FirstName != "" {
user = fmt.Sprintf("%s %s", u.FirstName, user)
}
if u.Username != "" {
user = fmt.Sprintf("@%s %s", u.Username, user)
}
return user
}
// ConstructFullChatName выковыривает из сообщения полный username чата, в формате @username FirstName LastName (id).
func ConstructFullChatName(c *echotron.Chat) string {
chat := fmt.Sprintf("(%d)", c.ID)
if c.LastName != "" {
chat = fmt.Sprintf("%s %s", c.LastName, chat)
}
if c.FirstName != "" {
chat = fmt.Sprintf("%s %s", c.FirstName, chat)
}
if c.Username != "" {
chat = fmt.Sprintf("@%s %s", c.Username, chat)
}
return chat
}
// ConstructPartialUserUsername пытается найти и вытащить username, если такового нет, вытаскивает First/Last Name, если
// такового нет, то возвращает ID.
func ConstructPartialUserUsername(u *echotron.User) string {
switch {
case u.Username != "":
return fmt.Sprintf("@%s", u.Username)
case u.FirstName != "" && u.LastName != "":
return fmt.Sprintf("%s %s", u.FirstName, u.LastName)
case u.FirstName != "":
return u.FirstName
case u.LastName != "":
return u.LastName
default:
return fmt.Sprintf("%d", u.ID)
}
}
// ConstructPartialUserUsername пытается найти и вытащить username, если такового нет, вытаскивает First/Last Name, если
// такового нет, то возвращает ID.
func ConstructPartialChatUsername(c *echotron.Chat) string {
switch {
case c.Username != "":
return fmt.Sprintf("@%s", c.Username)
case c.FirstName != "" && c.LastName != "":
return fmt.Sprintf("%s %s", c.FirstName, c.LastName)
case c.FirstName != "":
return c.FirstName
case c.LastName != "":
return c.LastName
default:
return fmt.Sprintf("%d", c.ID)
}
}
// ConstructUserFirstLastName Пытается найти и вытащить first name и last name пользователя, если не получается, то
// вначале пытается фоллбэчится на first name, потом на last name, потом на username.
func ConstructUserFirstLastName(u *echotron.User) string {
var user string
switch {
case u.FirstName != "" && u.LastName != "":
user = fmt.Sprintf("%s %s", u.FirstName, u.LastName)
case u.FirstName != "":
user = u.FirstName
case u.LastName != "":
user = u.LastName
case u.Username != "":
user = fmt.Sprintf("@%s", u.Username)
default:
user = fmt.Sprintf("%d", u.ID)
}
return user
}
// ConstructTelegramHighlightName генерирует имя пользователя, которое триггерит на стороне клиента ивент меншена
// определённого пользователя.
func ConstructTelegramHighlightName(u *echotron.User) string {
var (
username string
link string
)
link = fmt.Sprintf("tg://user?id=%d", u.ID)
// Тут мы предполагаем, что как минимум либо firstname либо lastname либо username всегда есть. По сути так оно и
// должно быть.
switch {
case u.FirstName != "" && u.LastName != "":
username = fmt.Sprintf("%s %s", u.FirstName, u.LastName)
case u.FirstName != "":
username = u.FirstName
case u.LastName != "":
username = u.LastName
default:
username = u.Username
}
return fmt.Sprintf("[%s](%s)", username, link)
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,349 @@
package tg
import (
"encoding/json"
"fmt"
"math/rand/v2"
"regexp"
"strings"
"aleesa-telegram-go/internal/log"
"github.com/NicoNex/echotron/v3"
"github.com/davecgh/go-spew/spew"
)
// telegramMsgParser парсит ивент, прилетевший из bot api.
func telegramMsgParser(msg *echotron.Update) {
if Shutdown {
// Если мы завершаем работу программы, то нам ничего обрабатывать не надо.
return
}
// Сообщение о том, что этот чятик изменился, например, превратился в супергруппу.
if (msg.Message.MigrateFromChatID < 0) && (msg.Message.MigrateToChatID < 0) { //nolint: revive,staticcheck
// TODO: поддержать миграцию настроек чата на новый chatId.
// TODO: подумать, что можно сделать с настройками бэкэндов. Кажись, ничего, но надо глянуть.
return
}
// Здесь начинается парсинг, подразумевающий какие-то сообщения бота в чат, поэтому с этой точки можно/нужно
// получить информацию о самом боте.
// TODO: пораскинуть, можно ли как-то кэшировать эту информацию, чтобы не дёргать api по чём зря.
var (
rmsg rMsg
errorOccured bool
)
me, err := tg.GetMe()
if err != nil {
log.Errorf("Unable to get info about myself, %s", err)
errorOccured = true
}
// Типа, при некоторых обстоятельствах, мы можем получить более внятное сообщение. Но это надо проверять.
if !me.Ok {
log.Errorf("Unable to get info about myself: %d %s", me.ErrorCode, me.Description)
errorOccured = true
}
if errorOccured {
return
}
// Люди пришли в чят.
if msg.Message.NewChatMembers != nil { //nolint: revive,staticcheck
// TODO: проверить, надо ли приветствовать, выбрать одну из приветственных фраз, потянуть время, типа, мы пишем
// текст и поприветствовать вновь прибывшего.
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GreetMsg") == "1" {
// Пользователи, которых мы приветсвуем, одной строкой.
var users string
if len(msg.Message.NewChatMembers) > 1 {
lastOne := msg.Message.NewChatMembers[len(msg.Message.NewChatMembers)-1]
theRest := msg.Message.NewChatMembers[:len(msg.Message.NewChatMembers)-1]
var usersSlice []string
for _, user := range theRest {
usersSlice = append(usersSlice, ConstructTelegramHighlightName(user))
}
users = strings.Join(usersSlice, ", ")
users += fmt.Sprintf(" and %s", ConstructTelegramHighlightName(lastOne))
} else {
users = ConstructTelegramHighlightName(msg.Message.NewChatMembers[0])
}
phrase := fmt.Sprintf(
"%s, %s. Представьтес, пожалуйста, и расскажите, что вас сюда привело.",
introduceGreet[rand.IntN(len(introduceGreet))],
users,
)
// Потянуть время, изобразить настоящего человека.
resp, err := tg.SendChatAction(
echotron.Typing,
msg.Message.From.ID,
&echotron.ChatActionOptions{MessageThreadID: int(msg.Message.ThreadID)},
)
// TODO: вероятно, ошибку надо засылать в обработчик ошибок, так как в ней может быть миграция чятика или
// что-то, что тоже надо хэндлить.
if err != nil || !resp.Ok {
log.Errorf(
"unable to send message to telegram api: %s\nResponse dump: %s",
err,
spew.Sdump(resp),
)
}
resp1, err := tg.SendMessage(
phrase,
msg.Message.Chat.ID,
&echotron.MessageOptions{ParseMode: "MarkdownV2", MessageThreadID: int64(msg.Message.ThreadID)},
)
// TODO: вероятно, ошибку надо засылать в обработчик ошибок, так как в ней может быть миграция чятика или
// что-то, что тоже надо хэндлить.
if err != nil || !resp1.Ok {
log.Errorf(
"unable to send message to telegram api: %s\nResponse dump: %s",
err,
spew.Sdump(resp1),
)
}
}
return
}
// Человек ушёл из чата.
if msg.Message.LeftChatMember != nil {
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "GoodbyeMsg") == "1" {
user := ConstructUserFirstLastName(msg.Message.From)
goodbye := fmt.Sprintf("Прощаемся с %s", user)
opts := echotron.MessageOptions{DisableNotification: true, ParseMode: "MarkdownV2"}
if msg.Message.ThreadID != 0 {
opts.MessageThreadID = int64(msg.Message.ThreadID)
}
if res, err := tg.SendMessage(goodbye, msg.Message.Chat.ID, &opts); err != nil {
log.Errorf("Unable to send message to telegram api: %s", err)
} else if !res.Ok {
log.Errorf("Unable to send message to telegram api: %s", res.Description)
}
}
return
}
// Если сообщение было зацензурено, то дальше его обрабатывать не надо.
if Censor(msg) {
return
}
switch msg.Message.Chat.Type {
case "private":
// Всё что можно было похэндлить, что не содержало текста, считаем, что обработали.
if msg.Message.Text == "" {
return
}
// Считаем, что приватное сообщение всегда нуждается в ответе, иначе, зачем писать боту?
rmsg.Misc.Answer = 1
// Засылаем фразу в misc-канал (в роутер).
rmsg.Chatid = fmt.Sprintf("%d", msg.Message.Chat.ID)
rmsg.Userid = fmt.Sprintf("%d", msg.Message.From.ID)
rmsg.Message = msg.Message.Text
rmsg.Mode = "private"
rmsg.Plugin = "telegram"
rmsg.From = "telegram"
rmsg.Misc.Csign = Config.Csign
rmsg.Misc.Fwdcnt = 1
rmsg.Misc.Botnick = ConstructPartialUserUsername(me.Result)
rmsg.Misc.Username = ConstructPartialUserUsername(msg.Message.From)
rmsg.Misc.GoodMorning = 0
rmsg.Misc.Msgformat = 0
// Форматировать что-то всё-таки надо, но благо, это только команды, поэтому попробуем распознать сообщение как
// команду.
r, err := cmdParser(me, msg)
if err != nil {
log.Infof(
"Unable to parse message as command in private conversation with %s, message was: %s",
ConstructFullUserName(msg.Message.From),
msg.Message.Text,
)
return
}
// Если сообщение не было опознанно как команда, засылаем его "как есть" туда, в качель.
if !r {
data, err := json.Marshal(rmsg)
if err != nil {
log.Warnf("Unable to to serialize message for redis: %s", err)
return
}
// Заталкиваем наш json в редиску.
if err := RedisClient.Publish(ctx, Config.Redis.Channel, data).Err(); err != nil {
log.Warnf("Unable to send data to redis channel %s: %s", Config.Redis.Channel, err)
} else {
log.Debugf("Sent msg to redis channel %s: %s", Config.Redis.Channel, string(data))
}
}
case "group", "supergroup":
// Обрабатываем сообщения для групп и супергрупп. По сути это примерно одинаковые вещи/сущности.
// Цензор удаляет сообщения из чятика, если они "неправильные" - от имени других каналов, аудиосообщения,
// содержат картинки, видео аудио итп, это настраивается через команду !admin censor.
if GetSetting(fmt.Sprintf("%d", msg.Message.Chat.ID), "censor") == "1" {
// Если цензор отработал, больше делать нечего, сообщения уже нету.
if Censor(msg) {
return
}
}
// Если фраза менее 3-х букв, просто игнорируем её.
if len(msg.Message.Text) <= 3 {
return
}
// TODO: если человек ответил на авто-приветствие, это надо обнаруживать и пропускать.
// Pасылаем сообщение в парсер команд.
res, err := cmdParser(me, msg)
if err != nil {
log.Errorf(
"Unable to parse message from telegram api as a command. Message was: %s, error: %s",
msg.Message.Text,
err,
)
}
// Если сообщение - команда, то на этом наши приключения закончились.
if res {
return
}
// Здесь же, если фраза обращена к боту (ник или имя), выставляем флажок, что надо ответить
quotedBotUsername := regexp.QuoteMeta(me.Result.Username)
quotedBotFirstName := regexp.QuoteMeta(me.Result.FirstName)
quotedBotLastName := regexp.QuoteMeta(me.Result.LastName)
if messageContainsBotUsername, err := regexp.MatchString(quotedBotUsername, msg.Message.Text); err != nil {
log.Errorf("An error occured while matching message with bot username: %s", err)
return
} else if messageContainsBotUsername {
rmsg.Misc.Answer = 1
m := regexp.MustCompile(quotedBotUsername)
msg.Message.Text = m.ReplaceAllString(msg.Message.Text, "")
}
// TODO: обращение может содержать знаки препинания, пробелы перед, пробелы после имени бота. Надо бы это поддержать.
// TODO: унести вырезание имени бота в отдельную процедуру.
switch {
// Если у нас есть и имя и фамилия бота, то попробуем их слепить вместе, через "1 и более" пробел и вырезать
// полученное из входящей фразы.
case quotedBotFirstName != "" && quotedBotLastName != "":
messageContainsBotName, err := regexp.MatchString(
quotedBotFirstName+"[[:space:]]+"+quotedBotLastName,
msg.Message.Text,
)
if err != nil {
log.Errorf("An error occured while matching message with bot firstname and lastname: %s", err)
return
}
if messageContainsBotName {
rmsg.Misc.Answer = 1
m := regexp.MustCompile(quotedBotFirstName + "[[:space:]]+" + quotedBotLastName)
msg.Message.Text = m.ReplaceAllString(msg.Message.Text, "")
}
// Если у нас есть только имя, то попробуем его вырезать из входящей фразы.
case quotedBotFirstName != "":
messageContainsBotName, err := regexp.MatchString(quotedBotFirstName, msg.Message.Text)
if err != nil {
log.Errorf("An error occured while matching message with bot firstname: %s", err)
return
}
if messageContainsBotName {
rmsg.Misc.Answer = 1
m := regexp.MustCompile(quotedBotFirstName)
msg.Message.Text = m.ReplaceAllString(msg.Message.Text, "")
}
// Если у нас есть только фамилия, то попробуем его вырезать из входящей фразы.
case quotedBotLastName != "":
messageContainsBotName, err := regexp.MatchString(quotedBotLastName, msg.Message.Text)
if err != nil {
log.Errorf("An error occured while matching message with bot lastname: %s", err)
return
}
if messageContainsBotName {
rmsg.Misc.Answer = 1
m := regexp.MustCompile(quotedBotLastName)
msg.Message.Text = m.ReplaceAllString(msg.Message.Text, "")
}
}
// Засылаем фразу в misc-канал (в роутер).
rmsg.Chatid = fmt.Sprintf("%d", msg.Message.Chat.ID)
rmsg.Userid = fmt.Sprintf("%d", msg.Message.From.ID)
rmsg.Message = msg.Message.Text
rmsg.Mode = "public"
rmsg.Plugin = "telegram"
rmsg.From = "telegram"
rmsg.Misc.Csign = Config.Csign
rmsg.Misc.Fwdcnt = 1
rmsg.Misc.Botnick = ConstructPartialUserUsername(me.Result)
rmsg.Misc.Username = ConstructPartialUserUsername(msg.Message.From)
rmsg.Misc.GoodMorning = 0
// Форматирование нужно только для вывода некоторых ответов на команды, команды мы ловим выше по тексту, так что
// смело ставим тут 0.
rmsg.Misc.Msgformat = 0
data, err := json.Marshal(rmsg)
if err != nil {
log.Warnf("Unable to to serialize message for redis: %s", err)
return
}
// Заталкиваем наш json в редиску.
if err := RedisClient.Publish(ctx, Config.Redis.Channel, data).Err(); err != nil {
log.Warnf("Unable to send data to redis channel %s: %s", Config.Redis.Channel, err)
} else {
log.Debugf("Sent msg to redis channel %s: %s", Config.Redis.Channel, string(data))
}
// case "channel":
// Тут мы ничего сделать не можем.
} //nolint:wsl
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

68
internal/tg/types.go Normal file
View File

@@ -0,0 +1,68 @@
package tg
// myConfig структура, описывающая формат Конфига.
type myConfig struct {
Redis struct {
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
Channel string `json:"channel,omitempty"`
MyChannel string `json:"my_channel,omitempty"`
} `json:"redis"`
Telegram struct {
Token string `json:"token,omitempty"`
}
Loglevel string `json:"loglevel,omitempty"`
Log string `json:"log,omitempty"`
Csign string `json:"csign,omitempty"`
ForwardsMax int64 `json:"forwards_max,omitempty"`
DataDir string `json:"data_dir,omitempty"`
}
// rMsg структура, описывающая формат входящего сообщения из pubsub-канала redis-ки.
type rMsg struct {
From string `json:"from,omitempty"`
Chatid string `json:"chatid,omitempty"`
Userid string `json:"userid,omitempty"`
ThreadID string `json:"threadid,omitempty"`
Message string `json:"message,omitempty"`
Plugin string `json:"plugin,omitempty"`
Mode string `json:"mode,omitempty"`
Misc struct {
Answer int64 `json:"answer,omitempty"`
Botnick string `json:"bot_nick,omitempty"`
Csign string `json:"csign,omitempty"`
Fwdcnt int64 `json:"fwd_cnt,omitempty"`
GoodMorning int64 `json:"good_morning,omitempty"`
// В этом поле мы пишем 1, если бэкэнд должен в ответе форматировать сообщение в MarkdownV2, что какбэ
// не оч хорошо, так как мы выносим особенности реализации бэкэнда сюда.
Msgformat int64 `json:"msg_format,omitempty"`
// Судя по перловой реализации, сюда всегда попадает [$fullname](tg://user?id=$userid).
// Согласно докуметации, и перловой реализации бота, мы проставляем это поле тогда, когда мы точно знаем, что в
// бэкэнде оно используется.
Username string `json:"username,omitempty"`
} `json:"Misc"`
}
// sMsg структура, описывающая исходящее сообщение в pubsub-канал redis-ки.
type sMsg struct {
From string `json:"from"`
Chatid string `json:"chatid"`
Userid string `json:"userid"`
ThreadID string `json:"threadid,omitempty"`
Message string `json:"message"`
Plugin string `json:"plugin"`
Mode string `json:"mode"`
Misc struct {
Answer int64 `json:"answer"`
Botnick string `json:"bot_nick"`
Csign string `json:"csign"`
Fwdcnt int64 `json:"fwd_cnt"`
GoodMorning int64 `json:"good_morning"`
Msgformat int64 `json:"msg_format"`
Username string `json:"username"`
} `json:"misc"`
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */