feat: add RCON integration and Minecraft command system

- Add RCON configuration management with `rcon` command
- Add Minecraft command execution with `mc` command
- Add automatic message forwarding from groups to Minecraft
- Add group_rcon_configs database table for RCON settings
This commit is contained in:
2025-08-06 10:08:09 +08:00
parent b720c05e23
commit f95b8bfc8b
8 changed files with 385 additions and 1 deletions

View File

@@ -42,6 +42,8 @@ func init() {
"dice": cmd_dice,
"memberinfo": cmd_memberinfo,
"info": cmd_info,
"rcon": cmd_rcon,
"mc": cmd_mc,
}
for key := range cmdMap {

223
cmds/mc.go Normal file
View File

@@ -0,0 +1,223 @@
package cmds
import (
"fmt"
"strings"
"go-hurobot/config"
"go-hurobot/qbot"
"github.com/gorcon/rcon"
)
func cmd_mc(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if args.Size < 2 {
c.SendMsg(raw, "Usage: mc <command>")
return
}
// Get RCON configuration for this group
var rconConfig qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", raw.GroupID).First(&rconConfig)
if result.Error != nil {
return
}
if !rconConfig.Enabled {
c.SendMsg(raw, "RCON is disabled for this group")
return
}
// Join all arguments after 'mc' as the command
command := strings.Join(args.Contents[1:], " ")
// Check permissions for non-master users
if raw.UserID != config.MasterID && !isAllowedCommand(command) {
c.SendMsg(raw, "Permission denied. You can only use query commands.")
return
}
// Execute RCON command
response, err := executeRconCommand(rconConfig.Address, rconConfig.Password, command)
if err != nil {
c.SendMsg(raw, fmt.Sprintf("RCON error: %s", err.Error()))
return
}
// Send response back (limit to avoid spam)
if len(response) > 1000 {
response = response[:1000] + "... (truncated)"
}
if response == "" {
response = "No output"
}
c.SendMsg(raw, qbot.CQReply(raw.UserID)+response)
}
func executeRconCommand(address, password, command string) (string, error) {
// Connect to RCON server
conn, err := rcon.Dial(address, password)
if err != nil {
return "", fmt.Errorf("failed to connect: %w", err)
}
defer conn.Close()
// Execute command
response, err := conn.Execute(command)
if err != nil {
return "", fmt.Errorf("failed: %w", err)
}
return response, nil
}
// ForwardMessageToMC forwards a group message to Minecraft server if RCON is enabled
func ForwardMessageToMC(c *qbot.Client, msg *qbot.Message) {
// Skip bot's own messages
if msg.UserID == config.BotID {
return
}
// Get RCON configuration for this group
var rconConfig qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).First(&rconConfig)
// Skip if RCON not configured or disabled
if result.Error != nil || !rconConfig.Enabled {
return
}
// Get user's nickname from database
var user qbot.Users
nickname := msg.Card // Default to group card name
userResult := qbot.PsqlDB.Where("user_id = ?", msg.UserID).First(&user)
if userResult.Error == nil && user.Nickname != "" {
nickname = user.Nickname
}
// Clean the message content for Minecraft (remove special characters)
cleanContent := cleanMessageForMC(msg.Content)
// Create tellraw command
tellrawCmd := fmt.Sprintf("tellraw @a {\"text\":\"<%s> %s\"}",
escapeMinecraftText(nickname),
escapeMinecraftText(cleanContent))
// Execute the command
executeRconCommand(rconConfig.Address, rconConfig.Password, tellrawCmd)
}
// cleanMessageForMC removes or replaces characters that might cause issues in Minecraft
func cleanMessageForMC(content string) string {
// Remove or replace problematic characters
content = strings.ReplaceAll(content, "\n", " ")
content = strings.ReplaceAll(content, "\r", " ")
content = strings.ReplaceAll(content, "\t", " ")
// Remove multiple spaces
for strings.Contains(content, " ") {
content = strings.ReplaceAll(content, " ", " ")
}
return strings.TrimSpace(content)
}
// escapeMinecraftText escapes special characters for Minecraft JSON text
func escapeMinecraftText(text string) string {
text = strings.ReplaceAll(text, "\\", "\\\\")
text = strings.ReplaceAll(text, "\"", "\\\"")
text = strings.ReplaceAll(text, "\n", "\\n")
text = strings.ReplaceAll(text, "\r", "\\r")
text = strings.ReplaceAll(text, "\t", "\\t")
return text
}
// isAllowedCommand checks if a command is allowed for non-master users
func isAllowedCommand(command string) bool {
// Remove leading slash if present
command = strings.TrimPrefix(command, "/")
// Split command into parts for analysis
parts := strings.Fields(strings.ToLower(command))
if len(parts) == 0 {
return false
}
mainCmd := parts[0]
// Allowed commands for non-master users (query/read-only commands)
switch mainCmd {
case "list":
return true
case "seed":
return true
case "version":
return true
case "data":
// Only allow "data get" commands
if len(parts) >= 2 && parts[1] == "get" {
return true
}
return false
case "team":
// Only allow "team list"
if len(parts) >= 2 && parts[1] == "list" {
return true
}
return false
case "whitelist":
// Only allow "whitelist list"
if len(parts) >= 2 && parts[1] == "list" {
return true
}
return false
case "banlist":
return true
case "locate":
// Allow all locate subcommands (structure, biome, poi)
return true
case "worldborder":
// Only allow "worldborder get"
if len(parts) >= 2 && parts[1] == "get" {
return true
}
return false
case "datapack":
// Only allow "datapack list"
if len(parts) >= 2 && parts[1] == "list" {
return true
}
return false
case "function":
// Allow function queries (without execution)
// This is tricky - for safety, only allow when no arguments that suggest execution
if len(parts) == 1 {
return true // Just "function" command shows help
}
return false
case "gamerule":
// Allow gamerule queries (when no value is being set)
if len(parts) <= 2 {
return true // "gamerule" or "gamerule <rule>" (query)
}
return false // "gamerule <rule> <value>" (modification)
case "difficulty":
// Allow difficulty query (when no value is being set)
if len(parts) == 1 {
return true // Just "difficulty" (query)
}
return false // "difficulty <value>" (modification)
case "defaultgamemode":
// Allow defaultgamemode query (when no value is being set)
if len(parts) == 1 {
return true // Just "defaultgamemode" (query)
}
return false // "defaultgamemode <value>" (modification)
default:
return false
}
}

