refactor: replace WebSocket with bidirectional HTTP for NapCat communication

refactor: migrate command handling from function pointers to object-oriented interface
feat: replace run.sh with YAML-based configuration file
This commit is contained in:
2025-10-31 20:30:39 +08:00
parent f54f04d287
commit abbe419bf8
41 changed files with 2312 additions and 1967 deletions

View File

@@ -2,46 +2,66 @@ package cmds
import (
"go-hurobot/qbot"
"strconv"
"strings"
)
func cmd_callme(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
var callmeHelpMsg string = `Set or query your nickname.
Usage:
/callme
/callme <nickname>
/callme <@user>
/callme <@user> <nickname>`
type CallmeCommand struct {
cmdBase
}
func NewCallmeCommand() *CallmeCommand {
return &CallmeCommand{
cmdBase: cmdBase{
Name: "callme",
HelpMsg: callmeHelpMsg,
Permission: getCmdPermLevel("callme"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 3, // callme <nickname> <@id>
MinArgs: 1, // callme
},
}
}
func (cmd *CallmeCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *CallmeCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
var targetID uint64
var nickname string
var isQuery bool = true
switch args.Size {
switch len(args) {
case 1: // callme
targetID = msg.UserID
targetID = src.UserID
case 2: // callme <nickname> / callme <@id>
if args.Types[1] == qbot.At {
if strings.HasPrefix(args[1], "--at=") {
// callme <@id>
targetID = str2uin64(args.Contents[1])
targetID = str2uin64(strings.TrimPrefix(args[1], "--at="))
} else {
// callme <nickname>
targetID = msg.UserID
nickname = args.Contents[1]
targetID = src.UserID
nickname = args[1]
isQuery = false
}
case 3: // callme <@id> <nickname> 或 callme <nickname> <@id>
case 3: // callme <nickname> <@id>
isQuery = false
if args.Types[1] == qbot.At {
// callme <@id> <nickname>
targetID = str2uin64(args.Contents[1])
nickname = args.Contents[2]
} else if args.Types[2] == qbot.At {
// callme <nickname> <@id>
targetID = str2uin64(args.Contents[2])
nickname = args.Contents[1]
if userid, ok := strings.CutPrefix(args[2], "--at="); ok {
targetID = str2uin64(userid)
nickname = args[1]
} else {
return
}
default:
c.SendMsg(msg, `Usage:
- callme
- callme <nickname>
- callme <@id>
- callme <@id> <nickname>`)
c.SendMsg(src.GroupID, src.UserID, callmeHelpMsg)
return
}
@@ -49,14 +69,14 @@ func cmd_callme(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
var user qbot.Users
result := qbot.PsqlDB.Where("user_id = ?", targetID).First(&user)
if result.Error != nil || user.Nickname == "" {
c.SendMsg(msg, "")
c.SendMsg(src.GroupID, src.UserID, "")
return
}
c.SendMsg(msg, user.Nickname)
c.SendMsg(src.GroupID, src.UserID, user.Nickname)
} else {
user := qbot.Users{
UserID: targetID,
Name: msg.Nickname,
Name: nickname,
Nickname: nickname,
}
@@ -65,13 +85,13 @@ func cmd_callme(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
).FirstOrCreate(&user)
if result.Error != nil {
c.SendMsg(msg, "failed")
c.SendMsg(src.GroupID, src.UserID, "failed")
return
}
if targetID == msg.UserID {
c.SendMsg(msg, "Update nickname: "+nickname)
if targetID == src.UserID {
c.SendMsg(src.GroupID, src.UserID, "Update nickname: "+nickname)
} else {
c.SendMsg(msg, "Update nickname for "+strconv.FormatUint(targetID, 10)+": "+nickname)
c.SendMsg(src.GroupID, src.UserID, "Update nickname for ["+qbot.CQAt(targetID)+"]: "+nickname)
}
}
}

View File

@@ -6,158 +6,297 @@ import (
"github.com/google/shlex"
"go-hurobot/config"
"go-hurobot/qbot"
)
var maxCommandLength int = 0
type ArgsList struct {
Contents []string
Types []qbot.MsgType
Size int
type srcMsg struct {
MsgID uint64
UserID uint64
GroupID uint64
Card string
Role string
Time uint64
Raw string
}
type CmdHandler func(*qbot.Client, *qbot.Message, *ArgsList)
const (
atPrefix string = "--at="
replyPrefix string = "--reply="
facePrefix string = "--face="
imagePrefix string = "--image="
recordPrefix string = "--record="
filePrefix string = "--file="
forwardPrefix string = "--forward="
jsonPrefix string = "--json="
)
var cmdMap map[string]CmdHandler
type command interface {
Self() *cmdBase
Exec(c *qbot.Client, args []string, src *srcMsg, begin int)
}
type cmdBase struct {
Name string // Command name
HelpMsg string // Help message
Permission config.Permission // Permission requirement
AllowPrefix bool // Whether to allow prefixes (like @)
NeedRawMsg bool // Whether raw message is needed
MaxArgs int // Maximum number of arguments
MinArgs int // Minimum number of arguments
}
const commandPrefix = '/'
var cmdMap map[string]command
func init() {
cmdMap = map[string]CmdHandler{
"echo": cmd_echo,
"specialtitle": cmd_specialtitle,
"psql": cmd_psql,
"group": cmd_group,
"delete": cmd_delete,
"llm": cmd_llm,
"callme": cmd_callme,
"debug": cmd_debug,
"essence": cmd_essence,
"draw": cmd_draw,
"which": cmd_which,
"fx": cmd_er,
"crypto": cmd_crypto,
"event": cmd_event,
"sh": cmd_sh,
"rps": cmd_rps,
"dice": cmd_dice,
"memberinfo": cmd_memberinfo,
"info": cmd_info,
"rcon": cmd_rcon,
"mc": cmd_mc,
}
for key := range cmdMap {
if len(key) > maxCommandLength {
maxCommandLength = len(key)
}
cmdMap = map[string]command{
"callme": NewCallmeCommand(),
"config": NewConfigCommand(),
"crypto": NewCryptoCommand(),
"delete": NewDeleteCommand(),
"dice": NewDiceCommand(),
"draw": NewDrawCommand(),
"echo": NewEchoCommand(),
"essence": NewEssenceCommand(),
"fx": NewErCommand(),
"group": NewGroupCommand(),
"llm": NewLlmCommand(),
"mc": NewMcCommand(),
"memberinfo": NewMemberinfoCommand(),
"psql": NewPsqlCommand(),
"py": NewPyCommand(),
"rcon": NewRconCommand(),
"rps": NewRpsCommand(),
"sh": NewShCommand(),
"specialtitle": NewSpecialtitleCommand(),
"testapi": NewTestapiCommand(),
"which": NewWhichCommand(),
}
}
func HandleCommand(c *qbot.Client, msg *qbot.Message) bool {
skip := 0
if len(msg.Array) == 0 {
func HandleCommand(c *qbot.Client, msg *qbot.Message) bool /* is command */ {
cmdName, raw, skip := parseCmd(msg)
if skip == -1 {
return false
}
if msg.Array[0].Type == qbot.Reply {
skip++
if len(msg.Array) > 1 && msg.Array[1].Type == qbot.At {
skip++
}
} else if msg.Array[0].Type == qbot.At {
skip++
if cmdName == "" {
return false
}
var raw string
if skip != 0 {
if p := findNthClosingBracket(msg.Raw, skip); p != len(msg.Raw) {
raw = msg.Raw
msg.Raw = msg.Raw[p:]
} else {
return false
}
cmd, exists := cmdMap[cmdName]
if !exists {
return false
}
handler := findCommand(getCommandName(msg.Raw))
if handler != nil {
if args := splitArguments(msg, skip); args != nil {
handler(c, msg, args)
}
src := &srcMsg{
MsgID: msg.MsgID,
UserID: msg.UserID,
GroupID: msg.GroupID,
Card: msg.Card,
Role: msg.Role,
Time: msg.Time,
Raw: raw,
}
if raw != "" {
msg.Raw = raw
cmdBase := cmd.Self()
// check permission
if !checkCmdPermission(cmdBase.Name, src.UserID) {
c.SendMsg(src.GroupID, src.UserID, cmdBase.Name+": Permission denied")
return true
}
return handler != nil
// check if allow prefix
if skip != 0 && !cmdBase.AllowPrefix {
return true
}
// parse arguments
var args []string
if cmdBase.NeedRawMsg {
args = []string{cmdName, raw}
} else {
args = splitArguments(msg)
}
if args == nil {
return false
}
argCount := len(args) - skip
if (cmdBase.MinArgs > 0 && argCount < cmdBase.MinArgs) || (cmdBase.MaxArgs > 0 && argCount > cmdBase.MaxArgs) {
c.SendMsg(src.GroupID, src.UserID, cmdBase.HelpMsg)
return true
}
// check if is help command
if isHelpRequest(args, skip) {
c.SendMsg(src.GroupID, src.UserID, cmdBase.HelpMsg)
return true
}
// execute command
cmd.Exec(c, args, src, skip)
return true
}
func splitArguments(msg *qbot.Message, skip int) *ArgsList {
result := &ArgsList{
Contents: make([]string, 0, 20),
Types: make([]qbot.MsgType, 0, 20),
Size: 0,
// calculate the number of prefixes to skip
func parseCmd(msg *qbot.Message) (string, string, int) {
if len(msg.Array) == 0 {
return "", "", -1
}
if skip < 0 {
skip = 0
for skip := 0; skip < len(msg.Array); skip++ {
switch msg.Array[skip].Type {
case qbot.Reply, qbot.At:
continue
case qbot.Text:
content := msg.Array[skip].Content
// Skip leading spaces
offset := 0
for offset < len(content) && content[offset] == ' ' {
offset++
}
// Check if starts with command prefix
if offset >= len(content) || content[offset] != commandPrefix {
return "", "", -1
}
offset++ // skip the '/' character
// Find the skip-th ']' character in msg.Raw and extract content after it
rawStart := 0
for i := 0; i < skip; i++ {
idx := strings.Index(msg.Raw[rawStart:], "]")
if idx == -1 {
break
}
rawStart += idx + 1
}
// Skip to the command prefix in raw
for rawStart < len(msg.Raw) && msg.Raw[rawStart] != commandPrefix {
rawStart++
}
if rawStart >= len(msg.Raw) {
return "", "", -1
}
rawStart++ // skip the '/' character
raw := msg.Raw[rawStart:]
// Find command name (up to first space)
cmdIndex := strings.Index(raw, " ")
if cmdIndex == -1 {
return raw, "", skip
}
return raw[:cmdIndex], raw[cmdIndex+1:], skip
default:
return "", "", -1
}
}
return "", "", -1
}
// isHelpRequest checks if it is a help request
func isHelpRequest(args []string, skip int) bool {
if len(args) <= skip {
return false
}
firstArg := args[skip]
return firstArg == "-h" || firstArg == "-?" || firstArg == "--help"
}
func getCmdPermLevel(cmdName string) config.Permission {
cmdCfg := config.Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
return config.Master
}
return cmdCfg.GetPermissionLevel()
}
// checkCmdPermission checks if user has permission to execute specified command
// considering permission, allow_users, reject_users in command configuration
func checkCmdPermission(cmdName string, userID uint64) bool {
cmdCfg := config.Cfg.GetCmdConfig(cmdName)
// If not configured, use default permission check
if cmdCfg == nil {
return true
}
if skip >= len(msg.Array) {
return result
userPerm := config.GetUserPermission(userID)
requiredPerm := cmdCfg.GetPermissionLevel()
// Master users are not restricted by reject_users
if userPerm == config.Master {
return true
}
for _, item := range msg.Array[skip:] {
// Check reject_users (effective when command permission < master)
if requiredPerm < config.Master && cmdCfg.IsInRejectList(userID) {
return false
}
// Check allow_users (effective when command permission > guest)
if requiredPerm > config.Guest {
// If there is an allow_users list, only allow users in the list
if len(cmdCfg.AllowUsers) > 0 {
return cmdCfg.IsInAllowList(userID)
}
}
// Check basic permission
return userPerm >= requiredPerm
}
func splitArguments(msg *qbot.Message) []string {
result := make([]string, 0, 20)
firstText := true
for _, item := range msg.Array {
if item.Type == qbot.Text {
texts, err := shlex.Split(item.Content)
content := item.Content
if firstText {
content = item.Content[1:]
firstText = false
}
texts, err := shlex.Split(content)
if err != nil {
return nil
}
result.Contents = append(result.Contents, texts...)
result.Types = appendRepeatedValues(result.Types, qbot.Text, len(texts))
result.Size += len(texts)
result = append(result, texts...)
} else {
result.Contents = append(result.Contents, item.Content)
result.Types = append(result.Types, item.Type)
result.Size++
result = append(result, msgItemToArg(item))
}
}
return result
}
func findNthClosingBracket(s string, n int) int {
count := 0
for i, char := range s {
if char == ']' {
count++
if count == n {
i++
for i < len(s) && s[i] == ' ' {
i++
}
return i
}
}
func msgItemToArg(item qbot.MsgItem) string {
var prefix string
switch item.Type {
case qbot.At:
prefix = atPrefix
case qbot.Face:
prefix = facePrefix
case qbot.Image:
prefix = imagePrefix
case qbot.Record:
prefix = recordPrefix
case qbot.Reply:
prefix = replyPrefix
case qbot.File:
prefix = filePrefix
case qbot.Forward:
prefix = forwardPrefix
case qbot.Json:
prefix = jsonPrefix
default:
return item.Content
}
return 0
}
func findCommand(cmd string) CmdHandler {
if cmd == "" {
return nil
}
return cmdMap[cmd]
}
func getCommandName(s string) string {
sliced := false
if len(s) > maxCommandLength+1 {
s = s[:maxCommandLength+1]
sliced = true
}
if i := strings.IndexAny(s, " \n"); i != -1 {
return s[:i]
}
if sliced {
return ""
}
return s
return prefix + item.Content
}
func decodeSpecialChars(raw string) string {
@@ -189,12 +328,3 @@ func str2uin64(s string) uint64 {
}
return value
}
func appendRepeatedValues[T any](slice []T, value T, count int) []T {
newSlice := make([]T, len(slice)+count)
copy(newSlice, slice)
for i := len(slice); i < len(newSlice); i++ {
newSlice[i] = value
}
return newSlice
}

317
cmds/config.go Normal file
View File

@@ -0,0 +1,317 @@
package cmds
import (
"fmt"
"go-hurobot/config"
"go-hurobot/qbot"
"strconv"
"strings"
)
const configHelpMsg string = `Manage bot configuration.
Usage: config <subcommand> [args...]
Subcommands:
admin add @user... Add user(s) as admin
admin rm @user... Remove user(s) from admin
admin list List all admins
allow <cmd> add @user... Add user(s) to command allow list
allow <cmd> rm @user... Remove user(s) from command allow list
allow <cmd> list List users in command allow list
reject <cmd> add @user... Add user(s) to command reject list
reject <cmd> rm @user... Remove user(s) from command reject list
reject <cmd> list List users in command reject list
reload Reload configuration from file
Examples:
/config admin add @user1 @user2
/config admin list
/config allow sh add @user
/config reject llm list
/config reload`
type ConfigCommand struct {
cmdBase
}
func NewConfigCommand() *ConfigCommand {
return &ConfigCommand{
cmdBase: cmdBase{
Name: "config",
HelpMsg: configHelpMsg,
Permission: config.Admin,
AllowPrefix: false,
NeedRawMsg: false,
MinArgs: 2,
},
}
}
func (cmd *ConfigCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *ConfigCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
subCmd := args[1]
switch subCmd {
case "admin":
cmd.handleAdmin(c, args, src)
case "allow":
cmd.handleAllow(c, args, src)
case "reject":
cmd.handleReject(c, args, src)
case "reload":
cmd.handleReload(c, src)
default:
c.SendMsg(src.GroupID, src.UserID, "Unknown subcommand: "+subCmd)
}
}
func (cmd *ConfigCommand) handleAdmin(c *qbot.Client, args []string, src *srcMsg) {
if len(args) < 3 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config admin [add|rm|list] [@user...]")
return
}
action := args[2]
switch action {
case "add":
if len(args) < 4 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config admin add @user...")
return
}
userIDs := extractUserIDs(args[3:])
if len(userIDs) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Please mention at least one user with @")
return
}
results := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
err := config.AddAdmin(userID)
if err != nil {
results = append(results, fmt.Sprintf("❌ %d: %s", userID, err.Error()))
} else {
results = append(results, fmt.Sprintf("✅ %d: set as admin", userID))
}
}
c.SendMsg(src.GroupID, src.UserID, strings.Join(results, "\n"))
case "rm":
if len(args) < 4 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config admin rm @user...")
return
}
userIDs := extractUserIDs(args[3:])
if len(userIDs) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Please mention at least one user with @")
return
}
results := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
err := config.RemoveAdmin(userID)
if err != nil {
results = append(results, fmt.Sprintf("❌ %d: %s", userID, err.Error()))
} else {
results = append(results, fmt.Sprintf("✅ %d: removed from admin", userID))
}
}
c.SendMsg(src.GroupID, src.UserID, strings.Join(results, "\n"))
case "list":
admins := config.GetAdmins()
if len(admins) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Admin list is empty")
} else {
adminStrs := make([]string, len(admins))
for i, u := range admins {
adminStrs[i] = strconv.FormatUint(u, 10)
}
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Admins: %s", strings.Join(adminStrs, ", ")))
}
default:
c.SendMsg(src.GroupID, src.UserID, "Unknown action: "+action)
}
}
func extractUserIDs(args []string) []uint64 {
userIDs := make([]uint64, 0, len(args))
seen := make(map[uint64]bool)
for _, arg := range args {
if !strings.HasPrefix(arg, atPrefix) {
continue
}
userID := str2uin64(strings.TrimPrefix(arg, atPrefix))
if userID != 0 && !seen[userID] {
seen[userID] = true
userIDs = append(userIDs, userID)
}
}
return userIDs
}
func (cmd *ConfigCommand) handleAllow(c *qbot.Client, args []string, src *srcMsg) {
if len(args) < 3 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config allow <cmd> [add|rm|list] [@user...]")
return
}
cmdName := args[2]
if len(args) < 4 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config allow <cmd> [add|rm|list] [@user...]")
return
}
action := args[3]
switch action {
case "add":
if len(args) < 5 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config allow <cmd> add @user...")
return
}
userIDs := extractUserIDs(args[4:])
if len(userIDs) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Please mention at least one user with @")
return
}
results := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
err := config.AddAllowUser(cmdName, userID)
if err != nil {
results = append(results, fmt.Sprintf("❌ %d: %s", userID, err.Error()))
} else {
results = append(results, fmt.Sprintf("✅ %d: added to %s allow list", userID, cmdName))
}
}
c.SendMsg(src.GroupID, src.UserID, strings.Join(results, "\n"))
case "rm":
if len(args) < 5 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config allow <cmd> rm @user...")
return
}
userIDs := extractUserIDs(args[4:])
if len(userIDs) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Please mention at least one user with @")
return
}
results := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
err := config.RemoveAllowUser(cmdName, userID)
if err != nil {
results = append(results, fmt.Sprintf("❌ %d: %s", userID, err.Error()))
} else {
results = append(results, fmt.Sprintf("✅ %d: removed from %s allow list", userID, cmdName))
}
}
c.SendMsg(src.GroupID, src.UserID, strings.Join(results, "\n"))
case "list":
users, err := config.GetAllowUsers(cmdName)
if err != nil {
c.SendMsg(src.GroupID, src.UserID, "Failed to get allow list: "+err.Error())
return
}
if len(users) == 0 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Allow list for %s is empty", cmdName))
} else {
userStrs := make([]string, len(users))
for i, u := range users {
userStrs[i] = strconv.FormatUint(u, 10)
}
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Allow list for %s: %s", cmdName, strings.Join(userStrs, ", ")))
}
default:
c.SendMsg(src.GroupID, src.UserID, "Unknown action: "+action)
}
}
func (cmd *ConfigCommand) handleReject(c *qbot.Client, args []string, src *srcMsg) {
if len(args) < 3 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config reject <cmd> [add|rm|list] [@user...]")
return
}
cmdName := args[2]
if len(args) < 4 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config reject <cmd> [add|rm|list] [@user...]")
return
}
action := args[3]
switch action {
case "add":
if len(args) < 5 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config reject <cmd> add @user...")
return
}
userIDs := extractUserIDs(args[4:])
if len(userIDs) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Please mention at least one user with @")
return
}
results := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
err := config.AddRejectUser(cmdName, userID)
if err != nil {
results = append(results, fmt.Sprintf("❌ %d: %s", userID, err.Error()))
} else {
results = append(results, fmt.Sprintf("✅ %d: added to %s reject list", userID, cmdName))
}
}
c.SendMsg(src.GroupID, src.UserID, strings.Join(results, "\n"))
case "rm":
if len(args) < 5 {
c.SendMsg(src.GroupID, src.UserID, "Usage: config reject <cmd> rm @user...")
return
}
userIDs := extractUserIDs(args[4:])
if len(userIDs) == 0 {
c.SendMsg(src.GroupID, src.UserID, "Please mention at least one user with @")
return
}
results := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
err := config.RemoveRejectUser(cmdName, userID)
if err != nil {
results = append(results, fmt.Sprintf("❌ %d: %s", userID, err.Error()))
} else {
results = append(results, fmt.Sprintf("✅ %d: removed from %s reject list", userID, cmdName))
}
}
c.SendMsg(src.GroupID, src.UserID, strings.Join(results, "\n"))
case "list":
users, err := config.GetRejectUsers(cmdName)
if err != nil {
c.SendMsg(src.GroupID, src.UserID, "Failed to get reject list: "+err.Error())
return
}
if len(users) == 0 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Reject list for %s is empty", cmdName))
} else {
userStrs := make([]string, len(users))
for i, u := range users {
userStrs[i] = strconv.FormatUint(u, 10)
}
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Reject list for %s: %s", cmdName, strings.Join(userStrs, ", ")))
}
default:
c.SendMsg(src.GroupID, src.UserID, "Unknown action: "+action)
}
}
func (cmd *ConfigCommand) handleReload(c *qbot.Client, src *srcMsg) {
err := config.ReloadConfig()
if err != nil {
c.SendMsg(src.GroupID, src.UserID, "Failed to reload config: "+err.Error())
} else {
c.SendMsg(src.GroupID, src.UserID, "Configuration reloaded successfully")
}
}

