问题: - 用户询问'我什么时候签到的'时,AI 说'系统只记录是否签到,没有时间戳' - 实际上数据库 signs 表有完整的 sign_time 和 sign_text 字段 解决方案: - 修改 executeCheck 方法,改用 ListSigns 查询今天的签到记录 - 返回完整的签到详情:签到时间(HH:MM:SS格式)+ 签到内容 - 未签到时仍返回简单提示 改动内容: - executeCheck 使用 ListSigns 替代 HasSignedOnDay - 构建 ListOptions 查询今天的签到记录(过滤 NodeID + 时间范围) - 返回格式:'XXX 今天已经签到过了。\n签到时间:10:30:45\n签到内容:上海-Kevin-GAT562签到' - 更新测试验证返回内容包含时间和签到文本 - 更新 mockSignStore.ListSigns 支持 NodeID 和 Limit 过滤 使用效果: - 用户:'我今天签到了吗?' → 返回是否签到 + 时间 + 内容 - 用户:'我什么时候签到的?' → 返回签到时间:10:30:45 测试: - ✅ 未签到场景测试通过 - ✅ 已签到场景测试通过(验证时间和内容) - ✅ 所有签到工具测试通过 - ✅ 项目编译成功 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
400 lines
13 KiB
Go
400 lines
13 KiB
Go
// Package sign 提供签到工具。当用户想签到时,把签到信息写入 signs 表,
|
||
// 每个节点每天只能签到一次。
|
||
//
|
||
// 节点身份(node_id / long_name / short_name)由 autoreply 在处理队列消息时
|
||
// 通过 ctx 注入(见 agenttool.NodeContext);text_message 包本身不含名字,
|
||
// 队列记录里的 long_name/short_name 经常为空,因此签到工具会在名字缺失时
|
||
// 用 node_id 查 nodeinfo 表补全。签到正文里的地区、名字、设备等字段则由
|
||
// LLM 从用户消息中提取后作为工具参数传入。
|
||
package sign
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"meshtastic_mqtt_server/internal/agenttool"
|
||
storepkg "meshtastic_mqtt_server/internal/store"
|
||
|
||
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model"
|
||
)
|
||
|
||
// SignStore 定义签到工具所需的持久化能力,通常由 *store.Store 实现。
|
||
type SignStore interface {
|
||
CreateSign(nodeID string, longName, shortName *string, signText string, signTime time.Time) (*storepkg.SignRecord, error)
|
||
HasSignedOnDay(nodeID string, day time.Time) (bool, error)
|
||
GetNodeInfo(nodeID string) (*storepkg.NodeInfoRecord, error)
|
||
CountSigns(opts storepkg.ListOptions) (int64, error)
|
||
CountSignsByDay(opts storepkg.ListOptions) ([]storepkg.SignDayCount, error)
|
||
ListSigns(opts storepkg.ListOptions) ([]storepkg.SignRecord, error)
|
||
}
|
||
|
||
// Tool 是签到工具。
|
||
type Tool struct {
|
||
enabled bool
|
||
store SignStore
|
||
}
|
||
|
||
// Name returns the tool name
|
||
func (t *Tool) Name() string { return "sign" }
|
||
|
||
// Enabled returns whether the tool is enabled
|
||
func (t *Tool) Enabled() bool { return t.enabled && t.store != nil }
|
||
|
||
// ToolDefinition returns the OpenAI tool definition
|
||
func (t *Tool) ToolDefinition(description string) *model.Tool {
|
||
desc := "签到工具。支持三种操作:\n" +
|
||
"1. 签到操作(action=sign):记录节点今日签到信息,每个节点每天只能签到一次。必填参数:地区、名字、设备\n" +
|
||
"2. 查询操作(action=query):查询签到统计。可按日期范围查询,默认查询今天。返回签到总数和按天的统计数据\n" +
|
||
"3. 检查操作(action=check):检查当前节点今天是否已签到。用于回答用户'我今天签到了吗'之类的问题"
|
||
if description != "" {
|
||
desc = description
|
||
}
|
||
return &model.Tool{
|
||
Type: model.ToolTypeFunction,
|
||
Function: &model.FunctionDefinition{
|
||
Name: "sign",
|
||
Description: desc,
|
||
Parameters: map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"action": map[string]any{
|
||
"type": "string",
|
||
"enum": []string{"sign", "query", "check"},
|
||
"description": "操作类型:sign=签到,query=查询签到统计,check=检查今天是否已签到",
|
||
},
|
||
"region": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时必填:地区,例如 \"上海闵行\"、\"安徽\"、\"广东深圳\"",
|
||
},
|
||
"name": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时必填:签到用户的名字/呼号,例如 \"Kevin\"、\"TaoEngine\"",
|
||
},
|
||
"device": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时必填:使用的设备型号,例如 \"GAT562\"、\"EBYTE_EoRa_S3\"",
|
||
},
|
||
"tx_power": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时可选:发射功率,例如 \"25mW\"、\"100mW\"",
|
||
},
|
||
"antenna_length": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时可选:天线长度,例如 \"5dBi\"、\"1.2m\"",
|
||
},
|
||
"altitude": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时可选:身处高度,例如 \"30m\"、\"海拔500m\"",
|
||
},
|
||
"raw_text": map[string]any{
|
||
"type": "string",
|
||
"description": "签到时可选:用户原始签到文本。当无法准确拆分地区/名字/设备时,传入用户原文作为签到正文",
|
||
},
|
||
"date": map[string]any{
|
||
"type": "string",
|
||
"description": "查询时可选:查询日期,格式 YYYY-MM-DD,例如 \"2024-06-23\"。不填则查询今天",
|
||
},
|
||
"days": map[string]any{
|
||
"type": "integer",
|
||
"description": "查询时可选:查询最近N天的数据,例如 7 表示最近7天。不填则只查询date指定的那一天",
|
||
},
|
||
},
|
||
"required": []string{"action"},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// Execute executes the sign tool
|
||
func (t *Tool) Execute(ctx context.Context, args string, runtime agenttool.Runtime) (string, error) {
|
||
if t.store == nil {
|
||
return "", fmt.Errorf("sign store is not configured")
|
||
}
|
||
|
||
var params signParams
|
||
if err := json.Unmarshal([]byte(args), ¶ms); err != nil {
|
||
return "", fmt.Errorf("failed to parse arguments: %w", err)
|
||
}
|
||
|
||
// 根据 action 参数路由到不同的操作
|
||
switch strings.ToLower(strings.TrimSpace(params.Action)) {
|
||
case "query":
|
||
return t.executeQuery(ctx, params, runtime)
|
||
case "check":
|
||
return t.executeCheck(ctx, params, runtime)
|
||
case "sign", "":
|
||
return t.executeSign(ctx, params, runtime)
|
||
default:
|
||
return "", fmt.Errorf("无效的操作类型:%s,只支持 sign、query 或 check", params.Action)
|
||
}
|
||
}
|
||
|
||
// executeSign 执行签到操作
|
||
func (t *Tool) executeSign(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
|
||
params.Region = strings.TrimSpace(params.Region)
|
||
params.Name = strings.TrimSpace(params.Name)
|
||
params.Device = strings.TrimSpace(params.Device)
|
||
params.RawText = strings.TrimSpace(params.RawText)
|
||
if params.Region == "" || params.Name == "" || params.Device == "" {
|
||
if params.RawText == "" {
|
||
return "", fmt.Errorf("region, name, device 都是必填参数")
|
||
}
|
||
}
|
||
|
||
// 节点身份来自消息上下文,而非 LLM 回填,保证「每节点每天一次」判定可靠。
|
||
node, ok := agenttool.NodeContextFromContext(ctx)
|
||
if !ok || strings.TrimSpace(node.NodeID) == "" {
|
||
return "", fmt.Errorf("缺少发送节点上下文,无法签到")
|
||
}
|
||
|
||
now := runtime.Now
|
||
if now.IsZero() {
|
||
now = time.Now()
|
||
}
|
||
|
||
// 每个节点每天只能签到一次
|
||
signed, err := t.store.HasSignedOnDay(node.NodeID, now)
|
||
if err != nil {
|
||
return fmt.Sprintf("签到失败:检查今日签到状态时出错:%v", err), nil
|
||
}
|
||
if signed {
|
||
return fmt.Sprintf("%s 今天已经签到过了,每个节点每天只能签到一次。", displayName(node)), nil
|
||
}
|
||
|
||
signText := buildSignText(params)
|
||
if signText == "" {
|
||
// 结构化字段缺失时回退到用户原始文本
|
||
signText = params.RawText
|
||
}
|
||
longName, shortName := resolveNodeNames(t, node)
|
||
|
||
record, err := t.store.CreateSign(node.NodeID, longName, shortName, signText, now)
|
||
if err != nil {
|
||
return fmt.Sprintf("签到失败:%v", err), nil
|
||
}
|
||
|
||
return fmt.Sprintf("签到成功!%s\n签到内容:%s", displayName(node), record.SignText), nil
|
||
}
|
||
|
||
// executeCheck 检查当前节点今天是否已签到,并返回签到详情
|
||
func (t *Tool) executeCheck(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
|
||
// 节点身份来自消息上下文
|
||
node, ok := agenttool.NodeContextFromContext(ctx)
|
||
if !ok || strings.TrimSpace(node.NodeID) == "" {
|
||
return "", fmt.Errorf("缺少发送节点上下文,无法检查签到状态")
|
||
}
|
||
|
||
now := runtime.Now
|
||
if now.IsZero() {
|
||
now = time.Now()
|
||
}
|
||
|
||
// 构建查询选项:查询今天的签到记录
|
||
loc := now.Location()
|
||
if loc == nil {
|
||
loc = time.Local
|
||
}
|
||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||
end := start.AddDate(0, 0, 1)
|
||
|
||
opts := storepkg.ListOptions{
|
||
NodeID: node.NodeID,
|
||
Since: &start,
|
||
Until: &end,
|
||
Limit: 1,
|
||
}
|
||
|
||
// 查询数据库获取今天的签到记录
|
||
signs, err := t.store.ListSigns(opts)
|
||
if err != nil {
|
||
return "", fmt.Errorf("查询签到记录失败:%w", err)
|
||
}
|
||
|
||
if len(signs) == 0 {
|
||
return fmt.Sprintf("%s 今天还没有签到。", displayName(node)), nil
|
||
}
|
||
|
||
// 返回签到详情
|
||
sign := signs[0]
|
||
signTimeStr := sign.SignTime.Format("15:04:05")
|
||
return fmt.Sprintf("%s 今天已经签到过了。\n签到时间:%s\n签到内容:%s",
|
||
displayName(node), signTimeStr, sign.SignText), nil
|
||
}
|
||
|
||
// executeQuery 执行查询操作
|
||
func (t *Tool) executeQuery(ctx context.Context, params signParams, runtime agenttool.Runtime) (string, error) {
|
||
now := runtime.Now
|
||
if now.IsZero() {
|
||
now = time.Now()
|
||
}
|
||
|
||
// 解析查询日期
|
||
var targetDate time.Time
|
||
if params.Date != "" {
|
||
var err error
|
||
targetDate, err = time.Parse("2006-01-02", params.Date)
|
||
if err != nil {
|
||
return "", fmt.Errorf("日期格式错误,应为 YYYY-MM-DD:%v", err)
|
||
}
|
||
} else {
|
||
// 默认查询今天
|
||
targetDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||
}
|
||
|
||
// 构建查询选项
|
||
var opts storepkg.ListOptions
|
||
if params.Days > 0 {
|
||
// 查询最近N天
|
||
since := targetDate.AddDate(0, 0, -params.Days+1)
|
||
until := targetDate.Add(24*time.Hour - time.Nanosecond)
|
||
opts.Since = &since
|
||
opts.Until = &until
|
||
} else {
|
||
// 只查询指定的那一天
|
||
since := targetDate
|
||
until := targetDate.Add(24*time.Hour - time.Nanosecond)
|
||
opts.Since = &since
|
||
opts.Until = &until
|
||
}
|
||
|
||
// 获取总数
|
||
total, err := t.store.CountSigns(opts)
|
||
if err != nil {
|
||
return "", fmt.Errorf("查询签到总数失败:%w", err)
|
||
}
|
||
|
||
// 获取按天统计
|
||
dayCounts, err := t.store.CountSignsByDay(opts)
|
||
if err != nil {
|
||
return "", fmt.Errorf("查询按天统计失败:%w", err)
|
||
}
|
||
|
||
// 构建返回消息
|
||
var result strings.Builder
|
||
if params.Days > 0 {
|
||
result.WriteString(fmt.Sprintf("最近 %d 天的签到统计:\n", params.Days))
|
||
} else {
|
||
result.WriteString(fmt.Sprintf("%s 的签到统计:\n", targetDate.Format("2006-01-02")))
|
||
}
|
||
result.WriteString(fmt.Sprintf("总计:%d 人次\n\n", total))
|
||
|
||
if len(dayCounts) > 0 {
|
||
result.WriteString("按天统计:\n")
|
||
for _, dc := range dayCounts {
|
||
result.WriteString(fmt.Sprintf("- %s: %d 人\n", dc.Date, dc.Count))
|
||
}
|
||
} else {
|
||
result.WriteString("该时间段内没有签到记录")
|
||
}
|
||
|
||
return result.String(), nil
|
||
}
|
||
|
||
// resolveNodeNames 取出节点的 long_name / short_name。
|
||
// text_message 包本身不含名字,队列记录里的 long_name/short_name 经常为空;
|
||
// 此时用 node_id 查 nodeinfo 表补全,保证签到记录里能看到节点名。
|
||
// 仍查不到则返回两个 nil,签到照常进行(仅名字字段为空)。
|
||
func resolveNodeNames(t *Tool, node agenttool.NodeContext) (*string, *string) {
|
||
var longName, shortName *string
|
||
if ln := strings.TrimSpace(node.LongName); ln != "" {
|
||
longName = &ln
|
||
}
|
||
if sn := strings.TrimSpace(node.ShortName); sn != "" {
|
||
shortName = &sn
|
||
}
|
||
// 队列上下文已有名字就直接用,无需查库
|
||
if longName != nil && shortName != nil {
|
||
return longName, shortName
|
||
}
|
||
if t.store == nil {
|
||
return longName, shortName
|
||
}
|
||
info, err := t.store.GetNodeInfo(node.NodeID)
|
||
if err != nil {
|
||
return longName, shortName
|
||
}
|
||
if info == nil {
|
||
return longName, shortName
|
||
}
|
||
if longName == nil && info.LongName != nil {
|
||
if v := strings.TrimSpace(*info.LongName); v != "" {
|
||
longName = &v
|
||
}
|
||
}
|
||
if shortName == nil && info.ShortName != nil {
|
||
if v := strings.TrimSpace(*info.ShortName); v != "" {
|
||
shortName = &v
|
||
}
|
||
}
|
||
return longName, shortName
|
||
}
|
||
|
||
// signParams 是签到工具的入参。
|
||
type signParams struct {
|
||
Action string `json:"action"` // 操作类型:sign=签到,query=查询
|
||
Region string `json:"region"` // 签到时使用
|
||
Name string `json:"name"` // 签到时使用
|
||
Device string `json:"device"` // 签到时使用
|
||
TxPower string `json:"tx_power"` // 签到时使用
|
||
AntennaLength string `json:"antenna_length"` // 签到时使用
|
||
Altitude string `json:"altitude"` // 签到时使用
|
||
RawText string `json:"raw_text"` // 签到时使用:用户原始签到文本
|
||
Date string `json:"date"` // 查询时使用:日期 YYYY-MM-DD
|
||
Days int `json:"days"` // 查询时使用:查询最近N天
|
||
}
|
||
|
||
// buildSignText 按参考格式拼装签到正文:地区-名字-设备签到,可选信息附在括号内。
|
||
// 当 region/name/device 任一缺失时返回空串,由调用方回退到 RawText。
|
||
func buildSignText(p signParams) string {
|
||
if strings.TrimSpace(p.Region) == "" || strings.TrimSpace(p.Name) == "" || strings.TrimSpace(p.Device) == "" {
|
||
return ""
|
||
}
|
||
text := fmt.Sprintf("%s-%s-%s签到", p.Region, p.Name, p.Device)
|
||
|
||
var extras []string
|
||
if v := strings.TrimSpace(p.TxPower); v != "" {
|
||
extras = append(extras, "发射功率 "+v)
|
||
}
|
||
if v := strings.TrimSpace(p.AntennaLength); v != "" {
|
||
extras = append(extras, "天线 "+v)
|
||
}
|
||
if v := strings.TrimSpace(p.Altitude); v != "" {
|
||
extras = append(extras, "高度 "+v)
|
||
}
|
||
if len(extras) > 0 {
|
||
text += "(" + strings.Join(extras, ",") + ")"
|
||
}
|
||
return text
|
||
}
|
||
|
||
func displayName(node agenttool.NodeContext) string {
|
||
if node.LongName != "" {
|
||
return node.LongName
|
||
}
|
||
if node.ShortName != "" {
|
||
return node.ShortName
|
||
}
|
||
return node.NodeID
|
||
}
|
||
|
||
// RawState returns the tool state
|
||
func (t *Tool) RawState() any {
|
||
return map[string]any{"enabled": t.enabled, "has_store": t.store != nil}
|
||
}
|
||
|
||
func init() {
|
||
agenttool.Register(agenttool.Descriptor{
|
||
Name: "sign",
|
||
Load: func(path string, options agenttool.LoadOptions) (agenttool.LoadedTool, error) {
|
||
tool := &Tool{enabled: true}
|
||
if store, ok := options.Value("store").(SignStore); ok && store != nil {
|
||
tool.store = store
|
||
}
|
||
return tool, nil
|
||
},
|
||
})
|
||
}
|