Files
trbot/plugins/plugin_teamspeak3.go

1279 lines
38 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugins
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"trbot/utils"
"trbot/utils/configs"
"trbot/utils/flaterr"
"trbot/utils/handler_params"
"trbot/utils/plugin_utils"
"trbot/utils/task"
"trbot/utils/type/contain"
"trbot/utils/type/message_utils"
"trbot/utils/yaml"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/multiplay/go-ts3"
"github.com/reugn/go-quartz/job"
"github.com/reugn/go-quartz/quartz"
"github.com/rs/zerolog"
)
var tsConfig ServerConfig
var tsConfigPath string = filepath.Join(configs.YAMLDatabaseDir, "teamspeak/", configs.YAMLFileName)
var botNickName string = "trbot_teamspeak_plugin"
var botInstance *bot.Bot
type ServerConfig struct {
rw sync.RWMutex
c *ts3.Client
s Status
// get `Name` And `Password` in `TeamSpeak 3 Client` -> `Tools` -> `ServerQuery Login`
URL string `yaml:"URL"`
Name string `yaml:"Name"`
Password string `yaml:"Password"`
GroupID int64 `yaml:"GroupID"`
PollingInterval int `yaml:"PollingInterval"`
SendMessageMode bool `yaml:"SendMessageMode"`
AutoDeleteMessage bool `yaml:"AutoDeleteMessage"`
DeleteTimeoutInMinute int `yaml:"DeleteTimeoutInMinute"`
PinMessageMode bool `yaml:"PinMessageMode"`
DeleteOldPinnedMessage bool `yaml:"DeleteOldPinnedMessage"`
PinnedMessageID int `yaml:"PinnedMessageID"`
}
// Connect 检查配置并尝试连接到 TeamSpeak 服务器
func (sc *ServerConfig) Connect(ctx context.Context) error {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Int64("GroupID", sc.GroupID).
Logger()
var err error
sc.rw.Lock()
defer sc.rw.Unlock()
// 如果服务器地址为空不允许重新启动
if sc.URL == "" {
logger.Error().
Str("path", tsConfigPath).
Msg("No URL in config")
return fmt.Errorf("no URL in config")
}
if sc.c != nil {
logger.Info().Msg("Closing client...")
err = sc.c.Close()
if err != nil {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to close client")
}
// 尽管它有时会返回错误,但可能还是会正常关闭,所以等一等,然后抛弃旧的客户端
time.Sleep(10 * time.Second)
sc.c = nil
}
logger.Info().Msg("Starting client...")
sc.c, err = ts3.NewClient(sc.URL)
if err != nil {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to connect to server")
return fmt.Errorf("failed to connnect to server: %w", err)
}
logger.Info().Msg("Checking credentials...")
// ServerQuery 账号名或密码为空也不允许重新启动
if sc.Name == "" || sc.Password == "" {
logger.Error().
Str("path", tsConfigPath).
Msg("No Name/Password in config")
return fmt.Errorf("no Name/Password in config")
}
logger.Info().Msg("Logining...")
err = sc.c.Login(sc.Name, sc.Password)
if err != nil {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to login to server")
return fmt.Errorf("failed to login to server: %w", err)
}
logger.Info().Msg("Checking Group ID...")
// 检查要设定通知的群组 ID 是否存在
if sc.GroupID == 0 {
logger.Error().
Str("path", tsConfigPath).
Msg("No GroupID in config")
return fmt.Errorf("no GroupID in config")
}
logger.Info().Msg("Testing connection...")
// 显示服务端版本测试一下连接
v, err := sc.c.Version()
if err != nil {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to get server version")
return fmt.Errorf("failed to get server version: %w", err)
}
logger.Info().
Str("version", v.Version).
Str("platform", v.Platform).
Int("build", v.Build).
Msg("TeamSpeak server connected")
logger.Info().Msg("Switching virtual servers...")
// 切换默认虚拟服务器
err = sc.c.Use(1)
if err != nil {
logger.Error().
Err(err).
Msg("Failed to switch server")
return fmt.Errorf("failed to switch server: %w", err)
}
logger.Info().Msg("Checking nickname...")
// 改一下 bot 自己的 nickname使得在检测用户列表时默认不显示自己
m, err := sc.c.Whoami()
if err != nil {
logger.Error().
Err(err).
Msg("Failed to get bot info")
return fmt.Errorf("failed to get bot info: %w", err)
} else if m != nil && m.ClientName != botNickName {
logger.Info().Msg("Setting nickname...")
// 当 bot 自己的 nickname 不等于配置文件中的 nickname 时,才进行修改
err = sc.c.SetNick(botNickName)
if err != nil {
logger.Error().
Err(err).
Msg("Failed to set bot nickname")
return fmt.Errorf("failed to set nickname: %w", err)
}
}
logger.Info().Msg("Successfully connected!")
if !sc.s.IsCheckClientTaskScheduled {
err = sc.ScheduleCheckClientTask(ctx)
if err != nil {
logger.Error().
Err(err).
Msg("Failed to schedule check client task")
return fmt.Errorf("failed to schedule check client task: %w", err)
}
}
if !sc.s.IsDeleteMessageTaskScheduled {
err = sc.ScheduleDeleteMessageTask(ctx)
if err != nil {
logger.Error().
Err(err).
Msg("Failed to schedule delete message task")
return fmt.Errorf("failed to schedule delete message task: %w", err)
}
}
return nil
}
// CheckClient 检查 TeamSpeak 服务器的状态和用户数量,根据配置通知用户或尝试重连
func (sc *ServerConfig) CheckClient(ctx context.Context) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
onlineClients, err := sc.c.Server.ClientList()
if err != nil {
sc.s.CheckFailedCount++
logger.Error().
Err(err).
Int("failedCount", sc.s.CheckFailedCount).
Msg("Failed to get online client")
if err.Error() == "not connected" {
// 连不上服务器直接暂停人物为并尝试重连
sc.PauseCheckClientTask(ctx, true)
} else if sc.s.CheckFailedCount >= 5 {
// 不是连不上服务器,则累积到五次后再重连
sc.PauseCheckClientTask(ctx, true)
botMessage, err := botInstance.SendMessage(ctx, &bot.SendMessageParams{
ChatID: sc.GroupID,
Text: "已连续五次检查在线客户端失败,开始尝试自动重连",
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "failed to check online client 5 times, start auto reconnect").
Msg(flaterr.SendMessage.Str())
} else {
sc.s.OldMessageID = append(tsConfig.s.OldMessageID, OldMessageID{
Date: int(time.Now().Unix()),
ID: botMessage.ID,
})
}
}
return
} else {
var nowOnlineClient []OnlineClient
sc.s.CheckFailedCount = 0 // 重置失败计数
for _, client := range onlineClients {
if client.Nickname == botNickName { continue }
var isExist bool
for _, user := range sc.s.BeforeOnlineClient {
if user.Username == client.Nickname {
nowOnlineClient = append(nowOnlineClient, user)
isExist = true
}
}
if !isExist {
nowOnlineClient = append(nowOnlineClient, OnlineClient{
Username: client.Nickname,
JoinTime: time.Now(),
})
}
}
added, removed := diffSlices(sc.s.BeforeOnlineClient, nowOnlineClient)
if sc.SendMessageMode && len(added) + len(removed) > 0 {
logger.Debug().
Int("clientJoin", len(added)).
Int("clientLeave", len(removed)).
Msg("online client change detected")
sc.NotifyClientChange(ctx, added, removed)
}
if sc.PinMessageMode {
sc.NewPinnedMessage(ctx)
sc.ChangePinnedMessage(ctx, nowOnlineClient, added, removed)
}
sc.s.BeforeOnlineClient = nowOnlineClient
}
}
// ScheduleOrResumeCheckClientTask 创建检查客户端任务
func (sc *ServerConfig) ScheduleCheckClientTask(ctx context.Context) error {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
err := task.ScheduleTask(ctx, task.Task{
Name: fmt.Sprintf("check_client_%d", sc.GroupID),
Group: "teamspeak3",
Job: job.NewFunctionJobWithDesc(
func(ctx context.Context) (int, error) {
sc.CheckClient(ctx)
return 0, nil
},
"check teamspeak client changes",
),
Trigger: quartz.NewSimpleTrigger(time.Second * time.Duration(sc.PollingInterval)),
})
if err != nil {
logger.Error().Err(err).Msg("Failed to schedule check client job")
return err
}
sc.s.IsCheckClientTaskScheduled = true
sc.s.IsCheckClientTaskRunning = true
return nil
}
// ResumeCheckClientTask 恢复检查客户端任务
func (sc *ServerConfig) ResumeCheckClientTask(ctx context.Context) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if !sc.s.IsCheckClientTaskRunning {
err := task.ResumeTask(ctx, fmt.Sprintf("check_client_%d", sc.GroupID), "teamspeak3")
if err != nil {
logger.Error().Err(err).Msg("Failed to resume check client task")
} else {
sc.s.IsCheckClientTaskRunning = true
}
}
}
// PauseCheckClientTask 暂停检查客户端任务,可选是否开始重试
func (sc *ServerConfig) PauseCheckClientTask(ctx context.Context, retry bool) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if sc.s.IsCheckClientTaskRunning {
err := task.PauseTask(ctx, fmt.Sprintf("check_client_%d", sc.GroupID), "teamspeak3")
if err != nil {
logger.Error().Err(err).Msg("Failed to pause check client task")
} else {
sc.s.IsCheckClientTaskRunning = false
}
}
if retry && !sc.s.IsInRetryLoop {
go sc.RetryLoop(ctx)
logger.Warn().Msg("Start retry connect loop")
}
}
// NotifyClientChange 通过在对话中发送信息的方式来通知用户变化
func (sc *ServerConfig) NotifyClientChange(ctx context.Context, add, remove []string) {
var pendingMessage string
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if len(add) > 0 {
pendingMessage += fmt.Sprintln("以下用户进入了服务器:")
for _, nickname := range add {
pendingMessage += fmt.Sprintf("用户 [ %s ]\n", nickname)
}
}
if len(remove) > 0 {
pendingMessage += fmt.Sprintln("以下用户离开了服务器:")
for _, nickname := range remove {
pendingMessage += fmt.Sprintf("用户 [ %s ]\n", nickname)
}
}
msg, err := botInstance.SendMessage(ctx, &bot.SendMessageParams{
ChatID: sc.GroupID,
Text: pendingMessage,
ParseMode: models.ParseModeHTML,
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "teamspeak user change notify").
Msg(flaterr.SendMessage.Str())
}
if tsConfig.s.IsDeleteMessageTaskScheduled {
if tsConfig.AutoDeleteMessage {
tsConfig.ResumeDeleteMessageTask(ctx)
tsConfig.s.OldMessageID = append(tsConfig.s.OldMessageID, OldMessageID{
Date: msg.Date,
ID: msg.ID,
})
} else if tsConfig.s.IsDeleteMessageTaskRunning {
tsConfig.PauseDeleteMessageTask(ctx)
tsConfig.s.OldMessageID = []OldMessageID{}
}
}
}
// ChangePinnedMessage 通过编辑置顶消息的方式来通知用户变化
func (sc *ServerConfig) ChangePinnedMessage(ctx context.Context, online []OnlineClient, add, remove []string) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
// 没有新加入和离开用户,等待一阵子后再更新用户在线时间
if len(add) + len(remove) == 0 && sc.s.CheckCount < (60 / tsConfig.PollingInterval) {
sc.s.CheckCount++
return
} else {
sc.s.CheckCount = 0
}
var pendingMessage string = fmt.Sprintf("%s | ", time.Now().Format("15:04"))
if len(online) > 0 {
pendingMessage += fmt.Sprintf("有 %d 位用户在线:\n<blockquote>", len(online))
for _, client := range online {
pendingMessage += fmt.Sprintf("[ %s ] 已在线 %.1f 分钟\n", client.Username, time.Since(client.JoinTime).Minutes())
}
pendingMessage += "</blockquote>\n"
} else {
pendingMessage += "没有用户在线\n\n"
}
if len(add) + len(remove) > 0 {
if len(add) > 0 {
pendingMessage += fmt.Sprintln("以下用户进入了服务器:")
for _, nickname := range add {
pendingMessage += fmt.Sprintf("用户 [ %s ]\n", nickname)
}
}
if len(remove) > 0 {
pendingMessage += fmt.Sprintln("以下用户离开了服务器:")
for _, nickname := range remove {
pendingMessage += fmt.Sprintf("用户 [ %s ]\n", nickname)
}
}
}
if !sc.s.IsMessagePinned {
pendingMessage += "<blockquote expandable>无法置顶用户列表消息,请检查机器人是否拥有对应的权限,您也可以手动置顶此消息</blockquote>"
}
_, err := botInstance.EditMessageText(ctx, &bot.EditMessageTextParams{
ChatID: sc.GroupID,
MessageID: sc.PinnedMessageID,
Text: pendingMessage,
ParseMode: models.ParseModeHTML,
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "teamspeak user change notify").
Msg(flaterr.EditMessageText.Str())
}
}
// ScheduleDeleteMessageTask 创建定时删除旧通知信息的任务
func (sc *ServerConfig) ScheduleDeleteMessageTask(ctx context.Context) error {
logger := zerolog.Ctx(ctx)
// 根据 sc.DeleteTimeoutInMinute 计划一个任务
// 定时检查 sc.s.OldMessageID 中是否有过期的消息,如果有则删除
err := task.ScheduleTask(ctx, task.Task{
Name: fmt.Sprintf("delete_old_message_%d", sc.GroupID),
Group: "teamspeak3",
Job: job.NewFunctionJobWithDesc(
func(ctx context.Context) (int, error) {
var needDelMsgIDs []int
var keepMsgIDList []OldMessageID
for _, msg := range sc.s.OldMessageID {
if time.Now().Unix() > int64(msg.Date + sc.DeleteTimeoutInMinute * 60) {
needDelMsgIDs = append(needDelMsgIDs, msg.ID)
} else {
keepMsgIDList = append(keepMsgIDList, msg)
}
}
sc.s.OldMessageID = keepMsgIDList
if len(needDelMsgIDs) > 0 {
_, err := botInstance.DeleteMessages(ctx, &bot.DeleteMessagesParams{
ChatID: sc.GroupID,
MessageIDs: needDelMsgIDs,
})
if err != nil {
logger.Error().
Err(err).
Ints("msgIDs", needDelMsgIDs).
Int("deleteMessageMinute", sc.DeleteTimeoutInMinute).
Str("content", "teamspeak user change notify").
Msg(flaterr.DeleteMessage.Str())
return 1, err
}
}
return 0, nil
},
"delete teamspeak user change notify message",
),
Trigger: quartz.NewSimpleTrigger(time.Minute * time.Duration(sc.DeleteTimeoutInMinute)),
})
if err != nil {
logger.Error().Err(err).Msg("Failed to schedule delete message job")
return err
}
sc.s.IsDeleteMessageTaskScheduled = true
sc.s.IsDeleteMessageTaskRunning = true
// 如果 sc.AutoDeleteMessage 不为真,则就是没有启用自动删除消息功能,所以暂停任务
if !sc.AutoDeleteMessage {
sc.PauseDeleteMessageTask(ctx)
}
return nil
}
// ResumeDeleteMessageTask 恢复删除旧通知信息的任务
func (sc *ServerConfig) ResumeDeleteMessageTask(ctx context.Context) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if !tsConfig.s.IsDeleteMessageTaskRunning {
err := task.ResumeTask(ctx, fmt.Sprintf("delete_old_message_%d", tsConfig.GroupID), "teamspeak3")
if err != nil {
logger.Error().Err(err).Msg("Failed to resume delete old message task")
} else {
tsConfig.s.IsDeleteMessageTaskRunning = true
}
}
}
// PauseDeleteMessageTask 暂停删除旧通知信息的任务
func (sc *ServerConfig) PauseDeleteMessageTask(ctx context.Context) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if tsConfig.s.IsDeleteMessageTaskRunning {
err := task.PauseTask(ctx, fmt.Sprintf("delete_old_message_%d", tsConfig.GroupID), "teamspeak3")
if err != nil {
logger.Error().Err(err).Msg("Failed to pause delete old message task")
} else {
tsConfig.s.IsDeleteMessageTaskRunning = false
}
}
}
// RetryLoop 将循环尝试连接到服务器,直到成功为止
func (sc *ServerConfig) RetryLoop(ctx context.Context) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
sc.s.IsInRetryLoop = true
defer func() {
sc.s.IsInRetryLoop = false
logger.Info().Msg("reconnect loop exited")
}()
sc.s.RetryCount = 0
retryTicker := time.NewTicker(time.Second * 5)
defer retryTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-sc.s.ResetTicker:
sc.ResumeCheckClientTask(ctx)
logger.Info().Msg("reconnect by command success")
return
case <-retryTicker.C:
logger.Info().
Int("retryCount", sc.s.RetryCount).
Msg("try reconnect...")
// 实际上并不生效...
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second * 30)
defer cancel()
err := sc.Connect(timeoutCtx)
if err != nil {
// 出现错误时,先降低 ticker 速度,然后尝试重新初始化
// 无法成功则等待下一个周期继续尝试
if sc.s.RetryCount < 15 {
sc.s.RetryCount++
retryTicker.Reset(time.Duration(sc.s.RetryCount * 20) * time.Second)
}
logger.Warn().
Int("retryCount", sc.s.RetryCount).
Time("nextRetry", time.Now().Add(time.Duration(sc.s.RetryCount * 20) * time.Second)).
Msg("reconnect failed")
} else {
// 重新初始化成功则恢复查询任务
sc.ResumeCheckClientTask(ctx)
logger.Info().Msg("reconnect success")
botMessage, err := botInstance.SendMessage(ctx, &bot.SendMessageParams{
ChatID: sc.GroupID,
Text: "已成功与服务器重新建立连接",
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "success reconnect to server notice").
Msg(flaterr.SendMessage.Str())
} else {
sc.s.OldMessageID = append(tsConfig.s.OldMessageID, OldMessageID{
Date: int(time.Now().Unix()),
ID: botMessage.ID,
})
}
return
}
}
}
}
// BuildConfigKeyboard 构建一个 &models.InlineKeyboardMarkup 类型的配置键盘
func (sc *ServerConfig) BuildConfigKeyboard() models.ReplyMarkup {
var buttons [][]models.InlineKeyboardButton
if sc.SendMessageMode {
buttons = append(buttons, []models.InlineKeyboardButton{
{
Text: utils.TextForTrueOrFalse(sc.SendMessageMode, "✅ ", "") + "发送消息通知",
CallbackData: "teamspeak_sendmessage",
},
{
Text: utils.TextForTrueOrFalse(sc.AutoDeleteMessage, "✅ ", "") + "自动删除旧消息",
CallbackData: "teamspeak_sendmessage_autodelete",
},
})
} else {
buttons = append(buttons, []models.InlineKeyboardButton{{
Text: utils.TextForTrueOrFalse(sc.SendMessageMode, "✅ ", "") + "发送消息通知",
CallbackData: "teamspeak_sendmessage",
}})
}
if sc.PinMessageMode {
buttons = append(buttons, []models.InlineKeyboardButton{
{
Text: utils.TextForTrueOrFalse(sc.PinMessageMode, "✅ ", "") + "显示在置顶消息",
CallbackData: "teamspeak_pinmessage",
},
{
Text: utils.TextForTrueOrFalse(sc.DeleteOldPinnedMessage, "✅ ", "") + "删除旧的置顶消息",
CallbackData: "teamspeak_pinmessage_deletepinedmessage",
},
})
} else {
buttons = append(buttons, []models.InlineKeyboardButton{{
Text: utils.TextForTrueOrFalse(sc.PinMessageMode, "✅ ", "") + "显示在置顶消息",
CallbackData: "teamspeak_pinmessage",
}})
}
buttons = append(buttons, []models.InlineKeyboardButton{{
Text: "关闭菜单",
CallbackData: "delete_this_message",
}},)
return &models.InlineKeyboardMarkup{ InlineKeyboard: buttons }
}
// CheckPinnedMessage 检查是否存在置顶的消息,并检查它是否可以被编辑。
//
// 如果可以编辑,则不进行其他操作,否则将删除或取消固定消息,并重新发送一条新的消息用于编辑。
//
// 若不存在置顶消息,则发送一条新的消息用于编辑。
func (sc *ServerConfig) CheckPinnedMessage(ctx context.Context) {
// 尝试编辑旧的消息
if sc.IsPinnedMessageCanEdit(ctx) {
// 因为没有简单的方法得知旧消息有没有被置顶,就假设已经成功置顶了
sc.s.IsMessagePinned = true
} else {
// 无法编辑,就取消置顶或删除消息,再由后续逻辑发送新消息
sc.RemovePinnedMessage(ctx, false)
}
// 发送一条新的信息
sc.NewPinnedMessage(ctx)
}
// IsPinnedMessageCanEdit 当 sc.PinnedMessageID 不为 0 时检查是否可以编辑被固定的消息
func (sc *ServerConfig) IsPinnedMessageCanEdit(ctx context.Context) bool {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if sc.PinnedMessageID != 0 {
_, err := botInstance.EditMessageText(ctx, &bot.EditMessageTextParams{
ChatID: sc.GroupID,
MessageID: sc.PinnedMessageID,
Text: fmt.Sprintf("%s | 开始监听 Teamspeak 3 用户状态", time.Now().Format("15:04")),
})
if err != nil {
if err.Error() == "bad request, Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message" {
// 机器人重启的太快,导致消息文本相同,但实际上还是能编辑的
return true
}
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "start listen teamspeak user changes").
Msg(flaterr.EditMessageText.Str())
} else {
return true
}
}
return false
}
// NewPinnedMessage 当 sc.PinnedMessageID 为 0 时发送一条新的消息用于编辑
func (sc *ServerConfig) NewPinnedMessage(ctx context.Context) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
if sc.PinnedMessageID == 0 {
message, err := botInstance.SendMessage(ctx, &bot.SendMessageParams{
ChatID: sc.GroupID,
Text: fmt.Sprintf("%s | 开始监听 Teamspeak 3 用户状态", time.Now().Format("15:04")),
DisableNotification: true,
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "start listen teamspeak user changes").
Msg(flaterr.SendMessage.Str())
return
}
sc.PinnedMessageID = message.ID // 虽然后面可能会因为权限问题没法成功置顶,不过为了防止重复发送,所以假设它已经被置顶了
err = yaml.SaveYAML(tsConfigPath, &tsConfig)
if err != nil {
logger.Error().
Err(err).
Str("path", KeywordDataPath).
Msg("Failed to save teamspeak data after pin message")
} else {
// 置顶消息提醒
ok, err := botInstance.PinChatMessage(ctx, &bot.PinChatMessageParams{
ChatID: sc.GroupID,
MessageID: message.ID,
DisableNotification: true,
})
if ok {
sc.s.IsMessagePinned = true
// 删除置顶消息提示
plugin_utils.AddHandlerByMessageTypeHandlers(plugin_utils.ByMessageTypeHandler{
PluginName: "remove pin message notice",
ChatType: message.Chat.Type,
MessageType: message_utils.PinnedMessage,
ForChatID: sc.GroupID,
AllowAutoTrigger: true,
MessageHandler: func(opts *handler_params.Message) error {
if opts.Message.PinnedMessage != nil && opts.Message.PinnedMessage.Message.ID == sc.PinnedMessageID {
_, err := opts.Thebot.DeleteMessage(opts.Ctx, &bot.DeleteMessageParams{
ChatID: sc.GroupID,
MessageID: opts.Message.ID,
})
// 不管成功与否,都注销这个 handler
plugin_utils.RemoveHandlerByMessageTypeHandler(models.ChatTypeSupergroup, message_utils.PinnedMessage, sc.GroupID, "remove pin message notice")
return err
}
return nil
},
})
} else {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Str("content", "listen teamspeak user changes").
Msg(flaterr.PinChatMessage.Str())
}
}
}
}
// RemovePinnedMessage 当 sc.PinnedMessageID 不为 0 时取消或删除置顶消息
//
// keepID 参数仅在 sc.DeleteOldPinnedMessage 不为 true 时才生效
func (sc *ServerConfig) RemovePinnedMessage(ctx context.Context, keepID bool) {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
// 取消置顶或删除上一次的置顶消息
if sc.PinnedMessageID != 0 {
if sc.DeleteOldPinnedMessage {
_, err := botInstance.DeleteMessage(ctx, &bot.DeleteMessageParams{
ChatID: sc.GroupID,
MessageID: sc.PinnedMessageID,
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Int("messageID", sc.PinnedMessageID).
Str("content", "latest pinned online client status").
Msg(flaterr.DeleteMessage.Str())
}
} else {
_, err := botInstance.UnpinChatMessage(ctx, &bot.UnpinChatMessageParams{
ChatID: sc.GroupID,
MessageID: sc.PinnedMessageID,
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", sc.GroupID).
Int("messageID", sc.PinnedMessageID).
Str("content", "latest pinned online client status").
Msg(flaterr.UnpinChatMessage.Str())
}
}
if sc.DeleteOldPinnedMessage || !keepID {
// 如果设置是删除旧消息,或不需要保留 ID则清空消息 ID
sc.PinnedMessageID = 0
}
err := saveTeamspeakData(ctx)
if err != nil {
logger.Error().Err(err).Msg("Failed to save teamspeak data after delete or unpin message")
}
}
}
type Status struct {
IsMessagePinned bool
ResetTicker (chan bool)
IsInRetryLoop bool
RetryCount int
CheckCount int
CheckFailedCount int
BeforeOnlineClient []OnlineClient
IsCheckClientTaskScheduled bool
IsCheckClientTaskRunning bool
IsDeleteMessageTaskScheduled bool
IsDeleteMessageTaskRunning bool
OldMessageID []OldMessageID
}
type OldMessageID struct {
Date int
ID int
}
type OnlineClient struct {
Username string
JoinTime time.Time
}
func init() {
plugin_utils.AddInitializer(plugin_utils.Initializer{
Name: "teamspeak",
Func: initTeamSpeak,
})
plugin_utils.AddDataBaseHandler(plugin_utils.DatabaseHandler{
Name: "teamspeak",
Loader: readTeamspeakData,
Saver: saveTeamspeakData,
})
plugin_utils.AddHandlerHelpInfo(plugin_utils.HandlerHelp{
Name: "TeamSpeak",
Description: "注意:使用此功能需要先在配置文件中手动填写配置文件\n\n此功能可以按照设定好的轮询时间来检查 TeamSpeak 服务器中的用户列表,并可以在用户列表发送变动时在群组中发送提醒\n\n使用 /ts3 命令来随时查看服务器在线用户和监听状态\n支持设定多种提醒方式更新置顶消息、发送消息\n自定义配置轮询间隔单位 秒)\n自定义删除旧通知消息超时单位 分钟)\n服务器掉线自动重连若 bot 首次启动未能连接成功,则需要手动发送 /ts3 命令后才可自动重连)",
})
plugin_utils.AddSlashCommandHandlers(plugin_utils.SlashCommand{
SlashCommand: "ts3",
MessageHandler: showStatus,
})
plugin_utils.AddCallbackQueryHandlers(plugin_utils.CallbackQuery{
CallbackDataPrefix: "teamspeak",
CallbackQueryHandler: teamspeakCallbackHandler,
})
}
// initTeamSpeak 从 tsConfigPath 读取服务器配置后调用 tsConfig.Connect 尝试连接服务器
func initTeamSpeak(ctx context.Context, thebot *bot.Bot) error {
// 保存 bot 实例
botInstance = thebot
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
logger.Info().Msg("Reading config file...")
// 读取配置文件
err := readTeamspeakData(ctx)
if err != nil {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to read teamspeak config data")
return fmt.Errorf("failed to read teamspeak config data: %w", err)
}
if tsConfig.s.ResetTicker == nil {
tsConfig.s.ResetTicker = make(chan bool)
}
if tsConfig.PollingInterval == 0 {
tsConfig.PollingInterval = 5
}
if tsConfig.DeleteTimeoutInMinute == 0 {
tsConfig.DeleteTimeoutInMinute = 10
}
if tsConfig.PinMessageMode {
// 启用功能时检查消息是否存在或是否可编辑
tsConfig.CheckPinnedMessage(ctx)
} else if tsConfig.PinnedMessageID != 0 {
// 禁用功能时且消息 ID 不为 0 时优先解除置顶
tsConfig.RemovePinnedMessage(ctx, true)
}
logger.Info().
Int64("ChatID", tsConfig.GroupID).
Msg("Initializing TeamSpeak client...")
err = tsConfig.Connect(ctx)
if err != nil {
logger.Error().
Err(err).
Int64("ChatID", tsConfig.GroupID).
Msg("Failed to initialize TeamSpeak client")
return fmt.Errorf("failed to initialize TeamSpeak client: %w", err)
}
logger.Info().
Int64("ChatID", tsConfig.GroupID).
Msg("Successfully initialized TeamSpeak")
return nil
}
// readTeamspeakData 从 tsConfigPath 读取服务器配置并加载到 tsConfig 中
func readTeamspeakData(ctx context.Context) error {
logger := zerolog.Ctx(ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
err := yaml.LoadYAML(tsConfigPath, &tsConfig)
if err != nil {
if os.IsNotExist(err) {
logger.Warn().
Err(err).
Str("path", tsConfigPath).
Msg("Not found teamspeak config file. Created new one")
err = yaml.SaveYAML(tsConfigPath, &ServerConfig{
PollingInterval: 10,
SendMessageMode: true,
DeleteTimeoutInMinute: 10,
})
if err != nil {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to create empty config")
return fmt.Errorf("failed to create empty config: %w", err)
}
} else {
logger.Error().
Err(err).
Str("path", tsConfigPath).
Msg("Failed to read config file")
// 读取配置文件内容失败也不允许重新启动
return fmt.Errorf("failed to read config file: %w", err)
}
}
return err
}
// saveTeamspeakData 保存 tsConfig 配置到 tsConfigPath 文件中
func saveTeamspeakData(ctx context.Context) error {
err := yaml.SaveYAML(tsConfigPath, &tsConfig)
if err != nil {
zerolog.Ctx(ctx).Error().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Err(err).
Str("path", tsConfigPath).
Msg("Failed to save teamspeak data")
return fmt.Errorf("failed to save teamspeak data: %w", err)
}
return nil
}
// showStatus 响应 `/ts3` 命令,显示用户列表或触发手动连接,可显示连接错误信息
func showStatus(opts *handler_params.Message) error {
logger := zerolog.Ctx(opts.Ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Logger()
var handlerErr flaterr.MultErr
var pendingMessage string
// 正常运行就输出用户列表,否则发送错误信息
if tsConfig.s.IsCheckClientTaskScheduled && tsConfig.s.IsCheckClientTaskRunning {
onlineClients, err := tsConfig.c.Server.ClientList()
if err != nil {
logger.Error().
Err(err).
Msg("Failed to get online client")
handlerErr.Addf("failed to get online client: %w", err)
pendingMessage = fmt.Sprintf("获取服务器用户列表时发生了一些错误:\n<blockquote expandable>%s</blockquote>", utils.IgnoreHTMLTags(err.Error()))
} else {
pendingMessage += fmt.Sprintln("在线客户端:")
var userCount int
for _, client := range onlineClients {
if client.Nickname == botNickName {
// 统计用户数量时跳过此机器人
continue
}
pendingMessage += fmt.Sprintf("用户 [ %s ] ", client.Nickname)
userCount++
if client.OnlineClientExt != nil {
pendingMessage += fmt.Sprintf("在线时长 %d", *client.OnlineClientTimes.LastConnected)
}
pendingMessage += "\n"
}
if userCount == 0 {
pendingMessage = "当前无用户在线"
}
}
} else {
timeoutCtx, cancel := context.WithTimeout(opts.Ctx, time.Second * 30)
err := tsConfig.Connect(timeoutCtx)
if err != nil {
pendingMessage = fmt.Sprintf("teamspeak 插件发生了一些错误:\n<blockquote expandable>%s</blockquote>\n\n", err)
if tsConfig.s.IsCheckClientTaskScheduled{
pendingMessage += "您可以使用 /ts3 命令来尝试重新连接或等待自动重连"
} else {
pendingMessage += "尝试重新初始化失败,由于检查任务未在运行,您需要手动使用 /ts3 命令来尝试重新连接"
}
handlerErr.Addf("failed to reinit teamspeak plugin: %w", err)
} else {
if tsConfig.s.IsInRetryLoop {
tsConfig.s.ResetTicker <- true
}
pendingMessage = "尝试重新初始化成功,现可正常运行"
}
cancel()
}
var buttons models.ReplyMarkup
// 显示管理按钮
if opts.Message.Chat.ID == tsConfig.GroupID && contain.Int64(opts.Message.From.ID, utils.GetChatAdminIDs(opts.Ctx, opts.Thebot, tsConfig.GroupID)...) || contain.Int64(opts.Message.From.ID, configs.BotConfig.AdminIDs...) {
buttons = &models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{{{
Text: "管理此功能",
CallbackData: "teamspeak",
}}}}
}
// 发送消息
_, err := opts.Thebot.SendMessage(opts.Ctx, &bot.SendMessageParams{
ChatID: opts.Message.Chat.ID,
Text: pendingMessage,
ParseMode: models.ParseModeHTML,
ReplyParameters: &models.ReplyParameters{ MessageID: opts.Message.ID },
ReplyMarkup: buttons,
})
if err != nil {
logger.Error().
Err(err).
Int64("chatID", opts.Message.Chat.ID).
Str("content", "teamspeak online client status").
Msg(flaterr.SendMessage.Str())
handlerErr.Addt(flaterr.SendMessage, "teamspeak online client status", err)
}
return handlerErr.Flat()
}
// diffSlices 比较两个 OnlineClient 类型切片,返回新增和删除的字符串类型切片
func diffSlices(before, now []OnlineClient) (added, removed []string) {
beforeMap := make(map[string]bool)
nowMap := make(map[string]bool)
// 把 A 和 B 转成 map
for _, item := range before { beforeMap[item.Username] = true }
for _, item := range now { nowMap[item.Username] = true }
// 删除
for item := range nowMap {
if !beforeMap[item] { added = append(added, item)}
}
// 新增
for item := range beforeMap {
if !nowMap[item] { removed = append(removed, item) }
}
return
}
// teamspeakCallbackHandler 响应前缀为 "teamspeak" 的 callbackQuery 请求
func teamspeakCallbackHandler(opts *handler_params.CallbackQuery) error {
logger := zerolog.Ctx(opts.Ctx).
With().
Str("pluginName", "teamspeak3").
Str(utils.GetCurrentFuncName()).
Str("callbackQueryData", opts.CallbackQuery.Data).
Logger()
var handlerErr flaterr.MultErr
if contain.Int64(opts.CallbackQuery.From.ID, utils.GetChatAdminIDs(opts.Ctx, opts.Thebot, tsConfig.GroupID)...) || contain.Int64(opts.CallbackQuery.From.ID, configs.BotConfig.AdminIDs...) {
var needEdit bool = true
var needSave bool = false
var needEditTask bool = false
switch opts.CallbackQuery.Data {
case "teamspeak_pinmessage":
if tsConfig.PinMessageMode && !tsConfig.SendMessageMode {
needEdit = false
_, err := opts.Thebot.AnswerCallbackQuery(opts.Ctx, &bot.AnswerCallbackQueryParams{
CallbackQueryID: opts.CallbackQuery.ID,
Text: "您至少要保留一个检测用户变动的方式",
ShowAlert: true,
})
if err != nil {
logger.Error().
Err(err).
Str("content", "at least need one notice method").
Msg(flaterr.AnswerCallbackQuery.Str())
handlerErr.Addt(flaterr.AnswerCallbackQuery, "at least need one notice method", err)
}
} else {
tsConfig.PinMessageMode = !tsConfig.PinMessageMode
needSave = true
if tsConfig.PinMessageMode {
if tsConfig.PinnedMessageID != 0 {
// 机器人启动时没有使用置顶模式,但后续开了,而且消息 ID 不为零,就假设已经成功置顶了
tsConfig.s.IsMessagePinned = true
}
} else {
// 关闭时解除当前消息的置顶
tsConfig.RemovePinnedMessage(opts.Ctx, true)
}
}
case "teamspeak_pinmessage_deletepinedmessage":
tsConfig.DeleteOldPinnedMessage = !tsConfig.DeleteOldPinnedMessage
needSave = true
case "teamspeak_sendmessage":
if tsConfig.SendMessageMode && !tsConfig.PinMessageMode {
needEdit = false
_, err := opts.Thebot.AnswerCallbackQuery(opts.Ctx, &bot.AnswerCallbackQueryParams{
CallbackQueryID: opts.CallbackQuery.ID,
Text: "您至少要保留一个检测用户变动的方式",
ShowAlert: true,
})
if err != nil {
logger.Error().
Err(err).
Str("content", "at least need one notice method").
Msg(flaterr.AnswerCallbackQuery.Str())
handlerErr.Addt(flaterr.AnswerCallbackQuery, "at least need one notice method", err)
}
} else {
tsConfig.SendMessageMode = !tsConfig.SendMessageMode
needSave = true
needEditTask = true
}
case "teamspeak_sendmessage_autodelete":
tsConfig.AutoDeleteMessage = !tsConfig.AutoDeleteMessage
needSave = true
needEditTask = true
}
if needEditTask {
if tsConfig.s.IsDeleteMessageTaskScheduled {
if tsConfig.SendMessageMode && tsConfig.AutoDeleteMessage {
tsConfig.ResumeDeleteMessageTask(opts.Ctx)
} else {
tsConfig.PauseDeleteMessageTask(opts.Ctx)
tsConfig.s.OldMessageID = []OldMessageID{}
}
} else {
_, err := opts.Thebot.AnswerCallbackQuery(opts.Ctx, &bot.AnswerCallbackQueryParams{
CallbackQueryID: opts.CallbackQuery.ID,
Text: "您的操作已保存,但因为删除消息的任务没有添加成功,无法自动删除消息,请尝试重启机器人",
ShowAlert: true,
})
if err != nil {
logger.Error().
Err(err).
Str("content", "delete message task not scheduled").
Msg(flaterr.AnswerCallbackQuery.Str())
handlerErr.Addt(flaterr.AnswerCallbackQuery, "delete message task not scheduled", err)
}
}
}
if needEdit {
_, err := opts.Thebot.EditMessageText(opts.Ctx, &bot.EditMessageTextParams{
ChatID: opts.CallbackQuery.Message.Message.Chat.ID,
MessageID: opts.CallbackQuery.Message.Message.ID,
Text: "选择通知用户变动的方式",
ReplyMarkup: tsConfig.BuildConfigKeyboard(),
})
if err != nil {
logger.Error().
Err(err).
Str("content", "teamspeak manage keyboard").
Msg(flaterr.EditMessageText.Str())
handlerErr.Addt(flaterr.EditMessageText, "teamspeak manage keyboard", err)
}
}
if needSave {
err := saveTeamspeakData(opts.Ctx)
if err != nil {
logger.Error().
Err(err).
Msg("Failed to save teamspeak data")
handlerErr.Addf("failed to save teamspeak data: %w", err)
}
}
} else {
_, err := opts.Thebot.AnswerCallbackQuery(opts.Ctx, &bot.AnswerCallbackQueryParams{
CallbackQueryID: opts.CallbackQuery.ID,
Text: "您没有权限修改此内容",
ShowAlert: true,
})
if err != nil {
logger.Error().
Err(err).
Str("content", "no permission to change teamspeak config").
Msg(flaterr.AnswerCallbackQuery.Str())
handlerErr.Addt(flaterr.AnswerCallbackQuery, "no permission to change teamspeak config", err)
}
}
return handlerErr.Flat()
}