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
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
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 {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue