diff --git a/config.go b/config.go index 26ecfe7..a8c5ade 100644 --- a/config.go +++ b/config.go @@ -34,7 +34,7 @@ func parseConfig(path string) (myConfig, error) { // Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку. // Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку - // сериализуем в json, а потом json преврщааем в стркутурку. Не очень эффективно, но он и не часто требуется. + // сериализуем в json, а потом json превращааем в структурку. Не очень эффективно, но он и не часто требуется. var ( sampleConfig myConfig tmp map[string]interface{} diff --git a/globals.go b/globals.go index c3a0de0..9f521b6 100644 --- a/globals.go +++ b/globals.go @@ -19,7 +19,7 @@ var forwardMax int64 = 5 var redisClient *redis.Client var subscriber *redis.PubSub -// Main context +// Main context. var ctx = context.Background() // Ставится в true, если мы получили сигнал на выключение diff --git a/main.go b/main.go index fcaca95..1db11b6 100644 --- a/main.go +++ b/main.go @@ -86,7 +86,7 @@ func main() { log.SetOutput(logfile) } - // Инициализируем redis-клиента + // Инициализируем redis-клиента. redisClient = redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", config.Redis.Server, config.Redis.Port), }) @@ -95,8 +95,6 @@ func main() { subscriber = redisClient.Subscribe(ctx, config.Redis.MyChannel) redisMsgChan := subscriber.Channel() - // telego init here - // Самое время поставить траппер сигналов. signal.Notify(sigChan, syscall.SIGINT, @@ -106,7 +104,7 @@ func main() { go sigHandler() go telega(config) - // Обработчик событий от редиски + // Обработчик событий от редиски. for msg := range redisMsgChan { if shutdown { continue diff --git a/msg-parsers.go b/msg-parsers.go index 89f0476..1b95355 100644 --- a/msg-parsers.go +++ b/msg-parsers.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "regexp" "strconv" "github.com/NicoNex/echotron/v3" @@ -12,7 +13,7 @@ import ( // telego msg parser -// redisMsgParser парсит json-чики прилетевшие из REDIS-ки, причём, json-чики должны быть относительно валидными. +// redisMsgParser парсит json-чики прилетевшие из REDIS-ки, причём json-чики должны быть относительно валидными. func redisMsgParser(msg string) { if shutdown { // Если мы завершаем работу программы, то нам ничего обрабатывать не надо. @@ -120,20 +121,36 @@ func telegramMsgParser(msg *echotron.Update) { } // Сообщение о том, что этот чятик изменился, например превратился в супергруппу. - if msg.Message.MigrateFromChatID < 0 && msg.Message.MigrateToChatID < 0 { - // TODO: поддержать миграцию настроек чата га новый chatId. + if (msg.Message.MigrateFromChatID < 0) && (msg.Message.MigrateToChatID < 0) { + // TODO: поддержать миграцию настроек чата на новый chatId. // TODO: подумать, что можно сделать с настройками бэкэндов. Кажись, ничего, но надо глянуть. } // Люди пришли в чят. if msg.Message.NewChatMembers != nil { - // TODO: проверить, надо ли приветсвовать, выбрать одну из приветственных фраз, потянуть время, типа, мы пишем - // текст и поприветсвовать вновьприбывшего + // TODO: проверить, надо ли приветствовать, выбрать одну из приветственных фраз, потянуть время, типа, мы пишем + // текст и поприветствовать вновь прибывшего. } // Человек ушёл из чата. if msg.Message.LeftChatMember != nil { - // TODO: проверить, надо ли прощаться с ушедшим участником чата и попрощаться, если надо + 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) + } + } } var ( @@ -145,11 +162,14 @@ func telegramMsgParser(msg *echotron.Update) { 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 } @@ -164,12 +184,11 @@ func telegramMsgParser(msg *echotron.Update) { return } - // handle private messages + // Считаем, что приватное сообщение всегда нуждается в ответе, иначе, зачем писать боту? rmsg.Misc.Answer = 1 - // если фраза является командой - засылаем её в парсер команд - // Засылаем фразу в misc-канал (в роутер) - rmsg.Chatid = fmt.Sprintf("%d", msg.ChatID()) + // Засылаем фразу в 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" @@ -177,9 +196,10 @@ func telegramMsgParser(msg *echotron.Update) { rmsg.From = "telegram" rmsg.Misc.Csign = config.Csign rmsg.Misc.Fwdcnt = 1 - rmsg.Misc.Botnick = me.Result.Username - rmsg.Misc.Username = "" + rmsg.Misc.Botnick = ConstructPartialUserUsername(me.Result) + rmsg.Misc.Username = ConstructPartialUserUsername(msg.Message.From) rmsg.Misc.GoodMorning = 0 + // TODO: детектить нужно ли форматировать ответ. Для этого фактически надо парсить простые команды, как минимум. rmsg.Misc.Msgformat = 0 @@ -191,7 +211,7 @@ func telegramMsgParser(msg *echotron.Update) { return } - // Заталкиваем наш json в редиску + // Заталкиваем наш 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 { @@ -199,14 +219,136 @@ func telegramMsgParser(msg *echotron.Update) { } case "group", "supergroup": - // handle public (super)group messages - // Здесь работает цензор, если он включён. - // Здесь же, если человек ответил на авто-приветствие, это надо обнаруживать и пропускать. - // Здесь же, если фраза менее 3-х букв, просто игнорируем её - // Здесь же, если фраза является командой - засылаем её в парсер команд + // Обрабатываем сообщения для групп и супергрупп. По сути это примерно одинаковые вещи/сущности. + + // Цензор удаляет сообщения из чятика, если они "неправильные" - от имени других каналов, аудиосообщения, + // содержат картинки, видео аудио итп, это настраивается через команду !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: если человек ответил на авто-приветствие, это надо обнаруживать и пропускать. + + // Если фраза является командой - засылаем её в парсер команд. + if msg.Message.Text[0:len(config.Csign)] == config.Csign { + if err := cmdParser(msg.Message.Text); err != nil { + log.Errorf( + "Unable to parse message from telegram api as a command. Message was: %s, error: %s", + msg.Message.Text, + err, + ) + } + } + // Здесь же, если фраза обращена к боту (ник или имя), выставляем флажок, что надо ответить - // Здесь же, пытаемся убрать из фразы ник или имя бота - // Засылаем фразу в misc-канал (в роутер) + 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: обращение может содержать знаки препинания, пробелы перед, пробелы после. Надо бы это поддержать. + 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 + + // TODO: детектить нужно ли форматировать ответ. Для этого фактически надо парсить простые команды, как минимум. + 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 @@ -218,4 +360,131 @@ func cmdParser(cmd string) error { return err } +// Censor парсит сообщения в поисках непотребных данных и если он их находит, то сообщение удаляется. +// Непотребными могут быть аудиосообщения, аудиофайлы, видеосообщения, сообщения от имени других каналов итп. +// Это могут настроить админы чятика через команду !admin censor. +// TODO: Реализовать censor-а. +func Censor(msg *echotron.Update) bool { + result := false + + if GetSetting(fmt.Sprintf("%d", msg.ChatID()), "VoiceMsg") == "1" { + result = true + + 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) + } + } + + return result +} + +// 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 +} + /* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/settings-db-util.go b/settings-db-util.go index e56801d..50def61 100644 --- a/settings-db-util.go +++ b/settings-db-util.go @@ -21,7 +21,7 @@ func StoreKV(db *pebble.DB, key string, value string) error { return err } -// FetchV достаёт значение по ключу +// FetchV достаёт значение по ключу. func FetchV(db *pebble.DB, key string) (string, error) { var kArray = []byte(key) @@ -41,7 +41,7 @@ func FetchV(db *pebble.DB, key string) (string, error) { } // getSetting достаёт настройку из БД с настройками. -func getSetting(chatID string, setting string) string { +func GetSetting(chatID string, setting string) string { var err error chatHash := sha256.Sum256([]byte(chatID)) @@ -52,7 +52,7 @@ func getSetting(chatID string, setting string) string { var options pebble.Options // По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными // настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук, - // что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений + // что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений. options.L0CompactionFileThreshold = 8 settingsDB[database], err = pebble.Open(config.DataDir+"/"+database, &options) @@ -64,7 +64,7 @@ func getSetting(chatID string, setting string) string { value, err := FetchV(settingsDB[database], setting) - // Если из базы ничего не вынулось, по каким-то причинам, то просто вернём пустую строку + // Если из базы ничего не вынулось, по каким-то причинам, то просто вернём пустую строку. if err != nil { if errors.Is(err, pebble.ErrNotFound) { log.Debugf("Unable to get value for %s: no record found in db %s", setting, database) @@ -82,18 +82,18 @@ func getSetting(chatID string, setting string) string { return value } -// saveSetting сохраняет настройку в БД с настройками -func saveSetting(chatID string, setting string, value string) error { +// saveSetting сохраняет настройку в БД с настройками. +func GaveSetting(chatID string, setting string, value string) error { var chatHash = sha256.Sum256([]byte(chatID)) var database = fmt.Sprintf("settings_db/%x", chatHash) var err error - // Если БД не открыта, откроем её + // Если БД не открыта, откроем её. if _, ok := settingsDB[database]; !ok { var options pebble.Options // По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными // настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук, - // что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений + // что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений. options.L0CompactionFileThreshold = 8 settingsDB[database], err = pebble.Open(config.DataDir+"/"+database, &options) diff --git a/util.go b/util.go index f3b01a3..b07a9a4 100644 --- a/util.go +++ b/util.go @@ -32,12 +32,12 @@ func readConfig() { for _, location := range locations { fileInfo, err := os.Stat(location) - // Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём + // Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём. if err != nil { continue } - // Конфиг-файл длинноват для конфига, попробуем следующего кандидата + // Конфиг-файл длинноват для конфига, попробуем следующего кандидата. if fileInfo.Size() > 65535 { log.Warnf("Config file %s is too long for config, skipping", location) @@ -46,7 +46,7 @@ func readConfig() { buf, err := os.ReadFile(location) - // Не удалось прочитать, попробуем следующего кандидата + // Не удалось прочитать, попробуем следующего кандидата. if err != nil { log.Warnf("Skip reading config file %s: %s", location, err) @@ -63,7 +63,7 @@ func readConfig() { err = hjson.Unmarshal(buf, &tmp) - // Не удалось распарсить - попробуем следующего кандидата + // Не удалось распарсить - попробуем следующего кандидата. if err != nil { log.Warnf("Skip parsing config file %s: %s", location, err) @@ -72,7 +72,7 @@ func readConfig() { tmpjson, err := json.Marshal(tmp) - // Не удалось преобразовать map-ку в json + // Не удалось преобразовать map-ку в json. if err != nil { log.Warnf("Skip parsing config file %s: %s", location, err) @@ -154,7 +154,7 @@ func sigHandler() { case syscall.SIGQUIT: log.Infoln("Got SIGQUIT, quitting") - // Заходим на новую итерацию, если у нас "неинтересный" сигнал + // Заходим на новую итерацию, если у нас "неинтересный" сигнал. default: continue }