feat: add support for external script commands

This commit is contained in:
2026-01-17 14:52:34 +08:00
parent 6b0f0c451c
commit a9654ed9aa
5 changed files with 185 additions and 1 deletions

41
example-command-cn.sh Executable file
View File

@@ -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"

41
example-command.sh Executable file
View File

@@ -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 <something>"
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"

3
go.mod
View File

@@ -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
)

10
go.sum
View File

@@ -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=

View File

@@ -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
}