View File

@@ -67,157 +67,176 @@ type TickerResp struct {
} `json:"data"`
}
func cmd_crypto(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if args.Size < 2 {
c.SendMsg(msg, "用法:\n1.crypto <币种> - 查询币种对USDT价格\n2.crypto <源币种> <目标币种> - 查询币种对目标货币价格\n例如: crypto BTC 或 crypto BTC USD")
return
}
const cryptoHelpMsg string = `Query cryptocurrency prices.
Usage:
/crypto <coin> - Query coin price in USDT
/crypto <from_coin> <to_coin> - Query coin price in target currency
Examples:
/crypto BTC
/crypto BTC USD`
if args.Size == 2 {
coin := strings.ToUpper(args.Contents[1])
handleSingleCrypto(c, msg, coin)
return
}
if args.Size == 3 {
fromCoin := strings.ToUpper(args.Contents[1])
toCurrency := strings.ToUpper(args.Contents[2])
handleCryptoCurrencyPair(c, msg, fromCoin, toCurrency)
return
}
c.SendMsg(msg, "参数数量错误")
type CryptoCommand struct {
cmdBase
}
func handleSingleCrypto(c *qbot.Client, msg *qbot.Message, coin string) {
log.Printf("查询单个加密货币: %s", coin)
func NewCryptoCommand() *CryptoCommand {
return &CryptoCommand{
cmdBase: cmdBase{
Name: "crypto",
HelpMsg: cryptoHelpMsg,
Permission: getCmdPermLevel("crypto"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 3,
MinArgs: 2,
},
}
}
func (cmd *CryptoCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *CryptoCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if len(args) == 2 {
coin := strings.ToUpper(args[1])
handleSingleCrypto(c, src, coin)
} else if len(args) == 3 {
fromCoin := strings.ToUpper(args[1])
toCurrency := strings.ToUpper(args[2])
handleCryptoCurrencyPair(c, src, fromCoin, toCurrency)
}
}
func handleSingleCrypto(c *qbot.Client, src *srcMsg, coin string) {
log.Printf("Query single cryptocurrency: %s", coin)
price, err := getCryptoPrice(coin, "USDT")
if err != nil {
log.Printf("查询%s价格失败: %v", coin, err)
c.SendMsg(msg, fmt.Sprintf("查询失败: %s", err.Error()))
log.Printf("Failed to query %s price: %v", coin, err)
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Query failed: %s", err.Error()))
return
}
c.SendMsg(msg, fmt.Sprintf("1 %s = %s USDT", coin, price))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("1 %s = %s USDT", coin, price))
}
func handleCryptoCurrencyPair(c *qbot.Client, msg *qbot.Message, fromCoin string, toCurrency string) {
log.Printf("查询加密货币对: %s -> %s", fromCoin, toCurrency)
func handleCryptoCurrencyPair(c *qbot.Client, src *srcMsg, fromCoin string, toCurrency string) {
log.Printf("Query cryptocurrency pair: %s -> %s", fromCoin, toCurrency)
usdPrice, err := getCryptoPrice(fromCoin, "USD")
if err != nil {
log.Printf("查询%s USD价格失败: %v", fromCoin, err)
c.SendMsg(msg, fmt.Sprintf("查询%s价格失败: %s", fromCoin, err.Error()))
log.Printf("Failed to query %s USD price: %v", fromCoin, err)
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Failed to query %s price: %s", fromCoin, err.Error()))
return
}
usdPriceFloat, err := strconv.ParseFloat(usdPrice, 64)
if err != nil {
log.Printf("价格解析失败: %v", err)
c.SendMsg(msg, fmt.Sprintf("价格解析失败: %s", err.Error()))
log.Printf("Price parsing failed: %v", err)
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Price parsing failed: %s", err.Error()))
return
}
if toCurrency == "USD" {
c.SendMsg(msg, fmt.Sprintf("%s 最新USD价格: %.4f", fromCoin, usdPriceFloat))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%s latest USD price: %.4f", fromCoin, usdPriceFloat))
return
}
log.Printf("需要汇率换算: USD -> %s", toCurrency)
log.Printf("Need exchange rate conversion: USD -> %s", toCurrency)
exchangeRate, err := getExchangeRate("USD", toCurrency)
if err != nil {
log.Printf("获取汇率失败: %v", err)
c.SendMsg(msg, fmt.Sprintf("获取汇率失败: %s", err.Error()))
log.Printf("Failed to get exchange rate: %v", err)
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Failed to get exchange rate: %s", err.Error()))
return
}
finalPrice := usdPriceFloat * exchangeRate
log.Printf("换算完成: %s USD价格 %.4f, 汇率 %.4f, 最终价格 %.4f %s", fromCoin, usdPriceFloat, exchangeRate, finalPrice, toCurrency)
c.SendMsg(msg, fmt.Sprintf("1 %s=%.4f %s", fromCoin, finalPrice, toCurrency))
log.Printf("Conversion complete: %s USD price %.4f, exchange rate %.4f, final price %.4f %s", fromCoin, usdPriceFloat, exchangeRate, finalPrice, toCurrency)
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("1 %s=%.4f %s", fromCoin, finalPrice, toCurrency))
}
func getCryptoPrice(coin string, quoteCurrency string) (string, error) {
instId := coin + "-" + quoteCurrency + "-SWAP"
url := "https://bot-forward.lavacreeper.net/api/v5/market/ticker?instId=" + instId
log.Printf("请求加密货币价格: %s", url)
log.Printf("Request cryptocurrency price: %s", url)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("请求创建失败: %v", err)
return "", fmt.Errorf("request creation failed: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Okx-Python-Client")
req.Header.Set("X-API-Key", config.OkxMirrorAPIKey)
req.Header.Set("X-API-Key", config.Cfg.ApiKeys.OkxMirrorAPIKey)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
return "", fmt.Errorf("request failed: %v", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Printf("关闭响应体失败: %v", err)
log.Printf("Failed to close response body: %v", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP错误: %d", resp.StatusCode)
return "", fmt.Errorf("HTTP error: %d", resp.StatusCode)
}
var ticker TickerResp
if err := json.NewDecoder(resp.Body).Decode(&ticker); err != nil {
return "", fmt.Errorf("解析失败: %v", err)
return "", fmt.Errorf("parsing failed: %v", err)
}
if ticker.Code != "0" || len(ticker.Data) == 0 {
return "", fmt.Errorf("API返回错误: %s", ticker.Msg)
return "", fmt.Errorf("API returned error: %s", ticker.Msg)
}
log.Printf("获取到价格: %s = %s", instId, ticker.Data[0].Last)
log.Printf("Got price: %s = %s", instId, ticker.Data[0].Last)
return ticker.Data[0].Last, nil
}
func getExchangeRate(baseCode string, targetCode string) (float64, error) {
if config.ExchangeRateAPIKey == "" {
return 0, fmt.Errorf("汇率API密钥未配置")
if config.Cfg.ApiKeys.ExchangeRateAPIKey == "" {
return 0, fmt.Errorf("exchange rate API key not configured")
}
url := fmt.Sprintf("https://v6.exchangerate-api.com/v6/%s/latest/%s", config.ExchangeRateAPIKey, baseCode)
url := fmt.Sprintf("https://v6.exchangerate-api.com/v6/%s/latest/%s", config.Cfg.ApiKeys.ExchangeRateAPIKey, baseCode)
log.Printf("请求汇率: %s", url)
log.Printf("Request exchange rate: %s", url)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return 0, fmt.Errorf("汇率请求失败: %v", err)
return 0, fmt.Errorf("exchange rate request failed: %v", err)
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Printf("关闭响应体失败: %v", err)
log.Printf("Failed to close response body: %v", err)
}
}(resp.Body)
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("汇率API HTTP错误: %d", resp.StatusCode)
return 0, fmt.Errorf("exchange rate API HTTP error: %d", resp.StatusCode)
}
var exchangeData ExchangeRateResponse
if err := json.NewDecoder(resp.Body).Decode(&exchangeData); err != nil {
return 0, fmt.Errorf("汇率数据解析失败: %v", err)
return 0, fmt.Errorf("exchange rate data parsing failed: %v", err)
}
if exchangeData.Result != "success" {
return 0, fmt.Errorf("汇率API返回错误: %s", exchangeData.Result)
return 0, fmt.Errorf("exchange rate API returned error: %s", exchangeData.Result)
}
rate, exists := exchangeData.ConversionRates[targetCode]
if !exists {
return 0, fmt.Errorf("不支持的货币: %s", targetCode)
return 0, fmt.Errorf("unsupported currency: %s", targetCode)
}
log.Printf("获取到汇率: 1 %s = %f %s", baseCode, rate, targetCode)
log.Printf("Got exchange rate: 1 %s = %f %s", baseCode, rate, targetCode)
return rate, nil
}

View File

@@ -1,14 +0,0 @@
package cmds
import (
"go-hurobot/config"
"go-hurobot/qbot"
"strings"
)
func cmd_debug(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if msg.UserID == config.MasterID {
c.SendMsg(msg, decodeSpecialChars(strings.Trim(msg.Raw[6:], " \n")))
}
}

View File

@@ -3,25 +3,48 @@ package cmds
import (
"go-hurobot/qbot"
"log"
"math/rand"
"strconv"
"strings"
)
func cmd_delete(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
// 禁止某个无锡人滥用删除功能只有0.6%概率允许
if msg.UserID == 3112813730 {
if rand.Float64() > 0.006 {
c.SendMsg(msg, "无锡人本次运气不佳,删除失败!建议明天再试,或者考虑搬家")
return
const deleteHelpMsg = `Delete a message by replying to it.
Usage: [Reply to a message] /delete`
type DeleteCommand struct {
cmdBase
}
func NewDeleteCommand() *DeleteCommand {
return &DeleteCommand{
cmdBase: cmdBase{
Name: "delete",
HelpMsg: deleteHelpMsg,
Permission: getCmdPermLevel("delete"),
AllowPrefix: true, // Allow prefix
NeedRawMsg: false,
MaxArgs: 1,
MinArgs: 1,
},
}
}
func (cmd *DeleteCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *DeleteCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
// Check for --reply= parameter
var replyMsgID uint64
if after, ok := strings.CutPrefix(args[0], "--reply="); ok {
if msgid, err := strconv.ParseUint(after, 10, 64); err == nil {
replyMsgID = msgid
}
}
if msg.Array[0].Type == qbot.Reply {
if msgid, err := strconv.ParseUint(msg.Array[0].Content, 10, 64); err == nil {
c.DeleteMsg(msgid)
log.Printf("delete message %d", msgid)
}
if replyMsgID != 0 {
c.DeleteMsg(replyMsgID)
log.Printf("delete message %d", replyMsgID)
} else {
c.SendMsg(msg, "请回复一条需要删除的消息,并确保 bot 有权限删除它")
c.SendMsg(src.GroupID, src.UserID, "Please reply to a message to delete it, and ensure the bot has permission to delete it")
}
}

View File

@@ -4,6 +4,30 @@ import (
"go-hurobot/qbot"
)
func cmd_dice(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
c.SendMsg(msg, qbot.CQDice())
const diceHelpMsg = "Roll a dice.\nUsage: /dice"
type DiceCommand struct {
cmdBase
}
func NewDiceCommand() *DiceCommand {
return &DiceCommand{
cmdBase: cmdBase{
Name: "dice",
HelpMsg: diceHelpMsg,
Permission: getCmdPermLevel("dice"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 1,
MinArgs: 1,
},
}
}
func (cmd *DiceCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *DiceCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
c.SendMsg(src.GroupID, src.UserID, qbot.CQDice())
}

View File

@@ -13,6 +13,11 @@ import (
"go-hurobot/qbot"
)
const drawHelpMsg string = `Generate images from text prompts.
Usage: /draw <prompt> [--size <size>]
Supported sizes: 1328x1328, 1584x1056, 1140x1472, 1664x928, 928x1664
Example: /draw a cat --size 1328x1328`
type ImageGenerationRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
@@ -31,30 +36,45 @@ type ImageGenerationResponse struct {
Seed int64 `json:"seed"`
}
func cmd_draw(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if args.Size < 2 {
helpMsg := `Usage: draw <prompt> [--size <1328x1328|1584x1056|1140x1472|1664x928|928x1664>]`
c.SendMsg(msg, helpMsg)
type DrawCommand struct {
cmdBase
}
func NewDrawCommand() *DrawCommand {
return &DrawCommand{
cmdBase: cmdBase{
Name: "draw",
HelpMsg: drawHelpMsg,
Permission: getCmdPermLevel("draw"),
AllowPrefix: false,
NeedRawMsg: false,
MinArgs: 2,
},
}
}
func (cmd *DrawCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *DrawCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if config.Cfg.ApiKeys.DrawApiKey == "" {
c.SendMsg(src.GroupID, src.UserID, "No API key")
return
}
if config.ApiKey == "" {
c.SendMsg(msg, "No API key")
return
}
prompt, imageSize, err := parseDrawArgs(args.Contents[1:])
prompt, imageSize, err := parseDrawArgs(args[1:])
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
if prompt == "" {
c.SendMsg(msg, "Please provide a prompt")
c.SendMsg(src.GroupID, src.UserID, "Please provide a prompt")
return
}
c.SendMsg(msg, "Image generating...")
c.SendMsg(src.GroupID, src.UserID, "Image generating...")
reqData := ImageGenerationRequest{
Model: "Qwen/Qwen-Image",
@@ -66,17 +86,17 @@ func cmd_draw(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
jsonData, err := json.Marshal(reqData)
if err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
req, err := http.NewRequest("POST", "https://api.siliconflow.cn/v1/images/generations", bytes.NewBuffer(jsonData))
req, err := http.NewRequest("POST", config.Cfg.ApiKeys.DrawUrlBase, bytes.NewBuffer(jsonData))
if err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
req.Header.Set("Authorization", "Bearer "+config.ApiKey)
req.Header.Set("Authorization", "Bearer "+config.Cfg.ApiKeys.DrawApiKey)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{
@@ -85,35 +105,35 @@ func cmd_draw(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
resp, err := client.Do(req)
if err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
if resp.StatusCode != 200 {
c.SendMsg(msg, fmt.Sprintf("%d\n%s", resp.StatusCode, string(body)))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%d\n%s", resp.StatusCode, string(body)))
return
}
var imgResp ImageGenerationResponse
if err := json.Unmarshal(body, &imgResp); err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
if len(imgResp.Images) == 0 {
c.SendMsg(msg, "error: 未生成任何图片")
c.SendMsg(src.GroupID, src.UserID, "error: no images generated")
return
}
imageURL := imgResp.Images[0].URL
c.SendMsg(msg, qbot.CQReply(msg.MsgID)+qbot.CQImage(imageURL))
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+qbot.CQImage(imageURL))
}
func parseDrawArgs(args []string) (prompt, imageSize string, err error) {
@@ -130,12 +150,12 @@ func parseDrawArgs(args []string) (prompt, imageSize string, err error) {
if i+1 < len(args) {
size := args[i+1]
if !isValidSize(size) {
return "", "", fmt.Errorf("不支持的图片尺寸: %s\n支持的尺寸: 1328x1328, 1584x1056, 1140x1472, 1664x928, 928x1664", size)
return "", "", fmt.Errorf("unsupported image size: %s\nSupported sizes: 1328x1328, 1584x1056, 1140x1472, 1664x928, 928x1664", size)
}
imageSize = size
i += 2
} else {
return "", "", fmt.Errorf("--size: 需要指定尺寸值")
return "", "", fmt.Errorf("--size: size value required")
}
default:
promptParts = append(promptParts, arg)

View File

@@ -5,6 +5,43 @@ import (
"strings"
)
func cmd_echo(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
c.SendMsg(msg, strings.Trim(msg.Raw[4:], " \n"))
const echoHelpMsg string = `Echoes messages to a target destination.
Usage: /echo [options] <content>
Options:
-d, -r Decode special characters
-e Encode special characters
Example: /echo "Hello, world!"`
type EchoCommand struct {
cmdBase
}
func NewEchoCommand() *EchoCommand {
return &EchoCommand{
cmdBase: cmdBase{
Name: "echo",
HelpMsg: echoHelpMsg,
Permission: getCmdPermLevel("echo"),
AllowPrefix: false,
NeedRawMsg: false,
MinArgs: 2,
},
}
}
func (cmd *EchoCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *EchoCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
if len(args) >= 3 && args[1][0] == '-' {
switch args[1] {
case "-r":
c.SendMsg(src.GroupID, src.UserID, encodeSpecialChars(src.Raw[3:]))
case "-d":
c.SendMsg(src.GroupID, src.UserID, decodeSpecialChars(args[2]))
}
} else {
c.SendMsg(src.GroupID, src.UserID, strings.Join(args[1:], " "))
}
}

View File

@@ -1,85 +0,0 @@
package cmds
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"go-hurobot/config"
"go-hurobot/qbot"
)
type ExchangeRateResponse struct {
Result string `json:"result"`
BaseCode string `json:"base_code"`
ConversionRates map[string]float64 `json:"conversion_rates"`
}
func cmd_er(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if args.Size < 3 {
c.SendMsg(msg, "用法: fx <源币种> <目标币种>\n例如: fx CNY HKD")
return
}
if config.ExchangeRateAPIKey == "" {
return
}
fromCurrency := strings.ToUpper(args.Contents[1])
toCurrency := strings.ToUpper(args.Contents[2])
url := fmt.Sprintf("https://v6.exchangerate-api.com/v6/%s/latest/%s", config.ExchangeRateAPIKey, fromCurrency)
log.Println(url)
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.SendMsg(msg, fmt.Sprintf("%d", resp.StatusCode))
return
}
var exchangeData ExchangeRateResponse
if err := json.NewDecoder(resp.Body).Decode(&exchangeData); err != nil {
c.SendMsg(msg, fmt.Sprintf("%v", err))
return
}
if exchangeData.Result != "success" {
c.SendMsg(msg, fmt.Sprintf("%v", exchangeData.Result))
return
}
toRate, exists := exchangeData.ConversionRates[toCurrency]
if !exists {
c.SendMsg(msg, fmt.Sprintf("Unsupported %s", toCurrency))
return
}
fromRate, exists := exchangeData.ConversionRates[fromCurrency]
if !exists {
c.SendMsg(msg, fmt.Sprintf("Unsupported %s", fromCurrency))
return
}
rate1to2 := toRate / fromRate
rate2to1 := fromRate / toRate
result := fmt.Sprintf("1 %s = %.4f %s\n1 %s = %.4f %s",
fromCurrency, rate1to2, toCurrency,
toCurrency, rate2to1, fromCurrency)
c.SendMsg(msg, result)
}

View File

@@ -5,32 +5,65 @@ import (
"go-hurobot/qbot"
"slices"
"strconv"
"strings"
)
func cmd_essence(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if !slices.Contains(config.BotOwnerGroupIDs, raw.GroupID) {
return
}
help := "请回复一条消息,再使用 essence [set|delete]"
if raw.Array[0].Type != qbot.Reply {
c.SendMsg(raw, help)
return
}
msgID, err := strconv.ParseUint(raw.Array[0].Content, 10, 64)
if err != nil {
return
}
if args.Size == 2 {
if args.Contents[1] == "delete" {
c.DeleteGroupEssence(msgID)
} else if args.Contents[1] == "set" {
c.SetGroupEssence(msgID)
} else {
c.SendMsg(raw, help)
}
} else if args.Size == 1 {
c.SetGroupEssence(msgID)
} else {
c.SendMsg(raw, help)
const essenceHelpMsg string = `Manage essence messages.
Usage: [Reply to a message] /essence [add|rm]`
type EssenceCommand struct {
cmdBase
}
func NewEssenceCommand() *EssenceCommand {
return &EssenceCommand{
cmdBase: cmdBase{
Name: "essence",
HelpMsg: essenceHelpMsg,
Permission: getCmdPermLevel("essence"),
AllowPrefix: false,
NeedRawMsg: false,
},
}
}
func (cmd *EssenceCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *EssenceCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
if !slices.Contains(config.Cfg.Permissions.BotOwnerGroupIDs, src.GroupID) {
return
}
// 查找 --reply= 参数
var msgID uint64
for _, arg := range args {
if strings.HasPrefix(arg, "--reply=") {
if id, err := strconv.ParseUint(strings.TrimPrefix(arg, "--reply="), 10, 64); err == nil {
msgID = id
break
}
}
}
if msgID == 0 {
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
return
}
if len(args) == 2 {
switch args[1] {
case "rm":
c.DeleteGroupEssence(msgID)
case "add":
c.SetGroupEssence(msgID)
default:
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
}
} else if len(args) == 1 {
c.SetGroupEssence(msgID)
} else {
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
}
}

View File

@@ -1,282 +0,0 @@
package cmds
import (
"fmt"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"go-hurobot/qbot"
)
func cmd_event(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
const help = `Usage: event [list | del <idx> | clear | msg=<regex> reply=<text> [user=<user_id>] [rand=<0.0-1.0>]]
Examples:
event list - 查看所有事件
event del 0 - 删除第 0 个事件
event clear - 删除所有事件
event msg=hello reply=world - 添加事件:当消息包含 "hello" 时,回复 "world"
event msg=".*test.*" reply="matched" rand=0.5 - 添加事件:当消息包含 "test" 时,回复 "matched",触发概率 50%`
if args.Size == 1 {
c.SendMsg(raw, help)
return
}
switch args.Contents[1] {
case "list":
listUserEvents(c, raw)
case "del", "delete":
if args.Size != 3 {
c.SendMsg(raw, "Usage: event del <idx>")
return
}
deleteUserEvent(c, raw, args.Contents[2])
case "clear":
clearUserEvents(c, raw)
default:
// Parse parameters for adding new event
addUserEvent(c, raw, args)
}
}
func listUserEvents(c *qbot.Client, msg *qbot.Message) {
var events []qbot.UserEvents
result := qbot.PsqlDB.Where("user_id = ?", msg.UserID).
Order("event_idx").Find(&events)
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
return
}
if len(events) == 0 {
c.SendMsg(msg, "No events found")
return
}
var output strings.Builder
output.WriteString("Your events:\n")
for _, event := range events {
output.WriteString(fmt.Sprintf("[%d] msg=%q reply=%q rand=%.2f\n",
event.EventIdx, event.MsgRegex, event.ReplyText, event.RandProb))
}
c.SendMsg(msg, output.String())
}
func deleteUserEvent(c *qbot.Client, msg *qbot.Message, idxStr string) {
idx, err := strconv.Atoi(idxStr)
if err != nil || idx < 0 || idx > 9 {
c.SendMsg(msg, "Invalid index. Must be 0-9")
return
}
result := qbot.PsqlDB.Where("user_id = ? AND event_idx = ?", msg.UserID, idx).
Delete(&qbot.UserEvents{})
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
return
}
if result.RowsAffected == 0 {
c.SendMsg(msg, "Event not found")
return
}
c.SendMsg(msg, fmt.Sprintf("Deleted event %d", idx))
}
func clearUserEvents(c *qbot.Client, msg *qbot.Message) {
result := qbot.PsqlDB.Where("user_id = ?", msg.UserID).Delete(&qbot.UserEvents{})
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
return
}
c.SendMsg(msg, fmt.Sprintf("Cleared %d events", result.RowsAffected))
}
func addUserEvent(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
// Parse key=value parameters
params := parseEventParams(args)
msgRegex, ok := params["msg"]
if !ok {
c.SendMsg(msg, "Missing required parameter: msg")
return
}
replyText, ok := params["reply"]
if !ok {
c.SendMsg(msg, "Missing required parameter: reply")
return
}
// Validate regex
if _, err := regexp.Compile(msgRegex); err != nil {
c.SendMsg(msg, "Invalid regex: "+err.Error())
return
}
// Parse optional parameters
targetUserID := msg.UserID
if userIDStr, ok := params["user"]; ok {
if uid := str2uin64(userIDStr); uid != 0 {
targetUserID = uid
} else {
c.SendMsg(msg, "Invalid user ID")
return
}
}
randProb := float32(1.0)
if randStr, ok := params["rand"]; ok {
if prob, err := strconv.ParseFloat(randStr, 32); err != nil || prob < 0 || prob > 1 {
c.SendMsg(msg, "Invalid rand value. Must be 0.0-1.0")
return
} else {
randProb = float32(prob)
}
}
// Count existing events for this user
var count int64
qbot.PsqlDB.Model(&qbot.UserEvents{}).Where("user_id = ?", targetUserID).Count(&count)
if count >= 10 {
c.SendMsg(msg, "Maximum 10 events per user")
return
}
// Find next available index
var existingIndexes []int
qbot.PsqlDB.Model(&qbot.UserEvents{}).Where("user_id = ?", targetUserID).
Pluck("event_idx", &existingIndexes)
nextIdx := 0
for nextIdx < 10 {
found := false
for _, idx := range existingIndexes {
if idx == nextIdx {
found = true
break
}
}
if !found {
break
}
nextIdx++
}
// Create new event
event := qbot.UserEvents{
UserID: targetUserID,
EventIdx: nextIdx,
MsgRegex: msgRegex,
ReplyText: decodeSpecialChars(replyText),
RandProb: randProb,
CreatedAt: time.Now(),
}
if err := qbot.PsqlDB.Create(&event).Error; err != nil {
c.SendMsg(msg, "Database error: "+err.Error())
return
}
c.SendMsg(msg, fmt.Sprintf("Added event %d: msg=%q reply=%q rand=%.2f",
nextIdx, msgRegex, replyText, randProb))
}
func parseEventParams(args *ArgsList) map[string]string {
params := make(map[string]string)
// Join all arguments starting from index 1
fullArgs := strings.Join(args.Contents[1:], " ")
// Parse key=value pairs, handling quoted values
i := 0
for i < len(fullArgs) {
// Skip whitespace
for i < len(fullArgs) && fullArgs[i] == ' ' {
i++
}
if i >= len(fullArgs) {
break
}
// Find key
keyStart := i
for i < len(fullArgs) && fullArgs[i] != '=' && fullArgs[i] != ' ' {
i++
}
if i >= len(fullArgs) || fullArgs[i] != '=' {
// Not a key=value pair, skip to next space
for i < len(fullArgs) && fullArgs[i] != ' ' {
i++
}
continue
}
key := fullArgs[keyStart:i]
i++ // skip '='
// Find value
valueStart := i
var value string
if i < len(fullArgs) && (fullArgs[i] == '"' || fullArgs[i] == '\'') {
// Quoted value
quote := fullArgs[i]
i++ // skip opening quote
valueStart = i
for i < len(fullArgs) && fullArgs[i] != quote {
i++
}
value = fullArgs[valueStart:i]
if i < len(fullArgs) {
i++ // skip closing quote
}
} else {
// Unquoted value
for i < len(fullArgs) && fullArgs[i] != ' ' {
i++
}
value = fullArgs[valueStart:i]
}
params[key] = value
}
return params
}
// CheckUserEvents checks if any user events should trigger for the given message
func CheckUserEvents(c *qbot.Client, msg *qbot.Message) bool {
var events []qbot.UserEvents
result := qbot.PsqlDB.Where("user_id = ?", msg.UserID).Find(&events)
if result.Error != nil {
return false
}
triggered := false
for _, event := range events {
// Check if regex matches
if regex, err := regexp.Compile(event.MsgRegex); err == nil {
if regex.MatchString(msg.Content) || regex.MatchString(msg.Raw) {
// Check probability
if event.RandProb >= 1.0 || rand.Float32() <= event.RandProb {
c.SendMsg(msg, event.ReplyText)
triggered = true
}
}
}
}
return triggered
}

106
cmds/fx.go Normal file
View File

@@ -0,0 +1,106 @@
package cmds
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"go-hurobot/config"
"go-hurobot/qbot"
)
type ExchangeRateResponse struct {
Result string `json:"result"`
BaseCode string `json:"base_code"`
ConversionRates map[string]float64 `json:"conversion_rates"`
}
const erHelpMsg string = `Query foreign exchange rates.
Usage: fx <from_currency> <to_currency>
Example: fx CNY HKD`
type ErCommand struct {
cmdBase
}
func NewErCommand() *ErCommand {
return &ErCommand{
cmdBase: cmdBase{
Name: "fx",
HelpMsg: erHelpMsg,
Permission: getCmdPermLevel("fx"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 3,
MinArgs: 3,
},
}
}
func (cmd *ErCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *ErCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if config.Cfg.ApiKeys.ExchangeRateAPIKey == "" {
return
}
fromCurrency := strings.ToUpper(args[1])
toCurrency := strings.ToUpper(args[2])
url := fmt.Sprintf("https://v6.exchangerate-api.com/v6/%s/latest/%s", config.Cfg.ApiKeys.ExchangeRateAPIKey, fromCurrency)
log.Println(url)
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%d", resp.StatusCode))
return
}
var exchangeData ExchangeRateResponse
if err := json.NewDecoder(resp.Body).Decode(&exchangeData); err != nil {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", err))
return
}
if exchangeData.Result != "success" {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%v", exchangeData.Result))
return
}
toRate, exists := exchangeData.ConversionRates[toCurrency]
if !exists {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Unsupported %s", toCurrency))
return
}
fromRate, exists := exchangeData.ConversionRates[fromCurrency]
if !exists {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Unsupported %s", fromCurrency))
return
}
rate1to2 := toRate / fromRate
rate2to1 := fromRate / toRate
result := fmt.Sprintf("1 %s = %.4f %s\n1 %s = %.4f %s",
fromCurrency, rate1to2, toCurrency,
toCurrency, rate2to1, fromCurrency)
c.SendMsg(src.GroupID, src.UserID, result)
}

View File

@@ -9,63 +9,65 @@ import (
"strings"
)
func cmd_group(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if !slices.Contains(config.BotOwnerGroupIDs, raw.GroupID) {
return
}
const help = "Usage: group [rename <group name> | op [@user1 @user2 ...] | deop [@user1 @user2 ...] | banme <time> | ban @user <time>]"
if args.Size == 1 {
c.SendMsg(raw, help)
return
}
switch args.Contents[1] {
case "rename":
if args.Size < 3 {
c.SendMsg(raw, help)
} else {
newName := decodeSpecialChars(strings.Join(args.Contents[2:], " "))
c.SendMsg(raw, fmt.Sprintf("rename: %q", newName))
c.SetGroupName(raw.GroupID, newName)
}
case "op":
setGroupAdmin(c, raw, args, true)
case "deop":
setGroupAdmin(c, raw, args, false)
case "banme":
if args.Size != 3 {
c.SendMsg(raw, help)
} else {
time, err := strconv.Atoi(args.Contents[2])
if err != nil || time < 1 || time > 24*60*30 {
c.SendMsg(raw, "Invalid time duration")
return
}
c.SetGroupBan(raw.GroupID, raw.UserID, time*60)
}
case "ban":
if args.Size != 4 {
c.SendMsg(raw, help)
} else if args.Types[2] == qbot.At {
time, err := strconv.Atoi(args.Contents[3])
if err != nil || time < 1 || time > 24*60*30 {
c.SendMsg(raw, "Invalid time duration")
return
}
if raw.UserID == 3112813730 {
c.SetGroupBan(raw.GroupID, raw.UserID, time*60)
} else {
c.SetGroupBan(raw.GroupID, str2uin64(args.Contents[2]), time*60)
}
} else {
c.SendMsg(raw, "Invalid user")
}
const groupHelpMsg string = `Manage group settings.
Usage: group [rename <name> | op [@users...] | deop [@users...] | banme <minutes> | ban @user <minutes>]
Examples:
/group rename awa
/group op @user1 @user2`
type GroupCommand struct {
cmdBase
}
func NewGroupCommand() *GroupCommand {
return &GroupCommand{
cmdBase: cmdBase{
Name: "group",
HelpMsg: groupHelpMsg,
Permission: getCmdPermLevel("group"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 4,
MinArgs: 2,
},
}
}
func setGroupAdmin(c *qbot.Client, raw *qbot.Message, args *ArgsList, isOp bool) {
targetUserIDs, err := extractTargetUsers(args, 2, raw.UserID)
func (cmd *GroupCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *GroupCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if !slices.Contains(config.Cfg.Permissions.BotOwnerGroupIDs, src.GroupID) {
return
}
if len(args) == 1 {
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
return
}
switch args[1] {
case "rename":
newName := decodeSpecialChars(strings.Join(args[2:], " "))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("rename: %q", newName))
c.SetGroupName(src.GroupID, newName)
case "op":
setGroupAdmin(c, src, args, true)
case "deop":
setGroupAdmin(c, src, args, false)
case "ban":
time, err := strconv.Atoi(args[3])
if err != nil || time < 1 || time > 24*60*30 {
c.SendMsg(src.GroupID, src.UserID, "Invalid time duration")
return
}
c.SetGroupBan(src.GroupID, str2uin64(strings.TrimPrefix(args[2], "--at=")), time*60)
}
}
func setGroupAdmin(c *qbot.Client, src *srcMsg, args []string, isOp bool) {
targetUserIDs, err := extractTargetUsers(args, 2, src.UserID)
if err != nil {
c.SendMsg(raw, "Invalid argument: "+err.Error())
c.SendMsg(src.GroupID, src.UserID, "Invalid argument: "+err.Error())
return
}
@@ -78,8 +80,8 @@ func setGroupAdmin(c *qbot.Client, raw *qbot.Message, args *ArgsList, isOp bool)
}
for _, userID := range targetUserIDs {
if userID == config.BotID {
c.SendMsg(raw, fmt.Sprintf("Cannot %s bot", action))
if userID == config.Cfg.Permissions.BotID {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Cannot %s bot", action))
continue
}
if !userIDSet[userID] {
@@ -96,28 +98,28 @@ func setGroupAdmin(c *qbot.Client, raw *qbot.Message, args *ArgsList, isOp bool)
}
for _, userID := range validUserIDs {
c.SetGroupAdmin(raw.GroupID, userID, isOp)
c.SetGroupAdmin(src.GroupID, userID, isOp)
}
if len(validUserIDs) == 1 {
c.SendMsg(raw, fmt.Sprintf("%s: %d", action, validUserIDs[0]))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%s: %d", action, validUserIDs[0]))
} else {
userIDStrings := make([]string, len(validUserIDs))
for i, id := range validUserIDs {
userIDStrings[i] = strconv.FormatUint(id, 10)
}
c.SendMsg(raw, fmt.Sprintf("%s: %s", action, strings.Join(userIDStrings, ", ")))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("%s: %s", action, strings.Join(userIDStrings, ", ")))
}
}
func extractTargetUsers(args *ArgsList, startIndex int, defaultUserID uint64) ([]uint64, error) {
func extractTargetUsers(args []string, startIndex int, defaultUserID uint64) ([]uint64, error) {
var targetUserIDs []uint64
hasAtUsers := false
for i := startIndex; i < args.Size; i++ {
if args.Types[i] == qbot.At {
for i := startIndex; i < len(args); i++ {
if strings.HasPrefix(args[i], "--at=") {
hasAtUsers = true
targetUserIDs = append(targetUserIDs, str2uin64(args.Contents[i]))
targetUserIDs = append(targetUserIDs, str2uin64(strings.TrimPrefix(args[i], "--at=")))
} else {
return nil, fmt.Errorf("use @ to mention users")
}

View File

@@ -1,19 +0,0 @@
package cmds
import (
"fmt"
"go-hurobot/qbot"
"os/exec"
"strings"
)
func cmd_info(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
cmd := exec.Command("top", "-l", "1", "-n", "0")
output, err := cmd.Output()
if err != nil {
c.SendReplyMsg(msg, fmt.Sprintf("Failed to get system info: %v", err))
return
}
c.SendReplyMsg(msg, strings.TrimSpace(string(output)))
}

View File

@@ -8,9 +8,39 @@ import (
"go-hurobot/qbot"
)
func cmd_llm(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if args.Size < 2 {
c.SendMsg(msg, "Usage:\nllm prompt [新提示词]\nllm max-history [能看见的历史消息数]\nllm enable/disable\nllm status\nllm model [模型]\nllm supplier [API供应商]")
const llmHelpMsg string = `Configure LLM settings.
Usage:
/llm prompt [new_prompt] - Set or view system prompt
/llm max-history [number] - Set or view max history messages
/llm enable|disable - Enable or disable LLM
/llm status - View current settings
/llm model [model_name] - Set or view model
/llm supplier [supplier_name] - Set or view API supplier`
type LlmCommand struct {
cmdBase
}
func NewLlmCommand() *LlmCommand {
return &LlmCommand{
cmdBase: cmdBase{
Name: "llm",
HelpMsg: llmHelpMsg,
Permission: getCmdPermLevel("llm"),
AllowPrefix: false,
NeedRawMsg: false,
MinArgs: 2,
},
}
}
func (cmd *LlmCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *LlmCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if len(args) < 2 {
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
return
}
@@ -24,7 +54,7 @@ func cmd_llm(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
}
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
First(&llmConfig).Error
if err != nil {
@@ -44,7 +74,7 @@ func cmd_llm(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
Model: "deepseek-ai/DeepSeek-V3",
}
qbot.PsqlDB.Table("group_llm_configs").Create(map[string]any{
"group_id": msg.GroupID,
"group_id": src.GroupID,
"prompt": llmConfig.Prompt,
"max_history": llmConfig.MaxHistory,
"enabled": llmConfig.Enabled,
@@ -54,67 +84,67 @@ func cmd_llm(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
})
}
switch args.Contents[1] {
switch args[1] {
case "prompt":
if args.Size == 2 {
c.SendMsg(msg, fmt.Sprintf("prompt: %s", llmConfig.Prompt))
if len(args) == 2 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("prompt: %s", llmConfig.Prompt))
} else {
newPrompt := strings.Join(args.Contents[2:], " ")
newPrompt := strings.Join(args[2:], " ")
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("prompt", newPrompt).Error
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
} else {
c.SendMsg(msg, "prompt updated")
c.SendMsg(src.GroupID, src.UserID, "prompt updated")
}
}
case "max-history":
if args.Size == 2 {
c.SendMsg(msg, fmt.Sprintf("max-history: %d", llmConfig.MaxHistory))
if len(args) == 2 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("max-history: %d", llmConfig.MaxHistory))
} else {
maxHistory, err := strconv.Atoi(args.Contents[2])
maxHistory, err := strconv.Atoi(args[2])
if err != nil {
c.SendMsg(msg, "Enter a valid number")
c.SendMsg(src.GroupID, src.UserID, "Enter a valid number")
return
}
if maxHistory < 0 {
c.SendMsg(msg, "max-history cannot be negative")
c.SendMsg(src.GroupID, src.UserID, "max-history cannot be negative")
return
}
if maxHistory > 300 {
c.SendMsg(msg, "max-history cannot exceed 300")
c.SendMsg(src.GroupID, src.UserID, "max-history cannot exceed 300")
return
}
err = qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("max_history", maxHistory).Error
if err != nil {
c.SendMsg(msg, "Failed: "+err.Error())
c.SendMsg(src.GroupID, src.UserID, "Failed: "+err.Error())
} else {
c.SendMsg(msg, "max-history updated")
c.SendMsg(src.GroupID, src.UserID, "max-history updated")
}
}
case "enable":
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("enabled", true).Error
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
} else {
c.SendMsg(msg, "Enabled LLM")
c.SendMsg(src.GroupID, src.UserID, "Enabled LLM")
}
case "disable":
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("enabled", false).Error
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
} else {
c.SendMsg(msg, "Disabled LLM")
c.SendMsg(src.GroupID, src.UserID, "Disabled LLM")
}
case "status":
@@ -125,75 +155,75 @@ func cmd_llm(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
llmConfig.Model,
llmConfig.Prompt,
)
c.SendMsg(msg, status)
c.SendMsg(src.GroupID, src.UserID, status)
case "tokens":
var user qbot.Users
if args.Size == 2 {
err := qbot.PsqlDB.Where("user_id = ?", msg.UserID).First(&user).Error
if len(args) == 2 {
err := qbot.PsqlDB.Where("user_id = ?", src.UserID).First(&user).Error
if err != nil {
c.SendMsg(msg, "Failed to get token usage")
c.SendMsg(src.GroupID, src.UserID, "Failed to get token usage")
return
}
c.SendMsg(msg, fmt.Sprintf("Token usage: %d", user.TokenUsage))
} else if args.Size == 3 && args.Types[2] == qbot.At {
targetID := str2uin64(args.Contents[2])
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Token usage: %d", user.TokenUsage))
} else if len(args) == 3 && strings.HasPrefix(args[2], "--at=") {
targetID := str2uin64(strings.TrimPrefix(args[2], "--at="))
err := qbot.PsqlDB.Where("user_id = ?", targetID).First(&user).Error
if err != nil {
c.SendMsg(msg, "Failed to get token usage")
c.SendMsg(src.GroupID, src.UserID, "Failed to get token usage")
return
}
c.SendMsg(msg, fmt.Sprintf("Token usage for %s: %d", args.Contents[2], user.TokenUsage))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Token usage for %s: %d", args[2], user.TokenUsage))
} else {
c.SendMsg(msg, "Usage:\nllm tokens\nllm tokens @user")
c.SendMsg(src.GroupID, src.UserID, "Usage:\nllm tokens\nllm tokens @user")
}
case "debug":
if args.Size == 2 {
c.SendMsg(msg, fmt.Sprintf("debug: %v", llmConfig.Debug))
if len(args) == 2 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("debug: %v", llmConfig.Debug))
} else {
debugValue := strings.ToLower(args.Contents[2])
debugValue := strings.ToLower(args[2])
if debugValue != "on" && debugValue != "off" {
return
}
newDebug := debugValue == "on"
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("debug", newDebug).Error
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
} else {
c.SendMsg(msg, fmt.Sprintf("debug = %v", newDebug))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("debug = %v", newDebug))
}
}
case "model":
if args.Size == 2 {
c.SendMsg(msg, fmt.Sprintf("model: %s", llmConfig.Model))
if len(args) == 2 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("model: %s", llmConfig.Model))
} else {
newModel := args.Contents[2]
newModel := args[2]
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("model", newModel).Error
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
} else {
c.SendMsg(msg, fmt.Sprintf("model updated to %s", newModel))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("model updated to %s", newModel))
}
}
case "supplier":
if args.Size == 2 {
c.SendMsg(msg, fmt.Sprintf("supplier: %s", llmConfig.Supplier))
if len(args) == 2 {
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("supplier: %s", llmConfig.Supplier))
} else {
newSupplier := args.Contents[2]
newSupplier := args[2]
var exists int64
qbot.PsqlDB.Table("suppliers").
Where("name = ?", newSupplier).
Count(&exists)
if exists == 0 {
c.SendMsg(msg, fmt.Sprintf("unknown supplier: %s", newSupplier))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("unknown supplier: %s", newSupplier))
return
}
@@ -207,25 +237,25 @@ func cmd_llm(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
// Update supplier
err := qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("supplier", newSupplier).Error
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
// Auto-switch model to supplier default if provided
if strings.TrimSpace(sup.DefaultModel) != "" {
_ = qbot.PsqlDB.Table("group_llm_configs").
Where("group_id = ?", msg.GroupID).
Where("group_id = ?", src.GroupID).
Update("model", sup.DefaultModel).Error
c.SendMsg(msg, fmt.Sprintf("supplier updated to %s, model -> %s", newSupplier, sup.DefaultModel))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("supplier updated to %s, model -> %s", newSupplier, sup.DefaultModel))
} else {
c.SendMsg(msg, fmt.Sprintf("supplier updated to %s", newSupplier))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("supplier updated to %s", newSupplier))
}
}
default:
c.SendMsg(msg, fmt.Sprintf("Unrecognized parameter >>%s<<", args.Contents[1]))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("Unrecognized parameter >>%s<<", args[1]))
}
}

View File

@@ -12,15 +12,35 @@ import (
"gorm.io/gorm"
)
func cmd_mc(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if args.Size < 2 {
c.SendMsg(raw, "Usage: mc <command>")
return
}
const mcHelpMsg string = `Execute Minecraft RCON commands.
Usage: /mc <command>
Example: /mc list`
type McCommand struct {
cmdBase
}
func NewMcCommand() *McCommand {
return &McCommand{
cmdBase: cmdBase{
Name: "mc",
HelpMsg: mcHelpMsg,
Permission: getCmdPermLevel("mc"),
AllowPrefix: false,
NeedRawMsg: false,
MinArgs: 2,
},
}
}
func (cmd *McCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *McCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
// Get RCON configuration for this group
var rconConfig qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", raw.GroupID).First(&rconConfig)
result := qbot.PsqlDB.Where("group_id = ?", src.GroupID).First(&rconConfig)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
@@ -32,36 +52,36 @@ func cmd_mc(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
}
if !rconConfig.Enabled {
c.SendMsg(raw, "RCON is disabled for this group")
c.SendMsg(src.GroupID, src.UserID, "RCON is disabled for this group")
return
}
// Join all arguments after 'mc' as the command
command := strings.Join(args.Contents[1:], " ")
command := strings.Join(args[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.")
if src.UserID != config.Cfg.Permissions.MasterID && !isAllowedCommand(command) {
c.SendMsg(src.GroupID, src.UserID, "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()))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("RCON error: %s", err.Error()))
return
}
// Send response back (limit to avoid spam)
if len(response) > 1000 {
response = response[:1000] + "... (truncated)"
if len(response) > 2048 {
response = response[:2048] + "... (truncated)"
}
if response == "" {
response = "No output"
}
c.SendMsg(raw, qbot.CQReply(raw.UserID)+response)
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+response)
}
func executeRconCommand(address, password, command string) (string, error) {

View File

@@ -3,42 +3,69 @@ package cmds
import (
"fmt"
"go-hurobot/qbot"
"strings"
"time"
)
func cmd_memberinfo(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
// 只能在群聊中使用
if msg.GroupID == 0 {
const memberinfoHelpMsg string = `Query group member information.
Usage: /memberinfo [@user]
Example: /memberinfo @user`
type MemberinfoCommand struct {
cmdBase
}
func NewMemberinfoCommand() *MemberinfoCommand {
return &MemberinfoCommand{
cmdBase: cmdBase{
Name: "memberinfo",
HelpMsg: memberinfoHelpMsg,
Permission: getCmdPermLevel("memberinfo"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 2,
MinArgs: 2,
},
}
}
func (cmd *MemberinfoCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *MemberinfoCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
// Only available in group chats
if src.GroupID == 0 {
return
}
var targetUserID uint64
if args.Size >= 2 && args.Types[1] == qbot.At {
targetUserID = str2uin64(args.Contents[1])
if len(args) >= 2 && strings.HasPrefix(args[1], "--at=") {
targetUserID = str2uin64(strings.TrimPrefix(args[1], "--at="))
} else {
targetUserID = msg.UserID
targetUserID = src.UserID
}
if targetUserID == 0 {
c.SendReplyMsg(msg, "Invalid user ID")
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+"Invalid user ID")
return
}
// 获取群成员信息
memberInfo, err := c.GetGroupMemberInfo(msg.GroupID, targetUserID, false)
// Get group member information
memberInfo, err := c.GetGroupMemberInfo(src.GroupID, targetUserID, false)
if err != nil {
c.SendReplyMsg(msg, fmt.Sprintf("Failed to get member info: %v", err))
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+fmt.Sprintf("Failed to get member info: %v", err))
return
}
response := fmt.Sprintf(
"QQ: %d\n"+
"昵称: %s\n"+
"名片: %s\n"+
"性别: %s\n"+
"权限: %s\n"+
"等级: Lv %s",
"QQ: %d\n"+
"Nickname: %s\n"+
"Card: %s\n"+
"Gender: %s\n"+
"Role: %s\n"+
"Level: Lv %s",
memberInfo.UserID,
memberInfo.Nickname,
getCardOrNickname(memberInfo.Card, memberInfo.Nickname),
@@ -47,30 +74,30 @@ func cmd_memberinfo(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
memberInfo.Level)
if memberInfo.Age > 0 {
response += fmt.Sprintf("\n年龄: %d", memberInfo.Age)
response += fmt.Sprintf("\nAge: %d", memberInfo.Age)
}
if memberInfo.Area != "" {
response += fmt.Sprintf("\n地区: %s", memberInfo.Area)
response += fmt.Sprintf("\nArea: %s", memberInfo.Area)
}
if memberInfo.Title != "" {
response += fmt.Sprintf("\n头衔: %s", memberInfo.Title)
response += fmt.Sprintf("\nTitle: %s", memberInfo.Title)
}
if memberInfo.ShutUpTimestamp > 0 {
shutUpTime := time.Unix(memberInfo.ShutUpTimestamp, 0)
if shutUpTime.After(time.Now()) {
response += fmt.Sprintf("\n禁言到期: %s", shutUpTime.Format("2006-01-02 15:04:05"))
response += fmt.Sprintf("\nMuted until: %s", shutUpTime.Format("2006-01-02 15:04:05"))
}
}
if memberInfo.JoinTime > 0 {
joinTime := time.Unix(int64(memberInfo.JoinTime), 0)
response += fmt.Sprintf("\n加群时间: %s", joinTime.Format("2006-01-02 15:04:05"))
response += fmt.Sprintf("\nJoined: %s", joinTime.Format("2006-01-02 15:04:05"))
}
c.SendReplyMsg(msg, response)
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+response)
}
func getCardOrNickname(card, nickname string) string {
@@ -94,11 +121,11 @@ func getSexString(sex string) string {
func getRoleString(role string) string {
switch role {
case "owner":
return "👑群主"
return "Owner"
case "admin":
return "管理员"
return "Admin"
case "member":
return "🐱成员"
return "Member"
default:
return role
}

View File

@@ -3,27 +3,47 @@ package cmds
import (
"database/sql"
"fmt"
"go-hurobot/config"
"go-hurobot/qbot"
"strings"
)
func cmd_psql(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if raw.UserID != config.MasterID {
c.SendMsg(raw, fmt.Sprintf("%s: Permission denied", args.Contents[0]))
return
}
const psqlHelpMsg = `Execute PostgreSQL queries.
Usage: /psql <query>
Example: /psql SELECT * FROM users LIMIT 10`
rows, err := qbot.PsqlDB.Raw(decodeSpecialChars(raw.Raw[5:])).Rows()
type PsqlCommand struct {
cmdBase
}
func NewPsqlCommand() *PsqlCommand {
return &PsqlCommand{
cmdBase: cmdBase{
Name: "psql",
HelpMsg: psqlHelpMsg,
Permission: getCmdPermLevel("psql"),
AllowPrefix: false,
NeedRawMsg: true, // uses raw message
MinArgs: 2,
},
}
}
func (cmd *PsqlCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *PsqlCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
query := args[len(args)-1]
rows, err := qbot.PsqlDB.Raw(decodeSpecialChars(query)).Rows()
if err != nil {
c.SendMsg(raw, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
c.SendMsg(raw, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
@@ -41,7 +61,7 @@ func cmd_psql(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
}
if err := rows.Scan(values...); err != nil {
c.SendMsg(raw, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
@@ -58,10 +78,10 @@ func cmd_psql(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
count++
}
if err = rows.Err(); err != nil {
c.SendMsg(raw, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
} else if result == "" {
c.SendMsg(raw, "[]")
c.SendMsg(src.GroupID, src.UserID, "[]")
} else {
c.SendMsg(raw, result)
c.SendMsg(src.GroupID, src.UserID, encodeSpecialChars(result))
}
}

79
cmds/py.go Normal file
View File

@@ -0,0 +1,79 @@
package cmds
import (
"fmt"
"go-hurobot/config"
"go-hurobot/qbot"
"log"
"os/exec"
"strings"
"time"
)
const pyHelpMsg string = `Execute Python code.
Usage: /py <python_code>
Example: /py print("Hello, World!")`
type PyCommand struct {
cmdBase
}
func NewPyCommand() *PyCommand {
return &PyCommand{
cmdBase: cmdBase{
Name: "py",
HelpMsg: pyHelpMsg,
Permission: getCmdPermLevel("py"),
AllowPrefix: false,
NeedRawMsg: true,
MinArgs: 2,
},
}
}
func (cmd *PyCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *PyCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if len(args) <= 1 {
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+cmd.HelpMsg)
return
}
// get interpreter path
interpreter := config.Cfg.Python.Interpreter
if interpreter == "" {
interpreter = "python3"
}
pythonCode := decodeSpecialChars(src.Raw)
pythonCode = strings.TrimSpace(pythonCode)
pythonCmd := exec.Command(interpreter, "-c", pythonCode)
done := make(chan error, 1)
var output []byte
go func() {
var err error
output, err = pythonCmd.CombinedOutput()
log.Printf("run python command: %s, output: %s, error: %v",
pythonCode, string(output), err)
done <- err
}()
select {
case err := <-done:
if err == nil {
// success
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+truncateString(string(output)))
} else {
// failed
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+fmt.Sprintf("%v\n%s", err, truncateString(string(output))))
}
case <-time.After(300 * time.Second):
pythonCmd.Process.Kill()
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+fmt.Sprintf("Timeout: %q", pythonCode))
}
}

View File

@@ -1,40 +0,0 @@
package cmds
import (
"bytes"
"encoding/json"
"fmt"
"go-hurobot/qbot"
)
func cmd_rawmsg(c *qbot.Client, raw *qbot.Message, args *ArgsList) {
if args.Size >= 2 && (args.Contents[1] == "-f" || args.Contents[1] == "--format") {
if args.Size >= 3 {
switch args.Contents[2] {
case "json": // default
case "%v":
fallthrough
case "%+v":
fallthrough
case "%#v":
c.SendReplyMsg(raw, fmt.Sprintf(args.Contents[2], raw))
return
default:
c.SendReplyMsg(raw, fmt.Sprintf("Unknown format %q", args.Contents[2]))
return
}
} else {
c.SendReplyMsg(raw, fmt.Sprintf("Usage: %s [-f|--format format]", args.Contents[0]))
return
}
}
jsonStr, _ := json.Marshal(raw)
jsonBytes := []byte(jsonStr)
var out bytes.Buffer
err := json.Indent(&out, jsonBytes, "", " ")
if err != nil {
c.SendReplyMsg(raw, string(jsonStr))
} else {
c.SendReplyMsg(raw, out.String())
}
}

View File

@@ -5,52 +5,64 @@ import (
"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]
const rconHelpMsg string = `Manage RCON configuration.
Usage: /rcon [status | set <address> <password> | enable | disable]
Examples:
rcon status
rcon set '127.0.0.1:25575' 'password'
rcon enable
rcon disable`
/rcon status
/rcon set 127.0.0.1:25575 password
/rcon enable
/rcon disable`
if args.Size == 1 {
c.SendMsg(raw, help)
return
}
type RconCommand struct {
cmdBase
}
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 NewRconCommand() *RconCommand {
return &RconCommand{
cmdBase: cmdBase{
Name: "rcon",
HelpMsg: rconHelpMsg,
Permission: getCmdPermLevel("rcon"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 4,
MinArgs: 2,
},
}
}
func showRconStatus(c *qbot.Client, msg *qbot.Message) {
func (cmd *RconCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *RconCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
switch args[1] {
case "status":
showRconStatus(c, src)
case "set":
if len(args) != 4 {
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
return
}
setRconConfig(c, src, args[2], args[3])
case "enable":
toggleRcon(c, src, true)
case "disable":
toggleRcon(c, src, false)
default:
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
}
}
func showRconStatus(c *qbot.Client, src *srcMsg) {
var config qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).First(&config)
result := qbot.PsqlDB.Where("group_id = ?", src.GroupID).First(&config)
if result.Error != nil {
c.SendMsg(msg, "RCON not configured for this group")
c.SendMsg(src.GroupID, src.UserID, "RCON not configured for this group")
return
}
@@ -64,37 +76,37 @@ func showRconStatus(c *qbot.Client, msg *qbot.Message) {
response := fmt.Sprintf("RCON Status: %s\nAddress: %s\nPassword: %s",
status, config.Address, maskedPassword)
c.SendMsg(msg, response)
c.SendMsg(src.GroupID, src.UserID, response)
}
func setRconConfig(c *qbot.Client, msg *qbot.Message, address, password string) {
func setRconConfig(c *qbot.Client, src *srcMsg, 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)")
c.SendMsg(src.GroupID, src.UserID, "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")
c.SendMsg(src.GroupID, src.UserID, "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")
c.SendMsg(src.GroupID, src.UserID, "Invalid port number")
return
}
config := qbot.GroupRconConfigs{
GroupID: msg.GroupID,
GroupID: src.GroupID,
Address: address,
Password: password,
Enabled: false, // Default to disabled for security
Enabled: true, // Default to disabled for security
}
// Use Upsert to create or update
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).Assign(
result := qbot.PsqlDB.Where("group_id = ?", src.GroupID).Assign(
qbot.GroupRconConfigs{
Address: address,
Password: password,
@@ -102,28 +114,28 @@ func setRconConfig(c *qbot.Client, msg *qbot.Message, address, password string)
).FirstOrCreate(&config)
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
c.SendMsg(src.GroupID, src.UserID, "Database error: "+result.Error.Error())
return
}
c.SendMsg(msg, fmt.Sprintf("RCON configuration updated:\nAddress: %s\nStatus: disabled (use 'rcon enable' to enable)", address))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("RCON configuration updated: %s:%s", address, password))
}
func toggleRcon(c *qbot.Client, msg *qbot.Message, enabled bool) {
func toggleRcon(c *qbot.Client, src *srcMsg, enabled bool) {
// Check if configuration exists
var config qbot.GroupRconConfigs
result := qbot.PsqlDB.Where("group_id = ?", msg.GroupID).First(&config)
result := qbot.PsqlDB.Where("group_id = ?", src.GroupID).First(&config)
if result.Error != nil {
c.SendMsg(msg, "RCON not configured for this group. Use 'rcon set' first.")
c.SendMsg(src.GroupID, src.UserID, "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)
result = qbot.PsqlDB.Model(&config).Where("group_id = ?", src.GroupID).Update("enabled", enabled)
if result.Error != nil {
c.SendMsg(msg, "Database error: "+result.Error.Error())
c.SendMsg(src.GroupID, src.UserID, "Database error: "+result.Error.Error())
return
}
@@ -131,5 +143,5 @@ func toggleRcon(c *qbot.Client, msg *qbot.Message, enabled bool) {
if enabled {
status = "enabled"
}
c.SendMsg(msg, fmt.Sprintf("RCON %s for this group", status))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("RCON %s for this group", status))
}

View File

@@ -4,6 +4,31 @@ import (
"go-hurobot/qbot"
)
func cmd_rps(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
c.SendMsg(msg, qbot.CQRps())
const rpsHelpMsg string = `Play rock-paper-scissors.
Usage: /rps`
type RpsCommand struct {
cmdBase
}
func NewRpsCommand() *RpsCommand {
return &RpsCommand{
cmdBase: cmdBase{
Name: "rps",
HelpMsg: rpsHelpMsg,
Permission: getCmdPermLevel("rps"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 1,
MinArgs: 1,
},
}
}
func (cmd *RpsCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *RpsCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
c.SendMsg(src.GroupID, src.UserID, qbot.CQRps())
}

View File

@@ -2,7 +2,6 @@ package cmds
import (
"fmt"
"go-hurobot/config"
"go-hurobot/qbot"
"log"
"os/exec"
@@ -10,14 +9,18 @@ import (
"time"
)
const shHelpMsg string = `Execute shell commands.
Usage: /sh <command>
Example: /sh ls -la`
var workingDir string = "/tmp"
func truncateString(s string) string {
s = encodeSpecialChars(s)
const (
maxLines = 10
maxChars = 500
truncateMsg = "\n输出过长已自动截断"
maxLines = 20
maxChars = 1024
truncateMsg = "... (truncated)"
)
lineCount := strings.Count(s, "\n") + 1
@@ -40,42 +43,59 @@ func truncateString(s string) string {
return s
}
func cmd_sh(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if msg.UserID != config.MasterID {
type ShCommand struct {
cmdBase
}
func NewShCommand() *ShCommand {
return &ShCommand{
cmdBase: cmdBase{
Name: "sh",
HelpMsg: shHelpMsg,
Permission: getCmdPermLevel("sh"),
AllowPrefix: false,
NeedRawMsg: true,
MinArgs: 2,
},
}
}
func (cmd *ShCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *ShCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
if len(args) <= 1 {
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+cmd.HelpMsg)
return
}
if args.Size <= 1 {
c.SendReplyMsg(msg, "Usage: sh <command>")
return
}
rawcmd := decodeSpecialChars(src.Raw)
rawcmd := decodeSpecialChars(msg.Raw[3:])
if strings.HasPrefix(args.Contents[1], "cd") {
if strings.HasPrefix(args[1], "cd") {
absPath, err := exec.Command("bash", "-c",
fmt.Sprintf("cd %s && %s && pwd", workingDir, rawcmd)).CombinedOutput()
if err != nil {
c.SendReplyMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+err.Error())
return
}
workingDir = strings.TrimSpace(string(absPath))
c.SendReplyMsg(msg, workingDir)
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+workingDir)
return
}
cmd := exec.Command("bash", "-c", fmt.Sprintf("cd %s && %s", workingDir, rawcmd))
shellCmd := exec.Command("bash", "-c", fmt.Sprintf("cd %s && %s", workingDir, rawcmd))
done := make(chan error, 1)
var output []byte
go func() {
var err error
output, err = cmd.CombinedOutput()
output, err = shellCmd.CombinedOutput()
log.Printf("run command: %s, output: %s, error: %v",
strings.Join(args.Contents[1:], " "), string(output), err)
strings.Join(args[1:], " "), string(output), err)
done <- err
}()
@@ -83,13 +103,13 @@ func cmd_sh(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
case err := <-done:
if err == nil {
// success
c.SendReplyMsg(msg, truncateString(string(output)))
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+truncateString(string(output)))
} else {
// failed
c.SendReplyMsg(msg, fmt.Sprintf("%v\n%s", err, truncateString(string(output))))
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+fmt.Sprintf("%v\n%s", err, truncateString(string(output))))
}
case <-time.After(300 * time.Second):
cmd.Process.Kill()
c.SendReplyMsg(msg, fmt.Sprintf("Timeout: %q", rawcmd))
shellCmd.Process.Kill()
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+fmt.Sprintf("Timeout: %q", rawcmd))
}
}

View File

@@ -5,25 +5,61 @@ import (
"go-hurobot/qbot"
"slices"
"strconv"
"strings"
)
func cmd_specialtitle(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
if !slices.Contains(config.BotOwnerGroupIDs, msg.GroupID) {
const specialtitleHelpMsg string = `Set special title for group members.
Usage: /specialtitle [@user] <title>
Example: /specialtitle @user VIP`
type SpecialtitleCommand struct {
cmdBase
}
func NewSpecialtitleCommand() *SpecialtitleCommand {
return &SpecialtitleCommand{
cmdBase: cmdBase{
Name: "specialtitle",
HelpMsg: specialtitleHelpMsg,
Permission: getCmdPermLevel("specialtitle"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 3,
MinArgs: 2,
},
}
}
func (cmd *SpecialtitleCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *SpecialtitleCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
if !slices.Contains(config.Cfg.Permissions.BotOwnerGroupIDs, src.GroupID) {
return
}
if args.Size == 1 {
c.SendReplyMsg(msg, "Usage: specialtitle <specialtitle>")
} else if len(msg.Array) > 1 && msg.Array[1].Type != qbot.At {
c.SendReplyMsg(msg, "群头衔一定是一个文本!")
} else if length := len([]byte(args.Contents[1])); length > 18 {
c.SendReplyMsg(msg, "头衔长度不允许超过 18 字节,当前 "+strconv.FormatInt(int64(length), 10)+" 字节")
// check if the second parameter is @
var targetUserID uint64
var titleIdx int
if len(args) > 1 && strings.HasPrefix(args[1], "--at=") {
targetUserID = str2uin64(strings.TrimPrefix(args[1], "--at="))
titleIdx = 2
} else {
if len(msg.Array) > 1 {
id := str2uin64(msg.Array[1].Content)
c.SetGroupSpecialTitle(msg.GroupID, id, decodeSpecialChars(args.Contents[1]))
return
}
c.SetGroupSpecialTitle(msg.GroupID, msg.UserID, args.Contents[1])
targetUserID = src.UserID
titleIdx = 1
}
if titleIdx >= len(args) {
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+cmd.HelpMsg)
return
}
title := args[titleIdx]
if length := len([]byte(title)); length > 18 {
c.SendMsg(src.GroupID, src.UserID, qbot.CQReply(src.MsgID)+"Title length not allowed to exceed 18 bytes, currently "+strconv.FormatInt(int64(length), 10)+" bytes")
return
}
c.SetGroupSpecialTitle(src.GroupID, targetUserID, decodeSpecialChars(title))
}

104
cmds/testapi.go Normal file
View File

@@ -0,0 +1,104 @@
package cmds
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"go-hurobot/qbot"
)
const testapiHelpMsg string = `Test API functionality.
Usage: /testapi <action> [arg1="value1" arg2=value2 ...]
Example: /testapi get_login_info`
type TestapiCommand struct {
cmdBase
}
func NewTestapiCommand() *TestapiCommand {
return &TestapiCommand{
cmdBase: cmdBase{
Name: "testapi",
HelpMsg: testapiHelpMsg,
Permission: getCmdPermLevel("testapi"),
AllowPrefix: false,
NeedRawMsg: false,
},
}
}
func (cmd *TestapiCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *TestapiCommand) Exec(c *qbot.Client, args []string, src *srcMsg, _ int) {
if len(args) < 2 {
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
return
}
action := args[1]
// parse parameters
params := make(map[string]any)
for i := 2; i < len(args); i++ {
arg := args[i]
// find equal sign separator
if idx := strings.Index(arg, "="); idx != -1 {
key := arg[:idx]
value := arg[idx+1:]
// handle $group variable replacement
if value == "$group" {
params[key] = src.GroupID
continue
}
// check if it is a string type (surrounded by quotes)
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
// string type, remove quotes
params[key] = value[1 : len(value)-1]
} else {
// try to parse as numeric type
if intVal, err := strconv.ParseInt(value, 10, 64); err == nil {
params[key] = intVal
} else if floatVal, err := strconv.ParseFloat(value, 64); err == nil {
params[key] = floatVal
} else if boolVal, err := strconv.ParseBool(value); err == nil {
params[key] = boolVal
} else {
// if all parsing fails, treat as string
params[key] = value
}
}
}
}
// call test API
resp, err := c.SendTestAPIRequest(action, params)
var result string
if err != nil {
result = fmt.Sprintf("Error: %v", err)
} else if resp == "" {
result = "null"
} else {
// directly use the returned JSON string, and format it
var jsonMap map[string]interface{}
if err := json.Unmarshal([]byte(resp), &jsonMap); err == nil {
if jsonBytes, err := json.MarshalIndent(jsonMap, "", " "); err == nil {
result = string(jsonBytes)
} else {
result = resp // if formatting fails, return the original string
}
} else {
result = resp // if parsing fails, return the original string
}
}
c.SendMsg(src.GroupID, src.UserID, encodeSpecialChars(result))
}

View File

@@ -22,28 +22,49 @@ type NbnhhshResponse []struct {
Inputting []string `json:"inputting,omitempty"`
}
func cmd_which(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
const helpMsg = `Usage: which <text>`
if args.Size < 2 {
c.SendMsg(msg, helpMsg)
return
}
const whichHelpMsg string = `Query abbreviation meanings.
Usage: /which <text>
Example: /which yyds`
for i := 1; i < len(args.Types); i++ {
if args.Types[i] != qbot.Text {
c.SendMsg(msg, "Only plain text is allowed")
type WhichCommand struct {
cmdBase
}
func NewWhichCommand() *WhichCommand {
return &WhichCommand{
cmdBase: cmdBase{
Name: "which",
HelpMsg: whichHelpMsg,
Permission: getCmdPermLevel("which"),
AllowPrefix: false,
NeedRawMsg: false,
MaxArgs: 2,
MinArgs: 2,
},
}
}
func (cmd *WhichCommand) Self() *cmdBase {
return &cmd.cmdBase
}
func (cmd *WhichCommand) Exec(c *qbot.Client, args []string, src *srcMsg, begin int) {
// Check for non-text type parameters
for i := 1; i < len(args); i++ {
if strings.HasPrefix(args[i], "--") {
c.SendMsg(src.GroupID, src.UserID, "Only plain text is allowed")
return
}
}
text := strings.Join(args.Contents[1:], " ")
text := strings.Join(args[1:], " ")
if text == "" {
c.SendMsg(msg, helpMsg)
c.SendMsg(src.GroupID, src.UserID, cmd.HelpMsg)
return
}
if strings.Contains(text, ";") {
c.SendMsg(msg, "Multiple queries are not allowed")
c.SendMsg(src.GroupID, src.UserID, "Multiple queries are not allowed")
return
}
@@ -53,13 +74,13 @@ func cmd_which(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
jsonData, err := json.Marshal(reqData)
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
req, err := http.NewRequest("POST", "https://lab.magiconch.com/api/nbnhhsh/guess", bytes.NewBuffer(jsonData))
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
@@ -71,39 +92,39 @@ func cmd_which(c *qbot.Client, msg *qbot.Message, args *ArgsList) {
resp, err := client.Do(req)
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
if resp.StatusCode != 200 {
c.SendMsg(msg, fmt.Sprintf("http error %d", resp.StatusCode))
c.SendMsg(src.GroupID, src.UserID, fmt.Sprintf("http error %d", resp.StatusCode))
return
}
var nbnhhshResp NbnhhshResponse
if err := json.Unmarshal(body, &nbnhhshResp); err != nil {
c.SendMsg(msg, err.Error())
c.SendMsg(src.GroupID, src.UserID, err.Error())
return
}
if len(nbnhhshResp) == 0 {
c.SendMsg(msg, "null")
c.SendMsg(src.GroupID, src.UserID, "null")
return
}
result := nbnhhshResp[0]
if len(result.Trans) > 0 {
c.SendMsg(msg, strings.Join(result.Trans, ", "))
c.SendMsg(src.GroupID, src.UserID, strings.Join(result.Trans, ", "))
return
}
c.SendMsg(msg, "null")
c.SendMsg(src.GroupID, src.UserID, "null")
}

View File

@@ -1,100 +1,347 @@
package config
import (
"flag"
"fmt"
"log"
"os"
"strconv"
"slices"
"gopkg.in/yaml.v3"
)
var (
// export
ApiKey string
NapcatWSURL string
MasterID uint64
BotID uint64
// Config 配置结构体
type yamlConfig struct {
// NapCat 配置
NapcatHttpServer string `yaml:"napcat_http_server"` // 正向 HTTP 地址
ReverseHttpServer string `yaml:"reverse_http_server"` // 反向 HTTP 监听端口
PsqlHost string
PsqlPort uint16
PsqlUser string
PsqlPassword string
PsqlDbName string
// API Keys 配置
ApiKeys struct {
DrawUrlBase string `yaml:"draw_url_base"`
DrawApiKey string `yaml:"draw_api_key"`
ExchangeRateAPIKey string `yaml:"exchange_rate_api_key"`
OkxMirrorAPIKey string `yaml:"okx_mirror_api_key"`
Longport struct {
AppKey string `yaml:"app_key"`
AppSecret string `yaml:"app_secret"`
AccessToken string `yaml:"access_token"`
Region string `yaml:"region"`
EnableOvernight bool `yaml:"enable_overnight"`
} `yaml:"longport"`
} `yaml:"api_keys"`
ExchangeRateAPIKey string
OkxMirrorAPIKey string
)
// PostgreSQL 配置
PostgreSQL struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
User string `yaml:"user"`
Password string `yaml:"password"`
DbName string `yaml:"dbname"`
} `yaml:"postgresql"`
// Python 配置
Python struct {
Interpreter string `yaml:"interpreter"`
} `yaml:"python"`
// 权限配置
Permissions struct {
MasterID uint64 `yaml:"master_id"`
BotID uint64 `yaml:"bot_id"`
AdminIDs []uint64 `yaml:"admin_ids,omitempty"`
BotOwnerGroupIDs []uint64 `yaml:"bot_owner_group_ids,omitempty"`
Cmds []CmdConfig `yaml:"cmds,omitempty"`
} `yaml:"permissions"`
// 其他配置
ProxyURL string `yaml:"proxy_url,omitempty"`
}
// CmdConfig 命令配置结构
type CmdConfig struct {
Name string `yaml:"name"` // 命令名称
Permission string `yaml:"permission,omitempty"` // 权限级别: guest, admin, master默认为 master
AllowUsers []uint64 `yaml:"allow_users,omitempty"` // 允许执行的用户列表
RejectUsers []uint64 `yaml:"reject_users,omitempty"` // 拒绝执行的用户列表
}
type Permission int
const (
// environment values
env_NAPCAT_HOST = "NAPCAT_HOST"
env_ACCESS_TOKEN = "ACCESS_TOKEN"
env_API_KEY = "API_KEY"
env_MASTER_ID = "MASTER_ID"
env_BOT_ID = "BOT_ID"
env_PSQL_HOST = "PSQL_HOST"
env_PSQL_PORT = "PSQL_PORT"
env_PSQL_USER = "PSQL_USER"
env_PSQL_PASSWORD = "PSQL_PASSWORD"
env_PSQL_DBNAME = "PSQL_DBNAME"
env_EXCHANGE_RATE_API_KEY = "EXCHANGE_RATE_API_KEY"
env_OKX_MIRROR_API_KEY = "OKX_MIRROR_API_KEY"
Guest Permission = 0 // 所有人都可以使用
Admin Permission = 3 // 管理员及以上
Master Permission = 4 // 仅 Master
)
var BotOwnerGroupIDs = []uint64{
948697448,
866738031,
}
var Cfg yamlConfig
func getEnvString(env string, def string) string {
val := os.Getenv(env)
if val == "" {
return def
}
return val
}
func getEnvUInt(env string, def uint64) uint64 {
val := os.Getenv(env)
if val == "" {
return def
}
ret, err := strconv.ParseUint(val, 10, 64)
func LoadConfig(configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
log.Fatalf("Parse %s=%s failed: %s", env, val, err.Error())
return fmt.Errorf("读取配置文件失败: %w", err)
}
return ret
if err := yaml.Unmarshal(data, &Cfg); err != nil {
return fmt.Errorf("解析配置文件失败: %w", err)
}
// NapCat 默认值
if Cfg.NapcatHttpServer == "" {
Cfg.NapcatHttpServer = "http://127.0.0.1:3000"
}
if Cfg.ReverseHttpServer == "" {
Cfg.ReverseHttpServer = "0.0.0.0:3001"
}
// 权限默认值
if Cfg.Permissions.MasterID == 0 {
Cfg.Permissions.MasterID = 1006554341
}
if Cfg.Permissions.BotID == 0 {
Cfg.Permissions.BotID = 3552586437
}
// PostgreSQL 默认值
if Cfg.PostgreSQL.Host == "" {
Cfg.PostgreSQL.Host = "127.0.0.1"
}
if Cfg.PostgreSQL.Port == 0 {
Cfg.PostgreSQL.Port = 5432
}
// Longport 默认值
if Cfg.ApiKeys.Longport.Region == "" {
Cfg.ApiKeys.Longport.Region = "cn"
}
// Python 默认值
if Cfg.Python.Interpreter == "" {
Cfg.Python.Interpreter = "python3"
}
return nil
}
func getEnvPort(env string, def uint16) uint16 {
val := os.Getenv(env)
if val == "" {
return def
func LoadConfigFile() {
configPathPtr := flag.String("c", "config.yaml", "配置文件路径")
flag.Parse()
configPath = *configPathPtr
if err := LoadConfig(configPath); err != nil {
log.Fatalf("加载配置失败: %v", err)
}
ret, err := strconv.ParseUint(val, 10, 16)
log.Printf("配置加载成功: %s", configPath)
}
// GetCmdConfig 获取指定命令的配置
func (cfg *yamlConfig) GetCmdConfig(cmdName string) *CmdConfig {
for i := range cfg.Permissions.Cmds {
if cfg.Permissions.Cmds[i].Name == cmdName {
return &cfg.Permissions.Cmds[i]
}
}
return nil
}
func GetUserPermission(userID uint64) Permission {
if userID == Cfg.Permissions.MasterID {
return Master
}
if Cfg.IsAdmin(userID) {
return Admin
}
return Guest
}
// IsAdmin 检查用户是否是管理员
func (cfg *yamlConfig) IsAdmin(userID uint64) bool {
if userID == cfg.Permissions.MasterID {
return true
}
return slices.Contains(cfg.Permissions.AdminIDs, userID)
}
// IsInAllowList 检查用户是否在命令的允许列表中
func (c *CmdConfig) IsInAllowList(userID uint64) bool {
if len(c.AllowUsers) == 0 {
return false
}
for _, id := range c.AllowUsers {
if id == userID {
return true
}
}
return false
}
// IsInRejectList 检查用户是否在命令的拒绝列表中
func (c *CmdConfig) IsInRejectList(userID uint64) bool {
if len(c.RejectUsers) == 0 {
return false
}
for _, id := range c.RejectUsers {
if id == userID {
return true
}
}
return false
}
// GetPermissionLevel 获取权限级别(返回数值便于比较)
func (c *CmdConfig) GetPermissionLevel() Permission {
switch c.Permission {
case "guest":
return 0
case "admin":
return 3
case "master":
return 4
default:
return 4 // 默认为 master
}
}
func AddAdmin(userID uint64) error {
if userID == Cfg.Permissions.MasterID {
return fmt.Errorf("user is already master")
}
if slices.Contains(Cfg.Permissions.AdminIDs, userID) {
return fmt.Errorf("user is already admin")
}
Cfg.Permissions.AdminIDs = append(Cfg.Permissions.AdminIDs, userID)
return SaveConfig()
}
func RemoveAdmin(userID uint64) error {
if userID == Cfg.Permissions.MasterID {
return fmt.Errorf("cannot remove master")
}
idx := -1
for i, id := range Cfg.Permissions.AdminIDs {
if id == userID {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("user is not admin")
}
Cfg.Permissions.AdminIDs = append(Cfg.Permissions.AdminIDs[:idx], Cfg.Permissions.AdminIDs[idx+1:]...)
return SaveConfig()
}
func GetAdmins() []uint64 {
return Cfg.Permissions.AdminIDs
}
func AddAllowUser(cmdName string, userID uint64) error {
cmdCfg := Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
cmdCfg = &CmdConfig{Name: cmdName}
Cfg.Permissions.Cmds = append(Cfg.Permissions.Cmds, *cmdCfg)
cmdCfg = &Cfg.Permissions.Cmds[len(Cfg.Permissions.Cmds)-1]
}
if slices.Contains(cmdCfg.AllowUsers, userID) {
return fmt.Errorf("user already in allow list")
}
cmdCfg.AllowUsers = append(cmdCfg.AllowUsers, userID)
return SaveConfig()
}
func RemoveAllowUser(cmdName string, userID uint64) error {
cmdCfg := Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
return fmt.Errorf("command config not found")
}
idx := -1
for i, id := range cmdCfg.AllowUsers {
if id == userID {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("user not in allow list")
}
cmdCfg.AllowUsers = append(cmdCfg.AllowUsers[:idx], cmdCfg.AllowUsers[idx+1:]...)
return SaveConfig()
}
func GetAllowUsers(cmdName string) ([]uint64, error) {
cmdCfg := Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
return []uint64{}, nil
}
return cmdCfg.AllowUsers, nil
}
func AddRejectUser(cmdName string, userID uint64) error {
cmdCfg := Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
cmdCfg = &CmdConfig{Name: cmdName}
Cfg.Permissions.Cmds = append(Cfg.Permissions.Cmds, *cmdCfg)
cmdCfg = &Cfg.Permissions.Cmds[len(Cfg.Permissions.Cmds)-1]
}
if slices.Contains(cmdCfg.RejectUsers, userID) {
return fmt.Errorf("user already in reject list")
}
cmdCfg.RejectUsers = append(cmdCfg.RejectUsers, userID)
return SaveConfig()
}
func RemoveRejectUser(cmdName string, userID uint64) error {
cmdCfg := Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
return fmt.Errorf("command config not found")
}
idx := -1
for i, id := range cmdCfg.RejectUsers {
if id == userID {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("user not in reject list")
}
cmdCfg.RejectUsers = append(cmdCfg.RejectUsers[:idx], cmdCfg.RejectUsers[idx+1:]...)
return SaveConfig()
}
func GetRejectUsers(cmdName string) ([]uint64, error) {
cmdCfg := Cfg.GetCmdConfig(cmdName)
if cmdCfg == nil {
return []uint64{}, nil
}
return cmdCfg.RejectUsers, nil
}
var configPath string
func SetConfigPath(path string) {
configPath = path
}
func SaveConfig() error {
if configPath == "" {
configPath = "config.yaml"
}
data, err := yaml.Marshal(&Cfg)
if err != nil {
log.Fatalf("Parse port %s=%s failed: %s", env, val, err.Error())
return fmt.Errorf("marshal config failed: %w", err)
}
return uint16(ret)
err = os.WriteFile(configPath, data, 0644)
if err != nil {
return fmt.Errorf("write config file failed: %w", err)
}
return nil
}
func init() {
napcatHost := getEnvString(env_NAPCAT_HOST, "127.0.0.1:3001")
accessToken := os.Getenv(env_ACCESS_TOKEN)
ApiKey = os.Getenv(env_API_KEY)
NapcatWSURL = "ws://" + napcatHost
if accessToken != "" {
NapcatWSURL += "?access_token=" + accessToken
func ReloadConfig() error {
if configPath == "" {
configPath = "config.yaml"
}
MasterID = getEnvUInt(env_MASTER_ID, 1006554341)
BotID = getEnvUInt(env_BOT_ID, 3552586437)
PsqlHost = getEnvString(env_PSQL_HOST, "127.0.0.1")
PsqlPort = getEnvPort(env_PSQL_PORT, 5432)
PsqlUser = os.Getenv(env_PSQL_USER)
PsqlPassword = os.Getenv(env_PSQL_PASSWORD)
PsqlDbName = os.Getenv(env_PSQL_DBNAME)
ExchangeRateAPIKey = os.Getenv(env_EXCHANGE_RATE_API_KEY)
OkxMirrorAPIKey = os.Getenv(env_OKX_MIRROR_API_KEY)
return LoadConfig(configPath)
}

View File

@@ -1,17 +1,38 @@
version: '3.8'
version: "3"
services:
napcat:
environment:
- NAPCAT_UID=501
- NAPCAT_GID=20
- ACCOUNT=3552586437
volumes:
- ./napcat-app:/app
ports:
- 3000:3000
- 3001:3001
- 6099:6099
container_name: napcat
restart: on-failure
image: mlikiowa/napcat-docker:latest
networks:
- qbot
hurobot-psql:
image: postgres:latest
container_name: hurobot-psql
hostname: hurobot-psql
environment:
POSTGRES_USER: hurobot
POSTGRES_PASSWORD: hurobot
POSTGRES_PASSWORD: hurobot114514qwq
POSTGRES_DB: hurobot
volumes:
- ./psql-init:/docker-entrypoint-initdb.d
- ./psql-data:/var/lib/postgresql/data
ports:
- "5432:5432"
- "54321:5432"
restart: on-failure
networks:
- qbot
networks:
qbot:
driver: bridge

View File

@@ -43,19 +43,6 @@ CREATE TABLE group_llm_configs (
CREATE INDEX idx_messages_covering ON messages("group_id", "is_cmd", "time" DESC, "user_id", "content", "msg_id");
CREATE TABLE user_events (
"user_id" BIGINT NOT NULL,
"event_idx" INTEGER NOT NULL,
"msg_regex" TEXT NOT NULL,
"reply_text" TEXT NOT NULL,
"rand_prob" REAL NOT NULL DEFAULT 1.0,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY ("user_id", "event_idx"),
FOREIGN KEY ("user_id") REFERENCES users(user_id),
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,
@@ -66,11 +53,3 @@ CREATE TABLE group_rcon_configs (
INSERT INTO suppliers ("name", "base_url", "api_key", "default_model") VALUES
('siliconflow', 'https://api.siliconflow.cn/v1', '', 'deepseek-ai/DeepSeek-V3.1');
CREATE TABLE legacy_game (
"user_id" BIGINT NOT NULL,
"energy" INT NOT NULL DEFAULT 0,
"balance" INT NOT NULL DEFAULT 0,
PRIMARY KEY ("user_id"),
FOREIGN KEY ("user_id") REFERENCES users(user_id)
)

View File

@@ -1,590 +0,0 @@
CREATE TABLE tts_voices (
"name" TEXT NOT NULL PRIMARY KEY,
"local" TEXT NOT NULL
);
INSERT INTO tts_voices VALUES ('af-ZA-AdriNeural', 'af-ZA');
INSERT INTO tts_voices VALUES ('af-ZA-WillemNeural', 'af-ZA');
INSERT INTO tts_voices VALUES ('am-ET-MekdesNeural', 'am-ET');
INSERT INTO tts_voices VALUES ('am-ET-AmehaNeural', 'am-ET');
INSERT INTO tts_voices VALUES ('ar-AE-FatimaNeural', 'ar-AE');
INSERT INTO tts_voices VALUES ('ar-AE-HamdanNeural', 'ar-AE');
INSERT INTO tts_voices VALUES ('ar-BH-LailaNeural', 'ar-BH');
INSERT INTO tts_voices VALUES ('ar-BH-AliNeural', 'ar-BH');
INSERT INTO tts_voices VALUES ('ar-DZ-AminaNeural', 'ar-DZ');
INSERT INTO tts_voices VALUES ('ar-DZ-IsmaelNeural', 'ar-DZ');
INSERT INTO tts_voices VALUES ('ar-EG-SalmaNeural', 'ar-EG');
INSERT INTO tts_voices VALUES ('ar-EG-ShakirNeural', 'ar-EG');
INSERT INTO tts_voices VALUES ('ar-IQ-RanaNeural', 'ar-IQ');
INSERT INTO tts_voices VALUES ('ar-IQ-BasselNeural', 'ar-IQ');
INSERT INTO tts_voices VALUES ('ar-JO-SanaNeural', 'ar-JO');
INSERT INTO tts_voices VALUES ('ar-JO-TaimNeural', 'ar-JO');
INSERT INTO tts_voices VALUES ('ar-KW-NouraNeural', 'ar-KW');
INSERT INTO tts_voices VALUES ('ar-KW-FahedNeural', 'ar-KW');
INSERT INTO tts_voices VALUES ('ar-LB-LaylaNeural', 'ar-LB');
INSERT INTO tts_voices VALUES ('ar-LB-RamiNeural', 'ar-LB');
INSERT INTO tts_voices VALUES ('ar-LY-ImanNeural', 'ar-LY');
INSERT INTO tts_voices VALUES ('ar-LY-OmarNeural', 'ar-LY');
INSERT INTO tts_voices VALUES ('ar-MA-MounaNeural', 'ar-MA');
INSERT INTO tts_voices VALUES ('ar-MA-JamalNeural', 'ar-MA');
INSERT INTO tts_voices VALUES ('ar-OM-AyshaNeural', 'ar-OM');
INSERT INTO tts_voices VALUES ('ar-OM-AbdullahNeural', 'ar-OM');
INSERT INTO tts_voices VALUES ('ar-QA-AmalNeural', 'ar-QA');
INSERT INTO tts_voices VALUES ('ar-QA-MoazNeural', 'ar-QA');
INSERT INTO tts_voices VALUES ('ar-SA-ZariyahNeural', 'ar-SA');
INSERT INTO tts_voices VALUES ('ar-SA-HamedNeural', 'ar-SA');
INSERT INTO tts_voices VALUES ('ar-SY-AmanyNeural', 'ar-SY');
INSERT INTO tts_voices VALUES ('ar-SY-LaithNeural', 'ar-SY');
INSERT INTO tts_voices VALUES ('ar-TN-ReemNeural', 'ar-TN');
INSERT INTO tts_voices VALUES ('ar-TN-HediNeural', 'ar-TN');
INSERT INTO tts_voices VALUES ('ar-YE-MaryamNeural', 'ar-YE');
INSERT INTO tts_voices VALUES ('ar-YE-SalehNeural', 'ar-YE');
INSERT INTO tts_voices VALUES ('as-IN-YashicaNeural', 'as-IN');
INSERT INTO tts_voices VALUES ('as-IN-PriyomNeural', 'as-IN');
INSERT INTO tts_voices VALUES ('az-AZ-BanuNeural', 'az-AZ');
INSERT INTO tts_voices VALUES ('az-AZ-BabekNeural', 'az-AZ');
INSERT INTO tts_voices VALUES ('bg-BG-KalinaNeural', 'bg-BG');
INSERT INTO tts_voices VALUES ('bg-BG-BorislavNeural', 'bg-BG');
INSERT INTO tts_voices VALUES ('bn-BD-NabanitaNeural', 'bn-BD');
INSERT INTO tts_voices VALUES ('bn-BD-PradeepNeural', 'bn-BD');
INSERT INTO tts_voices VALUES ('bn-IN-TanishaaNeural', 'bn-IN');
INSERT INTO tts_voices VALUES ('bn-IN-BashkarNeural', 'bn-IN');
INSERT INTO tts_voices VALUES ('bs-BA-VesnaNeural', 'bs-BA');
INSERT INTO tts_voices VALUES ('bs-BA-GoranNeural', 'bs-BA');
INSERT INTO tts_voices VALUES ('ca-ES-JoanaNeural', 'ca-ES');
INSERT INTO tts_voices VALUES ('ca-ES-EnricNeural', 'ca-ES');
INSERT INTO tts_voices VALUES ('ca-ES-AlbaNeural', 'ca-ES');
INSERT INTO tts_voices VALUES ('cs-CZ-VlastaNeural', 'cs-CZ');
INSERT INTO tts_voices VALUES ('cs-CZ-AntoninNeural', 'cs-CZ');
INSERT INTO tts_voices VALUES ('cy-GB-NiaNeural', 'cy-GB');
INSERT INTO tts_voices VALUES ('cy-GB-AledNeural', 'cy-GB');
INSERT INTO tts_voices VALUES ('da-DK-ChristelNeural', 'da-DK');
INSERT INTO tts_voices VALUES ('da-DK-JeppeNeural', 'da-DK');
INSERT INTO tts_voices VALUES ('de-AT-IngridNeural', 'de-AT');
INSERT INTO tts_voices VALUES ('de-AT-JonasNeural', 'de-AT');
INSERT INTO tts_voices VALUES ('de-CH-LeniNeural', 'de-CH');
INSERT INTO tts_voices VALUES ('de-CH-JanNeural', 'de-CH');
INSERT INTO tts_voices VALUES ('de-DE-KatjaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-ConradNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-SeraphinaMultilingualNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-FlorianMultilingualNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-AmalaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-BerndNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-ChristophNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-ElkeNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-GiselaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-KasperNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-KillianNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-KlarissaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-KlausNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-LouisaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-MajaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-RalfNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-TanjaNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-Florian:DragonHDLatestNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('de-DE-Seraphina:DragonHDLatestNeural', 'de-DE');
INSERT INTO tts_voices VALUES ('el-GR-AthinaNeural', 'el-GR');
INSERT INTO tts_voices VALUES ('el-GR-NestorasNeural', 'el-GR');
INSERT INTO tts_voices VALUES ('en-AU-NatashaNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-WilliamNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-AnnetteNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-CarlyNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-DarrenNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-DuncanNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-ElsieNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-FreyaNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-JoanneNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-KenNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-KimNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-NeilNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-TimNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-AU-TinaNeural', 'en-AU');
INSERT INTO tts_voices VALUES ('en-CA-ClaraNeural', 'en-CA');
INSERT INTO tts_voices VALUES ('en-CA-LiamNeural', 'en-CA');
INSERT INTO tts_voices VALUES ('en-GB-SoniaNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-RyanNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-LibbyNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-AdaMultilingualNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-OllieMultilingualNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-AbbiNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-AlfieNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-BellaNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-ElliotNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-EthanNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-HollieNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-MaisieNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-NoahNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-OliverNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-OliviaNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-ThomasNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-GB-MiaNeural', 'en-GB');
INSERT INTO tts_voices VALUES ('en-HK-YanNeural', 'en-HK');
INSERT INTO tts_voices VALUES ('en-HK-SamNeural', 'en-HK');
INSERT INTO tts_voices VALUES ('en-IE-EmilyNeural', 'en-IE');
INSERT INTO tts_voices VALUES ('en-IE-ConnorNeural', 'en-IE');
INSERT INTO tts_voices VALUES ('en-IN-AaravNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-AashiNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-AartiNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-ArjunNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-AnanyaNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-KavyaNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-KunalNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-NeerjaNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-PrabhatNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-IN-RehaanNeural', 'en-IN');
INSERT INTO tts_voices VALUES ('en-KE-AsiliaNeural', 'en-KE');
INSERT INTO tts_voices VALUES ('en-KE-ChilembaNeural', 'en-KE');
INSERT INTO tts_voices VALUES ('en-NG-EzinneNeural', 'en-NG');
INSERT INTO tts_voices VALUES ('en-NG-AbeoNeural', 'en-NG');
INSERT INTO tts_voices VALUES ('en-NZ-MollyNeural', 'en-NZ');
INSERT INTO tts_voices VALUES ('en-NZ-MitchellNeural', 'en-NZ');
INSERT INTO tts_voices VALUES ('en-PH-RosaNeural', 'en-PH');
INSERT INTO tts_voices VALUES ('en-PH-JamesNeural', 'en-PH');
INSERT INTO tts_voices VALUES ('en-SG-LunaNeural', 'en-SG');
INSERT INTO tts_voices VALUES ('en-SG-WayneNeural', 'en-SG');
INSERT INTO tts_voices VALUES ('en-TZ-ImaniNeural', 'en-TZ');
INSERT INTO tts_voices VALUES ('en-TZ-ElimuNeural', 'en-TZ');
INSERT INTO tts_voices VALUES ('en-US-AvaMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AndrewMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-EmmaMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AlloyTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-EchoTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-FableTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-OnyxTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-NovaTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-ShimmerTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-BrianMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AvaNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AndrewNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-EmmaNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-BrianNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-JennyNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-GuyNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AriaNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-DavisNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-JaneNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-JasonNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-KaiNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-LunaNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-SaraNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-TonyNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-NancyNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-CoraMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-ChristopherMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-BrandonMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AmberNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AnaNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AshleyNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-BrandonNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-ChristopherNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-CoraNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-ElizabethNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-EricNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-JacobNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-JennyMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-MichelleNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-MonicaNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-RogerNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-RyanMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-SteffanNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AdamMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AIGenerate1Neural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AIGenerate2Neural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AmandaMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-AshTurboMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-BlueNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-DavisMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-DerekMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-DustinMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-EvelynMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-LewisMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-LolaMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-NancyMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-PhoebeMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-SamuelMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-SerenaMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-SteffanMultilingualNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Adam:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Andrew:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Andrew2:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Ava:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Brian:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Davis:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Emma:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Emma2:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Steffan:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Alloy:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Andrew3:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Aria:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Ava3:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Jenny:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-MultiTalker-Ava-Andrew:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Nova:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Phoebe:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-US-Serena:DragonHDLatestNeural', 'en-US');
INSERT INTO tts_voices VALUES ('en-ZA-LeahNeural', 'en-ZA');
INSERT INTO tts_voices VALUES ('en-ZA-LukeNeural', 'en-ZA');
INSERT INTO tts_voices VALUES ('es-AR-ElenaNeural', 'es-AR');
INSERT INTO tts_voices VALUES ('es-AR-TomasNeural', 'es-AR');
INSERT INTO tts_voices VALUES ('es-BO-SofiaNeural', 'es-BO');
INSERT INTO tts_voices VALUES ('es-BO-MarceloNeural', 'es-BO');
INSERT INTO tts_voices VALUES ('es-CL-CatalinaNeural', 'es-CL');
INSERT INTO tts_voices VALUES ('es-CL-LorenzoNeural', 'es-CL');
INSERT INTO tts_voices VALUES ('es-CO-SalomeNeural', 'es-CO');
INSERT INTO tts_voices VALUES ('es-CO-GonzaloNeural', 'es-CO');
INSERT INTO tts_voices VALUES ('es-CR-MariaNeural', 'es-CR');
INSERT INTO tts_voices VALUES ('es-CR-JuanNeural', 'es-CR');
INSERT INTO tts_voices VALUES ('es-CU-BelkysNeural', 'es-CU');
INSERT INTO tts_voices VALUES ('es-CU-ManuelNeural', 'es-CU');
INSERT INTO tts_voices VALUES ('es-DO-RamonaNeural', 'es-DO');
INSERT INTO tts_voices VALUES ('es-DO-EmilioNeural', 'es-DO');
INSERT INTO tts_voices VALUES ('es-EC-AndreaNeural', 'es-EC');
INSERT INTO tts_voices VALUES ('es-EC-LuisNeural', 'es-EC');
INSERT INTO tts_voices VALUES ('es-ES-ElviraNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-AlvaroNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-ArabellaMultilingualNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-IsidoraMultilingualNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-TristanMultilingualNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-XimenaMultilingualNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-AbrilNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-ArnauNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-DarioNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-EliasNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-EstrellaNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-IreneNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-LaiaNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-LiaNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-NilNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-SaulNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-TeoNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-TrianaNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-VeraNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-XimenaNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-Tristan:DragonHDLatestNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-ES-Ximena:DragonHDLatestNeural', 'es-ES');
INSERT INTO tts_voices VALUES ('es-GQ-TeresaNeural', 'es-GQ');
INSERT INTO tts_voices VALUES ('es-GQ-JavierNeural', 'es-GQ');
INSERT INTO tts_voices VALUES ('es-GT-MartaNeural', 'es-GT');
INSERT INTO tts_voices VALUES ('es-GT-AndresNeural', 'es-GT');
INSERT INTO tts_voices VALUES ('es-HN-KarlaNeural', 'es-HN');
INSERT INTO tts_voices VALUES ('es-HN-CarlosNeural', 'es-HN');
INSERT INTO tts_voices VALUES ('es-MX-DaliaNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-JorgeNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-BeatrizNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-CandelaNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-CarlotaNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-CecilioNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-GerardoNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-LarissaNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-LibertoNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-LucianoNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-MarinaNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-NuriaNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-PelayoNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-RenataNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-MX-YagoNeural', 'es-MX');
INSERT INTO tts_voices VALUES ('es-NI-YolandaNeural', 'es-NI');
INSERT INTO tts_voices VALUES ('es-NI-FedericoNeural', 'es-NI');
INSERT INTO tts_voices VALUES ('es-PA-MargaritaNeural', 'es-PA');
INSERT INTO tts_voices VALUES ('es-PA-RobertoNeural', 'es-PA');
INSERT INTO tts_voices VALUES ('es-PE-CamilaNeural', 'es-PE');
INSERT INTO tts_voices VALUES ('es-PE-AlexNeural', 'es-PE');
INSERT INTO tts_voices VALUES ('es-PR-KarinaNeural', 'es-PR');
INSERT INTO tts_voices VALUES ('es-PR-VictorNeural', 'es-PR');
INSERT INTO tts_voices VALUES ('es-PY-TaniaNeural', 'es-PY');
INSERT INTO tts_voices VALUES ('es-PY-MarioNeural', 'es-PY');
INSERT INTO tts_voices VALUES ('es-SV-LorenaNeural', 'es-SV');
INSERT INTO tts_voices VALUES ('es-SV-RodrigoNeural', 'es-SV');
INSERT INTO tts_voices VALUES ('es-US-PalomaNeural', 'es-US');
INSERT INTO tts_voices VALUES ('es-US-AlonsoNeural', 'es-US');
INSERT INTO tts_voices VALUES ('es-UY-ValentinaNeural', 'es-UY');
INSERT INTO tts_voices VALUES ('es-UY-MateoNeural', 'es-UY');
INSERT INTO tts_voices VALUES ('es-VE-PaolaNeural', 'es-VE');
INSERT INTO tts_voices VALUES ('es-VE-SebastianNeural', 'es-VE');
INSERT INTO tts_voices VALUES ('et-EE-AnuNeural', 'et-EE');
INSERT INTO tts_voices VALUES ('et-EE-KertNeural', 'et-EE');
INSERT INTO tts_voices VALUES ('eu-ES-AinhoaNeural', 'eu-ES');
INSERT INTO tts_voices VALUES ('eu-ES-AnderNeural', 'eu-ES');
INSERT INTO tts_voices VALUES ('fa-IR-DilaraNeural', 'fa-IR');
INSERT INTO tts_voices VALUES ('fa-IR-FaridNeural', 'fa-IR');
INSERT INTO tts_voices VALUES ('fi-FI-SelmaNeural', 'fi-FI');
INSERT INTO tts_voices VALUES ('fi-FI-HarriNeural', 'fi-FI');
INSERT INTO tts_voices VALUES ('fi-FI-NooraNeural', 'fi-FI');
INSERT INTO tts_voices VALUES ('fil-PH-BlessicaNeural', 'fil-PH');
INSERT INTO tts_voices VALUES ('fil-PH-AngeloNeural', 'fil-PH');
INSERT INTO tts_voices VALUES ('fr-BE-CharlineNeural', 'fr-BE');
INSERT INTO tts_voices VALUES ('fr-BE-GerardNeural', 'fr-BE');
INSERT INTO tts_voices VALUES ('fr-CA-SylvieNeural', 'fr-CA');
INSERT INTO tts_voices VALUES ('fr-CA-JeanNeural', 'fr-CA');
INSERT INTO tts_voices VALUES ('fr-CA-AntoineNeural', 'fr-CA');
INSERT INTO tts_voices VALUES ('fr-CA-ThierryNeural', 'fr-CA');
INSERT INTO tts_voices VALUES ('fr-CH-ArianeNeural', 'fr-CH');
INSERT INTO tts_voices VALUES ('fr-CH-FabriceNeural', 'fr-CH');
INSERT INTO tts_voices VALUES ('fr-FR-DeniseNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-HenriNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-VivienneMultilingualNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-RemyMultilingualNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-LucienMultilingualNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-AlainNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-BrigitteNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-CelesteNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-ClaudeNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-CoralieNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-EloiseNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-JacquelineNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-JeromeNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-JosephineNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-MauriceNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-YvesNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-YvetteNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-Remy:DragonHDLatestNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('fr-FR-Vivienne:DragonHDLatestNeural', 'fr-FR');
INSERT INTO tts_voices VALUES ('ga-IE-OrlaNeural', 'ga-IE');
INSERT INTO tts_voices VALUES ('ga-IE-ColmNeural', 'ga-IE');
INSERT INTO tts_voices VALUES ('gl-ES-SabelaNeural', 'gl-ES');
INSERT INTO tts_voices VALUES ('gl-ES-RoiNeural', 'gl-ES');
INSERT INTO tts_voices VALUES ('gu-IN-DhwaniNeural', 'gu-IN');
INSERT INTO tts_voices VALUES ('gu-IN-NiranjanNeural', 'gu-IN');
INSERT INTO tts_voices VALUES ('he-IL-HilaNeural', 'he-IL');
INSERT INTO tts_voices VALUES ('he-IL-AvriNeural', 'he-IL');
INSERT INTO tts_voices VALUES ('hi-IN-AaravNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-AnanyaNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-AartiNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-ArjunNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-KavyaNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-KunalNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-RehaanNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-SwaraNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hi-IN-MadhurNeural', 'hi-IN');
INSERT INTO tts_voices VALUES ('hr-HR-GabrijelaNeural', 'hr-HR');
INSERT INTO tts_voices VALUES ('hr-HR-SreckoNeural', 'hr-HR');
INSERT INTO tts_voices VALUES ('hu-HU-NoemiNeural', 'hu-HU');
INSERT INTO tts_voices VALUES ('hu-HU-TamasNeural', 'hu-HU');
INSERT INTO tts_voices VALUES ('hy-AM-AnahitNeural', 'hy-AM');
INSERT INTO tts_voices VALUES ('hy-AM-HaykNeural', 'hy-AM');
INSERT INTO tts_voices VALUES ('id-ID-GadisNeural', 'id-ID');
INSERT INTO tts_voices VALUES ('id-ID-ArdiNeural', 'id-ID');
INSERT INTO tts_voices VALUES ('is-IS-GudrunNeural', 'is-IS');
INSERT INTO tts_voices VALUES ('is-IS-GunnarNeural', 'is-IS');
INSERT INTO tts_voices VALUES ('it-IT-ElsaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-IsabellaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-DiegoNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-AlessioMultilingualNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-IsabellaMultilingualNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-GiuseppeMultilingualNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-MarcelloMultilingualNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-BenignoNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-CalimeroNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-CataldoNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-FabiolaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-FiammaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-GianniNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-GiuseppeNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-ImeldaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-IrmaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-LisandroNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-PalmiraNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-PierinaNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('it-IT-RinaldoNeural', 'it-IT');
INSERT INTO tts_voices VALUES ('iu-Cans-CA-SiqiniqNeural', 'iu-Cans-CA');
INSERT INTO tts_voices VALUES ('iu-Cans-CA-TaqqiqNeural', 'iu-Cans-CA');
INSERT INTO tts_voices VALUES ('iu-Latn-CA-SiqiniqNeural', 'iu-Latn-CA');
INSERT INTO tts_voices VALUES ('iu-Latn-CA-TaqqiqNeural', 'iu-Latn-CA');
INSERT INTO tts_voices VALUES ('ja-JP-NanamiNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-KeitaNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-AoiNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-DaichiNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-MayuNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-NaokiNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-ShioriNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-MasaruMultilingualNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-Masaru:DragonHDLatestNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('ja-JP-Nanami:DragonHDLatestNeural', 'ja-JP');
INSERT INTO tts_voices VALUES ('jv-ID-SitiNeural', 'jv-ID');
INSERT INTO tts_voices VALUES ('jv-ID-DimasNeural', 'jv-ID');
INSERT INTO tts_voices VALUES ('ka-GE-EkaNeural', 'ka-GE');
INSERT INTO tts_voices VALUES ('ka-GE-GiorgiNeural', 'ka-GE');
INSERT INTO tts_voices VALUES ('kk-KZ-AigulNeural', 'kk-KZ');
INSERT INTO tts_voices VALUES ('kk-KZ-DauletNeural', 'kk-KZ');
INSERT INTO tts_voices VALUES ('km-KH-SreymomNeural', 'km-KH');
INSERT INTO tts_voices VALUES ('km-KH-PisethNeural', 'km-KH');
INSERT INTO tts_voices VALUES ('kn-IN-SapnaNeural', 'kn-IN');
INSERT INTO tts_voices VALUES ('kn-IN-GaganNeural', 'kn-IN');
INSERT INTO tts_voices VALUES ('ko-KR-SunHiNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-InJoonNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-HyunsuMultilingualNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-BongJinNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-GookMinNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-HyunsuNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-JiMinNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-SeoHyeonNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-SoonBokNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('ko-KR-YuJinNeural', 'ko-KR');
INSERT INTO tts_voices VALUES ('lo-LA-KeomanyNeural', 'lo-LA');
INSERT INTO tts_voices VALUES ('lo-LA-ChanthavongNeural', 'lo-LA');
INSERT INTO tts_voices VALUES ('lt-LT-OnaNeural', 'lt-LT');
INSERT INTO tts_voices VALUES ('lt-LT-LeonasNeural', 'lt-LT');
INSERT INTO tts_voices VALUES ('lv-LV-EveritaNeural', 'lv-LV');
INSERT INTO tts_voices VALUES ('lv-LV-NilsNeural', 'lv-LV');
INSERT INTO tts_voices VALUES ('mk-MK-MarijaNeural', 'mk-MK');
INSERT INTO tts_voices VALUES ('mk-MK-AleksandarNeural', 'mk-MK');
INSERT INTO tts_voices VALUES ('ml-IN-SobhanaNeural', 'ml-IN');
INSERT INTO tts_voices VALUES ('ml-IN-MidhunNeural', 'ml-IN');
INSERT INTO tts_voices VALUES ('mn-MN-YesuiNeural', 'mn-MN');
INSERT INTO tts_voices VALUES ('mn-MN-BataaNeural', 'mn-MN');
INSERT INTO tts_voices VALUES ('mr-IN-AarohiNeural', 'mr-IN');
INSERT INTO tts_voices VALUES ('mr-IN-ManoharNeural', 'mr-IN');
INSERT INTO tts_voices VALUES ('ms-MY-YasminNeural', 'ms-MY');
INSERT INTO tts_voices VALUES ('ms-MY-OsmanNeural', 'ms-MY');
INSERT INTO tts_voices VALUES ('mt-MT-GraceNeural', 'mt-MT');
INSERT INTO tts_voices VALUES ('mt-MT-JosephNeural', 'mt-MT');
INSERT INTO tts_voices VALUES ('my-MM-NilarNeural', 'my-MM');
INSERT INTO tts_voices VALUES ('my-MM-ThihaNeural', 'my-MM');
INSERT INTO tts_voices VALUES ('nb-NO-PernilleNeural', 'nb-NO');
INSERT INTO tts_voices VALUES ('nb-NO-FinnNeural', 'nb-NO');
INSERT INTO tts_voices VALUES ('nb-NO-IselinNeural', 'nb-NO');
INSERT INTO tts_voices VALUES ('ne-NP-HemkalaNeural', 'ne-NP');
INSERT INTO tts_voices VALUES ('ne-NP-SagarNeural', 'ne-NP');
INSERT INTO tts_voices VALUES ('nl-BE-DenaNeural', 'nl-BE');
INSERT INTO tts_voices VALUES ('nl-BE-ArnaudNeural', 'nl-BE');
INSERT INTO tts_voices VALUES ('nl-NL-FennaNeural', 'nl-NL');
INSERT INTO tts_voices VALUES ('nl-NL-MaartenNeural', 'nl-NL');
INSERT INTO tts_voices VALUES ('nl-NL-ColetteNeural', 'nl-NL');
INSERT INTO tts_voices VALUES ('or-IN-SubhasiniNeural', 'or-IN');
INSERT INTO tts_voices VALUES ('or-IN-SukantNeural', 'or-IN');
INSERT INTO tts_voices VALUES ('pa-IN-OjasNeural', 'pa-IN');
INSERT INTO tts_voices VALUES ('pa-IN-VaaniNeural', 'pa-IN');
INSERT INTO tts_voices VALUES ('pl-PL-AgnieszkaNeural', 'pl-PL');
INSERT INTO tts_voices VALUES ('pl-PL-MarekNeural', 'pl-PL');
INSERT INTO tts_voices VALUES ('pl-PL-ZofiaNeural', 'pl-PL');
INSERT INTO tts_voices VALUES ('ps-AF-LatifaNeural', 'ps-AF');
INSERT INTO tts_voices VALUES ('ps-AF-GulNawazNeural', 'ps-AF');
INSERT INTO tts_voices VALUES ('pt-BR-FranciscaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-AntonioNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-MacerioMultilingualNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-ThalitaMultilingualNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-BrendaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-DonatoNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-ElzaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-FabioNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-GiovannaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-HumbertoNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-JulioNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-LeilaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-LeticiaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-ManuelaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-NicolauNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-ThalitaNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-ValerioNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-BR-YaraNeural', 'pt-BR');
INSERT INTO tts_voices VALUES ('pt-PT-RaquelNeural', 'pt-PT');
INSERT INTO tts_voices VALUES ('pt-PT-DuarteNeural', 'pt-PT');
INSERT INTO tts_voices VALUES ('pt-PT-FernandaNeural', 'pt-PT');
INSERT INTO tts_voices VALUES ('ro-RO-AlinaNeural', 'ro-RO');
INSERT INTO tts_voices VALUES ('ro-RO-EmilNeural', 'ro-RO');
INSERT INTO tts_voices VALUES ('ru-RU-SvetlanaNeural', 'ru-RU');
INSERT INTO tts_voices VALUES ('ru-RU-DmitryNeural', 'ru-RU');
INSERT INTO tts_voices VALUES ('ru-RU-DariyaNeural', 'ru-RU');
INSERT INTO tts_voices VALUES ('si-LK-ThiliniNeural', 'si-LK');
INSERT INTO tts_voices VALUES ('si-LK-SameeraNeural', 'si-LK');
INSERT INTO tts_voices VALUES ('sk-SK-ViktoriaNeural', 'sk-SK');
INSERT INTO tts_voices VALUES ('sk-SK-LukasNeural', 'sk-SK');
INSERT INTO tts_voices VALUES ('sl-SI-PetraNeural', 'sl-SI');
INSERT INTO tts_voices VALUES ('sl-SI-RokNeural', 'sl-SI');
INSERT INTO tts_voices VALUES ('so-SO-UbaxNeural', 'so-SO');
INSERT INTO tts_voices VALUES ('so-SO-MuuseNeural', 'so-SO');
INSERT INTO tts_voices VALUES ('sq-AL-AnilaNeural', 'sq-AL');
INSERT INTO tts_voices VALUES ('sq-AL-IlirNeural', 'sq-AL');
INSERT INTO tts_voices VALUES ('sr-Latn-RS-NicholasNeural', 'sr-Latn-RS');
INSERT INTO tts_voices VALUES ('sr-Latn-RS-SophieNeural', 'sr-Latn-RS');
INSERT INTO tts_voices VALUES ('sr-RS-SophieNeural', 'sr-RS');
INSERT INTO tts_voices VALUES ('sr-RS-NicholasNeural', 'sr-RS');
INSERT INTO tts_voices VALUES ('su-ID-TutiNeural', 'su-ID');
INSERT INTO tts_voices VALUES ('su-ID-JajangNeural', 'su-ID');
INSERT INTO tts_voices VALUES ('sv-SE-SofieNeural', 'sv-SE');
INSERT INTO tts_voices VALUES ('sv-SE-MattiasNeural', 'sv-SE');
INSERT INTO tts_voices VALUES ('sv-SE-HilleviNeural', 'sv-SE');
INSERT INTO tts_voices VALUES ('sw-KE-ZuriNeural', 'sw-KE');
INSERT INTO tts_voices VALUES ('sw-KE-RafikiNeural', 'sw-KE');
INSERT INTO tts_voices VALUES ('sw-TZ-RehemaNeural', 'sw-TZ');
INSERT INTO tts_voices VALUES ('sw-TZ-DaudiNeural', 'sw-TZ');
INSERT INTO tts_voices VALUES ('ta-IN-PallaviNeural', 'ta-IN');
INSERT INTO tts_voices VALUES ('ta-IN-ValluvarNeural', 'ta-IN');
INSERT INTO tts_voices VALUES ('ta-LK-SaranyaNeural', 'ta-LK');
INSERT INTO tts_voices VALUES ('ta-LK-KumarNeural', 'ta-LK');
INSERT INTO tts_voices VALUES ('ta-MY-KaniNeural', 'ta-MY');
INSERT INTO tts_voices VALUES ('ta-MY-SuryaNeural', 'ta-MY');
INSERT INTO tts_voices VALUES ('ta-SG-VenbaNeural', 'ta-SG');
INSERT INTO tts_voices VALUES ('ta-SG-AnbuNeural', 'ta-SG');
INSERT INTO tts_voices VALUES ('te-IN-ShrutiNeural', 'te-IN');
INSERT INTO tts_voices VALUES ('te-IN-MohanNeural', 'te-IN');
INSERT INTO tts_voices VALUES ('th-TH-PremwadeeNeural', 'th-TH');
INSERT INTO tts_voices VALUES ('th-TH-NiwatNeural', 'th-TH');
INSERT INTO tts_voices VALUES ('th-TH-AcharaNeural', 'th-TH');
INSERT INTO tts_voices VALUES ('tr-TR-EmelNeural', 'tr-TR');
INSERT INTO tts_voices VALUES ('tr-TR-AhmetNeural', 'tr-TR');
INSERT INTO tts_voices VALUES ('uk-UA-PolinaNeural', 'uk-UA');
INSERT INTO tts_voices VALUES ('uk-UA-OstapNeural', 'uk-UA');
INSERT INTO tts_voices VALUES ('ur-IN-GulNeural', 'ur-IN');
INSERT INTO tts_voices VALUES ('ur-IN-SalmanNeural', 'ur-IN');
INSERT INTO tts_voices VALUES ('ur-PK-UzmaNeural', 'ur-PK');
INSERT INTO tts_voices VALUES ('ur-PK-AsadNeural', 'ur-PK');
INSERT INTO tts_voices VALUES ('uz-UZ-MadinaNeural', 'uz-UZ');
INSERT INTO tts_voices VALUES ('uz-UZ-SardorNeural', 'uz-UZ');
INSERT INTO tts_voices VALUES ('vi-VN-HoaiMyNeural', 'vi-VN');
INSERT INTO tts_voices VALUES ('vi-VN-NamMinhNeural', 'vi-VN');
INSERT INTO tts_voices VALUES ('wuu-CN-XiaotongNeural', 'wuu-CN');
INSERT INTO tts_voices VALUES ('wuu-CN-YunzheNeural', 'wuu-CN');
INSERT INTO tts_voices VALUES ('yue-CN-XiaoMinNeural', 'yue-CN');
INSERT INTO tts_voices VALUES ('yue-CN-YunSongNeural', 'yue-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoxiaoNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunxiNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunjianNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoyiNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunyangNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaochenNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaochenMultilingualNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaohanNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaomengNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaomoNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoqiuNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaorouNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoruiNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoshuangNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoxiaoDialectsNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoxiaoMultilingualNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoyanNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoyouNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaoyuMultilingualNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-XiaozhenNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunfengNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunhaoNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunjieNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunxiaNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunyeNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunyiMultilingualNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunzeNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Xiaochen:DragonHDFlashLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Xiaoxiao:DragonHDFlashLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Xiaoxiao2:DragonHDFlashLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunfanMultilingualNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Yunxiao:DragonHDFlashLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-YunxiaoMultilingualNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Yunyi:DragonHDFlashLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Xiaochen:DragonHDLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-Yunfan:DragonHDLatestNeural', 'zh-CN');
INSERT INTO tts_voices VALUES ('zh-CN-guangxi-YunqiNeural', 'zh-CN-guangxi');
INSERT INTO tts_voices VALUES ('zh-CN-henan-YundengNeural', 'zh-CN-henan');
INSERT INTO tts_voices VALUES ('zh-CN-liaoning-XiaobeiNeural', 'zh-CN-liaoning');
INSERT INTO tts_voices VALUES ('zh-CN-liaoning-YunbiaoNeural', 'zh-CN-liaoning');
INSERT INTO tts_voices VALUES ('zh-CN-shaanxi-XiaoniNeural', 'zh-CN-shaanxi');
INSERT INTO tts_voices VALUES ('zh-CN-shandong-YunxiangNeural', 'zh-CN-shandong');
INSERT INTO tts_voices VALUES ('zh-CN-sichuan-YunxiNeural', 'zh-CN-sichuan');
INSERT INTO tts_voices VALUES ('zh-HK-HiuMaanNeural', 'zh-HK');
INSERT INTO tts_voices VALUES ('zh-HK-WanLungNeural', 'zh-HK');
INSERT INTO tts_voices VALUES ('zh-HK-HiuGaaiNeural', 'zh-HK');
INSERT INTO tts_voices VALUES ('zh-TW-HsiaoChenNeural', 'zh-TW');
INSERT INTO tts_voices VALUES ('zh-TW-YunJheNeural', 'zh-TW');
INSERT INTO tts_voices VALUES ('zh-TW-HsiaoYuNeural', 'zh-TW');
INSERT INTO tts_voices VALUES ('zu-ZA-ThandoNeural', 'zu-ZA');
INSERT INTO tts_voices VALUES ('zu-ZA-ThembaNeural', 'zu-ZA');

View File

@@ -10,14 +10,14 @@ import (
)
func messageHandler(c *qbot.Client, msg *qbot.Message) {
if msg.UserID != config.BotID {
if msg.UserID != config.Cfg.Permissions.BotID {
isCommand := cmds.HandleCommand(c, msg)
defer qbot.SaveDatabase(msg, isCommand)
mc.ForwardMessageToMC(c, msg)
if isCommand {
return
} else {
mc.ForwardMessageToMC(c, msg)
}
if llm.NeedLLMResponse(msg) {
@@ -28,8 +28,5 @@ func messageHandler(c *qbot.Client, msg *qbot.Message) {
legacy.GameCommandHandle(c, msg)
return
}
if cmds.CheckUserEvents(c, msg) {
return
}
}
}

View File

@@ -19,7 +19,7 @@ func NeedLLMResponse(msg *qbot.Message) bool {
return true
} else {
for _, item := range msg.Array {
if item.Type == qbot.At && item.Content == strconv.FormatUint(config.BotID, 10) {
if item.Type == qbot.At && item.Content == strconv.FormatUint(config.Cfg.Permissions.BotID, 10) {
return true
}
}
@@ -45,7 +45,7 @@ func SendLLMRequest(supplier string, messages []openai.ChatCompletionMessagePara
apiKey := supplierConf.APIKey
if apiKey == "" {
apiKey = config.ApiKey
return nil, fmt.Errorf("supplier %s api_key is empty", supplier)
}
if supplierConf.BaseURL == "" {
return nil, fmt.Errorf("supplier %s base_url is empty", supplier)
@@ -115,7 +115,7 @@ userinfo 1006554341 add 喜欢编程
First(&llmCustomConfig).Error
if err != nil || !llmCustomConfig.Enabled {
c.SendMsg(msg, err.Error())
c.SendMsg(msg.GroupID, msg.UserID, err.Error())
return
}
@@ -229,7 +229,7 @@ userinfo 1006554341 add 喜欢编程
chatHistory += currentMsgFormatted
messages = append(messages, openai.UserMessage("以下是最近的聊天记录请你根据最新的消息生成回复之前的消息可作为参考。你的id是"+
strconv.FormatUint(config.BotID, 10)+"\n"+chatHistory))
strconv.FormatUint(config.Cfg.Permissions.BotID, 10)+"\n"+chatHistory))
resp, err := SendLLMRequest(llmCustomConfig.Supplier, messages, llmCustomConfig.Model, 0.6)
if err != nil {
@@ -237,21 +237,27 @@ userinfo 1006554341 add 喜欢编程
return
}
// 检查响应是否有效
if resp == nil || len(resp.Choices) == 0 {
c.SendGroupMsg(msg.GroupID, "LLM 返回了空响应", false)
return
}
responseContent := resp.Choices[0].Message.Content
if llmCustomConfig.Debug {
c.SendReplyMsg(msg, responseContent)
c.SendMsg(msg.GroupID, msg.UserID, responseContent)
}
err = parseAndExecuteCommands(c, msg, responseContent)
if err != nil {
c.SendPrivateMsg(config.MasterID, "命令解析错误:\n"+err.Error(), false)
c.SendPrivateMsg(config.MasterID, responseContent, false)
c.SendPrivateMsg(config.MasterID, "消息来源:\ngroup_id="+strconv.FormatUint(msg.GroupID, 10)+"\nuser_id="+strconv.FormatUint(msg.UserID, 10)+"\nmsg="+msg.Content, false)
c.SendPrivateMsg(config.Cfg.Permissions.MasterID, "命令解析错误:\n"+err.Error(), false)
c.SendPrivateMsg(config.Cfg.Permissions.MasterID, responseContent, false)
c.SendPrivateMsg(config.Cfg.Permissions.MasterID, "消息来源:\ngroup_id="+strconv.FormatUint(msg.GroupID, 10)+"\nuser_id="+strconv.FormatUint(msg.UserID, 10)+"\nmsg="+msg.Content, false)
return
}
if resp != nil && resp.Usage.TotalTokens > 0 {
if resp.Usage.TotalTokens > 0 {
go qbot.PsqlDB.Table("users").
Where("user_id = ?", msg.UserID).
Update("token_usage", gorm.Expr("token_usage + ?", resp.Usage.TotalTokens))
@@ -544,7 +550,7 @@ func parseAndExecuteCommands(c *qbot.Client, msg *qbot.Message, content string)
if err == nil {
saveMsg := &qbot.Message{
GroupID: msg.GroupID,
UserID: config.BotID,
UserID: config.Cfg.Permissions.BotID,
Nickname: "狐萝bot",
Card: "狐萝bot",
Time: uint64(time.Now().Unix()),

View File

@@ -2,14 +2,18 @@ package main
import (
"fmt"
"go-hurobot/config"
"go-hurobot/qbot"
"os"
"os/signal"
"syscall"
"go-hurobot/qbot"
)
func main() {
// 初始化配置
config.LoadConfigFile()
qbot.InitDB()
bot := qbot.NewClient()
defer bot.Close()

View File

@@ -13,7 +13,7 @@ import (
// 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 {
if msg.UserID == config.Cfg.Permissions.BotID {
return
}

View File

@@ -1,5 +1,7 @@
package qbot
import "log"
func (c *Client) SendPrivateMsg(userID uint64, message string, autoEscape bool) (uint64, error) {
if message == "" {
message = " "
@@ -12,10 +14,11 @@ func (c *Client) SendPrivateMsg(userID uint64, message string, autoEscape bool)
"auto_escape": autoEscape,
},
}
resp, err := c.sendJsonWithEcho(&req)
resp, err := c.sendWithResponse(&req)
if err != nil {
return 0, err
}
log.Println("send-private: ", message)
return resp.Data.MessageId, nil
}
@@ -32,10 +35,11 @@ func (c *Client) SendGroupMsg(groupID uint64, message string, autoEscape bool) (
},
}
resp, err := c.sendJsonWithEcho(&req)
resp, err := c.sendWithResponse(&req)
if err != nil {
return 0, err
}
log.Println("send-group: ", message)
return resp.Data.MessageId, nil
}
@@ -123,26 +127,14 @@ func (c *Client) DeleteMsg(msgID uint64) error {
return err
}
func (c *Client) SendRecord(msg *Message, file string) {
c.SendMsg(msg, CQRecord(file))
}
func (c *Client) SendReplyMsg(msg *Message, message string) {
c.SendMsg(msg, CQReply(msg.MsgID)+message)
}
func (c *Client) SendMsg(msg *Message, message string) {
if msg.GroupID == 0 {
c.SendPrivateMsg(msg.UserID, message, false)
func (c *Client) SendMsg(groupID uint64, userID uint64, message string) {
if groupID == 0 {
c.SendPrivateMsg(userID, message, false)
} else {
c.SendGroupMsg(msg.GroupID, message, false)
c.SendGroupMsg(groupID, message, false)
}
}
func (c *Client) SendImage(msg *Message, url string) {
c.SendMsg(msg, CQImage(url))
}
func (c *Client) GetGroupMemberInfo(groupID uint64, userID uint64, noCache bool) (*GroupMemberInfo, error) {
req := cqRequest{
Action: "get_group_member_info",
@@ -152,9 +144,39 @@ func (c *Client) GetGroupMemberInfo(groupID uint64, userID uint64, noCache bool)
"no_cache": noCache,
},
}
resp, err := c.sendJsonWithEcho(&req)
resp, err := c.sendWithResponse(&req)
if err != nil {
return nil, err
}
return &resp.Data.GroupMemberInfo, nil
}
func (c *Client) GetGroupFileUrl(groupID uint64, fileID string, busid int32) (string, error) {
req := cqRequest{
Action: "get_group_file_url",
Params: map[string]any{
"group_id": groupID,
"file_id": fileID,
"busid": busid,
},
}
resp, err := c.sendWithResponse(&req)
if err != nil {
return "", err
}
return resp.Data.Url, nil
}
func (c *Client) SendTestAPIRequest(action string, params map[string]interface{}) (string, error) {
req := cqRequest{
Action: action,
Params: params,
}
resp, err := c.sendWithJSONResponse(&req)
if err != nil {
return "", err
}
return resp, nil
}

View File

@@ -1,6 +1,10 @@
package qbot
import (
"fmt"
"go-hurobot/config"
"log"
"strconv"
"time"
"gorm.io/driver/postgres"
@@ -52,13 +56,20 @@ type LegacyGame struct {
Balance int `gorm:"not null;column:balance;default:0"`
}
func initPsqlDB(dsn string) error {
func InitDB() {
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
config.Cfg.PostgreSQL.Host,
strconv.Itoa(int(config.Cfg.PostgreSQL.Port)),
config.Cfg.PostgreSQL.User,
config.Cfg.PostgreSQL.Password,
config.Cfg.PostgreSQL.DbName,
)
var err error
if PsqlDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}); err != nil {
return err
log.Fatalln(err)
}
PsqlConnected = true
return PsqlDB.AutoMigrate(&Users{}, &Messages{}, &UserEvents{}, &GroupRconConfigs{}, &LegacyGame{})
PsqlDB.AutoMigrate(&Users{}, &Messages{}, &UserEvents{}, &GroupRconConfigs{}, &LegacyGame{})
}
func SaveDatabase(msg *Message, isCmd bool) error {

View File

@@ -2,6 +2,7 @@ package qbot
import (
"encoding/json"
"fmt"
"log"
)
@@ -50,40 +51,74 @@ func parseMsgJson(raw *messageJson) *Message {
}
for _, msg := range raw.Message {
var jsonData map[string]any
if json.Unmarshal([]byte(msg.Data), &jsonData) != nil {
return nil
if err := json.Unmarshal(msg.Data, &jsonData); err != nil {
log.Printf("解析消息数据失败: %v, 原始数据: %s", err, string(msg.Data))
continue
}
switch msg.Type {
case "text":
result.Array = append(result.Array, MsgItem{
Type: Text,
Content: jsonData["text"].(string),
})
if text, ok := jsonData["text"].(string); ok {
result.Array = append(result.Array, MsgItem{
Type: Text,
Content: text,
})
}
case "at":
result.Array = append(result.Array, MsgItem{
Type: At,
Content: jsonData["qq"].(string),
})
// qq 可能是 string 或 number
var qqStr string
if qq, ok := jsonData["qq"].(string); ok {
qqStr = qq
} else if qq, ok := jsonData["qq"].(float64); ok {
qqStr = fmt.Sprintf("%.0f", qq)
}
if qqStr != "" {
result.Array = append(result.Array, MsgItem{
Type: At,
Content: qqStr,
})
}
case "face":
result.Array = append(result.Array, MsgItem{
Type: Face,
Content: jsonData["id"].(string),
})
// id 可能是 string 或 number
var idStr string
if id, ok := jsonData["id"].(string); ok {
idStr = id
} else if id, ok := jsonData["id"].(float64); ok {
idStr = fmt.Sprintf("%.0f", id)
}
if idStr != "" {
result.Array = append(result.Array, MsgItem{
Type: Face,
Content: idStr,
})
}
case "image":
result.Array = append(result.Array, MsgItem{
Type: Image,
Content: jsonData["url"].(string),
})
if url, ok := jsonData["url"].(string); ok {
result.Array = append(result.Array, MsgItem{
Type: Image,
Content: url,
})
}
case "record":
result.Array = append(result.Array, MsgItem{
Type: Record,
Content: jsonData["path"].(string),
})
if path, ok := jsonData["path"].(string); ok {
result.Array = append(result.Array, MsgItem{
Type: Record,
Content: path,
})
}
case "reply":
result.Array = append(result.Array, MsgItem{
Type: Reply,
Content: jsonData["id"].(string),
})
// reply 的 id 可能是 string 或 number
var replyId string
if id, ok := jsonData["id"].(string); ok {
replyId = id
} else if id, ok := jsonData["id"].(float64); ok {
replyId = fmt.Sprintf("%.0f", id)
}
if replyId != "" {
result.Array = append(result.Array, MsgItem{
Type: Reply,
Content: replyId,
})
}
case "file":
result.Array = append(result.Array, MsgItem{
Type: File,

View File

@@ -2,187 +2,173 @@
package qbot
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"strconv"
"net/http"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"go-hurobot/config"
)
func init() {
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
config.PsqlHost, strconv.Itoa(int(config.PsqlPort)), config.PsqlUser, config.PsqlPassword, config.PsqlDbName)
if err := initPsqlDB(dsn); err != nil {
log.Fatalln(err)
}
}
func NewClient() *Client {
client := &Client{
config: &Config{
Address: config.NapcatWSURL,
Reconnect: 3 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
retryCount: 0,
stopChan: make(chan bool),
}
go client.connect()
// 启动反向 HTTP 服务器
go client.startHTTPServer()
log.Printf("正向 HTTP 地址: %s", config.Cfg.NapcatHttpServer)
log.Printf("反向 HTTP 监听: http://%s", config.Cfg.ReverseHttpServer)
return client
}
func (c *Client) Close() {
if c.conn != nil {
c.conn.Close()
}
if c.stopChan != nil {
close(c.stopChan)
if c.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.server.Shutdown(ctx); err != nil {
log.Printf("HTTP server shutdown error: %v", err)
}
}
}
func (c *Client) connect() {
for {
select {
case <-c.stopChan:
return
default:
// TODO
}
// 启动反向 HTTP 服务器,接收 NapCat 推送的消息
func (c *Client) startHTTPServer() {
mux := http.NewServeMux()
mux.HandleFunc("/", c.handleHTTPEvent)
dialer := websocket.Dialer{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
HandshakeTimeout: c.config.ReadTimeout,
}
conn, _, err := dialer.Dial(c.config.Address, nil)
if err != nil {
log.Printf("Connect failed (%d): %v", c.retryCount+1, err)
c.retryCount++
time.Sleep(c.config.Reconnect)
continue
}
c.server = &http.Server{
Addr: config.Cfg.ReverseHttpServer,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
conn.SetPongHandler(func(string) error {
c.retryCount = 0
return nil
})
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
}
}
c.conn = conn
log.Println("Connected to NapCat")
go c.messageHandler()
// 处理 NapCat 推送的事件
func (c *Client) handleHTTPEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
}
func (c *Client) messageHandler() {
defer func() {
if c.conn != nil {
c.conn.Close()
}
}()
for {
// Receive message
_, msg, err := c.conn.ReadMessage()
if err != nil {
log.Printf("read error: %v", err)
c.reconnect()
return
}
// Unmarshal to map
jsonMap := make(map[string]any)
if err := json.Unmarshal(msg, &jsonMap); err != nil {
log.Printf("parse message error: %v", err)
continue
}
if jsonMap["echo"] != nil {
// Response to sent message
var resp cqResponse
if err := json.Unmarshal(msg, &resp); err == nil {
c.mutex.Lock()
if val, ok := c.pendingEcho.Load(resp.Echo); ok {
pr := val.(*pendingResponse)
pr.timer.Stop()
pr.ch <- &resp
c.pendingEcho.Delete(resp.Echo)
}
c.mutex.Unlock()
}
} else if postType, exists := jsonMap["post_type"]; exists {
// Server-initiated push
if str, ok := postType.(string); ok && str != "" {
go c.handleEvents(&str, &msg, &jsonMap)
}
}
}
}
func (c *Client) reconnect() {
if c.stopChan != nil {
close(c.stopChan)
}
c.stopChan = make(chan bool)
c.connect()
}
func (c *Client) sendJson(req *cqRequest) error {
jsonBytes, err := json.Marshal(req)
body, err := io.ReadAll(r.Body)
if err != nil {
return err
log.Printf("读取请求体失败: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if c.conn == nil {
return fmt.Errorf("connection not ready")
defer r.Body.Close()
// 解析 JSON
jsonMap := make(map[string]any)
if err := json.Unmarshal(body, &jsonMap); err != nil {
log.Printf("解析 JSON 失败: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, jsonBytes); err != nil {
return err
// 处理事件
if postType, exists := jsonMap["post_type"]; exists {
if str, ok := postType.(string); ok && str != "" {
go c.handleEvents(&str, &body, &jsonMap)
}
}
return nil
// 返回成功响应
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
func (c *Client) sendJsonWithEcho(req *cqRequest) (*cqResponse, error) {
// Generate echo key
echo := uuid.New().String()
req.Echo = echo
respCh := make(chan *cqResponse, 1)
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
// Save the key to pendingEcho
c.mutex.Lock()
c.pendingEcho.Store(echo, &pendingResponse{
ch: respCh,
timer: timeout,
})
c.mutex.Unlock()
// Send request
if err := c.sendJson(req); err != nil {
// 发送 API 请求到 NapCat正向 HTTP
// 统一的 HTTP 请求方法
func (c *Client) sendRequest(req *cqRequest) (*http.Response, error) {
jsonBytes, err := json.Marshal(req.Params)
if err != nil {
return nil, err
}
// Wait for response
select {
case resp := <-respCh:
if resp == nil {
return nil, fmt.Errorf("response channel closed")
} else {
log.Printf("Sent message: %v", req.Params)
}
return resp, nil
case <-timeout.C:
c.mutex.Lock()
c.pendingEcho.Delete(echo)
c.mutex.Unlock()
return nil, fmt.Errorf("wait response timeout")
httpReq, err := http.NewRequest(http.MethodPost, config.Cfg.NapcatHttpServer+"/"+req.Action, bytes.NewBuffer(jsonBytes))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
if config.Cfg.ApiKeys.Longport.AccessToken != "" {
httpReq.Header.Set("Authorization", "Bearer "+config.Cfg.ApiKeys.Longport.AccessToken)
}
return c.httpClient.Do(httpReq)
}
func (c *Client) sendJson(req *cqRequest) error {
resp, err := c.sendRequest(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *Client) sendWithResponse(req *cqRequest) (*cqResponse, error) {
resp, err := c.sendRequest(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
var cqResp cqResponse
if err := json.Unmarshal(body, &cqResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
return &cqResp, nil
}
// 发送请求并返回 JSON 字符串(用于测试 API
func (c *Client) sendWithJSONResponse(req *cqRequest) (string, error) {
resp, err := c.sendRequest(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
log.Printf("API 响应: %s", string(body))
return string(body), nil
}

View File

@@ -2,26 +2,12 @@ package qbot
import (
"encoding/json"
"sync"
"time"
"github.com/gorilla/websocket"
"net/http"
)
type Config struct {
Address string `json:"address"`
Reconnect time.Duration `json:"reconnect"`
ReadTimeout time.Duration `json:"read_timeout"`
WriteTimeout time.Duration `json:"write_timeout"`
}
type Client struct {
config *Config
conn *websocket.Conn
retryCount int
stopChan chan bool
pendingEcho sync.Map
mutex sync.Mutex
httpClient *http.Client
server *http.Server
eventHandlers struct {
onMessage func(c *Client, msg *Message)
}
@@ -78,15 +64,11 @@ type messageJson struct {
} `json:"message"`
}
type pendingResponse struct {
ch chan *cqResponse
timer *time.Timer
}
// 移除 pendingResponseHTTP 模式不需要等待响应
type cqRequest struct {
Action string `json:"action"`
Params map[string]any `json:"params"`
Echo string `json:"echo,omitempty"`
}
type GroupMemberInfo struct {
@@ -113,9 +95,9 @@ type cqResponse struct {
Retcode int `json:"retcode"`
Data struct {
MessageId uint64 `json:"message_id"`
Url string `json:"url"`
GroupMemberInfo
}
} `json:"data"`
Message string `json:"message"`
Wording string `json:"wording"`
Echo string `json:"echo"`
}

View File

@@ -1,40 +0,0 @@
#!/bin/bash
# NapCat WebSocket 服务器地址和 Token
export NAPCAT_HOST='127.0.0.1:3001'
export ACCESS_TOKEN='token-for-napcat'
# 机器人主人QQ号
export MASTER_ID=''
# 机器人QQ号
export BOT_ID=''
# Postgres 数据库配置 AI 对话读取历史记录使用
# Postgres 配置请查看 docker/docker-compose.yml
export PSQL_HOST='127.0.0.1'
export PSQL_PORT='5432'
export PSQL_USER='hurobot'
export PSQL_PASSWORD='hurobot'
export PSQL_DBNAME='hurobot'
# 硅基流动 API
export API_KEY='sk-'
# Exchange Rate API 配置,用于 fx 命令
# 参考 https://www.exchangerate-api.com/docs/
export EXCHANGE_RATE_API_KEY=''
# 长桥证券 API 配置,用于 stock 命令
# 参考 https://open.longportapp.com/docs/
export LONGPORT_APP_KEY=''
export LONGPORT_APP_SECRET=''
export LONGPORT_ACCESS_TOKEN=''
export LONGPORT_REGION='cn'
export LONGPORT_ENABLE_OVERNIGHT='false'
# OKX 镜像 API 配置,用于 stock 命令
# 参考 https://www.okx.com/docs-v5/
export OKX_MIRROR_API_KEY=''
/path/to/go-hurobot