0

[WIP] Handle leaving chat for users, parse message more thoroughly in public chats.

Add parser for users that left channel
Add semi-stub (partial) censor functionality
Add bot name cut-out in public chat
Add sending functionality for public chats
This commit is contained in:
eleksir
2024-06-30 18:48:13 +03:00
parent e75d9cabb5
commit 301eb52675
6 changed files with 307 additions and 40 deletions

View File

@@ -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{}

View File

@@ -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, если мы получили сигнал на выключение

View File

@@ -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

View File

@@ -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: */

View File

@@ -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)

12
util.go
View File

@@ -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
}