Initial commit

This commit is contained in:
eleksir
2025-05-08 18:12:52 +03:00
commit c1ad1d3925
1557 changed files with 650162 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
package moon
import "github.com/NicoNex/echotron/v3"
func animation(msg *echotron.Update) {
Log.Infof(
"User %s %s (username = %s, id = %d) sent animation to chat %s %s (%d)",
msg.Message.From.FirstName,
msg.Message.From.LastName,
msg.Message.From.Username,
msg.Message.From.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
}

View File

@@ -0,0 +1,95 @@
package moon
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/hjson/hjson-go"
)
// CasCheckID check given telegram user id vs CAS spammers base.
// https://api.cas.chat/check?user_id=821871410
func CasCheckID(id int64) (bool, error) {
var (
result bool
err error
url = fmt.Sprintf("https://api.cas.chat/check?user_id=%d", id)
c = &http.Client{
Timeout: 15 * time.Second,
}
banned CasTrue
notBanned CasFalse
)
resp, err := c.Get(url) //nolint: bodyclose, noctx
if err != nil {
return result, fmt.Errorf("unable to query api.cas.chat, %w", err)
}
// Looks like we should clse body as it it recommends in docs: https://pkg.go.dev/net/http .
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
Log.Errorf("Unable to close response body for request to %s: %s", url, err)
}
}(resp.Body)
// Client should not return with http status code other than 200.
if resp.StatusCode > 200 {
return result, fmt.Errorf("api.cas.chat returns non-200 http status code: %d %s", resp.StatusCode, resp.Status) //nolint: err113
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return result, fmt.Errorf("unable to read response from api.cas.chat, %w", err)
}
// looks like go native json decoder is pretty shitty, so we'll take hjson as less restrictive parser, parse json
// encode it back to struct, then parse struct as encode/json
var tmp map[string]any
err = hjson.Unmarshal(body, &tmp)
// Не удалось распарсить.
if err != nil {
return result, fmt.Errorf("unable to parse response from api.cas.chat via hjson: %w", err)
}
tmpjson, err := json.Marshal(tmp)
// Не удалось преобразовать map-ку в json.
if err != nil {
return result, fmt.Errorf("unable to parse back to json response from api.cas.chat parsed by hjson: %w", err)
}
if err := json.Unmarshal(tmpjson, &notBanned); err == nil {
// if ok field in json == false, then user is not in ban. (records about id not found)
if !notBanned.Ok {
return false, err
}
} // At this point we just ignore err != nil, because it *can be* other json type.
if err := json.Unmarshal(body, &banned); err == nil {
// if ok field in json == true, then user is banned. (records about id are found)
if banned.Ok {
Log.Debugf(
"api.cas.chat reported user with id=%d as banned: offences=%d ban_time=%s",
id,
banned.Result.Offenses,
banned.Result.TimeAdded,
)
return true, err
}
return result, fmt.Errorf("api.cas.chat returns strange structure: %s", string(body)) //nolint: err113
} // At this point we can't ignore err != nil because we do not know (yet?) other forms of json reply from cas api.
return result, fmt.Errorf("unable to parse response from api.cas.chat: %s", string(body)) //nolint: err113
}

View File