135
cmds/rcon.go Normal file
View File

@@ -0,0 +1,135 @@
package cmds
import (
"fmt"
"strconv"
"strings"
"go-hurobot/config"
"go-hurobot/qbot"
)
func cmd_rcon(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if raw.UserID != config.MasterID {
return
}
const help = `Usage: rcon [status | set <address> <password> | enable | disable]
Examples:
rcon status
rcon set '127.0.0.1:25575' 'password'
rcon enable
rcon disable`
if args.Size == 1 {
c.SendMsg(raw, help)
return
}
switch args.Contents[1] {
case "status":
showRconStatus(c, raw)
case "set":
if args.Size != 4 {
c.SendMsg(raw, "Usage: rcon set <address> <password>")
return
}
setRconConfig(c, raw, args.Contents[2], args.Contents[3])
case "enable":
toggleRcon(c, raw, true)
case "disable":
toggleRcon(c, raw, false)
default:
c.SendMsg(raw, help)
}
}
func showRconStatus(c *qbot.Client, msg *qbot.Message) {
var config qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).First(&config)
if result.Error != nil {
c.SendMsg(msg, "RCON not configured for this group")
return
}
status := "disabled"
if config.Enabled {
status = "enabled"
}
// Hide password for security
maskedPassword := strings.Repeat("*", len(config.Password))
response := fmt.Sprintf("RCON Status: %s\nAddress: %s\nPassword: %s",
status, config.Address, maskedPassword)
c.SendMsg(msg, response)
}
func setRconConfig(c *qbot.Client, msg *qbot.Message, address, password string) {
// Validate address format (should contain port)
if !strings.Contains(address, ":") {
c.SendMsg(msg, "Invalid address format. Use host:port (e.g., 127.0.0.1:25575)")
return
}
// Validate port
parts := strings.Split(address, ":")
if len(parts) != 2 {
c.SendMsg(msg, "Invalid address format. Use host:port")
return
}
if port, err := strconv.Atoi(parts[1]); err != nil || port < 1 || port > 65535 {
c.SendMsg(msg, "Invalid port number")
return
}
config := qbot.GroupRconConfigs{
GroupID: msg.GroupID,
Address: address,
Password: password,
Enabled: false, // Default to disabled for security
}
// Use Upsert to create or update
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).Assign(
qbot.GroupRconConfigs{
Address: address,
Password: password,
},
).FirstOrCreate(&config)
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
return
}
c.SendMsg(msg, fmt.Sprintf("RCON configuration updated:\nAddress: %s\nStatus: disabled (use 'rcon enable' to enable)", address))
}
func toggleRcon(c *qbot.Client, msg *qbot.Message, enabled bool) {
// Check if configuration exists
var config qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).First(&config)
if result.Error != nil {
c.SendMsg(msg, "RCON not configured for this group. Use 'rcon set' first.")
return
}
// Update enabled status
result = qbot.PsqlDB.Model(&config).Where("group_id = ?", msg.GroupID).Update("enabled", enabled)
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
return
}
status := "disabled"
if enabled {
status = "enabled"
}
c.SendMsg(msg, fmt.Sprintf("RCON %s for this group", status))
}

