fix feishu calendar delivery and timezone handling
This commit is contained in:
parent
5ac3b8fec1
commit
ffa90be27b
10 changed files with 839 additions and 8 deletions
|
|
@ -26,6 +26,7 @@ services:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||||
environment:
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
HTTP_PROXY: http://127.0.0.1:7890
|
HTTP_PROXY: http://127.0.0.1:7890
|
||||||
HTTPS_PROXY: http://127.0.0.1:7890
|
HTTPS_PROXY: http://127.0.0.1:7890
|
||||||
NO_PROXY: localhost,127.0.0.1,::1
|
NO_PROXY: localhost,127.0.0.1,::1
|
||||||
|
|
@ -68,6 +69,7 @@ services:
|
||||||
# Persistent workspace (sessions, memory, logs)
|
# Persistent workspace (sessions, memory, logs)
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||||
environment:
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
HTTP_PROXY: http://127.0.0.1:7890
|
HTTP_PROXY: http://127.0.0.1:7890
|
||||||
HTTPS_PROXY: http://127.0.0.1:7890
|
HTTPS_PROXY: http://127.0.0.1:7890
|
||||||
NO_PROXY: localhost,127.0.0.1,::1
|
NO_PROXY: localhost,127.0.0.1,::1
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ type processOptions struct {
|
||||||
SessionKey string // Session identifier for history/context
|
SessionKey string // Session identifier for history/context
|
||||||
Channel string // Target channel for tool execution
|
Channel string // Target channel for tool execution
|
||||||
ChatID string // Target chat ID for tool execution
|
ChatID string // Target chat ID for tool execution
|
||||||
|
SenderID string // Message sender identifier for tool execution
|
||||||
UserMessage string // User message content (may include prefix)
|
UserMessage string // User message content (may include prefix)
|
||||||
DefaultResponse string // Response when LLM returns empty
|
DefaultResponse string // Response when LLM returns empty
|
||||||
EnableSummary bool // Whether to trigger summarization
|
EnableSummary bool // Whether to trigger summarization
|
||||||
|
|
@ -131,6 +132,12 @@ func registerSharedTools(
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
|
|
||||||
|
// Feishu calendar tool
|
||||||
|
if strings.TrimSpace(cfg.Channels.Feishu.AppID) != "" &&
|
||||||
|
strings.TrimSpace(cfg.Channels.Feishu.AppSecret) != "" {
|
||||||
|
agent.Tools.Register(tools.NewFeishuCalendarTool(cfg.Channels.Feishu))
|
||||||
|
}
|
||||||
|
|
||||||
// Skill discovery and installation tools
|
// Skill discovery and installation tools
|
||||||
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
|
||||||
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
|
||||||
|
|
@ -382,6 +389,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
|
SenderID: msg.SenderID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: "I've completed processing but have no response to give.",
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
|
|
@ -747,6 +755,7 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
tc.Arguments,
|
tc.Arguments,
|
||||||
opts.Channel,
|
opts.Channel,
|
||||||
opts.ChatID,
|
opts.ChatID,
|
||||||
|
opts.SenderID,
|
||||||
asyncCallback,
|
asyncCallback,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
54
pkg/tools/context_values.go
Normal file
54
pkg/tools/context_values.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type executionContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
executionContextChannelKey executionContextKey = "tool_execution_channel"
|
||||||
|
executionContextChatIDKey executionContextKey = "tool_execution_chat_id"
|
||||||
|
executionContextSenderIDKey executionContextKey = "tool_execution_sender_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withExecutionContext(ctx context.Context, channel, chatID, senderID string) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(channel) != "" {
|
||||||
|
ctx = context.WithValue(ctx, executionContextChannelKey, strings.TrimSpace(channel))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(chatID) != "" {
|
||||||
|
ctx = context.WithValue(ctx, executionContextChatIDKey, strings.TrimSpace(chatID))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(senderID) != "" {
|
||||||
|
ctx = context.WithValue(ctx, executionContextSenderIDKey, strings.TrimSpace(senderID))
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolExecutionChannel(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(executionContextChannelKey).(string)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolExecutionChatID(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(executionContextChatIDKey).(string)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolExecutionSenderID(ctx context.Context) string {
|
||||||
|
if ctx == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, _ := ctx.Value(executionContextSenderIDKey).(string)
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
548
pkg/tools/feishu_calendar.go
Normal file
548
pkg/tools/feishu_calendar.go
Normal file
|
|
@ -0,0 +1,548 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkcalendar "github.com/larksuite/oapi-sdk-go/v3/service/calendar/v4"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultFeishuCalendarTimezone = "Asia/Shanghai"
|
||||||
|
|
||||||
|
// FeishuCalendarTool creates calendar events in Feishu/Lark.
|
||||||
|
type FeishuCalendarTool struct {
|
||||||
|
cfg config.FeishuConfig
|
||||||
|
client *lark.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFeishuCalendarTool(cfg config.FeishuConfig) *FeishuCalendarTool {
|
||||||
|
return &FeishuCalendarTool{
|
||||||
|
cfg: cfg,
|
||||||
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuCalendarTool) Name() string {
|
||||||
|
return "feishu_calendar"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuCalendarTool) Description() string {
|
||||||
|
return "Create events in Feishu/Lark calendar. Use this when user asks to add a calendar item, schedule, or agenda in Feishu."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuCalendarTool) Parameters() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"summary": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Event title/summary.",
|
||||||
|
},
|
||||||
|
"start_time": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Start time. Prefer RFC3339 (e.g. 2026-02-25T14:00:00+08:00). Also supports 'YYYY-MM-DD HH:MM'.",
|
||||||
|
},
|
||||||
|
"end_time": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional end time. If omitted, duration_minutes is used.",
|
||||||
|
},
|
||||||
|
"duration_minutes": map[string]any{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Duration in minutes when end_time is omitted. Default 30.",
|
||||||
|
},
|
||||||
|
"timezone": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional IANA timezone (e.g. Asia/Shanghai, UTC). Used for local datetime inputs.",
|
||||||
|
},
|
||||||
|
"description": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional event description.",
|
||||||
|
},
|
||||||
|
"calendar_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional calendar ID. If omitted, tool uses primary calendar.",
|
||||||
|
},
|
||||||
|
"location_name": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional event location name.",
|
||||||
|
},
|
||||||
|
"location_address": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional event location address.",
|
||||||
|
},
|
||||||
|
"attendee_user_ids": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "string"},
|
||||||
|
"description": "Optional attendee user_id list.",
|
||||||
|
},
|
||||||
|
"reminder_minutes": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{"type": "integer"},
|
||||||
|
"description": "Optional reminder minutes before start (e.g. [10, 30]).",
|
||||||
|
},
|
||||||
|
"recurrence": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional RFC5545 recurrence rule, e.g. FREQ=DAILY;INTERVAL=1.",
|
||||||
|
},
|
||||||
|
"need_notification": map[string]any{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to notify attendees by bot notification. Default true.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"summary", "start_time"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuCalendarTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
if strings.TrimSpace(t.cfg.AppID) == "" || strings.TrimSpace(t.cfg.AppSecret) == "" {
|
||||||
|
return ErrorResult("feishu app_id/app_secret is missing in config.channels.feishu")
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, ok := getStringArg(args, "summary")
|
||||||
|
if !ok || strings.TrimSpace(summary) == "" {
|
||||||
|
return ErrorResult("summary is required")
|
||||||
|
}
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
|
||||||
|
startRaw, ok := getStringArg(args, "start_time")
|
||||||
|
if !ok || strings.TrimSpace(startRaw) == "" {
|
||||||
|
return ErrorResult("start_time is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tzInput, _ := getStringArg(args, "timezone")
|
||||||
|
tzInput = strings.TrimSpace(tzInput)
|
||||||
|
loc := defaultFeishuLocation()
|
||||||
|
if tzInput != "" {
|
||||||
|
var err error
|
||||||
|
loc, err = time.LoadLocation(tzInput)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid timezone %q, use IANA timezone like Asia/Shanghai", tzInput))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start, startHasExplicitTZ, err := parseFeishuDateTime(strings.TrimSpace(startRaw), loc)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid start_time: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var end time.Time
|
||||||
|
if endRaw, ok := getStringArg(args, "end_time"); ok && strings.TrimSpace(endRaw) != "" {
|
||||||
|
end, _, err = parseFeishuDateTime(strings.TrimSpace(endRaw), loc)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("invalid end_time: %v", err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
duration, err := parseOptionalIntArg(args, "duration_minutes", 30, 1, 24*60*30)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
end = start.Add(time.Duration(duration) * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !end.After(start) {
|
||||||
|
return ErrorResult("end time must be after start time")
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarID, _ := getStringArg(args, "calendar_id")
|
||||||
|
calendarID = strings.TrimSpace(calendarID)
|
||||||
|
if shouldUseFeishuPrimaryCalendar(calendarID) {
|
||||||
|
resolvedID, err := t.resolvePrimaryCalendarID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("resolve primary calendar failed: %v", err))
|
||||||
|
}
|
||||||
|
calendarID = resolvedID
|
||||||
|
}
|
||||||
|
|
||||||
|
eventTZ, err := resolveFeishuEventTimezone(tzInput, start, startHasExplicitTZ)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBuilder := larkcalendar.NewCalendarEventBuilder().
|
||||||
|
Summary(summary).
|
||||||
|
StartTime(buildFeishuTimeInfo(start, eventTZ)).
|
||||||
|
EndTime(buildFeishuTimeInfo(end, eventTZ))
|
||||||
|
|
||||||
|
if description, ok := getStringArg(args, "description"); ok && strings.TrimSpace(description) != "" {
|
||||||
|
eventBuilder.Description(strings.TrimSpace(description))
|
||||||
|
}
|
||||||
|
|
||||||
|
locationName, _ := getStringArg(args, "location_name")
|
||||||
|
locationAddress, _ := getStringArg(args, "location_address")
|
||||||
|
locationName = strings.TrimSpace(locationName)
|
||||||
|
locationAddress = strings.TrimSpace(locationAddress)
|
||||||
|
if locationName != "" || locationAddress != "" {
|
||||||
|
locationBuilder := larkcalendar.NewEventLocationBuilder()
|
||||||
|
if locationName != "" {
|
||||||
|
locationBuilder.Name(locationName)
|
||||||
|
}
|
||||||
|
if locationAddress != "" {
|
||||||
|
locationBuilder.Address(locationAddress)
|
||||||
|
}
|
||||||
|
eventBuilder.Location(locationBuilder.Build())
|
||||||
|
}
|
||||||
|
|
||||||
|
if recurrence, ok := getStringArg(args, "recurrence"); ok && strings.TrimSpace(recurrence) != "" {
|
||||||
|
eventBuilder.Recurrence(strings.TrimSpace(recurrence))
|
||||||
|
}
|
||||||
|
|
||||||
|
attendeeIDs, err := parseStringSliceArg(args, "attendee_user_ids")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
inviteeUserIDs := buildFeishuInviteeUserIDs(attendeeIDs, toolExecutionChannel(ctx), toolExecutionSenderID(ctx))
|
||||||
|
|
||||||
|
reminderMinutes, err := parseReminderMinutesArg(args, "reminder_minutes")
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
if len(reminderMinutes) > 0 {
|
||||||
|
reminders := make([]*larkcalendar.Reminder, 0, len(reminderMinutes))
|
||||||
|
for _, minutes := range reminderMinutes {
|
||||||
|
reminders = append(reminders, larkcalendar.NewReminderBuilder().Minutes(minutes).Build())
|
||||||
|
}
|
||||||
|
eventBuilder.Reminders(reminders)
|
||||||
|
}
|
||||||
|
|
||||||
|
needNotification, err := parseBoolArg(args, "need_notification", true)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(err.Error())
|
||||||
|
}
|
||||||
|
eventBuilder.NeedNotification(needNotification)
|
||||||
|
|
||||||
|
req := larkcalendar.NewCreateCalendarEventReqBuilder().
|
||||||
|
CalendarId(calendarID).
|
||||||
|
UserIdType(larkcalendar.UserIdTypeCreateCalendarEventUserId).
|
||||||
|
IdempotencyKey(generateFeishuIdempotencyKey()).
|
||||||
|
CalendarEvent(eventBuilder.Build()).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := t.client.Calendar.V4.CalendarEvent.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("tools.feishu_calendar", "Create Feishu calendar event request failed", map[string]any{
|
||||||
|
"calendar_id": calendarID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ErrorResult(fmt.Sprintf("create feishu calendar event failed: %v", err))
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
fields := map[string]any{
|
||||||
|
"calendar_id": calendarID,
|
||||||
|
"code": resp.Code,
|
||||||
|
"msg": resp.Msg,
|
||||||
|
}
|
||||||
|
if len(resp.RawBody) > 0 {
|
||||||
|
fields["raw_body"] = string(resp.RawBody)
|
||||||
|
}
|
||||||
|
if resp.Code == 99991672 {
|
||||||
|
fields["hint"] = "missing app scopes: enable calendar and calendar event write scopes, then publish app version"
|
||||||
|
}
|
||||||
|
logger.ErrorCF("tools.feishu_calendar", "Feishu calendar event rejected", fields)
|
||||||
|
return ErrorResult(fmt.Sprintf("feishu calendar api error: code=%d msg=%s", resp.Code, resp.Msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
eventID := ""
|
||||||
|
eventLink := ""
|
||||||
|
if resp.Data != nil && resp.Data.Event != nil {
|
||||||
|
if resp.Data.Event.EventId != nil {
|
||||||
|
eventID = *resp.Data.Event.EventId
|
||||||
|
}
|
||||||
|
if resp.Data.Event.AppLink != nil {
|
||||||
|
eventLink = *resp.Data.Event.AppLink
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attendeesAdded := len(inviteeUserIDs) == 0
|
||||||
|
attendeeWarning := ""
|
||||||
|
if len(inviteeUserIDs) > 0 {
|
||||||
|
if strings.TrimSpace(eventID) == "" {
|
||||||
|
attendeeWarning = "event_id missing, unable to add attendees"
|
||||||
|
} else if err := t.addFeishuEventAttendees(ctx, calendarID, eventID, inviteeUserIDs, needNotification); err != nil {
|
||||||
|
attendeeWarning = err.Error()
|
||||||
|
} else {
|
||||||
|
attendeesAdded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attendeeWarning != "" {
|
||||||
|
eventLink = ""
|
||||||
|
logger.WarnCF("tools.feishu_calendar", "Feishu attendee auto-add failed", map[string]any{
|
||||||
|
"calendar_id": calendarID,
|
||||||
|
"event_id": eventID,
|
||||||
|
"sender_id": toolExecutionSenderID(ctx),
|
||||||
|
"warning": attendeeWarning,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCF("tools.feishu_calendar", "Feishu calendar event created", map[string]any{
|
||||||
|
"calendar_id": calendarID,
|
||||||
|
"event_id": eventID,
|
||||||
|
"start_unix": start.Unix(),
|
||||||
|
"end_unix": end.Unix(),
|
||||||
|
"attendees": len(inviteeUserIDs),
|
||||||
|
"added": attendeesAdded,
|
||||||
|
})
|
||||||
|
|
||||||
|
result := fmt.Sprintf(
|
||||||
|
"Feishu calendar event created: %s (%s to %s, calendar_id=%s)",
|
||||||
|
summary,
|
||||||
|
start.Format(time.RFC3339),
|
||||||
|
end.Format(time.RFC3339),
|
||||||
|
calendarID,
|
||||||
|
)
|
||||||
|
if eventID != "" {
|
||||||
|
result += fmt.Sprintf(", event_id=%s", eventID)
|
||||||
|
}
|
||||||
|
if eventLink != "" {
|
||||||
|
result += fmt.Sprintf(", link=%s", eventLink)
|
||||||
|
}
|
||||||
|
if attendeeWarning != "" {
|
||||||
|
result += fmt.Sprintf(", warning=%s", attendeeWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SilentResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuCalendarTool) resolvePrimaryCalendarID(ctx context.Context) (string, error) {
|
||||||
|
resp, err := t.client.Calendar.V4.Calendar.Primary(
|
||||||
|
ctx,
|
||||||
|
larkcalendar.NewPrimaryCalendarReqBuilder().Build(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("call primary calendar api: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return "", fmt.Errorf("feishu calendar primary api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
if resp.Data == nil || len(resp.Data.Calendars) == 0 {
|
||||||
|
return "", fmt.Errorf("no primary calendar returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userCalendar := range resp.Data.Calendars {
|
||||||
|
if userCalendar == nil || userCalendar.Calendar == nil || userCalendar.Calendar.CalendarId == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
calendarID := strings.TrimSpace(*userCalendar.Calendar.CalendarId)
|
||||||
|
if calendarID != "" {
|
||||||
|
return calendarID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("primary calendar id is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFeishuDateTime(raw string, loc *time.Location) (time.Time, bool, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return time.Time{}, false, fmt.Errorf("empty datetime")
|
||||||
|
}
|
||||||
|
|
||||||
|
if unix, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
if loc == nil {
|
||||||
|
return time.Unix(unix, 0), false, nil
|
||||||
|
}
|
||||||
|
return time.Unix(unix, 0).In(loc), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||||
|
return t, true, nil
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return t, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if loc == nil {
|
||||||
|
loc = defaultFeishuLocation()
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02 15:04",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02T15:04",
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if t, err := time.ParseInLocation(layout, raw, loc); err == nil {
|
||||||
|
return t, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, false, fmt.Errorf("unsupported datetime format: %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFeishuEventTimezone(requested string, parsed time.Time, hasExplicitTZ bool) (string, error) {
|
||||||
|
if requested != "" {
|
||||||
|
if _, err := time.LoadLocation(requested); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid timezone %q, use IANA timezone like Asia/Shanghai", requested)
|
||||||
|
}
|
||||||
|
return requested, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasExplicitTZ {
|
||||||
|
_, offset := parsed.Zone()
|
||||||
|
if offset == 0 {
|
||||||
|
return "UTC", nil
|
||||||
|
}
|
||||||
|
if offset%3600 == 0 && offset >= -14*3600 && offset <= 14*3600 {
|
||||||
|
hours := offset / 3600
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("Etc/GMT-%d", hours), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Etc/GMT+%d", -hours), nil
|
||||||
|
}
|
||||||
|
return "UTC", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultFeishuCalendarTimezone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFeishuTimeInfo(ts time.Time, timezone string) *larkcalendar.TimeInfo {
|
||||||
|
builder := larkcalendar.NewTimeInfoBuilder().
|
||||||
|
Timestamp(strconv.FormatInt(ts.Unix(), 10))
|
||||||
|
if strings.TrimSpace(timezone) != "" {
|
||||||
|
builder.Timezone(strings.TrimSpace(timezone))
|
||||||
|
}
|
||||||
|
return builder.Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseReminderMinutesArg(args map[string]any, key string) ([]int, error) {
|
||||||
|
raw, exists := args[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize := func(v any) (int, error) {
|
||||||
|
n, err := toInt(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%s must be integers", key)
|
||||||
|
}
|
||||||
|
if n < 0 || n > 525600 {
|
||||||
|
return 0, fmt.Errorf("%s value %d is out of range [0, 525600]", key, n)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]int, 0)
|
||||||
|
switch typed := raw.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range typed {
|
||||||
|
n, err := normalize(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values = append(values, n)
|
||||||
|
}
|
||||||
|
case []int:
|
||||||
|
for _, item := range typed {
|
||||||
|
n, err := normalize(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values = append(values, n)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
n, err := normalize(typed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s must be an integer or array of integers", key)
|
||||||
|
}
|
||||||
|
values = append(values, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(values)
|
||||||
|
return slices.Compact(values), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFeishuLocation() *time.Location {
|
||||||
|
loc, err := time.LoadLocation(defaultFeishuCalendarTimezone)
|
||||||
|
if err != nil {
|
||||||
|
return time.Local
|
||||||
|
}
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUseFeishuPrimaryCalendar(calendarID string) bool {
|
||||||
|
calendarID = strings.TrimSpace(calendarID)
|
||||||
|
return calendarID == "" || strings.EqualFold(calendarID, "primary")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFeishuIdempotencyKey() string {
|
||||||
|
return uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFeishuInviteeUserIDs(
|
||||||
|
attendeeIDs []string,
|
||||||
|
channel string,
|
||||||
|
senderID string,
|
||||||
|
) []string {
|
||||||
|
ids := make([]string, 0, len(attendeeIDs)+1)
|
||||||
|
seen := make(map[string]struct{}, len(attendeeIDs)+1)
|
||||||
|
addID := func(raw string) {
|
||||||
|
id := strings.TrimSpace(raw)
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
for _, attendeeID := range attendeeIDs {
|
||||||
|
addID(attendeeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ids) == 0 &&
|
||||||
|
strings.EqualFold(strings.TrimSpace(channel), "feishu") &&
|
||||||
|
strings.TrimSpace(senderID) != "" &&
|
||||||
|
!strings.EqualFold(strings.TrimSpace(senderID), "unknown") {
|
||||||
|
addID(senderID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *FeishuCalendarTool) addFeishuEventAttendees(
|
||||||
|
ctx context.Context,
|
||||||
|
calendarID string,
|
||||||
|
eventID string,
|
||||||
|
inviteeUserIDs []string,
|
||||||
|
needNotification bool,
|
||||||
|
) error {
|
||||||
|
if len(inviteeUserIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
attendees := make([]*larkcalendar.CalendarEventAttendee, 0, len(inviteeUserIDs))
|
||||||
|
for _, invitee := range inviteeUserIDs {
|
||||||
|
attendees = append(attendees, larkcalendar.NewCalendarEventAttendeeBuilder().
|
||||||
|
Type("user").
|
||||||
|
UserId(invitee).
|
||||||
|
Build())
|
||||||
|
}
|
||||||
|
|
||||||
|
req := larkcalendar.NewCreateCalendarEventAttendeeReqBuilder().
|
||||||
|
CalendarId(calendarID).
|
||||||
|
EventId(eventID).
|
||||||
|
UserIdType(larkcalendar.UserIdTypeCreateCalendarEventAttendeeUserId).
|
||||||
|
Body(larkcalendar.NewCreateCalendarEventAttendeeReqBodyBuilder().
|
||||||
|
Attendees(attendees).
|
||||||
|
NeedNotification(needNotification).
|
||||||
|
Build()).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := t.client.Calendar.V4.CalendarEventAttendee.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add attendees request failed: %w", err)
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("add attendees api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
165
pkg/tools/feishu_calendar_test.go
Normal file
165
pkg/tools/feishu_calendar_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFeishuDateTime(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to load location: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectHasTZ bool
|
||||||
|
expectUnix int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unix timestamp",
|
||||||
|
input: "1739935200",
|
||||||
|
expectHasTZ: false,
|
||||||
|
expectUnix: 1739935200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rfc3339 with timezone",
|
||||||
|
input: "2026-02-25T14:30:00+08:00",
|
||||||
|
expectHasTZ: true,
|
||||||
|
expectUnix: 1772001000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "local datetime",
|
||||||
|
input: "2026-02-25 09:15",
|
||||||
|
expectHasTZ: false,
|
||||||
|
expectUnix: time.Date(2026, 2, 25, 9, 15, 0, 0, loc).Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, hasTZ, err := parseFeishuDateTime(tt.input, loc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseFeishuDateTime() error = %v", err)
|
||||||
|
}
|
||||||
|
if hasTZ != tt.expectHasTZ {
|
||||||
|
t.Fatalf("hasExplicitTZ = %v, want %v", hasTZ, tt.expectHasTZ)
|
||||||
|
}
|
||||||
|
if got.Unix() != tt.expectUnix {
|
||||||
|
t.Fatalf("unix = %d, want %d", got.Unix(), tt.expectUnix)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFeishuEventTimezone(t *testing.T) {
|
||||||
|
parsed := time.Unix(1772001000, 0) // 2026-02-25T14:30:00+08:00 equivalent
|
||||||
|
|
||||||
|
got, err := resolveFeishuEventTimezone("Asia/Shanghai", parsed, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveFeishuEventTimezone returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "Asia/Shanghai" {
|
||||||
|
t.Fatalf("timezone = %q, want %q", got, "Asia/Shanghai")
|
||||||
|
}
|
||||||
|
|
||||||
|
withOffset := time.FixedZone("UTC-5", -5*3600)
|
||||||
|
got, err = resolveFeishuEventTimezone("", time.Date(2026, 2, 25, 9, 0, 0, 0, withOffset), true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveFeishuEventTimezone returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != "Etc/GMT+5" {
|
||||||
|
t.Fatalf("timezone = %q, want %q", got, "Etc/GMT+5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseReminderMinutesArg(t *testing.T) {
|
||||||
|
got, err := parseReminderMinutesArg(map[string]any{
|
||||||
|
"reminder_minutes": []any{30.0, 10.0, 30.0},
|
||||||
|
}, "reminder_minutes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseReminderMinutesArg() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != 10 || got[1] != 30 {
|
||||||
|
t.Fatalf("got %v, want [10 30]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseReminderMinutesArg(map[string]any{
|
||||||
|
"reminder_minutes": []any{"10"},
|
||||||
|
}, "reminder_minutes"); err == nil {
|
||||||
|
t.Fatalf("expected error for non-integer reminder item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldUseFeishuPrimaryCalendar(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
calendarID string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "empty", calendarID: "", want: true},
|
||||||
|
{name: "spaces", calendarID: " ", want: true},
|
||||||
|
{name: "primary lower", calendarID: "primary", want: true},
|
||||||
|
{name: "primary mixed case", calendarID: "Primary", want: true},
|
||||||
|
{name: "primary with spaces", calendarID: " PRIMARY ", want: true},
|
||||||
|
{name: "real calendar id", calendarID: "feishu.cn_xxx@group.calendar.feishu.cn", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := shouldUseFeishuPrimaryCalendar(tt.calendarID)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("shouldUseFeishuPrimaryCalendar(%q) = %v, want %v", tt.calendarID, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateFeishuIdempotencyKey(t *testing.T) {
|
||||||
|
key := generateFeishuIdempotencyKey()
|
||||||
|
if key == "" {
|
||||||
|
t.Fatalf("generateFeishuIdempotencyKey() returned empty key")
|
||||||
|
}
|
||||||
|
if _, err := uuid.Parse(key); err != nil {
|
||||||
|
t.Fatalf("generateFeishuIdempotencyKey() returned non-uuid value: %q, err=%v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFeishuInviteeUserIDs(t *testing.T) {
|
||||||
|
t.Run("explicit user attendees only", func(t *testing.T) {
|
||||||
|
got := buildFeishuInviteeUserIDs([]string{"u1", " u2 ", "u1"}, "feishu", "5bc38gcb")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(ids) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0] != "u1" || got[1] != "u2" {
|
||||||
|
t.Fatalf("ids = %v, want [u1 u2]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("auto include sender for feishu when empty", func(t *testing.T) {
|
||||||
|
got := buildFeishuInviteeUserIDs(nil, "feishu", "5bc38gcb")
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("len(ids) = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0] != "5bc38gcb" {
|
||||||
|
t.Fatalf("ids[0] = %q, want 5bc38gcb", got[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("do not include unknown sender", func(t *testing.T) {
|
||||||
|
got := buildFeishuInviteeUserIDs(nil, "feishu", "unknown")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("len(ids) = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no auto include for non-feishu", func(t *testing.T) {
|
||||||
|
got := buildFeishuInviteeUserIDs(nil, "telegram", "123")
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("len(ids) = %d, want 0", len(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ func (r *ToolRegistry) Get(name string) (Tool, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
|
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult {
|
||||||
return r.ExecuteWithContext(ctx, name, args, "", "", nil)
|
return r.ExecuteWithContext(ctx, name, args, "", "", "", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
|
||||||
|
|
@ -47,8 +47,11 @@ func (r *ToolRegistry) ExecuteWithContext(
|
||||||
name string,
|
name string,
|
||||||
args map[string]any,
|
args map[string]any,
|
||||||
channel, chatID string,
|
channel, chatID string,
|
||||||
|
senderID string,
|
||||||
asyncCallback AsyncCallback,
|
asyncCallback AsyncCallback,
|
||||||
) *ToolResult {
|
) *ToolResult {
|
||||||
|
ctx = withExecutionContext(ctx, channel, chatID, senderID)
|
||||||
|
|
||||||
logger.InfoCF("tool", "Tool execution started",
|
logger.InfoCF("tool", "Tool execution started",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"tool": name,
|
"tool": name,
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
|
||||||
}
|
}
|
||||||
r.Register(ct)
|
r.Register(ct)
|
||||||
|
|
||||||
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
|
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", "", nil)
|
||||||
|
|
||||||
if ct.channel != "telegram" {
|
if ct.channel != "telegram" {
|
||||||
t.Errorf("expected channel 'telegram', got %q", ct.channel)
|
t.Errorf("expected channel 'telegram', got %q", ct.channel)
|
||||||
|
|
@ -160,7 +160,7 @@ func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
|
||||||
}
|
}
|
||||||
r.Register(ct)
|
r.Register(ct)
|
||||||
|
|
||||||
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
|
r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", "", nil)
|
||||||
|
|
||||||
if ct.channel != "" || ct.chatID != "" {
|
if ct.channel != "" || ct.chatID != "" {
|
||||||
t.Error("SetContext should not be called with empty channel/chatID")
|
t.Error("SetContext should not be called with empty channel/chatID")
|
||||||
|
|
@ -178,7 +178,7 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
|
||||||
called := false
|
called := false
|
||||||
cb := func(_ context.Context, _ *ToolResult) { called = true }
|
cb := func(_ context.Context, _ *ToolResult) { called = true }
|
||||||
|
|
||||||
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
|
result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", "", cb)
|
||||||
if at.cb == nil {
|
if at.cb == nil {
|
||||||
t.Error("expected SetCallback to have been called")
|
t.Error("expected SetCallback to have been called")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ var defaultDenyPatterns = []*regexp.Regexp{
|
||||||
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
regexp.MustCompile(`\bsource\s+.*\.sh\b`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
guardPathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
||||||
|
envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
func NewExecTool(workingDir string, restrict bool) *ExecTool {
|
func NewExecTool(workingDir string, restrict bool) *ExecTool {
|
||||||
return NewExecToolWithConfig(workingDir, restrict, nil)
|
return NewExecToolWithConfig(workingDir, restrict, nil)
|
||||||
}
|
}
|
||||||
|
|
@ -556,10 +561,13 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
|
matches := guardPathPattern.FindAllStringIndex(cmd, -1)
|
||||||
matches := pathPattern.FindAllString(cmd, -1)
|
for _, match := range matches {
|
||||||
|
raw := cmd[match[0]:match[1]]
|
||||||
|
if pathMatchIsEnvAssignmentValue(cmd, match[0]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, raw := range matches {
|
|
||||||
p, err := filepath.Abs(raw)
|
p, err := filepath.Abs(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
@ -579,6 +587,25 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathMatchIsEnvAssignmentValue(command string, matchStart int) bool {
|
||||||
|
if matchStart <= 0 || matchStart > len(command) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStart := strings.LastIndexAny(command[:matchStart], " \t\r\n") + 1
|
||||||
|
if tokenStart < 0 || tokenStart >= matchStart {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := command[tokenStart:matchStart]
|
||||||
|
eq := strings.Index(prefix, "=")
|
||||||
|
if eq <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return envVarNamePattern.MatchString(prefix[:eq])
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
func (t *ExecTool) SetTimeout(timeout time.Duration) {
|
||||||
t.timeout = timeout
|
t.timeout = timeout
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -272,3 +272,26 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShellTool_RestrictToWorkspace_AllowsEnvAssignmentWithSlash(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tool := NewExecTool(tmpDir, true)
|
||||||
|
|
||||||
|
guardErr := tool.guardCommand("TZ=Asia/Shanghai date '+%Y-%m-%d'", tmpDir)
|
||||||
|
if guardErr != "" {
|
||||||
|
t.Fatalf("expected env assignment path to be ignored, got guard error: %s", guardErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellTool_RestrictToWorkspace_StillBlocksRealPathAfterEnvAssignment(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tool := NewExecTool(tmpDir, true)
|
||||||
|
|
||||||
|
guardErr := tool.guardCommand("TZ=Asia/Shanghai cat /etc/passwd", tmpDir)
|
||||||
|
if guardErr == "" {
|
||||||
|
t.Fatalf("expected real path access to be blocked")
|
||||||
|
}
|
||||||
|
if !strings.Contains(guardErr, "outside working dir") {
|
||||||
|
t.Fatalf("expected outside working dir error, got: %s", guardErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ func RunToolLoop(
|
||||||
// Execute tool (no async callback for subagents - they run independently)
|
// Execute tool (no async callback for subagents - they run independently)
|
||||||
var toolResult *ToolResult
|
var toolResult *ToolResult
|
||||||
if config.Tools != nil {
|
if config.Tools != nil {
|
||||||
toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil)
|
toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, "", nil)
|
||||||
} else {
|
} else {
|
||||||
toolResult = ErrorResult("No tools available")
|
toolResult = ErrorResult("No tools available")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue