Global code ovehaul - remove CAS and other, add 15 minute greet check
- Remove CAS, as it no longer works for us - Remove other checks - Implement 15 minute greet timeout check - Fix chat logos - Ditch thirdparty logger, use stdlib log facility wrapper for logging
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// animation parses message with animation.
|
||||
func animation(msg *echotron.Update) {
|
||||
if Cfg.LogChats {
|
||||
ChatLog(
|
||||
msg.Message.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,100 +0,0 @@
|
||||
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( //nolint: err113
|
||||
"api.cas.chat returns non-200 http status code: %d %s",
|
||||
resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
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, ¬Banned); 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
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,208 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// newChatMember parses one of types ChatMemberUpdated event.
|
||||
// https://core.telegram.org/bots/api#chatmemberupdated
|
||||
func chatMemberUpdated(msg *echotron.Update) {
|
||||
AddChat(&msg.ChatMember.Chat)
|
||||
AddUser(msg.ChatMember.NewChatMember.User)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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.ChatMember.Chat.ID)
|
||||
slices.Sort(ChatList)
|
||||
ChatList = slices.Compact(ChatList)
|
||||
|
||||
// Check some other attributes.
|
||||
switch {
|
||||
case 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",
|
||||
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.NewChatMember.User.ID,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
key := fmt.Sprintf("%d+%d", msg.ChatMember.Chat.ID, msg.ChatMember.NewChatMember.User.ID)
|
||||
AppearedMembers.Set(key, time.Now().Unix(), 1*time.Minute)
|
||||
PendingCASMembers.Set(key, time.Now().Unix(), 30*time.Minute)
|
||||
|
||||
case msg.ChatMember.OldChatMember.Status == "member" && msg.ChatMember.NewChatMember.Status == "kicked":
|
||||
Log.Infof(
|
||||
"User %s %s (username = %s, id = %d) in chat %s %s (%d) was banned",
|
||||
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,
|
||||
)
|
||||
|
||||
key := fmt.Sprintf("%d+%d", msg.ChatMember.Chat.ID, msg.ChatMember.NewChatMember.User.ID)
|
||||
|
||||
Log.Debugf(
|
||||
"Put user %s %s (username = %s, id = %d) in %s %s (%d) into SquashedMembers to debounce ban.",
|
||||
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,
|
||||
)
|
||||
|
||||
SquashedMembers.Set(key, true, 30*time.Second)
|
||||
|
||||
Log.Debugf(
|
||||
"Storing info about user %s %s (username = %s, id = %d) in chat %s %s (%d) to BannedMembers 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,
|
||||
)
|
||||
|
||||
if err := Cfg.SaveKeyValue(
|
||||
"BannedMembers",
|
||||
fmt.Sprintf("%d", msg.ChatMember.Chat.ID),
|
||||
fmt.Sprintf("%d", msg.ChatMember.NewChatMember.User.ID),
|
||||
fmt.Sprintf("%d", time.Now().Unix()),
|
||||
); err != nil {
|
||||
Log.Error(err)
|
||||
} else {
|
||||
Log.Infof("Info about banned userid %d saved to BannedMembers DB", msg.ChatMember.Chat.ID)
|
||||
}
|
||||
|
||||
default:
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,49 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ChatLog logs message to separate file, placed in basedir(Cfg.LogFile).
|
||||
func ChatLog(chatid int64, message string) {
|
||||
var (
|
||||
filename string
|
||||
)
|
||||
|
||||
if Cfg.LogFile != "" {
|
||||
filename = fmt.Sprintf("%s/%s", filepath.Dir(Cfg.LogFile), strconv.FormatInt(chatid, 10))
|
||||
} else {
|
||||
filename = os.DevNull
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Unable to open file %s for logging: %s", filename, err)
|
||||
|
||||
// TODO: Should we try harder?
|
||||
return
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
log.Logger = log.Output(
|
||||
zerolog.ConsoleWriter{
|
||||
Out: f,
|
||||
NoColor: true,
|
||||
TimeFormat: time.DateTime,
|
||||
PartsOrder: []string{"time", "message"},
|
||||
},
|
||||
)
|
||||
|
||||
log.Info().Msg(message)
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -5,10 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"bunny-bot/internal/elog"
|
||||
"bunny-bot/internal/mylog"
|
||||
|
||||
"github.com/hjson/hjson-go"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// parseConfig разбирает и валидирует даденный конфиг.
|
||||
@@ -66,34 +67,27 @@ func ParseConfig(path string) (MyConfig, error) {
|
||||
return MyConfig{}, fmt.Errorf("token field in config file %s must be set", path) //nolint: err113
|
||||
}
|
||||
|
||||
if _, err := zapcore.ParseLevel(sampleConfig.LogLevel); err != nil {
|
||||
if _, err := mylog.ParseLevel(sampleConfig.LogLevel); err != nil {
|
||||
sampleConfig.LogLevel = "info"
|
||||
}
|
||||
|
||||
// Chat logs are optional, but a configured path that does not exist is a
|
||||
// typo and must stop startup rather than silently disable logging.
|
||||
//
|
||||
// A relative chat_logs is resolved against the directory holding the config
|
||||
// file, not the process working directory: a daemon's cwd is wherever its
|
||||
// supervisor happened to start it, so a relative path there would move the
|
||||
// log directory on every restart under a different supervisor.
|
||||
if sampleConfig.ChatLogs != "" && !filepath.IsAbs(sampleConfig.ChatLogs) {
|
||||
sampleConfig.ChatLogs = filepath.Join(filepath.Dir(path), sampleConfig.ChatLogs)
|
||||
}
|
||||
|
||||
if err := elog.Init(sampleConfig.ChatLogs); err != nil {
|
||||
return MyConfig{}, fmt.Errorf("chat_logs field in config file %s is invalid: %w", path, err)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/cockroachdb/errors/oserror"
|
||||
"github.com/cockroachdb/pebble"
|
||||
)
|
||||
|
||||
// StoreKV saves given key and value in given db.
|
||||
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 gets value by given key from given db.
|
||||
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 gets setting for chat from given db name.
|
||||
// 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 db descriptor is not open yet, open it.
|
||||
if _, ok := DB[dataBaseDir]; !ok {
|
||||
var options pebble.Options
|
||||
/*
|
||||
* By default limit set on megs of data, but not on amount of files thus with defaults we have tons of
|
||||
* files. Default limit on tomporary files is 500, lets cut it down to 8.
|
||||
*/
|
||||
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 db returns nothing for some reason, just return ampty string.
|
||||
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 saves setting name and its value to given db name.
|
||||
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 db descriptor is not open yet, open it.
|
||||
if _, ok := DB[dataBaseDir]; !ok {
|
||||
var options pebble.Options
|
||||
/*
|
||||
* By default limit set on megs of data, but not on amount of files thus with defaults we have tons of
|
||||
* files. Default limit on tomporary files is 500, lets cut it down to 8.
|
||||
*/
|
||||
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: */
|
||||
@@ -1,50 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// editedMessage handles editedMessage type of update.
|
||||
func editedMessage(msg *echotron.Update) {
|
||||
AddUser(msg.EditedMessage.From)
|
||||
AddChat(&msg.EditedMessage.Chat)
|
||||
|
||||
// Edited message can contain all kinds of edited attributes that telegram allows to change in message.
|
||||
// Not sure is we should handle all of them.
|
||||
if Cfg.LogChats {
|
||||
if msg.EditedMessage.Text != "" {
|
||||
ChatLog(
|
||||
msg.EditedMessage.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"User %s %s (username = %s, id = %d) in %s %s (%d) edited message: %s",
|
||||
msg.EditedMessage.From.FirstName,
|
||||
msg.EditedMessage.From.LastName,
|
||||
msg.EditedMessage.From.Username,
|
||||
msg.EditedMessage.From.ID,
|
||||
msg.EditedMessage.Chat.Type,
|
||||
msg.EditedMessage.Chat.Title,
|
||||
msg.EditedMessage.Chat.ID,
|
||||
msg.EditedMessage.Text,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
ChatLog(
|
||||
msg.EditedMessage.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"User %s %s (username = %s, id = %d) in %s %s (%d) edited message",
|
||||
msg.EditedMessage.From.FirstName,
|
||||
msg.EditedMessage.From.LastName,
|
||||
msg.EditedMessage.From.Username,
|
||||
msg.EditedMessage.From.ID,
|
||||
msg.EditedMessage.Chat.Type,
|
||||
msg.EditedMessage.Chat.Title,
|
||||
msg.EditedMessage.Chat.ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
130
internal/moon/entityGuard.go
Normal file
130
internal/moon/entityGuard.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/mylog"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
)
|
||||
|
||||
// The Bot API always returns a User/Chat carrying its id, and echotron validates
|
||||
// nothing: lclient.dispatch (network.go:138) is json.Unmarshal plus a check() that
|
||||
// reads only the ok/error_code envelope (helpers.go:37). An entity that arrives
|
||||
// without an id therefore reaches us as a silent zero, with no layer below to
|
||||
// complain about it. These predicates are the whole of our defence.
|
||||
//
|
||||
// They are SILENT. A refusal is a fact, not an incident: reporting happens once, at
|
||||
// the ingress, where the offending update can be dumped whole. A predicate that logs
|
||||
// would make one broken update produce one line per entity that happens to meet it.
|
||||
|
||||
// usableUser reports whether a user entity is one we can actually address.
|
||||
//
|
||||
// The test is against zero and not against zero-or-less on purpose: a chat id is
|
||||
// legitimately negative for a group or supergroup, so a positivity check would
|
||||
// refuse every group chat in the project.
|
||||
func usableUser(u *echotron.User) bool {
|
||||
return u != nil && u.ID != 0
|
||||
}
|
||||
|
||||
// usableChat reports whether a chat entity is one we can actually address, with the
|
||||
// same reasoning and the same zero-not-positive rule.
|
||||
func usableChat(c *echotron.Chat) bool {
|
||||
return c != nil && c.ID != 0
|
||||
}
|
||||
|
||||
// usableUpdate reports whether an update is worth dispatching at all.
|
||||
//
|
||||
// Its branches mirror telegramMsgParser's, in the same order and under the same
|
||||
// conditions, so a branch added to one and not the other is visible by reading. That
|
||||
// is not tidiness: the alternative is a validator shaped differently from the
|
||||
// dispatcher, whose coverage nobody can check by eye.
|
||||
//
|
||||
// nil is tolerated only where absence is normal. A message with no author is a
|
||||
// channel post or a service message, and refusing those would disable chat logging
|
||||
// for channels. A nil element inside NewChatMembers is different — the array asserts
|
||||
// a newcomer exists — and so is a nil NewChatMember.User, which asserts who changed.
|
||||
// A nil OldChatMember.User is tolerated: some transitions have no previous member.
|
||||
func usableUpdate(u *echotron.Update) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case u.ChatMember != nil:
|
||||
return usableChatMember(u.ChatMember)
|
||||
case u.Message != nil:
|
||||
return usableMessage(u.Message)
|
||||
case u.EditedMessage != nil:
|
||||
return usableMessage(u.EditedMessage)
|
||||
default:
|
||||
// Nothing we act on. The dispatcher's spew fallback is where this belongs.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// usableChatMember checks a membership change.
|
||||
//
|
||||
// Its entities are values, not pointers: ChatMemberUpdated.Chat, .From and both
|
||||
// ChatMember structs can never be nil, so there is no nil test to fall back on and
|
||||
// the id check is the only thing that can catch a malformed one.
|
||||
func usableChatMember(cm *echotron.ChatMemberUpdated) bool {
|
||||
return usableChat(&cm.Chat) &&
|
||||
usableUser(cm.NewChatMember.User) &&
|
||||
(cm.OldChatMember.User == nil || usableUser(cm.OldChatMember.User)) &&
|
||||
usableUser(&cm.From)
|
||||
}
|
||||
|
||||
// usableMessage checks a message, or an edit of one — the two carry the same fields
|
||||
// and are treated the same way.
|
||||
//
|
||||
// Message.Chat is a value: a zero chat id would otherwise reach elog, which builds
|
||||
// chat_logs/<id>/yyyy/mm/dd.txt from it and would silently create a chat_logs/0
|
||||
// directory and write every malformed update into it.
|
||||
func usableMessage(m *echotron.Message) bool {
|
||||
if !usableChat(&m.Chat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if m.From != nil && !usableUser(m.From) {
|
||||
return false
|
||||
}
|
||||
|
||||
if m.LeftChatMember != nil && !usableUser(m.LeftChatMember) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, member := range m.NewChatMembers {
|
||||
if !usableUser(member) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// One level deep, and the nested chat only. The nested author is never rendered,
|
||||
// so a zero there is cosmetic; the level bound exists to keep the work constant.
|
||||
if m.PinnedMessage != nil && !usableChat(&m.PinnedMessage.Chat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if m.ReplyToMessage != nil && !usableChat(&m.ReplyToMessage.Chat) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// reportBrokenUpdate records an update we are refusing, in the terms it deserves:
|
||||
// this is not a user being difficult and not a transient API error, it is an
|
||||
// upstream that broke its own contract.
|
||||
//
|
||||
// The whole update is dumped, once. The dump is the point — the condition is
|
||||
// unreproducible and unqueryable, since there is no id to ask Telegram about — and a
|
||||
// partial dump of one field out of four would not be evidence of anything.
|
||||
func reportBrokenUpdate(u *echotron.Update) {
|
||||
mylog.Error(
|
||||
"Telegram sent an update with an entity carrying no id, refusing it: nothing in it can be greeted, recorded or banned, and an API that sends one is broken",
|
||||
)
|
||||
|
||||
mylog.Error(spew.Sdump(u))
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
25
internal/moon/exitCode.go
Normal file
25
internal/moon/exitCode.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package moon
|
||||
|
||||
import "os"
|
||||
|
||||
// ExitCode reports the status the process should leave with on a clean return
|
||||
// from run(). It is zero while the bot is running normally, and 128 + the
|
||||
// signal's number once a termination signal has begun the shutdown — the Unix
|
||||
// convention a supervisor can use to tell "stopped with a signal" from "stopped
|
||||
// cleanly", which D33 left permanently 0 before it.
|
||||
//
|
||||
// It never reports an error status: a failed shutdown still returns an error
|
||||
// from run(), and main exits 1 over that, the same as before.
|
||||
func ExitCode() int {
|
||||
return int(exitCode.Load())
|
||||
}
|
||||
|
||||
// recordExitCode stores the status the given terminating signal should leave.
|
||||
// Only the first termination signal matters — the handler goroutine returns
|
||||
// after one — so a later write cannot pass either.
|
||||
func recordExitCode(s os.Signal) {
|
||||
// The value is one of a fixed table of 128+n, never a caller-supplied int.
|
||||
exitCode.Store(int32(exitCodeFor(s))) //nolint:gosec // G115: exitCodeFor returns only 130, 131, 143 or 0.
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
28
internal/moon/exitCode_unix.go
Normal file
28
internal/moon/exitCode_unix.go
Normal file
@@ -0,0 +1,28 @@
|
||||
//go:build !windows
|
||||
|
||||
package moon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// exitCodeFor maps one terminating signal to the status a stopped process
|
||||
// should carry: 128 + the signal's number on Unix.
|
||||
func exitCodeFor(s os.Signal) int {
|
||||
switch s {
|
||||
case syscall.SIGINT:
|
||||
return signalExitBase + 2
|
||||
|
||||
case syscall.SIGTERM:
|
||||
return signalExitBase + 15
|
||||
|
||||
case syscall.SIGQUIT:
|
||||
return signalExitBase + 3
|
||||
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
18
internal/moon/exitCode_windows.go
Normal file
18
internal/moon/exitCode_windows.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build windows
|
||||
|
||||
package moon
|
||||
|
||||
import "os"
|
||||
|
||||
// exitCodeFor maps one terminating signal to the status a stopped process
|
||||
// should carry. Windows has no signal numbers to follow, so it imitates the
|
||||
// Unix convention: Ctrl+C is an interrupt, and an interrupt is 130.
|
||||
func exitCodeFor(s os.Signal) int {
|
||||
if s == os.Interrupt {
|
||||
return signalExitBase + 2
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -2,48 +2,143 @@ package moon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/akyoto/cache"
|
||||
"github.com/cockroachdb/pebble"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// greetCacheTTL is how long a join event or a chat message is retained. It
|
||||
// is deliberately larger than greetReplyWindow so that a sweep occurring at
|
||||
// the far end of the window still has the data it needs to decide.
|
||||
greetCacheTTL = 17 * time.Minute
|
||||
|
||||
// greetReplyWindow is how long a newcomer has to answer the greeting before
|
||||
// being treated as a spammer.
|
||||
greetReplyWindow = 15 * time.Minute
|
||||
|
||||
// greetSweepInterval is how often the sweeper runs.
|
||||
greetSweepInterval = 1 * time.Minute
|
||||
)
|
||||
|
||||
// signalExitBase is the offset a signal's exit status is derived from. The Unix
|
||||
// convention is 128 + n, where n is the signal number, so SIGINT is 130, SIGTERM
|
||||
// is 143 and SIGQUIT is 131 — what a shell reports for a process killed by a
|
||||
// signal, which is exactly what these statuses imitate.
|
||||
const signalExitBase = 128
|
||||
|
||||
// The outcomes of a squash attempt. Names state what happened, not how it was
|
||||
// detected, so callers never have to reinterpret them.
|
||||
const (
|
||||
// banApplied means Telegram accepted the ban.
|
||||
banApplied banOutcome = iota
|
||||
|
||||
// banSuppressed means we did not call the API at all: the debounce already
|
||||
// holds this user, i.e. we banned them moments ago and they are already out.
|
||||
banSuppressed
|
||||
|
||||
// banFailed means we called the API and it refused. The user is still in the
|
||||
// chat and was not retried.
|
||||
banFailed
|
||||
)
|
||||
|
||||
// The outcomes of retracting a greeting a banned member never answered. The three
|
||||
// cases are distinct because "nothing to delete" and "tried and failed" call for
|
||||
// different log lines, and only the second is worth an operator's attention.
|
||||
const (
|
||||
// nothingToRetract means no remembered greeting id was greater than zero, so
|
||||
// the API was never called.
|
||||
nothingToRetract retractOutcome = iota
|
||||
|
||||
// greetingRetracted means every remembered greeting was deleted.
|
||||
greetingRetracted
|
||||
|
||||
// greetingRetractFailed means at least one deletion was refused by Telegram.
|
||||
// The others still went: a failure never aborts the rest.
|
||||
greetingRetractFailed
|
||||
)
|
||||
|
||||
var (
|
||||
// htmlReplacer escapes the three characters HTML requires, all three pairs at
|
||||
// once so no ordering can be got wrong: "&" is never re-escaped by the "<" or
|
||||
// ">" rules. Created once because a Replacer is immutable and safe to share.
|
||||
htmlReplacer = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
|
||||
Cfg MyConfig
|
||||
SigChan = make(chan os.Signal, 1)
|
||||
Log *zap.SugaredLogger
|
||||
Tg echotron.API
|
||||
Tg tgClient
|
||||
|
||||
// Мапка с открытыми дескрипторами баз настроек.
|
||||
DB = make(map[string]*pebble.DB)
|
||||
// allowedUpdates is the set of update types Telegram is asked to deliver.
|
||||
//
|
||||
// It lives here, not inline in Telegram(), because it must stay in step with
|
||||
// the branches of telegramMsgParser: a type missing from this list makes its
|
||||
// branch unreachable, and nothing at compile time or in the test suite says
|
||||
// so. TestEveryDispatchedUpdateTypeIsRequested exists to catch exactly that.
|
||||
//
|
||||
// chat_join_request is deliberately absent. The community is open and join
|
||||
// requests are not moderated; Telegram auto-approves them when the type is
|
||||
// not requested, which is the intended behaviour.
|
||||
allowedUpdates = []echotron.UpdateType{
|
||||
"message",
|
||||
"edited_message",
|
||||
"chat_member",
|
||||
}
|
||||
|
||||
// SquashedMembers struct with banned members.
|
||||
// SquashedMembers holds members recently banned by us, used as a ban debounce.
|
||||
SquashedMembers = cache.New(1 * time.Minute)
|
||||
|
||||
// NewMembers struct with new members appeared via message with new_chat_members field set.
|
||||
NewMembers = cache.New(1 * time.Minute)
|
||||
// PendingGreetings holds recorded joins keyed by join timestamp in
|
||||
// microseconds. Used by the greeting sweeper to find newcomers who never
|
||||
// answered, and to retract the greetings we asked them.
|
||||
PendingGreetings = newEventCache[pendingGreeting](1 * time.Minute)
|
||||
|
||||
// AppearedMembers struct with new members appeared via ChatMemberUpdated event.
|
||||
AppearedMembers = cache.New(60 * time.Minute)
|
||||
// GreetAnswers holds text messages keyed by message timestamp in
|
||||
// microseconds. Used by the greeting sweeper to find proof that a newcomer
|
||||
// answered the greeting.
|
||||
GreetAnswers = newEventCache[*echotron.Message](1 * time.Minute)
|
||||
|
||||
// PendingCASMembers contains newly comed members that pass first cas check, but still waiting second cas check.
|
||||
PendingCASMembers = cache.New(60 * time.Minute)
|
||||
// closeOnce guards closeCaches: cache.Cache.Close() sends on an unbuffered
|
||||
// channel and therefore must be called exactly once per cache; calling it
|
||||
// twice deadlocks. Each cache is guarded so a failure partway through cannot
|
||||
// turn into a hang on the second attempt.
|
||||
closeOnce sync.Once
|
||||
|
||||
// GreetPending contains people that should answer to bot greet.
|
||||
GreetPending = cache.New(1 * time.Minute)
|
||||
// stopRequested is closed once, by shutdown, to ask every goroutine that
|
||||
// loops to return. One signal for all of them, rather than one per worker:
|
||||
// several workers can come and go (and tests start them deliberately), and
|
||||
// a per-worker channel would have to be closed exactly once each, which is a
|
||||
// panic waiting to happen.
|
||||
stopRequested = make(chan struct{})
|
||||
|
||||
// NoMessagePeople contains people that are never say something in chat.
|
||||
NoMessagePeople = cache.New(1 * time.Minute)
|
||||
// workers counts the running background goroutines — the update listener and
|
||||
// the greeting sweeper. shutdown waits on it, so no worker can be touching a
|
||||
// cache, the logs or the API while the teardown closes them.
|
||||
workers sync.WaitGroup
|
||||
|
||||
// People cache with users info.
|
||||
People = cache.New(8 * time.Hour)
|
||||
// workersMu guards workers and stopping. A worker must register before
|
||||
// shutdown starts waiting, and a goroutine that tries to start afterwards is
|
||||
// refused instead of racing WaitGroup.Add against Wait.
|
||||
workersMu sync.Mutex
|
||||
stopping bool
|
||||
|
||||
// Groups cache with groups info.
|
||||
Groups = cache.New(8 * time.Hour)
|
||||
// runningWorkers counts registered, not-yet-finished workers, so a caller
|
||||
// can tell "nothing started yet" from "started and still going".
|
||||
runningWorkers atomic.Int32
|
||||
|
||||
ChatList = make([]int64, 0)
|
||||
// exitCode carries the status the process should exit with on a clean
|
||||
// return from run(): zero while the bot is running and sleeping normally,
|
||||
// 128 + signal number once a termination signal has begun the shutdown.
|
||||
// It is an atomic because the handler goroutine writes it and main reads
|
||||
// it, and plain avoids any question of which goroutine wins.
|
||||
exitCode atomic.Int32
|
||||
|
||||
// shutdownOnce guards shutdown so two termination signals arriving together
|
||||
// cannot run the sequence twice.
|
||||
shutdownOnce sync.Once
|
||||
)
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
|
||||
98
internal/moon/greet.go
Normal file
98
internal/moon/greet.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/mylog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// greetings are the templates used to greet a newly joined member. The single
|
||||
// verb is replaced with the newcomer's computed display name. The fourth one is
|
||||
// intentionally kept verbatim despite looking like a typo.
|
||||
var greetings = []string{
|
||||
"Дарована, %s. Представьтес, пожалуйста, и расскажите, что вас сюда привело.",
|
||||
"Добро пожаловать в наше скромное коммунити, %s. Представьтес, пожалуйста, и расскажите, что вас сюда привело.",
|
||||
"Доброе утро, день или вечер, %s. Представьтес, пожалуйста, и расскажите, что вас сюда привело.",
|
||||
"Наше вам с кисточкой тут, на канальчике, %s. Представьтес, пожалуйста, и расскажите, что вас сюда привело.",
|
||||
}
|
||||
|
||||
// displayName builds the name substituted into a greeting for the given user.
|
||||
// Preference order: "FirstName LastName", then LastName alone, then FirstName
|
||||
// alone, then the username.
|
||||
func displayName(u *echotron.User) string {
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(strings.Join([]string{u.FirstName, u.LastName}, " "))
|
||||
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// sendGreeting sends a randomly chosen greeting for the given newcomer into the chat.
|
||||
//
|
||||
// It returns the id of the greeting it sent and whether it was actually
|
||||
// delivered. Both matter: a newcomer who never received the question must not be
|
||||
// judged on their silence, and a newcomer who is banned later must have the
|
||||
// question taken back down.
|
||||
//
|
||||
// The mention makes the greeting a clickable link to the newcomer, and the
|
||||
// message is sent in HTML mode because that is the only mode in which an
|
||||
// arbitrary display name can be embedded safely — see greetMention.
|
||||
func sendGreeting(chatid int64, u *echotron.User) (int, bool) {
|
||||
// Everything below dereferences u, including the error paths that name the
|
||||
// user, so an entity with no id is refused here rather than crashing later.
|
||||
if !usableUser(u) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// math/rand is sufficient here: we pick one of four static templates, not a
|
||||
// secret or a security-relevant value.
|
||||
greeting := greetings[rand.IntN(len(greetings))] //nolint: gosec // G404
|
||||
|
||||
text := strings.Replace(greeting, "%s", greetingAddressee(u), 1)
|
||||
|
||||
res, err := Tg.SendMessage(text, chatid, &echotron.MessageOptions{
|
||||
ParseMode: echotron.HTML,
|
||||
})
|
||||
if err != nil {
|
||||
mylog.Errorf("GREETING NOT DELIVERED to user %d in chat %d: %s", u.ID, chatid, err)
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
mylog.Debugf("Greeted user %d in chat %d", u.ID, chatid)
|
||||
|
||||
// A response carrying no message still means the greeting was delivered; we
|
||||
// simply have no id to retract later.
|
||||
return sentMessageID(res), true
|
||||
}
|
||||
|
||||
// greetingAddressee is the name the template's %s is replaced with: a mention of
|
||||
// the newcomer when they can be mentioned, and their plain display name when they
|
||||
// cannot, so an unmentionable user is still greeted rather than skipped.
|
||||
func greetingAddressee(u *echotron.User) string {
|
||||
if mention := greetMention(u); mention != "" {
|
||||
return mention
|
||||
}
|
||||
|
||||
return displayName(u)
|
||||
}
|
||||
|
||||
// sentMessageID pulls the id out of a send response, tolerating a response that
|
||||
// carries no message at all.
|
||||
func sentMessageID(res echotron.APIResponseMessage) int {
|
||||
if res.Result == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return res.Result.ID
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
52
internal/moon/greetCache.go
Normal file
52
internal/moon/greetCache.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/akyoto/cache"
|
||||
)
|
||||
|
||||
// newEventCache creates an eventCache whose expired entries are swept every
|
||||
// cleaningInterval.
|
||||
func newEventCache[T any](cleaningInterval time.Duration) *eventCache[T] {
|
||||
return &eventCache[T]{cache: cache.New(cleaningInterval)}
|
||||
}
|
||||
|
||||
// Set stores value under the microsecond key with the given TTL.
|
||||
func (m *eventCache[T]) Set(micros int64, value T, ttl time.Duration) {
|
||||
m.cache.Set(micros, value, ttl)
|
||||
}
|
||||
|
||||
// Snapshot returns a typed copy of all non-expired entries keyed by their
|
||||
// microsecond timestamp. Callers can range over the returned map without
|
||||
// performing any interface{} conversions.
|
||||
func (m *eventCache[T]) Snapshot() map[int64]T {
|
||||
out := make(map[int64]T)
|
||||
|
||||
m.cache.Range(
|
||||
func(k, v any) bool {
|
||||
key, ok := k.(int64)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
value, ok := v.(T)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
out[key] = value
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Delete removes the entry stored under the given microsecond key.
|
||||
func (m *eventCache[T]) Delete(micros int64) {
|
||||
m.cache.Delete(micros)
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
81
internal/moon/greetMention.go
Normal file
81
internal/moon/greetMention.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// greetMention renders a clickable mention of a newcomer for use inside a
|
||||
// greeting, or "" when the user cannot be mentioned at all.
|
||||
//
|
||||
// The mode is HTML, not markdown, and that is a correctness decision rather than
|
||||
// a preference: the label is a name Telegram gave us, and MarkdownV2 requires
|
||||
// escaping eighteen characters — "-" among them — so an ordinary "Анна-Мария"
|
||||
// would be rejected with a 400. A rejected greeting makes newChatMembers delete
|
||||
// the join event, which is how one hyphen would disable the only detector. HTML
|
||||
// needs "&", "<" and ">" escaped, and those essentially never occur in a name.
|
||||
//
|
||||
// The mention works without a @username, which is the common case for someone who
|
||||
// has only just joined a public group.
|
||||
func greetMention(u *echotron.User) string {
|
||||
if u == nil || u.ID <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(`<a href="tg://user?id=`)
|
||||
b.WriteString(strconv.FormatInt(u.ID, 10))
|
||||
b.WriteString(`">`)
|
||||
|
||||
for _, r := range mentionLabel(u) {
|
||||
switch r {
|
||||
case '&':
|
||||
b.WriteString("&")
|
||||
case '<':
|
||||
b.WriteString("<")
|
||||
case '>':
|
||||
b.WriteString(">")
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("</a>")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// escapeHTML escapes the three characters HTML requires. It exists for callers
|
||||
// that assemble markup themselves; the greeting path uses greetMention's own
|
||||
// single pass so the anchor and the label cannot drift apart.
|
||||
func escapeHTML(s string) string {
|
||||
return htmlReplacer.Replace(s)
|
||||
}
|
||||
|
||||
// mentionLabel is the text shown inside the mention link, in the preference order
|
||||
// a reader expects: full name, then either name alone, then the @username, and
|
||||
// finally the bare id so the link is never empty.
|
||||
func mentionLabel(u *echotron.User) string {
|
||||
if name := strings.TrimSpace(u.FirstName + " " + u.LastName); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
if u.LastName != "" {
|
||||
return u.LastName
|
||||
}
|
||||
|
||||
if u.FirstName != "" {
|
||||
return u.FirstName
|
||||
}
|
||||
|
||||
if u.Username != "" {
|
||||
return "@" + u.Username
|
||||
}
|
||||
|
||||
return strconv.FormatInt(u.ID, 10)
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
206
internal/moon/greetSweep.go
Normal file
206
internal/moon/greetSweep.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/mylog"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// SweepGreetings runs until a termination signal stops it. Every
|
||||
// greetSweepInterval it looks at join events that are at least greetReplyWindow
|
||||
// old and decides whether each newcomer ever answered the greeting:
|
||||
//
|
||||
// - answered (a message from that user exists after the join, within the
|
||||
// reply window) -> newcomer is legitimate, drop the join event(s);
|
||||
// - not answered -> newcomer is a spammer, ban them and drop the join
|
||||
// event(s).
|
||||
//
|
||||
// A user may appear in PendingGreetings more than once (rejoins). For every user we
|
||||
// re-query both caches so that a single answer rescues all of their join events
|
||||
// and a single unanswered join bans them once.
|
||||
//
|
||||
// The stop is checked between passes and never during one, so a sweep in
|
||||
// progress always finishes and can never be running while the caches are being
|
||||
// closed underneath it.
|
||||
func SweepGreetings() {
|
||||
if !startWorker() {
|
||||
return
|
||||
}
|
||||
|
||||
defer stopWorker()
|
||||
|
||||
timer := time.NewTimer(greetSweepInterval)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopRequested:
|
||||
mylog.Info("Greeting sweeper stopped")
|
||||
|
||||
return
|
||||
|
||||
case <-timer.C:
|
||||
sweepGreetings()
|
||||
|
||||
timer.Reset(greetSweepInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sweepGreetings performs a single sweep pass against the current wall clock.
|
||||
func sweepGreetings() {
|
||||
sweepGreetingsAt(time.Now())
|
||||
}
|
||||
|
||||
// sweepGreetingsAt performs a single sweep pass as of now. Taking the clock as
|
||||
// a parameter is what makes the reply-window boundary testable without sleeping
|
||||
// for 15 minutes.
|
||||
func sweepGreetingsAt(now time.Time) {
|
||||
joins := PendingGreetings.Snapshot()
|
||||
messages := GreetAnswers.Snapshot()
|
||||
|
||||
windowMicros := greetReplyWindow.Microseconds()
|
||||
nowMicros := now.UnixMicro()
|
||||
|
||||
// Group stale join events per (chat, user). A user is a distinct identity in
|
||||
// every chat: being silent in one chat says nothing about another, and
|
||||
// answering in one chat must not rescue them in another.
|
||||
staleByMember := make(map[chatUser][]joinRef)
|
||||
|
||||
for joinMicros, recorded := range joins {
|
||||
join := recorded.join
|
||||
|
||||
// Not stale yet.
|
||||
if nowMicros-joinMicros <= windowMicros {
|
||||
continue
|
||||
}
|
||||
|
||||
// A join event carries the newcomer's user in NewChatMembers.
|
||||
if join == nil || len(join.NewChatMembers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
user := join.NewChatMembers[0]
|
||||
|
||||
// An entity with no id cannot be banned, and the dump has already been
|
||||
// taken. Drop the join rather than keeping it: re-reporting the same
|
||||
// broken update every minute for the rest of its TTL is noise that
|
||||
// teaches an operator to ignore this log.
|
||||
if !usableUser(user) {
|
||||
PendingGreetings.Delete(joinMicros)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
member := chatUser{chatID: join.Chat.ID, userID: user.ID}
|
||||
staleByMember[member] = append(staleByMember[member], joinRef{
|
||||
micros: joinMicros,
|
||||
chat: join.Chat,
|
||||
user: user,
|
||||
greetingID: recorded.greetingID,
|
||||
})
|
||||
}
|
||||
|
||||
for member, refs := range staleByMember {
|
||||
// A single answer rescues every join this user has *in this chat*, no
|
||||
// matter which join the reply was actually answering.
|
||||
answered := false
|
||||
|
||||
for _, ref := range refs {
|
||||
if userAnswered(member.chatID, member.userID, ref.micros, windowMicros, messages) {
|
||||
answered = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if answered {
|
||||
mylog.Debugf(
|
||||
"User %d answered the greeting in chat %d, not banning",
|
||||
member.userID,
|
||||
member.chatID,
|
||||
)
|
||||
} else {
|
||||
mylog.Infof(
|
||||
"User %d did not answer the greeting in chat %d within %s, banning as spammer",
|
||||
member.userID,
|
||||
member.chatID,
|
||||
greetReplyWindow,
|
||||
)
|
||||
|
||||
// squash already logs every outcome at the level it deserves: a
|
||||
// ban that landed is a debug line and a duplicate we deliberately
|
||||
// suppressed is an info line. Only a refused ban needs anything
|
||||
// added here, because it is the one case where the user is still
|
||||
// in the chat and will never be re-judged.
|
||||
outcome := squash(&refs[0].chat, refs[0].user)
|
||||
if outcome == banFailed {
|
||||
mylog.Errorf(
|
||||
"BAN FAILED for user %d in chat %d — they stayed in the chat and were not re-judged",
|
||||
member.userID,
|
||||
member.chatID,
|
||||
)
|
||||
} else {
|
||||
// The user is out of the chat, so every question we asked them
|
||||
// is a leftover. That includes the debounced repeat: they are
|
||||
// banned either way, and the room must not be left asking a
|
||||
// question nobody can answer.
|
||||
retractAskedGreetings(refs)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop every join event of this user *in this chat* so the next pass
|
||||
// does not re-decide them. Joins in their other chats are untouched.
|
||||
forgetPendingGreetings(member.chatID, member.userID)
|
||||
}
|
||||
}
|
||||
|
||||
// userAnswered reports whether a message authored by userID in chatID exists
|
||||
// in messages with a timestamp in (joinMicros, joinMicros+windowMicros].
|
||||
//
|
||||
// Both the chat and the author must match: the newcomer must answer in the same
|
||||
// chat they were greeted in. Messages sent before the join do not count either,
|
||||
// because the greeting is only sent in response to the join.
|
||||
func userAnswered(
|
||||
chatID int64,
|
||||
userID int64,
|
||||
joinMicros int64,
|
||||
windowMicros int64,
|
||||
messages map[int64]*echotron.Message,
|
||||
) bool {
|
||||
for msgMicros, msg := range messages {
|
||||
if msg == nil || msg.From == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.From.ID != userID || msg.Chat.ID != chatID {
|
||||
continue
|
||||
}
|
||||
|
||||
if msgMicros > joinMicros && msgMicros <= joinMicros+windowMicros {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// forgetPendingGreetings removes every PendingGreetings entry whose newcomer is userID in the
|
||||
// given chat. Joins by the same user in other chats are deliberately preserved:
|
||||
// those are independent memberships with their own greetings and their own
|
||||
// verdicts.
|
||||
func forgetPendingGreetings(chatID int64, userID int64) {
|
||||
for joinMicros, recorded := range PendingGreetings.Snapshot() {
|
||||
join := recorded.join
|
||||
if join == nil || join.Chat.ID != chatID || len(join.NewChatMembers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if join.NewChatMembers[0] != nil && join.NewChatMembers[0].ID == userID {
|
||||
PendingGreetings.Delete(joinMicros)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,37 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// leftChatMember handles left_chat_member event.
|
||||
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,
|
||||
)
|
||||
|
||||
// Save people that left chat to pebbledb. To preserve their state.
|
||||
// Left folks can eventually return and in chat_mamber_updated event their state will be left->member.
|
||||
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: */
|
||||
24
internal/moon/logOnlyMessage.go
Normal file
24
internal/moon/logOnlyMessage.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/elog"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// logOnlyMessage writes a message to the chat log and nothing else.
|
||||
//
|
||||
// It handles the messages the greeting detector must NOT count as answers: a photo with no
|
||||
// caption, a sticker, a location, a leave, a title change. They are real events in the room
|
||||
// and belong in the log — which is also what makes a later ban diagnosable, since a moderator
|
||||
// can read what the member actually replied with — but a reply with no text is not an
|
||||
// introduction, and recording it in GreetAnswers would let any attachment rescue a spammer
|
||||
// from the ban.
|
||||
//
|
||||
// The dispatcher has already established that this is neither a join nor a text message; it
|
||||
// is not re-checked here.
|
||||
func logOnlyMessage(msg *echotron.Update) {
|
||||
elog.LogMessage(msg.Message)
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,31 +1,61 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"bunny-bot/internal/elog"
|
||||
"bunny-bot/internal/mylog"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// newChatMembers parses NewChatMember event.
|
||||
func newChatMembers(msg *echotron.Update) {
|
||||
// If there is no such chatid, means that no such cache too.
|
||||
Log.Debugf(
|
||||
"Add %s %s (%d) to list of known chats",
|
||||
msg.Message.Chat.Type,
|
||||
msg.Message.Chat.Title,
|
||||
msg.Message.Chat.ID,
|
||||
)
|
||||
// Telegram normally reports one newcomer per service message. If that ever
|
||||
// stops being true, the sweeper would only judge the first member, so make it
|
||||
// visible in the logs rather than silently ignoring the rest.
|
||||
if len(msg.Message.NewChatMembers) > 1 {
|
||||
mylog.Warnf(
|
||||
"MULTI-MEMBER JOIN in chat %d: %d newcomers in one event, only the first is moderated",
|
||||
msg.Message.Chat.ID,
|
||||
len(msg.Message.NewChatMembers),
|
||||
)
|
||||
}
|
||||
|
||||
ChatList = append(ChatList, msg.Message.Chat.ID)
|
||||
slices.Sort(ChatList)
|
||||
ChatList = slices.Compact(ChatList)
|
||||
|
||||
AddChat(&msg.Message.Chat)
|
||||
// Keep only the newcomers we can actually address. In production the ingress has
|
||||
// already refused such an update; this filter is the nil-safety that keeps a
|
||||
// direct caller from recording somebody we cannot name.
|
||||
newcomers := make([]*echotron.User, 0, len(msg.Message.NewChatMembers))
|
||||
|
||||
for _, user := range msg.Message.NewChatMembers {
|
||||
Log.Infof(
|
||||
if !usableUser(user) {
|
||||
continue
|
||||
}
|
||||
|
||||
newcomers = append(newcomers, user)
|
||||
}
|
||||
|
||||
if len(newcomers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Remember the join so the greeting sweeper can tell later whether this
|
||||
// newcomer answered, and so a ban can retract the greeting we sent.
|
||||
//
|
||||
// The join is recorded before the greeting exists, so the greeting id starts
|
||||
// at zero and is filled in once the send returns. A join whose greeting was
|
||||
// never delivered therefore carries zero, which is exactly the marker
|
||||
// retractGreetings skips.
|
||||
joinMicros := time.Now().UnixMicro()
|
||||
PendingGreetings.Set(joinMicros, pendingGreeting{join: msg.Message}, greetCacheTTL)
|
||||
|
||||
// Record the join in the chat log before anything else, so the arrival is on
|
||||
// record even if the greeting later fails. elog writes one line per newcomer
|
||||
// itself, so this is called once for the whole event rather than per user.
|
||||
elog.LogMessage(msg.Message)
|
||||
|
||||
for _, user := range newcomers {
|
||||
mylog.Infof(
|
||||
"User %s %s (username = %s, id = %d) joined %s %s (%d)",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
@@ -36,60 +66,23 @@ func newChatMembers(msg *echotron.Update) {
|
||||
msg.Message.Chat.ID,
|
||||
)
|
||||
|
||||
AddUser(user)
|
||||
// Greet the newcomer, so that a spammer who never answers becomes
|
||||
// detectable. If the greeting could not be delivered the newcomer never
|
||||
// saw the question, so they must not be judged on their silence.
|
||||
greetingID, delivered := sendGreeting(msg.Message.Chat.ID, user)
|
||||
if !delivered {
|
||||
PendingGreetings.Delete(joinMicros)
|
||||
|
||||
casBanned, err := CasCheckID(user.ID)
|
||||
|
||||
if err != nil {
|
||||
Log.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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,
|
||||
user.ID,
|
||||
)
|
||||
}
|
||||
|
||||
// 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,
|
||||
)
|
||||
|
||||
key := fmt.Sprintf("%d+%d", msg.Message.Chat.ID, user.ID)
|
||||
NewMembers.Set(key, time.Now().Unix(), 1*time.Minute)
|
||||
PendingCASMembers.Set(key, time.Now().Unix(), 30*time.Minute)
|
||||
// Re-record the join carrying the greeting's id. A member who joined
|
||||
// twice has one recorded join per greeting, so each id is retracted
|
||||
// separately.
|
||||
PendingGreetings.Set(joinMicros, pendingGreeting{
|
||||
join: msg.Message,
|
||||
greetingID: greetingID,
|
||||
}, greetCacheTTL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// photo handles messages with photos.
|
||||
func photo(msg *echotron.Update) {
|
||||
AddChat(&msg.Message.Chat)
|
||||
AddUser(msg.Message.From)
|
||||
|
||||
if Cfg.LogChats {
|
||||
if msg.Message.Text != "" {
|
||||
ChatLog(
|
||||
msg.Message.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"User %s %s (username = %s, id = %d) sent %d photos to %s %s (%d) with text: %s",
|
||||
msg.Message.From.FirstName,
|
||||
msg.Message.From.LastName,
|
||||
msg.Message.From.Username,
|
||||
msg.Message.From.ID,
|
||||
len(msg.Message.Photo),
|
||||
msg.Message.Chat.Type,
|
||||
msg.Message.Chat.Title,
|
||||
msg.Message.Chat.ID,
|
||||
msg.Message.Text,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
ChatLog(
|
||||
msg.Message.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"User %s %s (username = %s, id = %d) sent %d photos to %s %s (%d)",
|
||||
msg.Message.From.FirstName,
|
||||
msg.Message.From.LastName,
|
||||
msg.Message.From.Username,
|
||||
msg.Message.From.ID,
|
||||
len(msg.Message.Photo),
|
||||
msg.Message.Chat.Type,
|
||||
msg.Message.Chat.Title,
|
||||
msg.Message.Chat.ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,68 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
func reaction(msg *echotron.Update) {
|
||||
AddChat(&msg.MessageReaction.Chat)
|
||||
AddUser(&msg.MessageReaction.User)
|
||||
|
||||
if Cfg.LogChats {
|
||||
var reactionString string
|
||||
|
||||
for _, item := range msg.MessageReaction.NewReaction {
|
||||
switch item.Type {
|
||||
case "Emoji":
|
||||
reactionString += item.Emoji
|
||||
case "CustomEmoji":
|
||||
reactionString += item.CustomEmoji
|
||||
}
|
||||
}
|
||||
|
||||
if reactionString != "" {
|
||||
ChatLog(
|
||||
msg.MessageReaction.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"User %s %s (username = %s, id = %d) set reaction %s to chat %s %s (%d)",
|
||||
msg.MessageReaction.User.FirstName,
|
||||
msg.MessageReaction.User.LastName,
|
||||
msg.MessageReaction.User.Username,
|
||||
msg.MessageReaction.User.ID,
|
||||
reactionString,
|
||||
msg.MessageReaction.Chat.Type,
|
||||
msg.MessageReaction.Chat.Title,
|
||||
msg.MessageReaction.Chat.ID,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
for _, item := range msg.MessageReaction.OldReaction {
|
||||
switch item.Type {
|
||||
case "Emoji":
|
||||
reactionString += item.Emoji
|
||||
case "CustomEmoji":
|
||||
reactionString += item.CustomEmoji
|
||||
}
|
||||
}
|
||||
|
||||
ChatLog(
|
||||
msg.MessageReaction.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"User %s %s (username = %s, id = %d) removed reaction %s to chat %s %s (%d)",
|
||||
msg.MessageReaction.User.FirstName,
|
||||
msg.MessageReaction.User.LastName,
|
||||
msg.MessageReaction.User.Username,
|
||||
msg.MessageReaction.User.ID,
|
||||
reactionString,
|
||||
msg.MessageReaction.Chat.Type,
|
||||
msg.MessageReaction.Chat.Title,
|
||||
msg.MessageReaction.Chat.ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,24 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
)
|
||||
|
||||
// reactionCount handles reactionCount event.
|
||||
func reactionCount(msg *echotron.Update) {
|
||||
ChatLog(
|
||||
msg.MessageReaction.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"Reaction count event in %s %s (%d): %s",
|
||||
msg.MessageReaction.Chat.Type,
|
||||
msg.MessageReaction.Chat.Title,
|
||||
msg.MessageReaction.Chat.ID,
|
||||
spew.Sdump(msg),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
119
internal/moon/retractGreeting.go
Normal file
119
internal/moon/retractGreeting.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/elog"
|
||||
"bunny-bot/internal/mylog"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// retractGreetings deletes the greetings we sent to a member who has just been
|
||||
// banned for never answering them, so the room is not left asking a question of
|
||||
// someone who no longer has access.
|
||||
//
|
||||
// Ids <= 0 mean "no greeting was ever sent for that join" and are skipped
|
||||
// silently. A deletion Telegram refuses is logged and the pass continues: the
|
||||
// greeting is not worth abandoning the rest over, and a failed retraction never
|
||||
// changes the ban that has already landed.
|
||||
func retractGreetings(chat *echotron.Chat, user *echotron.User, greetingIDs []int) retractOutcome {
|
||||
retracted, failed := 0, 0
|
||||
|
||||
for _, id := range greetingIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := Tg.DeleteMessage(chat.ID, id); err != nil {
|
||||
failed++
|
||||
|
||||
reportRetractFailure(chat, user, id, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
retracted++
|
||||
|
||||
elog.LogGreetingRetracted(chat, user, id)
|
||||
}
|
||||
|
||||
return retractOutcomeFor(retracted, failed)
|
||||
}
|
||||
|
||||
// reportRetractFailure logs a refused deletion. It is a WARN rather than an ERROR
|
||||
// because the ban it followed already succeeded and nothing depends on the
|
||||
// greeting being gone.
|
||||
func reportRetractFailure(chat *echotron.Chat, user *echotron.User, id int, err error) {
|
||||
mylog.Warnf(
|
||||
"Left an unanswered greeting in place: cannot delete message %d in chat %d for user %d: %s",
|
||||
id,
|
||||
chat.ID,
|
||||
userIDOf(user),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// retractAskedGreetings takes down the questions this member was asked and never
|
||||
// answered. One id per recorded join, so a member who joined twice had two
|
||||
// greetings and both go.
|
||||
func retractAskedGreetings(refs []joinRef) {
|
||||
ids := make([]int, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
ids = append(ids, ref.greetingID)
|
||||
}
|
||||
|
||||
chat, user := refs[0].chat, refs[0].user
|
||||
|
||||
switch retractGreetings(&chat, user, ids) {
|
||||
case greetingRetracted:
|
||||
mylog.Debugf(
|
||||
"Deleted %d unanswered greeting(s) asked of user %d in chat %d",
|
||||
countNamedGreetings(ids),
|
||||
user.ID,
|
||||
chat.ID,
|
||||
)
|
||||
case greetingRetractFailed:
|
||||
mylog.Warnf(
|
||||
"Banned user %d in chat %d, but could not delete every greeting we asked them",
|
||||
user.ID,
|
||||
chat.ID,
|
||||
)
|
||||
case nothingToRetract:
|
||||
}
|
||||
}
|
||||
|
||||
// countNamedGreetings counts the ids that actually named a message; the rest
|
||||
// mean no greeting was ever delivered.
|
||||
func countNamedGreetings(ids []int) int {
|
||||
n := 0
|
||||
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// retractOutcomeFor collapses the two counts into the single value callers act on.
|
||||
func retractOutcomeFor(retracted int, failed int) retractOutcome {
|
||||
switch {
|
||||
case retracted > 0 && failed == 0:
|
||||
return greetingRetracted
|
||||
case failed > 0:
|
||||
return greetingRetractFailed
|
||||
default:
|
||||
return nothingToRetract
|
||||
}
|
||||
}
|
||||
|
||||
// userIDOf names a user in a log line without dereferencing a possibly nil one.
|
||||
func userIDOf(user *echotron.User) int64 {
|
||||
if user == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,293 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func RunAndBan() {
|
||||
for {
|
||||
var (
|
||||
ListAppearedMembers, ListNewMembers []string
|
||||
)
|
||||
|
||||
AppearedMembers.Range(
|
||||
func(k, _ any) bool {
|
||||
ListAppearedMembers = append(ListAppearedMembers, k.(string))
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
NewMembers.Range(
|
||||
func(k, _ any) bool {
|
||||
ListNewMembers = append(ListNewMembers, k.(string))
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
for _, AppearedMember := range ListAppearedMembers {
|
||||
for _, NewMember := range ListNewMembers {
|
||||
if AppearedMember == NewMember {
|
||||
AppearedMembers.Delete(AppearedMember)
|
||||
NewMembers.Delete(NewMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
ListAppearedMembers = ListAppearedMembers[:0]
|
||||
ListNewMembers = ListNewMembers[:0]
|
||||
ListAppearedMembers = slices.Clip(ListAppearedMembers)
|
||||
ListNewMembers = slices.Clip(ListNewMembers)
|
||||
|
||||
AppearedMembers.Range(
|
||||
func(k, _ any) bool {
|
||||
ListAppearedMembers = append(ListAppearedMembers, k.(string))
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
NewMembers.Range(
|
||||
func(k, _ any) bool {
|
||||
ListNewMembers = append(ListNewMembers, k.(string))
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
for _, AppearedMember := range ListAppearedMembers {
|
||||
eventTimestamp, found := AppearedMembers.Get(AppearedMember)
|
||||
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
eventTimeDelta := currentTime - eventTimestamp.(int64)
|
||||
|
||||
// Spammer?
|
||||
if eventTimeDelta > 10 {
|
||||
item := strings.Split(AppearedMember, "+")
|
||||
chatid, err := strconv.ParseInt(item[0], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Got broken key in AppearedMembers cache: %s", AppearedMember)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
userid, err := strconv.ParseInt(item[1], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Got broken key in AppearedMembers cache: %s", AppearedMember)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
user := GetUser(userid)
|
||||
chat := GetChat(chatid)
|
||||
|
||||
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",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
squash(chatid, userid)
|
||||
|
||||
AppearedMembers.Delete(AppearedMember)
|
||||
}
|
||||
}
|
||||
|
||||
for _, NewMember := range ListNewMembers {
|
||||
eventTimestamp, found := NewMembers.Get(NewMember)
|
||||
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
eventTimeDelta := currentTime - eventTimestamp.(int64)
|
||||
|
||||
// Spammer?
|
||||
if eventTimeDelta > 10 {
|
||||
item := strings.Split(NewMember, "+")
|
||||
chatid, err := strconv.ParseInt(item[0], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Got broken key in AppearedMembers cache: %s", NewMember)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
userid, err := strconv.ParseInt(item[1], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Got broken key in AppearedMembers cache: %s", NewMember)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
user := GetUser(userid)
|
||||
chat := GetChat(chatid)
|
||||
|
||||
Log.Debugf("Clean newChatMember event record older than 10 seconds for user user %s %s (username = %s, id = %d) in chat %s %s (%d)",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
NewMembers.Delete(NewMember)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func PeriodicCASCheck() {
|
||||
for {
|
||||
var (
|
||||
ListPendingMembers []string
|
||||
)
|
||||
|
||||
PendingCASMembers.Range(
|
||||
func(k, _ any) bool {
|
||||
ListPendingMembers = append(ListPendingMembers, k.(string))
|
||||
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
for _, PendingMember := range ListPendingMembers {
|
||||
eventTimestamp, found := PendingCASMembers.Get(PendingMember)
|
||||
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
eventTimeDelta := currentTime - eventTimestamp.(int64)
|
||||
|
||||
// It's time to check for second time this id if it was banned in CAS.
|
||||
if eventTimeDelta > 300 {
|
||||
item := strings.Split(PendingMember, "+")
|
||||
chatid, err := strconv.ParseInt(item[0], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Got broken key in PendingMembers cache: %s", PendingMember)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
userid, err := strconv.ParseInt(item[1], 10, 64)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Got broken key in PendingMembers cache: %s", PendingMember)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
value := Cfg.GetValue(
|
||||
"BannedMembers",
|
||||
fmt.Sprintf("%d", chatid),
|
||||
fmt.Sprintf("%d", userid),
|
||||
)
|
||||
|
||||
user := GetUser(userid)
|
||||
chat := GetChat(chatid)
|
||||
|
||||
if value != "" {
|
||||
Log.Debugf("User %s %s (username = %s, id = %d) in %s %s (%d) is already banned, skip cas check.",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
Log.Debugf(
|
||||
"Remove user %s %s (username = %s, id = %d) in %s %s (%d) from PendingCASMembers",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
key := fmt.Sprintf("%d+%d", chatid, userid)
|
||||
PendingCASMembers.Delete(key)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Log.Debugf("Checking user %s %s (username = %s, id = %d) in %s %s (%d) for second time in CAS",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
banned, err := CasCheckID(userid)
|
||||
|
||||
if err != nil {
|
||||
Log.Error(err)
|
||||
}
|
||||
|
||||
if banned {
|
||||
Log.Infof(
|
||||
"Ban user %s %s (username = %s, id = %d) in %s %s (%d) by CAS blacklist",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
squash(chatid, userid)
|
||||
}
|
||||
|
||||
Log.Debugf(
|
||||
"Remove user %s %s (username = %s, id = %d) in %s %s (%d) from PendingCASMembers",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
userid,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chatid,
|
||||
)
|
||||
|
||||
PendingCASMembers.Delete(PendingMember)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
97
internal/moon/shutdown.go
Normal file
97
internal/moon/shutdown.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/elog"
|
||||
"bunny-bot/internal/mylog"
|
||||
"fmt"
|
||||
|
||||
"github.com/akyoto/cache"
|
||||
)
|
||||
|
||||
// closeCaches shuts down every cache sweeper goroutine.
|
||||
//
|
||||
// Safe only once the update listener and the greeting sweeper have stopped:
|
||||
// cache.Close() reassigns the underlying sync.Map, which is a data race against
|
||||
// any concurrent reader. shutdown() enforces that order.
|
||||
func closeCaches() {
|
||||
closeOnce.Do(func() {
|
||||
for _, c := range []*cache.Cache{
|
||||
SquashedMembers,
|
||||
PendingGreetings.cache, GreetAnswers.cache,
|
||||
} {
|
||||
c.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Shutdown performs the ordered teardown, waits for it to finish, and reports
|
||||
// whether every stage of it succeeded.
|
||||
//
|
||||
// main calls it after Telegram returns, for two reasons. The listener returning is
|
||||
// not by itself a finished shutdown — a signal handler may be halfway through the
|
||||
// sequence at that moment, and main returning would end the process underneath it,
|
||||
// skipping stages and closing the log twice. And if the listener stopped for any
|
||||
// other reason, nobody would run the sequence at all.
|
||||
//
|
||||
// It is safe to call from both goroutines: sync.Once runs the body once and blocks
|
||||
// every other caller until that run completes, so both callers see the same result.
|
||||
func Shutdown() error {
|
||||
return shutdown()
|
||||
}
|
||||
|
||||
// shutdown stops everything in dependency order and waits for each stage.
|
||||
//
|
||||
// The order is not cosmetic. Every stage waits for the previous one to have
|
||||
// finished, so no goroutine is ever using a resource that the next stage is
|
||||
// closing:
|
||||
//
|
||||
// 1. the Telegram update listener — nothing else may add work;
|
||||
// 2. the greeting sweeper — the last periodic user of the caches;
|
||||
// 3. the caches — cache.Close() both signals the sweeper goroutines and
|
||||
// reassigns the underlying sync.Map, so it must run with nothing else
|
||||
// touching them;
|
||||
// 4. chat logging, then 5. the main log — so the last log lines, which
|
||||
// describe stages 1 to 3, still have somewhere to go.
|
||||
//
|
||||
// Stages 1 and 2 share one stop signal and one WaitGroup, so a worker that has
|
||||
// not started costs nothing and a worker that starts late is refused rather than
|
||||
// racing the wait.
|
||||
//
|
||||
// The main log's own failure is reported ahead of the chat log's, because it happened
|
||||
// last: a chat-log failure may no longer have been writable either.
|
||||
func shutdown() error {
|
||||
var result error
|
||||
|
||||
shutdownOnce.Do(func() {
|
||||
// Refuse new workers before signalling, so nothing can register between
|
||||
// the signal and the wait below.
|
||||
workersMu.Lock()
|
||||
stopping = true
|
||||
workersMu.Unlock()
|
||||
|
||||
// Ask every loop to return, then wait for all of them. Nothing may be
|
||||
// using a cache, a log or the API once the next stages start closing.
|
||||
close(stopRequested)
|
||||
workers.Wait()
|
||||
|
||||
closeCaches()
|
||||
|
||||
// Chat logging first: it writes to the main log, so it must be closed
|
||||
// before the main log is.
|
||||
chatErr := elog.Close()
|
||||
|
||||
// Since we shutting down, we have noting to do with log sync.
|
||||
logErr := mylog.Close()
|
||||
|
||||
switch {
|
||||
case logErr != nil:
|
||||
result = fmt.Errorf("main log: %w", logErr)
|
||||
case chatErr != nil:
|
||||
result = fmt.Errorf("chat log: %w", chatErr)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -2,39 +2,89 @@ package moon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"os/signal"
|
||||
|
||||
"bunny-bot/internal/mylog"
|
||||
)
|
||||
|
||||
// SigHandler.handles signals. Mainly closes persistent dbs and quits for good.
|
||||
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")
|
||||
// InstallSignalHandler registers every signal the bot cares about and starts the
|
||||
// handler goroutine. The signal sets differ per platform, so the knowledge lives
|
||||
// behind terminationSignals and reopenSignals rather than leaking into main.
|
||||
func InstallSignalHandler() {
|
||||
signal.Notify(SigChan, terminationSignals()...)
|
||||
signal.Notify(SigChan, reopenSignals()...)
|
||||
|
||||
// Make new iteration since we 've got signal we not interested in.
|
||||
default:
|
||||
go SigHandler()
|
||||
}
|
||||
|
||||
// SigHandler handles signals until one asks the process to quit.
|
||||
//
|
||||
// It stops the program; it does not end it. os.Exit here would skip main's
|
||||
// deferred cleanup, and would report success whether or not the teardown actually
|
||||
// worked — a service manager told "clean shutdown" over a log nobody can write to
|
||||
// again is worse than one told to restart us. So the handler runs the teardown and
|
||||
// returns; main decides the exit status from its own call to Shutdown.
|
||||
func SigHandler() {
|
||||
for s := range SigChan {
|
||||
if !handleSignal(s) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(DB) > 0 {
|
||||
Log.Debug("Closing runtime bot settings db")
|
||||
|
||||
for _, db := range DB {
|
||||
_ = db.Close()
|
||||
}
|
||||
if err := shutdown(); err != nil {
|
||||
// The main log is already closed by now, so this is the last line we
|
||||
// get to write; main still exits non-zero over the same failure.
|
||||
mylog.Errorf("Shutdown did not complete cleanly: %s", err)
|
||||
}
|
||||
|
||||
// Since we shutting down, we have noting to do with log sync.
|
||||
_ = Log.Sync()
|
||||
|
||||
os.Exit(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleSignal applies one signal and reports whether it means "shut down".
|
||||
//
|
||||
// Reopen signals re-open the log file, which is what a log rotation tool needs
|
||||
// after it has moved the file aside. Neither of them is expected on Windows,
|
||||
// and an unrecognised signal is ignored rather than treated as a quit.
|
||||
func handleSignal(s os.Signal) bool {
|
||||
if containsSignal(reopenSignals(), s) {
|
||||
mylog.Infof("Got %s, re-opening the log file", s)
|
||||
|
||||
if err := mylog.ReOpen(); err != nil {
|
||||
// Reopening can legitimately fail — the destination may not be a
|
||||
// file at all, or the logger may already be closed. Logging is
|
||||
// best-effort here because the log is exactly what just broke.
|
||||
mylog.Errorf("Unable to re-open the log file on %s: %s", s, err)
|
||||
} else {
|
||||
mylog.Info("Log file re-opened")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if !containsSignal(terminationSignals(), s) {
|
||||
// Make new iteration since we 've got signal we not interested in.
|
||||
return false
|
||||
}
|
||||
|
||||
// The status has to be recorded here, before the teardown runs: main reads
|
||||
// it after Shutdown returns, and neither of them can reconstruct which
|
||||
// signal triggered all of this by then.
|
||||
recordExitCode(s)
|
||||
|
||||
mylog.Infof("Got %s, quitting", s)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// containsSignal reports whether sig is in the set.
|
||||
func containsSignal(set []os.Signal, sig os.Signal) bool {
|
||||
for _, s := range set {
|
||||
if s == sig {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
|
||||
25
internal/moon/signals_unix.go
Normal file
25
internal/moon/signals_unix.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build !windows
|
||||
|
||||
package moon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// terminationSignals are the signals that ask the bot to shut down.
|
||||
func terminationSignals() []os.Signal {
|
||||
return []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT}
|
||||
}
|
||||
|
||||
// reopenSignals are the signals that ask the bot to re-open its log file.
|
||||
//
|
||||
// SIGHUP is the conventional daemon signal for "re-read your configuration and
|
||||
// re-open your logs"; SIGUSR1 is the other common choice for log re-opening
|
||||
// alone. Both are accepted so the bot fits either convention an operator's
|
||||
// logrotate configuration expects.
|
||||
func reopenSignals() []os.Signal {
|
||||
return []os.Signal{syscall.SIGHUP, syscall.SIGUSR1}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
22
internal/moon/signals_windows.go
Normal file
22
internal/moon/signals_windows.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package moon
|
||||
|
||||
import "os"
|
||||
|
||||
// terminationSignals are the signals that ask the bot to shut down.
|
||||
func terminationSignals() []os.Signal {
|
||||
return []os.Signal{os.Interrupt}
|
||||
}
|
||||
|
||||
// reopenSignals is empty on Windows.
|
||||
//
|
||||
// SIGHUP and SIGUSR1 are Unix signals that Windows never delivers — SIGUSR1 does
|
||||
// not even exist in the syscall package there — so there is nothing to re-open
|
||||
// the log for. The empty set keeps handleSignal platform-agnostic: a Windows
|
||||
// service restarts to rotate its logs instead of signalling.
|
||||
func reopenSignals() []os.Signal {
|
||||
return nil
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
@@ -1,32 +1,47 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/mylog"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// TODO: refactor to return bool, error
|
||||
|
||||
// squash bans given chat member.
|
||||
func squash(chatid int64, userid int64) {
|
||||
banUntilDate := time.Now().Unix() + 10
|
||||
|
||||
user := GetUser(userid)
|
||||
chat := GetChat(chatid)
|
||||
|
||||
// If there is no such chatid, means that no such cache too.
|
||||
if len(ChatList) == 0 || !slices.Contains(ChatList, chatid) {
|
||||
Log.Debugf("Add %s %s (%d) to list of known chats", chat.Type, chat.Title, chat.ID)
|
||||
|
||||
ChatList = append(ChatList, chatid)
|
||||
// squash bans the given chat member and reports what it actually did.
|
||||
//
|
||||
// The three outcomes are deliberately distinct. A debounced call is not a
|
||||
// failure: the user is already banned, which is the outcome we wanted, and the
|
||||
// caller must not report it as an error.
|
||||
//
|
||||
// The user and chat come from the join event that triggered the judgement
|
||||
// rather than from a cache. That event already carries both in full, so a cache
|
||||
// would only ever be a second, staler copy of data we are holding — and a miss
|
||||
// in it would produce a ban logged with blank names.
|
||||
func squash(chat *echotron.Chat, user *echotron.User) banOutcome {
|
||||
// A chat or user with no id is an entity we cannot address, not a ban that
|
||||
// failed. In production the ingress has already refused such an update, so
|
||||
// reaching here means a caller handed us one directly; the guard is the
|
||||
// nil-safety that keeps that from becoming a call against chat 0.
|
||||
if !usableChat(chat) {
|
||||
return banFailed
|
||||
}
|
||||
|
||||
if !usableUser(user) {
|
||||
return banFailed
|
||||
}
|
||||
|
||||
// Telegram treats any ban shorter than 30 seconds (and longer than 1 year)
|
||||
// as permanent, so this deliberately short offset produces a permanent ban.
|
||||
// Do not "fix" it into a longer duration.
|
||||
banUntilDate := time.Now().Unix() + 10
|
||||
|
||||
chatid, userid := chat.ID, user.ID
|
||||
|
||||
key := fmt.Sprintf("%d+%d", chatid, userid)
|
||||
|
||||
if _, exist := SquashedMembers.Get(key); exist {
|
||||
Log.Infof(
|
||||
mylog.Infof(
|
||||
"Skip banning user %s %s (username = %s, id = %d) in %s %s (%d), already banned",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
@@ -37,17 +52,18 @@ func squash(chatid int64, userid int64) {
|
||||
chat.ID,
|
||||
)
|
||||
|
||||
return
|
||||
return banSuppressed
|
||||
}
|
||||
|
||||
if res, err := Tg.BanChatMember(
|
||||
res, err := Tg.BanChatMember(
|
||||
chatid,
|
||||
userid,
|
||||
&echotron.BanOptions{
|
||||
UntilDate: int(banUntilDate),
|
||||
},
|
||||
); err != nil {
|
||||
Log.Errorf(
|
||||
)
|
||||
if err != nil {
|
||||
mylog.Errorf(
|
||||
"Unable to ban user user %s %s (username = %s, id = %d) in %s %s (%d): %s",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
@@ -56,85 +72,53 @@ func squash(chatid int64, userid int64) {
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
err,
|
||||
)
|
||||
|
||||
return banFailed
|
||||
}
|
||||
|
||||
if !res.Ok {
|
||||
mylog.Errorf(
|
||||
"Unable to ban user %s %s (username = %s, id = %d) in %s %s (%d): %s",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
res.Description,
|
||||
)
|
||||
} else {
|
||||
if !res.Ok {
|
||||
Log.Errorf(
|
||||
"Unable to ban user %s %s (username = %s, id = %d) in %s %s (%d): %s",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
res.Description,
|
||||
)
|
||||
} else {
|
||||
Log.Debugf(
|
||||
"Banned user %s %s (username = %s, id = %d) in %s %s (%d).",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
)
|
||||
|
||||
key := fmt.Sprintf("%d+%d", chatid, userid)
|
||||
|
||||
Log.Debugf(
|
||||
"Put user %s %s (username = %s, id = %d) in %s %s (%d) into SquashedMembers to debounce ban.",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
)
|
||||
|
||||
SquashedMembers.Set(key, true, 30*time.Second)
|
||||
|
||||
Log.Debugf(
|
||||
"Remove user %s %s (username = %s, id = %d) in %s %s (%d) from NewMembers.",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
)
|
||||
|
||||
NewMembers.Delete(key)
|
||||
|
||||
Log.Debugf(
|
||||
"Remove user %s %s (username = %s, id = %d) in %s %s (%d) from AppearedMembers.",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
)
|
||||
AppearedMembers.Delete(key)
|
||||
|
||||
if err := Cfg.SaveKeyValue(
|
||||
"BannedMembers",
|
||||
fmt.Sprintf("%d", chatid),
|
||||
fmt.Sprintf("%d", userid),
|
||||
fmt.Sprintf("%d", time.Now().Unix()),
|
||||
); err != nil {
|
||||
Log.Error(err)
|
||||
} else {
|
||||
Log.Infof("Info about banned userid %d saved to BannedMembers db", userid)
|
||||
}
|
||||
}
|
||||
return banFailed
|
||||
}
|
||||
|
||||
mylog.Debugf(
|
||||
"Banned user %s %s (username = %s, id = %d) in %s %s (%d).",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
)
|
||||
|
||||
mylog.Debugf(
|
||||
"Put user %s %s (username = %s, id = %d) in %s %s (%d) into SquashedMembers to debounce ban.",
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.ID,
|
||||
chat.Type,
|
||||
chat.Title,
|
||||
chat.ID,
|
||||
)
|
||||
|
||||
SquashedMembers.Set(key, true, 30*time.Second)
|
||||
|
||||
return banApplied
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
|
||||
@@ -1,74 +1,110 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"bunny-bot/internal/elog"
|
||||
"bunny-bot/internal/mylog"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
)
|
||||
|
||||
// Telegram main goroutine that implements bot.
|
||||
//
|
||||
// It blocks until a termination signal stops it, so the update listener can be
|
||||
// shut down in a defined order rather than simply abandoned when the process
|
||||
// exits.
|
||||
func Telegram(cfg MyConfig) {
|
||||
// A signal can arrive during startup; then this must not start at all.
|
||||
if !startWorker() {
|
||||
mylog.Info("Telegram not started: shutdown already in progress")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer stopWorker()
|
||||
|
||||
Tg = echotron.NewAPI(cfg.Token)
|
||||
|
||||
Log.Infof("Starting api update poller loop")
|
||||
mylog.Info("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)
|
||||
// The sweeper calls the API too, so it may only start once Tg exists.
|
||||
mylog.Info("Starting greeting sweeper periodic task")
|
||||
|
||||
go SweepGreetings()
|
||||
|
||||
updates := echotron.PollingUpdatesOptions(cfg.Token, true, echotron.UpdateOptions{
|
||||
AllowedUpdates: allowedUpdates,
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
// Stop between updates, never inside one: the handler currently running
|
||||
// finishes, so nothing is left half-processed when we return.
|
||||
case <-stopRequested:
|
||||
mylog.Info("Telegram update listener stopped")
|
||||
|
||||
return
|
||||
|
||||
case u, ok := <-updates:
|
||||
if !ok {
|
||||
mylog.Info("Telegram update poller ended")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
telegramMsgParser(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// telegramMsgParser parses events generated by echotron.
|
||||
func telegramMsgParser(msg *echotron.Update) {
|
||||
// The one place an update enters the program, and therefore the one place an
|
||||
// entity with no id is caught. Everything downstream — the caches, the sweeper,
|
||||
// the bans — may then assume the ids it holds are real.
|
||||
if !usableUpdate(msg) {
|
||||
reportBrokenUpdate(msg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case msg.Message != nil && msg.Message.Text != "":
|
||||
textMessage(msg)
|
||||
|
||||
case msg.EditedMessage != nil:
|
||||
editedMessage(msg)
|
||||
|
||||
// Membership status changes: joins, leaves, bans, promotions. Logged only —
|
||||
// moderation is the sweeper's job, on a timer, not the dispatcher's.
|
||||
//
|
||||
// This also catches the membership updates caused by our own bans, so every
|
||||
// ban leaves a second record in the chat log.
|
||||
case msg.ChatMember != nil:
|
||||
chatMemberUpdated(msg)
|
||||
elog.LogUserStatus(msg.ChatMember)
|
||||
|
||||
// New members entered to chat.
|
||||
// New members entered to chat. Checked before the text case so that a
|
||||
// join service message is never mistaken for ordinary chatter.
|
||||
case msg.Message != nil && msg.Message.NewChatMembers != nil:
|
||||
newChatMembers(msg)
|
||||
|
||||
// Chat member leaves chat.
|
||||
case msg.Message != nil && msg.Message.LeftChatMember != nil:
|
||||
leftChatMember(msg)
|
||||
// Ordinary text messages, used as greeting answers.
|
||||
case msg.Message != nil && msg.Message.Text != "":
|
||||
textMessage(msg)
|
||||
|
||||
case msg.Message != nil && msg.Message.Animation != nil:
|
||||
animation(msg)
|
||||
// Everything else that is still a message: a photo with no caption, a
|
||||
// sticker, a location, a leave, a title change. Logged, and deliberately NOT
|
||||
// recorded as a greeting answer — an attachment is not an introduction, and
|
||||
// letting one satisfy the detector would be a one-key bypass for a spammer.
|
||||
//
|
||||
// The condition is deliberately just "there is a message": elog already
|
||||
// renders every media and service field, and already writes nothing for a
|
||||
// message with no describable content, so emptiness is its decision to make.
|
||||
// Testing for a field being non-empty here is what lost these messages.
|
||||
case msg.Message != nil:
|
||||
logOnlyMessage(msg)
|
||||
|
||||
case msg.MessageReaction != nil:
|
||||
reaction(msg)
|
||||
|
||||
case msg.MessageReactionCount != nil:
|
||||
reactionCount(msg)
|
||||
|
||||
case msg.Message != nil && msg.Message.Photo != nil:
|
||||
photo(msg)
|
||||
// An edit revises a message already recorded, so it is a chat-log event
|
||||
// rather than a moderation one.
|
||||
case msg.EditedMessage != nil:
|
||||
elog.LogEditedMessage(msg.EditedMessage)
|
||||
|
||||
default:
|
||||
Log.Debug(spew.Sdump(msg))
|
||||
mylog.Debug(spew.Sdump(msg))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,27 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bunny-bot/internal/elog"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// textMessage handles text message events.
|
||||
// textMessage handles plain text messages. It does not moderate anything; it
|
||||
// only records the message so the greeting sweeper can tell that a newcomer
|
||||
// answered their greeting.
|
||||
func textMessage(msg *echotron.Update) {
|
||||
AddChat(&msg.Message.Chat)
|
||||
AddUser(msg.Message.From)
|
||||
|
||||
// Not sure if we must handle all kinds of these events.
|
||||
if Cfg.LogChats {
|
||||
switch {
|
||||
// Just log it, it seems later here should be ban/mute command set.
|
||||
case msg.Message != nil && msg.Message.ReplyToMessage == nil:
|
||||
ChatLog(
|
||||
msg.Message.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"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,
|
||||
),
|
||||
)
|
||||
|
||||
case msg.Message != nil && msg.Message.ReplyToMessage != nil:
|
||||
ChatLog(
|
||||
msg.Message.Chat.ID,
|
||||
fmt.Sprintf(
|
||||
"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,
|
||||
),
|
||||
)
|
||||
}
|
||||
if msg.Message == nil || msg.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Remember the message so the greeting sweeper can find the answer to a
|
||||
// greeting.
|
||||
GreetAnswers.Set(time.Now().UnixMicro(), msg.Message, greetCacheTTL)
|
||||
|
||||
// And record it in the chat's own log, which outlives the cache.
|
||||
elog.LogMessage(msg.Message)
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
|
||||
@@ -1,40 +1,82 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/akyoto/cache"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
LogChats bool `json:"logchats,omitempty"`
|
||||
CSign string `json:"csign,omitempty"`
|
||||
DataDir string `json:"datadir,omitempty"`
|
||||
|
||||
// ChatLogs is the directory for per-chat, per-day chat event logs. Empty
|
||||
// disables chat logging. If set, the directory must already exist: a path
|
||||
// that does not is a typo, and silently logging nowhere is the worst
|
||||
// possible response to one.
|
||||
ChatLogs string `json:"chat_logs,omitempty"`
|
||||
}
|
||||
|
||||
// CasFalse describes response from api.cas.chat when user is not blacklisted.
|
||||
type CasFalse struct {
|
||||
Ok bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
// tgClient is the subset of the Telegram API that bunny-bot actually calls.
|
||||
// It exists so tests can substitute a fake; echotron.API satisfies it.
|
||||
type tgClient interface {
|
||||
SendMessage(text string, chatID int64, opts *echotron.MessageOptions) (echotron.APIResponseMessage, error)
|
||||
BanChatMember(chatID int64, userID int64, opts *echotron.BanOptions) (echotron.APIResponseBool, error)
|
||||
DeleteMessage(chatID int64, messageID int) (echotron.APIResponseBase, error)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// chatUser identifies a user's membership of one specific chat. The same user is
|
||||
// a different identity in every chat: being silent in one says nothing about
|
||||
// another, and answering in one must not rescue them in another.
|
||||
type chatUser struct {
|
||||
chatID int64
|
||||
userID int64
|
||||
}
|
||||
|
||||
type Chatlog struct {
|
||||
LogFilePath string
|
||||
File *os.File
|
||||
// banOutcome is what a squash attempt actually did. The three cases are distinct
|
||||
// on purpose: a duplicate ban is a success, not a failure, and reporting it as
|
||||
// one makes a working ban look broken in the logs.
|
||||
type banOutcome int
|
||||
|
||||
// eventCache is a typed wrapper over akyoto/cache.Cache that stores values of
|
||||
// type T keyed by a Unix timestamp in microseconds. It hides the underlying
|
||||
// sync.Map so callers never deal with interface{} keys/values or per-access type
|
||||
// conversions.
|
||||
type eventCache[T any] struct {
|
||||
cache *cache.Cache
|
||||
}
|
||||
|
||||
// joinRef is one stale join, kept together with everything the verdict and the
|
||||
// retraction need so neither has to look anything up.
|
||||
type joinRef struct {
|
||||
micros int64
|
||||
|
||||
// chat and user come from the join event itself, so a ban can be logged with
|
||||
// the newcomer's real name and the chat's real title instead of a cache
|
||||
// lookup that may have expired or never been populated.
|
||||
chat echotron.Chat
|
||||
user *echotron.User
|
||||
|
||||
// greetingID is the question we asked them, deleted once they are banned for
|
||||
// never answering it. Zero means we never managed to ask.
|
||||
greetingID int
|
||||
}
|
||||
|
||||
// pendingGreeting is one recorded join: the message that announced it, and the
|
||||
// id of the greeting we sent in reply.
|
||||
//
|
||||
// The greeting id lives here rather than in a second cache, so that recording,
|
||||
// forgetting and retracting are all one lookup on one key. A zero greeting id
|
||||
// means "no greeting was ever delivered for this join" — never a failure.
|
||||
type pendingGreeting struct {
|
||||
join *echotron.Message
|
||||
greetingID int
|
||||
}
|
||||
|
||||
// retractOutcome is what a retraction pass actually achieved. The three cases are
|
||||
// distinct because "nothing to delete" and "tried and failed" call for different
|
||||
// log lines, and only the second is worth an operator's attention.
|
||||
type retractOutcome int
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package moon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
// AddUser remebers user info in People cache.
|
||||
func AddUser(u *echotron.User) {
|
||||
bytes, err := json.Marshal(u)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Unable to encode user info to json string: %s", err)
|
||||
}
|
||||
|
||||
People.Set(u.ID, bytes, 24*time.Hour)
|
||||
}
|
||||
|
||||
// GetUser returns user info structure.
|
||||
func GetUser(id int64) *echotron.User {
|
||||
var u *echotron.User
|
||||
|
||||
bytes, ok := People.Get(id)
|
||||
|
||||
if !ok {
|
||||
return u
|
||||
}
|
||||
|
||||
err := json.Unmarshal(bytes.([]byte), &u)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Unable to decode user info json: %s", err)
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// AddChat remebers chat info in Groups cache.
|
||||
func AddChat(c *echotron.Chat) {
|
||||
bytes, err := json.Marshal(c)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Unable to encode chat info to json string: %s", err)
|
||||
}
|
||||
|
||||
Groups.Set(c.ID, bytes, 24*time.Hour)
|
||||
}
|
||||
|
||||
// GetChat returns chat info structure.
|
||||
func GetChat(id int64) *echotron.Chat {
|
||||
var c *echotron.Chat
|
||||
|
||||
bytes, ok := Groups.Get(id)
|
||||
|
||||
if !ok {
|
||||
Log.Errorf("No group %d in Groups cache", id)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
err := json.Unmarshal(bytes.([]byte), &c)
|
||||
|
||||
if err != nil {
|
||||
Log.Errorf("Unable to decode chat info json: %s", err)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
36
internal/moon/worker.go
Normal file
36
internal/moon/worker.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package moon
|
||||
|
||||
// The worker registry: how a background goroutine joins the shutdown sequence,
|
||||
// and how it leaves it. The state itself lives in globals.go with the rest of the
|
||||
// package-level variables; this file holds only what a goroutine does with it.
|
||||
|
||||
// stopWorker unregisters a background goroutine that has finished.
|
||||
func stopWorker() {
|
||||
runningWorkers.Add(-1)
|
||||
workers.Done()
|
||||
}
|
||||
|
||||
// workerRunning reports whether any registered worker is still running.
|
||||
func workerRunning() bool {
|
||||
return runningWorkers.Load() > 0
|
||||
}
|
||||
|
||||
// startWorker registers a background goroutine with the shutdown sequence and
|
||||
// reports whether it may run. It returns false once shutdown has begun, in
|
||||
// which case the caller must return without starting: the stop signal is
|
||||
// already closed and the teardown may already have closed what it would use.
|
||||
func startWorker() bool {
|
||||
workersMu.Lock()
|
||||
defer workersMu.Unlock()
|
||||
|
||||
if stopping {
|
||||
return false
|
||||
}
|
||||
|
||||
workers.Add(1)
|
||||
runningWorkers.Add(1)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
Reference in New Issue
Block a user