View File

@@ -46,3 +46,11 @@ CREATE TABLE user_events (
CONSTRAINT check_rand_prob CHECK (rand_prob >= 0.0 AND rand_prob <= 1.0),
CONSTRAINT check_event_idx CHECK (event_idx >= 0 AND event_idx <= 9)
);
CREATE TABLE group_rcon_configs (
"group_id" BIGINT NOT NULL,
"address" TEXT NOT NULL,
"password" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY ("group_id")
);

1
go.mod
View File

@@ -26,6 +26,7 @@ require (
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorcon/rcon v1.4.0 // indirect
github.com/jinzhu/copier v0.3.5 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/longportapp/openapi-protobufs/gen/go v0.5.0 // indirect

2
go.sum
View File

@@ -35,6 +35,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorcon/rcon v1.4.0 h1:pYwZ8Rhcgfh/LhdPBncecuEo5thoFvPIuMSWovz1FME=
github.com/gorcon/rcon v1.4.0/go.mod h1:M6v6sNmr/NET9YIf+2rq+cIjTBridoy62uzQ58WgC1I=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@@ -11,6 +11,12 @@ func messageHandler(c *qbot.Client, msg *qbot.Message) {
if msg.UserID != config.BotID {
isCommand := cmds.HandleCommand(c, msg)
defer qbot.SaveDatabase(msg, isCommand)
// Forward non-command messages to Minecraft if RCON is enabled
if !isCommand {
cmds.ForwardMessageToMC(c, msg)
}
if isCommand {
return
}

View File

@@ -39,13 +39,20 @@ type UserEvents struct {
CreatedAt time.Time `gorm:"not null;column:created_at;default:now()"`
}
type GroupRconConfigs struct {
GroupID uint64 `gorm:"primaryKey;column:group_id"`
Address string `gorm:"not null;column:address"`
Password string `gorm:"not null;column:password"`
Enabled bool `gorm:"not null;column:enabled;default:false"`
}
func initPsqlDB(dsn string) error {
var err error
if PsqlDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}); err != nil {
return err
}
PsqlConnected = true
return PsqlDB.AutoMigrate(&Users{}, &Messages{}, &UserEvents{})
return PsqlDB.AutoMigrate(&Users{}, &Messages{}, &UserEvents{}, &GroupRconConfigs{})
}
func SaveDatabase(msg *Message, isCmd bool) error {