From a9654ed9aac90b34c629e6c409d0da8b7a4b8923 Mon Sep 17 00:00:00 2001 From: awfufu Date: Sat, 17 Jan 2026 14:52:34 +0800 Subject: [PATCH] feat: add support for external script commands --- example-command-cn.sh | 41 +++++++++++++++++++ example-command.sh | 41 +++++++++++++++++++ go.mod | 3 ++ go.sum | 10 ++++- internal/cmds/cmds.go | 91 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 1 deletion(-) create mode 100755 example-command-cn.sh create mode 100755 example-command.sh diff --git a/example-command-cn.sh b/example-command-cn.sh new file mode 100755 index 0000000..5ce8045 --- /dev/null +++ b/example-command-cn.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# example-command-cn.sh +# +# -> https://github.com/awfufu/go-hurobot/blob/main/example-command-cn.sh +# +# 这是一个关于如何为机器人创建外部命令的示例。 +# +# 1. 将此脚本(或其软链接)放置在机器人工作目录下的 `cmds/` 目录中。 +# 脚本或软链接的文件名将成为命令名。 +# 例如,如果你将其命名为 `hello`,则命令将为 `/hello`。 +# 确保脚本具有可执行权限:`chmod +x cmds/hello`。 +# +# 2. 获取参数: +# 传递给命令的参数可以通过标准的 bash 参数($1, $2, ...)获取。 +# +# 3. 获取消息上下文: +# 机器人会将原始消息的上下文注入为环境变量: +# - QBOT_USER_ID : 发送者的 QQ 号 +# - QBOT_GROUP_ID : 群组 ID(如果是群消息) +# - QBOT_MSG_ID : 消息 ID +# - QBOT_CHAT_TYPE : 聊天类型(数值) +# - QBOT_NAME : 发送者昵称 +# - QBOT_GROUP_CARD : 发送者群名片 +# +# 4. 返回响应: +# 任何打印到标准输出(echo)的内容都会作为回复发送回聊天。 + +USER_ID="${QBOT_USER_ID}" +NAME="${QBOT_NAME}" +ARG_1="$1" + +if [ -z "$ARG_1" ]; then + echo "用法: /example-command-cn.sh <内容>" + exit 0 +fi + +echo "你好, ${NAME} (${USER_ID})!" +echo "你说了: ${ARG_1}" +echo "这条消息是由外部脚本处理的。" +echo "了解更多: https://github.com/awfufu/go-hurobot/blob/main/example-command-cn.sh" diff --git a/example-command.sh b/example-command.sh new file mode 100755 index 0000000..03691f1 --- /dev/null +++ b/example-command.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# +# example-command.sh +# +# -> https://github.com/awfufu/go-hurobot/blob/main/example-command.sh +# +# This is an example of how to create an external command for the bot. +# +# 1. Place this script (or a symlink to it) in the `cmds/` directory inside the bot's working directory. +# The filename of the script/symlink will be the command name. +# For example, if you name it `hello`, the command will be `/hello`. +# Ensure the script is executable: `chmod +x cmds/hello`. +# +# 2. Accessing Arguments: +# Arguments passed to the command are available as standard bash arguments ($1, $2, ...). +# +# 3. Accessing Message Context: +# The bot injects the original message context as environment variables: +# - QBOT_USER_ID : Sender's QQ number +# - QBOT_GROUP_ID : Group ID (if in group) +# - QBOT_MSG_ID : Message ID +# - QBOT_CHAT_TYPE : Chat Type (numeric) +# - QBOT_NAME : Sender's Nickname +# - QBOT_GROUP_CARD : Sender's Group Card name +# +# 4. Returning Responses: +# Whatever you print to stdout (echo) will be sent back to the chat as a reply. + +USER_ID="${QBOT_USER_ID}" +NAME="${QBOT_NAME}" +ARG_1="$1" + +if [ -z "$ARG_1" ]; then + echo "Usage: /example-command.sh " + exit 0 +fi + +echo "Hello, ${NAME} (${USER_ID})!" +echo "You said: ${ARG_1}" +echo "This message was processed by a script." +echo "Learn more: https://github.com/awfufu/go-hurobot/blob/main/example-command.sh" diff --git a/go.mod b/go.mod index 7e2285e..a76b6d8 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,9 @@ require ( require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect golang.org/x/text v0.20.0 // indirect + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) diff --git a/go.sum b/go.sum index b4a489c..eefab08 100644 --- a/go.sum +++ b/go.sum @@ -2,18 +2,26 @@ github.com/Knetic/govaluate v3.0.0+incompatible h1:7o6+MAPhYTCF0+fdvoz1xDedhRb4f github.com/Knetic/govaluate v3.0.0+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0= github.com/awfufu/qbot v0.2.2 h1:rl7+j6JNjGzjjO5AHNqRXdM09Gwc+mQGsgBerJYVfVg= github.com/awfufu/qbot v0.2.2/go.mod h1:t6pYm54N7/YrxoMYFerZe2qx0t505iPeQQpTJvTNY+8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= diff --git a/internal/cmds/cmds.go b/internal/cmds/cmds.go index a8e7e56..6f7a4fa 100644 --- a/internal/cmds/cmds.go +++ b/internal/cmds/cmds.go @@ -1,11 +1,16 @@ package cmds import ( + "fmt" "log" "slices" "strconv" "strings" + "os" + "os/exec" + "path/filepath" + "github.com/awfufu/go-hurobot/internal/config" "github.com/awfufu/go-hurobot/internal/db" "github.com/awfufu/qbot" @@ -98,6 +103,7 @@ func HandleCommand(b *qbot.Sender, msg *qbot.Message) { cmd, exists := cmdMap[cmdName] if !exists { + tryFallbackCommand(b, msg, cmdName, raw) return } @@ -143,6 +149,9 @@ func HandleCommand(b *qbot.Sender, msg *qbot.Message) { if args == nil { return } + + b.SendGroupPoke(msg.GroupID, msg.UserID) + // argCount logic simplified as there is no skip argCount := len(args) if (cmdBase.MinArgs > 0 && argCount < cmdBase.MinArgs) || (cmdBase.MaxArgs > 0 && argCount > cmdBase.MaxArgs) { @@ -376,3 +385,85 @@ func str2int64(s string) int64 { } return value } + +func tryFallbackCommand(b *qbot.Sender, msg *qbot.Message, cmdName string, rawArgs string) { + // Security check: cmdName should be a simple filename, no path traversal + if strings.Contains(cmdName, "/") || strings.Contains(cmdName, "\\") || strings.Contains(cmdName, "..") { + return + } + + path := filepath.Join("cmds", cmdName) + info, err := os.Stat(path) + if err != nil { + return // Not found or error + } + if info.IsDir() { + return // Is directory + } + // Check if executable? os.Stat doesn't easily tell us, but we can try to exec. + // On Linux checking Mode() & 0111 is a hint, but attempting to run is robust. + + // Parse args + var args []string + if rawArgs != "" { + // Use shlex to split arguments like a shell + parts, err := shlex.Split(rawArgs) + if err != nil { + b.SendGroupMsg(msg.GroupID, "Error parsing arguments: "+err.Error()) + return + } + args = parts + } + + // Prepare command + cmd := exec.Command(path, args...) + + // Inject Environment Variables + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, + fmt.Sprintf("QBOT_CHAT_TYPE=%d", msg.ChatType), + fmt.Sprintf("QBOT_MSG_ID=%d", msg.MsgID), + fmt.Sprintf("QBOT_REPLY_ID=%d", msg.ReplyID), + fmt.Sprintf("QBOT_USER_ID=%d", msg.UserID), + "QBOT_NAME="+msg.Name, + fmt.Sprintf("QBOT_TIME=%d", msg.Time), + fmt.Sprintf("QBOT_GROUP_ID=%d", msg.GroupID), + "QBOT_GROUP_CARD="+msg.GroupCard, + fmt.Sprintf("QBOT_GROUP_ROLE=%d", msg.GroupRole), + ) + + // No special env or dir? "find work dir's ./cmds/" implies CWD is the bot's CWD. + // That is default for exec.Command. + + output, err := cmd.CombinedOutput() + outputStr := string(output) + + if err != nil { + log.Printf("Fallback cmd %s failed: %v", cmdName, err) + // If output is not empty, show it, otherwise show error + msgContent := fmt.Sprintf("Error: %v", err) + if len(outputStr) > 0 { + msgContent += "\nOutput:\n" + truncateFallbackOutput(outputStr) + } + b.SendGroupReplyMsg(msg.GroupID, msg.MsgID, msgContent) + return + } + + if len(outputStr) > 0 { + b.SendGroupReplyMsg(msg.GroupID, msg.MsgID, truncateFallbackOutput(outputStr)) + } else { + // No output, just verify success? maybe silent if success? + // User requirement "if not exist... ignore", but here it existed and ran. + // Usually a confused user wants feedback. + // "ok" or nothing? sh command returns "ok" only if empty output. + b.SendGroupReplyMsg(msg.GroupID, msg.MsgID, "ok") + } +} + +func truncateFallbackOutput(s string) string { + s = strings.TrimSpace(s) + if len(s) > 4000 { + return s[:4000] + "... (truncated)" + } + return s +}