fix feishu calendar delivery and timezone handling

This commit is contained in:
root 2026-02-23 20:46:55 +08:00
parent 5ac3b8fec1
commit ffa90be27b
10 changed files with 839 additions and 8 deletions

View file

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

View file

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

View 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)
}

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

View 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))
}
})
}

View file

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

View file

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

View file

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

View file

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

View file

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