From ffa90be27b2e18d3200caee70c761f24e5611e25 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 23 Feb 2026 20:46:55 +0800 Subject: [PATCH] fix feishu calendar delivery and timezone handling --- docker-compose.yml | 2 + pkg/agent/loop.go | 9 + pkg/tools/context_values.go | 54 +++ pkg/tools/feishu_calendar.go | 548 ++++++++++++++++++++++++++++++ pkg/tools/feishu_calendar_test.go | 165 +++++++++ pkg/tools/registry.go | 5 +- pkg/tools/registry_test.go | 6 +- pkg/tools/shell.go | 33 +- pkg/tools/shell_test.go | 23 ++ pkg/tools/toolloop.go | 2 +- 10 files changed, 839 insertions(+), 8 deletions(-) create mode 100644 pkg/tools/context_values.go create mode 100644 pkg/tools/feishu_calendar.go create mode 100644 pkg/tools/feishu_calendar_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 465ed52cc..8840da4c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace environment: + TZ: Asia/Shanghai HTTP_PROXY: http://127.0.0.1:7890 HTTPS_PROXY: http://127.0.0.1:7890 NO_PROXY: localhost,127.0.0.1,::1 @@ -68,6 +69,7 @@ services: # Persistent workspace (sessions, memory, logs) - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace environment: + TZ: Asia/Shanghai HTTP_PROXY: http://127.0.0.1:7890 HTTPS_PROXY: http://127.0.0.1:7890 NO_PROXY: localhost,127.0.0.1,::1 diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ad88fda8f..0603f4dc1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -45,6 +45,7 @@ type processOptions struct { SessionKey string // Session identifier for history/context Channel string // Target channel 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) DefaultResponse string // Response when LLM returns empty EnableSummary bool // Whether to trigger summarization @@ -131,6 +132,12 @@ func registerSharedTools( }) 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 registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, @@ -382,6 +389,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, + SenderID: msg.SenderID, UserMessage: msg.Content, DefaultResponse: "I've completed processing but have no response to give.", EnableSummary: true, @@ -747,6 +755,7 @@ func (al *AgentLoop) runLLMIteration( tc.Arguments, opts.Channel, opts.ChatID, + opts.SenderID, asyncCallback, ) diff --git a/pkg/tools/context_values.go b/pkg/tools/context_values.go new file mode 100644 index 000000000..6f530d318 --- /dev/null +++ b/pkg/tools/context_values.go @@ -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) +} diff --git a/pkg/tools/feishu_calendar.go b/pkg/tools/feishu_calendar.go new file mode 100644 index 000000000..82f6b8c16 --- /dev/null +++ b/pkg/tools/feishu_calendar.go @@ -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 +} diff --git a/pkg/tools/feishu_calendar_test.go b/pkg/tools/feishu_calendar_test.go new file mode 100644 index 000000000..7ad5d464c --- /dev/null +++ b/pkg/tools/feishu_calendar_test.go @@ -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)) + } + }) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index d37a093a8..e68f369dd 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -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 { - 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. @@ -47,8 +47,11 @@ func (r *ToolRegistry) ExecuteWithContext( name string, args map[string]any, channel, chatID string, + senderID string, asyncCallback AsyncCallback, ) *ToolResult { + ctx = withExecutionContext(ctx, channel, chatID, senderID) + logger.InfoCF("tool", "Tool execution started", map[string]any{ "tool": name, diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 8ae13b20c..45eca7602 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -143,7 +143,7 @@ func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) { } 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" { t.Errorf("expected channel 'telegram', got %q", ct.channel) @@ -160,7 +160,7 @@ func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) { } r.Register(ct) - r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) + r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", "", nil) if ct.channel != "" || ct.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 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 { t.Error("expected SetCallback to have been called") } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 5b3cf8952..1a8587639 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -72,6 +72,11 @@ var defaultDenyPatterns = []*regexp.Regexp{ 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 { return NewExecToolWithConfig(workingDir, restrict, nil) } @@ -556,10 +561,13 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "" } - pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) - matches := pathPattern.FindAllString(cmd, -1) + matches := guardPathPattern.FindAllStringIndex(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) if err != nil { continue @@ -579,6 +587,25 @@ func (t *ExecTool) guardCommand(command, cwd string) string { 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) { t.timeout = timeout } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 6d35815e8..0969268c2 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -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) + } +} diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index cdfe0d6ce..1eed28de1 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -134,7 +134,7 @@ func RunToolLoop( // Execute tool (no async callback for subagents - they run independently) var toolResult *ToolResult 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 { toolResult = ErrorResult("No tools available") }