feat(telegram): switch message type to markdown feat(controller): add welcome message for telegram bot.
356 lines
10 KiB
Go
356 lines
10 KiB
Go
package telegram
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"nukumizu-backend/config"
|
|
"nukumizu-backend/internal/controller"
|
|
"nukumizu-backend/internal/node"
|
|
"nukumizu-backend/internal/template"
|
|
"nukumizu-backend/postLog"
|
|
)
|
|
|
|
// TelegramController handles Telegram Bot interactions via long polling.
|
|
type TelegramController struct {
|
|
cfg config.TelegramConfig
|
|
client *Client
|
|
bot *User // The bot's own user object from getMe.
|
|
|
|
mu sync.Mutex
|
|
usernameToID map[string]int64 // resolved @username -> numeric user ID
|
|
}
|
|
|
|
// NewTelegramController creates a new Telegram controller.
|
|
func NewTelegramController(cfg config.TelegramConfig) *TelegramController {
|
|
t := &TelegramController{
|
|
cfg: cfg,
|
|
usernameToID: make(map[string]int64),
|
|
}
|
|
if cfg.Enabled {
|
|
t.client = NewClient(cfg.BotToken, cfg.NetworkUseProxy)
|
|
}
|
|
return t
|
|
}
|
|
|
|
// Name returns the controller name.
|
|
func (t *TelegramController) Name() string {
|
|
return "telegram"
|
|
}
|
|
|
|
// Start verifies the bot token via getMe and begins long polling.
|
|
func (t *TelegramController) Start() error {
|
|
if !t.cfg.Enabled {
|
|
return nil
|
|
}
|
|
if t.client == nil {
|
|
postLog.Warning("Telegram controller enabled but client is nil")
|
|
return nil
|
|
}
|
|
if t.cfg.BotToken == "" {
|
|
postLog.Warning("Telegram controller enabled but no bot token configured")
|
|
return nil
|
|
}
|
|
|
|
// The tutorial's first step: verify the token with getMe. The returned bot
|
|
// identity is used for @mention mode and echo prevention.
|
|
bot, err := t.client.GetMe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to verify Telegram bot token (getMe): %w", err)
|
|
}
|
|
t.bot = bot
|
|
postLog.Info(fmt.Sprintf("Telegram bot authenticated: @%s (id %d)", bot.Username, bot.ID))
|
|
|
|
postLog.Info("Telegram controller started (long polling)")
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
postLog.Error(fmt.Sprintf("Telegram poll loop panic recovered: %v", r))
|
|
}
|
|
}()
|
|
t.client.Listen(t.handleUpdate)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the Telegram controller and its poll loop.
|
|
func (t *TelegramController) Stop() {
|
|
if t.client != nil {
|
|
t.client.Stop()
|
|
}
|
|
postLog.Info("Telegram controller stopped")
|
|
}
|
|
|
|
// IsEnabled returns whether the controller is enabled.
|
|
func (t *TelegramController) IsEnabled() bool {
|
|
return t.cfg.Enabled
|
|
}
|
|
|
|
// handleUpdate processes a single Telegram update received via long polling.
|
|
func (t *TelegramController) handleUpdate(update Update) {
|
|
if update.Message == nil {
|
|
return
|
|
}
|
|
msg := update.Message
|
|
|
|
if config.GetConfig().System.DebugMode && config.GetConfig().Debug.ShowTelegramMsg {
|
|
raw, _ := json.Marshal(update)
|
|
postLog.Debug("Telegram update received: " + string(raw))
|
|
}
|
|
|
|
// Echo prevention: ignore messages from other bots and from this bot itself.
|
|
if msg.From == nil || msg.From.IsBot {
|
|
return
|
|
}
|
|
if t.bot != nil && msg.From.ID == t.bot.ID {
|
|
return
|
|
}
|
|
|
|
chatType := telegramChatType(msg.Chat.Type)
|
|
if chatType == "" {
|
|
return // channel or other unsupported chat type.
|
|
}
|
|
|
|
// Record username -> ID so admins configured by username can be reached.
|
|
t.recordUser(msg.From)
|
|
|
|
// Only text messages can carry commands.
|
|
if strings.TrimSpace(msg.Text) == "" {
|
|
return
|
|
}
|
|
|
|
cmd := controller.Command{
|
|
RawText: msg.Text,
|
|
ChatID: msg.Chat.ID,
|
|
ChatType: chatType,
|
|
SenderID: msg.From.ID,
|
|
}
|
|
|
|
response := t.processCommand(cmd)
|
|
if response == "" {
|
|
return
|
|
}
|
|
|
|
if err := t.client.SendMessage(msg.Chat.ID, response); err != nil {
|
|
postLog.Warning("Failed to send Telegram reply: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// processCommand validates an incoming message as a command and hands the
|
|
// complete command to the unified processor. It returns the response text to
|
|
// reply with; an empty response means the message was discarded.
|
|
func (t *TelegramController) processCommand(cmd controller.Command) string {
|
|
text := cmd.RawText
|
|
|
|
// In "at" listen mode, require a mention of the bot and strip it before
|
|
// parsing, mirroring the QQ CQ-at behavior.
|
|
if t.cfg.ListenMethod == "at" && t.bot != nil && t.bot.Username != "" {
|
|
mention := "@" + t.bot.Username
|
|
if !strings.Contains(text, mention) {
|
|
return "" // Not mentioned, ignore.
|
|
}
|
|
text = strings.ReplaceAll(text, mention, "")
|
|
}
|
|
|
|
// Telegram appends @botname to commands sent in groups (/list@MyBot); strip
|
|
// it so the command word parses.
|
|
text = stripCommandBotSuffix(text)
|
|
|
|
// First check whether the received message is a command.
|
|
parsed, ok := controller.ParseCommand(text)
|
|
if !ok {
|
|
return "" // Not a command, discard.
|
|
}
|
|
|
|
parsed.ChatID = cmd.ChatID
|
|
parsed.ChatType = cmd.ChatType
|
|
parsed.SenderID = cmd.SenderID
|
|
parsed.Source = "telegram"
|
|
|
|
// Hand the complete command to the unified processor, which checks group
|
|
// vs private, trusted groups, admin permissions, and executes it.
|
|
response, err := controller.GetManager().Trigger(parsed, t.cfg.TrustedGroups, t.resolvedAdminList(), t.cfg.ListenMethod)
|
|
if config.GetConfig().System.DebugMode && config.GetConfig().Debug.ShowTriggerCmdEcho {
|
|
postLog.Debug(fmt.Sprintf("[telegram] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID))
|
|
}
|
|
if err != nil {
|
|
postLog.Error("Telegram command processing failed: " + err.Error())
|
|
return ""
|
|
}
|
|
return response
|
|
}
|
|
|
|
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
|
|
// to all Telegram trusted groups and admins.
|
|
func (t *TelegramController) SendMessage(message string) error {
|
|
if !t.cfg.Enabled || t.client == nil {
|
|
return nil
|
|
}
|
|
t.sendToGroupsAndAdmins(message)
|
|
return nil
|
|
}
|
|
|
|
// SendStatusChange sends a status change notification via Telegram.
|
|
func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
|
|
if !t.cfg.Enabled || t.client == nil {
|
|
return nil
|
|
}
|
|
|
|
cfg := config.GetConfig()
|
|
params := template.BuildParamsFromStatusChange(change)
|
|
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
|
|
|
|
t.sendToGroupsAndAdmins(message)
|
|
return nil
|
|
}
|
|
|
|
// SendServerList sends the server list via Telegram.
|
|
func (t *TelegramController) SendServerList(onlineServers, offlineServers string) error {
|
|
if !t.cfg.Enabled || t.client == nil {
|
|
return nil
|
|
}
|
|
|
|
cfg := config.GetConfig()
|
|
params := template.BuildParamsFromServerList()
|
|
message := template.Render(cfg.ControllerMessage.ServerList, params)
|
|
|
|
t.sendToGroups(message)
|
|
return nil
|
|
}
|
|
|
|
// SendExecuteResult sends a command execution result via Telegram.
|
|
func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command, result string) error {
|
|
if !t.cfg.Enabled || t.client == nil {
|
|
return nil
|
|
}
|
|
|
|
cfg := config.GetConfig()
|
|
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
|
|
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
|
|
|
|
t.sendToGroups(message)
|
|
return nil
|
|
}
|
|
|
|
// telegramChatType maps a Telegram chat type to the unified ChatType value used
|
|
// by the controller package. Empty means the chat type is unsupported.
|
|
func telegramChatType(chatType string) string {
|
|
switch chatType {
|
|
case "private":
|
|
return "private"
|
|
case "group", "supergroup":
|
|
return "group"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// stripCommandBotSuffix removes a "@botname" suffix from the command word,
|
|
// turning "/list@MyBot" into "/list".
|
|
func stripCommandBotSuffix(text string) string {
|
|
if !strings.HasPrefix(text, "/") {
|
|
return text
|
|
}
|
|
|
|
// The command word is everything before the first space.
|
|
spaceIdx := strings.Index(text, " ")
|
|
if spaceIdx < 0 {
|
|
spaceIdx = len(text)
|
|
}
|
|
|
|
command, _, found := strings.Cut(text[1:spaceIdx], "@")
|
|
if !found {
|
|
return text
|
|
}
|
|
return "/" + command + text[spaceIdx:]
|
|
}
|
|
|
|
// recordUser stores the numeric ID for a sender's username so admins configured
|
|
// by username (rather than numeric ID) can be resolved later.
|
|
func (t *TelegramController) recordUser(u *User) {
|
|
if u == nil || u.Username == "" {
|
|
return
|
|
}
|
|
t.mu.Lock()
|
|
t.usernameToID[strings.ToLower(u.Username)] = u.ID
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
// resolveUsername resolves a Telegram username to its numeric ID, or returns
|
|
// ok == false when the user has not messaged the bot yet.
|
|
func (t *TelegramController) resolveUsername(username string) (int64, bool) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
id, ok := t.usernameToID[strings.ToLower(strings.TrimPrefix(username, "@"))]
|
|
return id, ok
|
|
}
|
|
|
|
// resolvedAdminList maps username-based admin entries to numeric IDs so the
|
|
// unified processor's numeric IsAdmin check works. Unresolvable entries (the
|
|
// user has not messaged the bot yet) pass through unchanged and simply never
|
|
// match.
|
|
func (t *TelegramController) resolvedAdminList() []string {
|
|
result := make([]string, 0, len(t.cfg.Admins))
|
|
for _, admin := range t.cfg.Admins {
|
|
if id, ok := t.resolveUsername(admin); ok {
|
|
result = append(result, strconv.FormatInt(id, 10))
|
|
} else {
|
|
result = append(result, admin)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// sendToGroupsAndAdmins sends a message to all trusted groups and admins.
|
|
func (t *TelegramController) sendToGroupsAndAdmins(message string) {
|
|
t.sendToGroups(message)
|
|
for _, admin := range t.cfg.Admins {
|
|
t.sendAdminMessage(admin, message)
|
|
}
|
|
}
|
|
|
|
// sendToGroups sends a message to all trusted groups.
|
|
func (t *TelegramController) sendToGroups(message string) {
|
|
for _, groupID := range t.cfg.TrustedGroups {
|
|
t.sendGroupMessage(groupID, message)
|
|
}
|
|
}
|
|
|
|
// sendGroupMessage sends a message to a single group chat.
|
|
func (t *TelegramController) sendGroupMessage(groupID string, message string) {
|
|
chatID, err := strconv.ParseInt(groupID, 10, 64)
|
|
if err != nil || chatID == 0 {
|
|
postLog.Warning(fmt.Sprintf("Invalid Telegram group chat ID: %s", groupID))
|
|
return
|
|
}
|
|
t.sendToChat(chatID, message)
|
|
}
|
|
|
|
// sendAdminMessage sends a message to an admin, resolving a username-based
|
|
// entry to a numeric ID. Telegram bots cannot start conversations, so an admin
|
|
// configured by username is only reachable after they have messaged the bot.
|
|
func (t *TelegramController) sendAdminMessage(admin string, message string) {
|
|
chatID, err := strconv.ParseInt(admin, 10, 64)
|
|
if err != nil || chatID == 0 {
|
|
// Not a numeric ID: try to resolve a username.
|
|
if id, ok := t.resolveUsername(admin); ok {
|
|
t.sendToChat(id, message)
|
|
return
|
|
}
|
|
postLog.Warning(fmt.Sprintf("Cannot reach Telegram admin %q: user has not messaged the bot yet (bots cannot start conversations)", admin))
|
|
return
|
|
}
|
|
t.sendToChat(chatID, message)
|
|
}
|
|
|
|
// sendToChat sends a message to a chat ID, logging failures.
|
|
func (t *TelegramController) sendToChat(chatID int64, message string) {
|
|
if err := t.client.SendMessage(chatID, message); err != nil {
|
|
postLog.Warning(fmt.Sprintf("Failed to send Telegram message to %d: %v", chatID, err))
|
|
}
|
|
}
|