@@ -0,0 +1,157 @@
package moon
import (
"fmt"
"slices"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/akyoto/cache"
)
// newChatMember parses one of types ChatMemberUpdated event.
// https://core.telegram.org/bots/api#chatmemberupdated
func newChatMember(msg *echotron.Update) {
Log.Infof(
"User %s %s (username = %s, id = %d) in chat %s %s (%d) now %s was %s",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
msg.ChatMember.NewChatMember.Status,
msg.ChatMember.OldChatMember.Status,
)
// If there is no such chatid, means that no such cache too.
if len(ChatList) == 0 || !slices.Contains(ChatList, msg.Message.Chat.ID) {
Log.Debugf(
"Creating caches for new %s %s (%d)",
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
NewMembers[msg.Message.Chat.ID] = cache.New(1 * time.Minute)
AppearedMembers[msg.Message.Chat.ID] = cache.New(1 * time.Minute)
SquashedMembers[msg.Message.Chat.ID] = cache.New(60 * time.Minute)
Log.Debugf(
"Add %s %s (%d) to list of known chats",
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
ChatList = append(ChatList, msg.Message.Chat.ID)
}
// Check some other attributes.
if msg.ChatMember.OldChatMember.Status == "left" && msg.ChatMember.NewChatMember.Status == "member" {
Log.Infof(
"Getting info about user %s %s (username = %s, id = %d) in chat %s %s (%d) from LeftMembers DB",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
leftTimestampString := Cfg.GetValue(
"LeftMembers",
fmt.Sprintf("%d", msg.ChatMember.Chat.ID),
fmt.Sprintf("%d", msg.ChatMember.NewChatMember.User.ID),
)
// Yes, this member definitely left chat at some point, we have no question to him/her.
if leftTimestampString != "" {
Log.Infof(
"User %s %s (username = %s, id = %d) in chat %s %s (%d) found in from LeftMembers DB ",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
return
}
Log.Infof(
"User %s %s (username = %s, id = %d) in chat %s %s (%d) not found in LeftMembers DB user %s %s (username = %s, id = %d) in chat %s %s (%d)",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
// Spam bots come with these attributes.
// msg.ChatMember.NewChatMember.Status = "member"
// msg.ChatMember.OldChatMember.User != nil
// msg.ChatMember.OldChatMember.Status = "left"
casBanned, err := CasCheckID(msg.ChatMember.NewChatMember.User.ID)
if err != nil {
Log.Errorf(
"Unable to check user %s %s (username = %s, id = %d) in chat %s %s (%d): %s",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
err,
)
} else if casBanned {
// Ban user if cas report it as banned.
Log.Infof(
"Ban user %s %s (username = %s, id = %d) in chat %s %s (%d) by CAS blacklist",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
squash(
msg.ChatMember.Chat.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
)
return
}
Log.Infof(
"Storing info about user %s %s (username = %s, id = %d) in chat %s %s (%d) to AppearedMember DB",
msg.ChatMember.NewChatMember.User.FirstName,
msg.ChatMember.NewChatMember.User.LastName,
msg.ChatMember.NewChatMember.User.Username,
msg.ChatMember.NewChatMember.User.ID,
msg.ChatMember.Chat.Type,
msg.ChatMember.Chat.Title,
msg.ChatMember.Chat.ID,
)
AppearedMembers[msg.ChatMember.Chat.ID].Set(msg.ChatMember.NewChatMember.User.ID, time.Now().Unix(), 1*time.Minute)
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

100
internal/moon/config.go Normal file
View File

@@ -0,0 +1,100 @@
package moon
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/hjson/hjson-go"
"go.uber.org/zap/zapcore"
)
// parseConfig разбирает и валидирует даденный конфиг.
func ParseConfig(path string) (MyConfig, error) {
fileInfo, err := os.Stat(path)
// Assume that no such file or directory error occurs.
if err != nil {
return MyConfig{}, fmt.Errorf("unable to find config: %w", err)
}
// Config file looks too long for config.
if fileInfo.Size() > 65535 {
err := fmt.Errorf("config file %s is too long for config, skipping", path) //nolint: err113
return MyConfig{}, err
}
buf, err := os.ReadFile(path)
// Noluck reading file.
if err != nil {
return MyConfig{}, err
}
/*
* According to docs hjson kinda can patse quirky json, but unmarshalls it to map. *
* Struct on output look more interesting: so we unmarshall config to map, then mashall back to json and then *
* unmarshall it via ecodinf/json to struct. Not very effective way but we do this not too often. *
*/
var (
sampleConfig MyConfig
tmp map[string]any
)
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.Token == "" {
return MyConfig{}, fmt.Errorf("token field in config file %s must be set", path) //nolint: err113
}
if _, err := zapcore.ParseLevel(sampleConfig.LogLevel); err != nil {
sampleConfig.LogLevel = "info"
}
// sampleConfig.Log = "" if not set
if sampleConfig.CSign == "" {
err := fmt.Errorf("csign field in config file %s must be set", path) //nolint: err113
return MyConfig{}, err
}
if sampleConfig.DataDir == "" {
return MyConfig{}, fmt.Errorf("data_dir field in config file %s must be set", path) //nolint: err113
}
if strings.Split(sampleConfig.DataDir, "")[0] != "/" {
executablePath, err := os.Executable()
if err != nil {
err := fmt.Errorf("unable to get current executable path: %w", err)
return MyConfig{}, err
}
sampleConfig.DataDir = fmt.Sprintf("%s/%s", filepath.Dir(executablePath), sampleConfig.DataDir)
}
return sampleConfig, err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

139
internal/moon/db.go Normal file
View File

@@ -0,0 +1,139 @@
package moon
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"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)
if err != nil {
return err
}
err = db.Flush()
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
}
// GetValue достаёт настройку из БД с настройками.
// TODO: should return string, error and should not log error internally.
func (cfg *MyConfig) GetValue(dataBaseName string, chatID string, setting string) string {
var err error
chatHash := sha256.Sum256([]byte(chatID))
dataBaseDir := fmt.Sprintf("%s/db/%s/%x", cfg.DataDir, dataBaseName, chatHash)
// Если БД не открыта, откроем её
if _, ok := DB[dataBaseDir]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений.
options.L0CompactionFileThreshold = 8
if err := os.MkdirAll(dataBaseDir, os.ModePerm); err != nil {
Log.Errorf("Unable to create db dir %s: %s", dataBaseDir, err)
return ""
}
DB[dataBaseDir], err = pebble.Open(dataBaseDir, &options)
if err != nil {
Log.Errorf("Unable to open db, %s: %s\n", dataBaseDir, err)
return ""
}
}
value, err := FetchV(DB[dataBaseDir], 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, dataBaseDir)
case errors.Is(err, fs.ErrNotExist):
Log.Debugf("Unable to get value for %s: db dir %s does not exist", setting, dataBaseDir)
case errors.Is(err, oserror.ErrNotExist):
Log.Debugf("Unable to get value for %s: db dir %s does not exist", setting, dataBaseDir)
default:
Log.Errorf("Unable to get value for %s in db dir %s: %s", setting, dataBaseDir, err)
}
return ""
}
return value
}
// SaveSetting сохраняет настройку в БД с настройками.
func (cfg *MyConfig) SaveKeyValue(dataBaseName string, chatID string, setting string, value string) error {
var (
chatHash = sha256.Sum256([]byte(chatID))
dataBaseDir = fmt.Sprintf("%s/db/%s/%x", cfg.DataDir, dataBaseName, chatHash)
err error
)
// Если БД не открыта, откроем её.
if _, ok := DB[dataBaseDir]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений.
options.L0CompactionFileThreshold = 8
if err := os.MkdirAll(dataBaseDir, os.ModePerm); err != nil {
return fmt.Errorf("unable to create db dir %s: %w", dataBaseDir, err)
}
DB[dataBaseDir], err = pebble.Open(dataBaseDir, &options)
if err != nil {
return fmt.Errorf("unable to open settings db, %s: %w", dataBaseDir, err)
}
}
if err := StoreKV(DB[dataBaseDir], setting, value); err != nil {
return fmt.Errorf("unable to save setting %s in database %s: %w", setting, dataBaseDir, err)
}
return nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

33
internal/moon/globals.go Normal file
View File

@@ -0,0 +1,33 @@
package moon
import (
"os"
"github.com/NicoNex/echotron/v3"
"github.com/akyoto/cache"
"github.com/cockroachdb/pebble"
"go.uber.org/zap"
)
var (
Cfg MyConfig
SigChan = make(chan os.Signal, 1)
Log *zap.SugaredLogger
Tg echotron.API
// Мапка с открытыми дескрипторами баз настроек.
DB = make(map[string]*pebble.DB)
// SquashedMembers struct with banned members.
SquashedMembers map[int64]*cache.Cache
// NewMembers struct with new members appeared via message with new_chat_members field set.
NewMembers map[int64]*cache.Cache
// AppearedMembers struct with new members appeared via ChatMemberUpdated event.
AppearedMembers map[int64]*cache.Cache
ChatList = make([]int64, 0)
)
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,34 @@
package moon
import (
"fmt"
"time"
"github.com/NicoNex/echotron/v3"
)
func leftChatMember(msg *echotron.Update) {
Log.Infof(
"User %s %s (username = %s, id = %d) left %s %s (%d)",
msg.Message.LeftChatMember.FirstName,
msg.Message.LeftChatMember.LastName,
msg.Message.LeftChatMember.Username,
msg.Message.LeftChatMember.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
if err := Cfg.SaveKeyValue(
"LeftMembers",
fmt.Sprintf("%d", msg.Message.Chat.ID),
fmt.Sprintf("%d", msg.Message.LeftChatMember.ID),
fmt.Sprintf("%d", time.Now().Unix()),
); err != nil {
Log.Error(err)
} else {
Log.Infof("Info about left userid %d saved to left users db", msg.Message.LeftChatMember.ID)
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,106 @@
package moon
import (
"slices"
"time"
"github.com/NicoNex/echotron/v3"
cache "github.com/akyoto/cache"
)
// newChatMembers parses NewChatMember event.
func newChatMembers(msg *echotron.Update) {
for _, user := range msg.Message.NewChatMembers {
Log.Infof(
"User %s %s (username = %s, id = %d) joined %s %s (%d)",
user.FirstName,
user.LastName,
user.Username,
user.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
// If there is no such chatid, means that no such cache too.
if !slices.Contains(ChatList, msg.Message.Chat.ID) {
Log.Debugf(
"Creating caches for new %s %s (%d)",
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
NewMembers[msg.Message.Chat.ID] = cache.New(1 * time.Minute)
AppearedMembers[msg.Message.Chat.ID] = cache.New(1 * time.Minute)
SquashedMembers[msg.Message.Chat.ID] = cache.New(60 * time.Minute)
Log.Debugf(
"Add %s %s (%d) to list of known chats",
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
ChatList = append(ChatList, msg.Message.Chat.ID)
}
casBanned, err := CasCheckID(user.ID)
if err != nil {
Log.Error(err)
}
// Ban user if cas report it as banned.
if casBanned {
Log.Infof(
"Ban user %s %s (username = %s, id = %d) in %s %s (%d) by CAS blacklist",
user.FirstName,
user.LastName,
user.Username,
user.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
squash(
msg.Message.Chat.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
user.ID,
user.FirstName,
user.LastName,
user.Username,
)
}
// Do not return on prev. step.
Log.Infof(
"Skip banning user %s %s (username = %s, id = %d) in %s %s (%d) because it was not found in CAS blacklist",
user.FirstName,
user.LastName,
user.Username,
user.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
Log.Infof(
"Add user %s %s (username = %s, id = %d) in %s %s (%d) to NewMember DB",
user.FirstName,
user.LastName,
user.Username,
user.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
)
NewMembers[msg.Message.Chat.ID].Set(user.ID, time.Now().Unix(), 1*time.Minute)
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

168
internal/moon/runAndBan.go Normal file
View File

@@ -0,0 +1,168 @@
package moon
import (
"time"
)
func RunAndBan() {
for {
// If user have both events, he is not spammer. So wipe him from unchecked events list, just for sure.
for _, chatid := range ChatList {
Log.Infof("RunAndBan by chatid: %d", chatid)
var ListAppearedMembers, ListNewMembers []int64
AppearedMembers[chatid].Range(
func(k, _ any) bool {
ListAppearedMembers = append(ListAppearedMembers, k.(int64))
return true
},
)
NewMembers[chatid].Range(
func(k, _ any) bool {
ListNewMembers = append(ListNewMembers, k.(int64))
return true
},
)
for _, AppearedMember := range ListAppearedMembers {
for _, NewMember := range ListNewMembers {
if AppearedMember == NewMember {
// TODO: Store also additional attributes in temporary database.
Log.Infof(
"Skip banning user %s %s (username = %s, id = %d) in %s %s (%d), because it has both events newMember and updateMember",
"",
"",
"",
NewMember,
"",
"",
chatid,
)
Log.Infof(
"Deleting user %s %s (username = %s, id = %d) in %s %s (%d), from AppearedMember DB",
"",
"",
"",
NewMember,
"",
"",
chatid,
)
AppearedMembers[chatid].Delete(AppearedMember)
Log.Infof(
"Deleting user %s %s (username = %s, id = %d) in %s %s (%d), from NewMember DB",
"",
"",
"",
NewMember,
"",
"",
chatid,
)
NewMembers[chatid].Delete(NewMember)
}
}
}
}
currentTime := time.Now().Unix()
// We would like to process only events that definitely older than 10 seconds!
for _, chatid := range ChatList {
var (
ListAppearedMembers, ListNewMembers []int64
)
AppearedMembers[chatid].Range(
func(k, v interface{}) bool {
ListAppearedMembers = append(ListAppearedMembers, k.(int64))
return true
},
)
NewMembers[chatid].Range(
func(k, v interface{}) bool {
ListNewMembers = append(ListNewMembers, k.(int64))
return true
},
)
for _, AppearedMember := range ListAppearedMembers {
eventTimestamp, found := AppearedMembers[chatid].Get(AppearedMember)
if !found {
continue
}
eventTimeDelta := currentTime - eventTimestamp.(int64)
// Looks like it is spammer!
if eventTimeDelta > 10 {
// TODO: Store also additional attributes in temporary database.
Log.Infof(
"Ban user %s %s (username = %s, id = %d) in chat %s %s (%d) that have no newChatMember message since chatMemberUpdated for more than 10 seconds",
"",
"",
"",
AppearedMember,
"",
"",
chatid,
)
squash(
chatid,
"",
"",
AppearedMember,
"",
"",
"",
)
AppearedMembers[chatid].Delete(AppearedMember)
}
}
for _, NewMember := range ListNewMembers {
eventTimestamp, found := NewMembers[chatid].Get(NewMember)
if !found {
continue
}
eventTimeDelta := currentTime - eventTimestamp.(int64)
// Totally new members should not have chatMemberUpdated events.
if eventTimeDelta > 15 {
// TODO: Store also additional attributes in temporary database.
Log.Debugf("Clean newChatMember event record older than 10 seconds for user user %s %s (username = %s, id = %d) in chat %s %s (%d)",
"",
"",
"",
NewMember,
"",
"",
chatid,
)
NewMembers[chatid].Delete(NewMember)
}
}
}
time.Sleep(10 * time.Second)
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,40 @@
package moon
import (
"os"
"syscall"
)
// SigHandler хэндлер сигналов закрывает все бд, все сетевые соединения и сваливает из приложения.
func SigHandler() {
for {
var s = <-SigChan
switch s {
case syscall.SIGINT:
Log.Infoln("Got SIGINT, quitting")
case syscall.SIGTERM:
Log.Infoln("Got SIGTERM, quitting")
case syscall.SIGQUIT:
Log.Infoln("Got SIGQUIT, quitting")
// Заходим на новую итерацию, если у нас "неинтересный" сигнал.
default:
continue
}
if len(DB) > 0 {
Log.Debug("Closing runtime bot settings db")
for _, db := range DB {
_ = db.Close()
}
}
// Since we shutting down, we have noting to do with log sync.
_ = Log.Sync()
os.Exit(0)
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

93
internal/moon/squash.go Normal file
View File

@@ -0,0 +1,93 @@
package moon
import (
"slices"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/akyoto/cache"
)
// TODO: refactor to return bool, error
// squash bans given chat member.
func squash(chatid int64, chattype, chattitle string, userid int64, firstname, lastname, username string) {
banUntilDate := time.Now().Unix() + 10
// If there is no such chatid, means that no such cache too.
if len(ChatList) == 0 || !slices.Contains(ChatList, chatid) {
Log.Debugf("Creating caches for new %s %s (%d)", chattype, chattitle, chatid)
NewMembers[chatid] = cache.New(1 * time.Minute)
AppearedMembers[chatid] = cache.New(1 * time.Minute)
SquashedMembers[chatid] = cache.New(5 * time.Minute)
Log.Debugf("Add %s %s (%d) to list of known chats", chattype, chattitle, chatid)
ChatList = append(ChatList, chatid)
}
if found, exist := SquashedMembers[chatid].Get(userid); exist && found.(bool) {
Log.Infof(
"Skip banning user %s %s (username = %s, id = %d) in %s %s (%d), already banned",
firstname,
lastname,
username,
userid,
chattype,
chattitle,
chatid,
)
return
}
if res, err := Tg.BanChatMember(
chatid,
userid,
&echotron.BanOptions{
UntilDate: int(banUntilDate),
},
); err != nil {
Log.Errorf(
"Unable to ban user user %s %s (username = %s, id = %d) in %s %s (%d): %s",
firstname,
lastname,
username,
userid,
chattype,
chattitle,
chatid,
res.Description,
)
} else {
if !res.Ok {
Log.Errorf(
"Unable to ban user %s %s (username = %s, id = %d) in %s %s (%d): %s",
firstname,
lastname,
username,
userid,
chattype,
chattitle,
chatid,
res.Description,
)
} else {
Log.Debugf(
"Banned user %s %s (username = %s, id = %d) in %s %s (%d).",
firstname,
lastname,
username,
userid,
chattype,
chattitle,
chatid,
)
SquashedMembers[chatid].Set(userid, true, 30*time.Second)
NewMembers[chatid].Delete(userid)
AppearedMembers[chatid].Delete(userid)
}
}
}

78
internal/moon/telegram.go Normal file
View File

@@ -0,0 +1,78 @@
package moon
import (
"github.com/NicoNex/echotron/v3"
"github.com/davecgh/go-spew/spew"
)
// Telegram основная горутинка, реализующая бота.
func Telegram(cfg MyConfig) {
Tg = echotron.NewAPI(cfg.Token)
Log.Infof("Starting api update poller loop")
for u := range echotron.PollingUpdatesOptions(cfg.Token, true, echotron.UpdateOptions{
AllowedUpdates: []echotron.UpdateType{
"message",
"edited_message",
"channel_post",
"edited_channel_post",
"message_reaction",
"message_reaction_count",
"inline_query",
"chosen_inline_result",
"callback_query",
"poll",
"poll_answer",
"my_chat_member",
"chat_member",
"chat_join_request",
"chat_boost",
"removed_chat_boost",
},
}) {
telegramMsgParser(u)
}
}
// telegramMsgParser parses events generated by echotron.
func telegramMsgParser(msg *echotron.Update) {
if msg.Message != nil && msg.Message.Text != "" {
textMessage(msg)
return
}
// ChatMember updated event. It goes to chatMemberUpdated.go.
if msg.ChatMember != nil {
if msg.ChatMember.NewChatMember.User != nil {
newChatMember(msg)
}
return
}
// New members entered to chat.
if msg.Message != nil && msg.Message.NewChatMembers != nil {
newChatMembers(msg)
return
}
// Chat member leaves chat.
if msg.Message != nil && msg.Message.LeftChatMember != nil {
leftChatMember(msg)
return
}
if msg.Message != nil && msg.Message.Animation != nil {
animation(msg)
return
}
Log.Debug(spew.Sdump(msg))
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,64 @@
package moon
import (
"fmt"
"time"
"github.com/NicoNex/echotron/v3"
)
func textMessage(msg *echotron.Update) {
if msg.Message.ReplyToMessage == nil {
// Just log it, it seems later here should be ban/mute command set.
Log.Infof(
"User %s %s (username = %s, id = %d) in %s %s (%d) says: %s",
msg.Message.From.FirstName,
msg.Message.From.LastName,
msg.Message.From.Username,
msg.Message.From.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
msg.Message.Text,
)
} else {
// Just log it.
Log.Infof(
"User %s %s (username = %s, id = %d) in %s %s (%d) replies to message %d by %s %s (username = %s, id = %d): %s",
msg.Message.From.FirstName,
msg.Message.From.LastName,
msg.Message.From.Username,
msg.Message.From.ID,
msg.Message.Chat.Type,
msg.Message.Chat.Title,
msg.Message.Chat.ID,
msg.Message.ReplyToMessage.ID,
msg.Message.ReplyToMessage.From.FirstName,
msg.Message.ReplyToMessage.From.LastName,
msg.Message.ReplyToMessage.From.Username,
msg.Message.ReplyToMessage.From.ID,
msg.Message.Text,
)
}
// Update database of new members :)
// Try to get timestamp when this member joined to chat
if joinTimestampString := Cfg.GetValue(
"NewMembers",
fmt.Sprintf("%d", msg.Message.Chat.ID),
fmt.Sprintf("%d", msg.Message.From.ID),
); joinTimestampString == "" {
if err := Cfg.SaveKeyValue(
"NewMembers",
fmt.Sprintf("%d", msg.Message.Chat.ID),
fmt.Sprintf("%d", msg.Message.From.ID),
fmt.Sprintf("%d", time.Now().Unix()),
); err != nil {
Log.Error(err)
} else {
Log.Infof("Info about new userid %d saved to new users db", msg.Message.From.ID)
}
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

33
internal/moon/types.go Normal file
View File

@@ -0,0 +1,33 @@
package moon
import (
"time"
)
// MyConfig describes bot config file format.
type MyConfig struct {
Name string `json:"name,omitempty"`
Token string `json:"token,omitempty"`
LogLevel string `json:"loglevel,omitempty"`
LogFile string `json:"logfile,omitempty"`
CSign string `json:"csign,omitempty"`
DataDir string `json:"datadir,omitempty"`
}
// CasFalse describes response from api.cas.chat when user is not blacklisted.
type CasFalse struct {
Ok bool `json:"ok"`
Description string `json:"description"`
}
// CasFalse describes response from api.cas.chat when user is blacklisted.
type CasTrue struct {
Ok bool `json:"ok"`
Result struct {
Reasons []int `json:"reasons"`
Offenses int `json:"offenses"`
TimeAdded time.Time `json:"time_added"`
} `json:"result"`
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */