Skip to content

Calendar 服务开发指南

本文深入讲解如何开发 lark-cli Calendar 服务的快捷命令,包括时间处理、递归分割查询、并发优化、事务性操作等核心模式。

📖 前置阅读:建议先阅读 快捷命令开发实战 了解快捷命令框架。


Calendar 服务概览

Calendar 服务提供 6 个快捷命令:

命令功能风险等级关键特性
+agenda查看日程read递归分割查询、去重排序
+create创建事件write事务性操作、自动回滚
+freebusy查询忙闲read时间范围查询
+room-find查找会议室read并发查询(10 workers)
+rsvp回复邀请write简单状态更新
+suggestion建议时间readAI 指导、排除时间段

时间处理模式

多格式时间解析

Calendar 服务需要支持多种时间格式。使用 common.ParseTime() 函数:

go
// 支持的格式
ParseTime("2026-01-02")                    // 日期 → 00:00:00
ParseTime("2026-01-02", "end")             // 日期 → 23:59:59
ParseTime("2026-01-02T15:04:05+08:00")     // ISO 8601 with TZ
ParseTime("2026-01-02T15:04:05")           // ISO 8601 without TZ
ParseTime("1742515200")                    // Unix 时间戳

时间范围处理

go
// 提供默认值
func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
    startInput := runtime.Str("start")
    if startInput == "" {
        startInput = time.Now().Format("2006-01-02")  // 默认今天
    }
    
    endInput := runtime.Str("end")
    if endInput == "" {
        endInput = startInput  // 默认等于开始日期
    }
    
    return startInput, endInput
}

时区处理

go
// 本地时区转换
func convertToLocalTime(timestamp string) string {
    ts, _ := strconv.ParseInt(timestamp, 10, 64)
    return time.Unix(ts, 0).Local().Format(time.RFC3339)
}

// 全天事件处理:调整结束日期 -1 秒
func adjustAllDayEndDate(dateStr string) string {
    t, _ := time.ParseInLocation("2006-01-02", dateStr, time.UTC)
    return t.Add(-1 * time.Second).Format("2006-01-02")
}

递归分割查询模式

API 限制

Calendar API 有两个重要限制:

go
const (
    maxInstanceViewSpanSeconds = 40 * 24 * 60 * 60  // 40 天限制
    minSplitWindowSeconds = 2 * 60 * 60              // 最小分割窗口 2 小时
)

// 错误代码
const (
    larkErrCalendarTimeRangeExceeded = 193103  // 时间范围超过 40 天
    larkErrCalendarTooManyInstances  = 193104  // 返回超过 1000 条实例
)

递归二分分割实现

go
func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext,
    calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) {
    
    // 递归深度限制
    if depth > 10 {
        return nil, output.Errorf(output.ExitInternal, "recursion_limit",
            "too many splits for instance_view")
    }
    
    span := endTime - startTime
    
    // 策略1:时间范围超过 40 天 → 二分分割
    if span > maxInstanceViewSpanSeconds {
        mid := startTime + span/2
        left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
        if err != nil {
            return nil, err
        }
        right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
        if err != nil {
            return nil, err
        }
        return append(left, right...), nil
    }
    
    // 调用 API
    result, err := runtime.RawAPI("GET",
        fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view",
            validate.EncodePathSegment(calendarId)),
        map[string]interface{}{
            "start_time": fmt.Sprintf("%d", startTime),
            "end_time":   fmt.Sprintf("%d", endTime),
        }, nil)
    
    if err != nil {
        // 处理错误代码 193103(时间范围超过限制)
        if isTimeRangeExceededError(err) {
            mid := startTime + span/2
            if mid <= startTime {
                return nil, output.Errorf(output.ExitAPI, "api_error",
                    "query failed: time range exceeds 40-day limit")
            }
            // 递归分割
            left, _ := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
            right, _ := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
            return append(left, right...), nil
        }
        
        // 处理错误代码 193104(超过 1000 条实例)
        if isTooManyInstancesError(err) {
            if span <= minSplitWindowSeconds {
                return nil, output.Errorf(output.ExitAPI, "api_error",
                    "query failed: more than 1000 instances in the time range")
            }
            // 继续分割
            mid := startTime + span/2
            left, _ := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1)
            right, _ := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1)
            return append(left, right...), nil
        }
        
        return nil, err
    }
    
    return extractItems(result), nil
}

去重和排序

go
func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} {
    // 去重:使用 event_id + start_time + end_time 作为 key
    seen := make(map[string]bool)
    var result []map[string]interface{}
    
    for _, e := range items {
        eventId, _ := e["event_id"].(string)
        startMap, _ := e["start_time"].(map[string]interface{})
        endMap, _ := e["end_time"].(map[string]interface{})
        startTs, _ := startMap["timestamp"].(string)
        endTs, _ := endMap["timestamp"].(string)
        
        key := eventId + "|" + startTs + "|" + endTs
        if !seen[key] {
            seen[key] = true
            result = append(result, e)
        }
    }
    
    // 按开始时间排序
    sort.Slice(result, func(i, j int) bool {
        si, _ := result[i]["start_time"].(map[string]interface{})
        sj, _ := result[j]["start_time"].(map[string]interface{})
        ti, _ := si["timestamp"].(string)
        tj, _ := sj["timestamp"].(string)
        ni, _ := strconv.ParseInt(ti, 10, 64)
        nj, _ := strconv.ParseInt(tj, 10, 64)
        return ni < nj
    })
    
    return result
}

并发查询优化

信号量模式

Calendar 的 +room-find 命令需要查询多个时间槽的会议室可用性。使用信号量模式控制并发度:

go
const roomFindWorkers = 10  // 并发度为 10

func collectRoomFindResults(slots []roomFindSlot, limit int,
    fetch func(roomFindSlot) ([]*roomFindSuggestion, error)) (*roomFindOutput, error) {
    
    if limit <= 0 {
        limit = 1
    }
    
    out := &roomFindOutput{
        TimeSlots: make([]*roomFindTimeSlot, 0, len(slots)),
    }
    
    var wg sync.WaitGroup
    var mu sync.Mutex
    var firstErr error
    sem := make(chan struct{}, limit)  // 信号量通道
    
    for _, slot := range slots {
        wg.Add(1)
        sem <- struct{}{}  // 获取信号量
        
        go func(slot roomFindSlot) {
            defer wg.Done()
            defer func() { <-sem }()  // 释放信号量
            
            suggestions, err := fetch(slot)
            mu.Lock()
            defer mu.Unlock()
            
            if err != nil {
                if firstErr == nil {
                    firstErr = err
                }
                return
            }
            
            out.TimeSlots = append(out.TimeSlots, &roomFindTimeSlot{
                Start:        slot.Start,
                End:          slot.End,
                MeetingRooms: suggestions,
            })
        }(slot)
    }
    
    wg.Wait()
    
    if firstErr != nil {
        return nil, firstErr
    }
    
    // 按时间排序结果
    sort.Slice(out.TimeSlots, func(i, j int) bool {
        return out.TimeSlots[i].Start < out.TimeSlots[j].Start
    })
    
    return out, nil
}

错误处理策略

  • 使用 sync.Mutex 保护共享状态
  • 记录第一个错误,继续处理其他任务
  • 所有 goroutine 完成后返回第一个错误
  • 不会因为单个任务失败而中断其他任务

事务性操作

创建事件 + 添加参会人 + 自动回滚

+create 命令需要支持事务性操作:创建事件后添加参会人,如果失败则自动回滚。

go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
    // 1. 创建事件
    eventData := map[string]interface{}{
        "summary":     runtime.Str("summary"),
        "description": runtime.Str("description"),
        "start_time":  map[string]interface{}{"timestamp": startTs},
        "end_time":    map[string]interface{}{"timestamp": endTs},
    }
    
    data, err := runtime.CallAPI("POST",
        fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", calendarId),
        map[string]interface{}{"user_id_type": "open_id"},
        eventData)
    if err != nil {
        return err
    }
    
    eventId := common.GetString(data, "event_id")
    
    // 2. 添加参会人(如果指定)
    if attendeeIds := runtime.Str("attendee-ids"); attendeeIds != "" {
        attendees := []map[string]interface{}{}
        for _, id := range common.SplitCSV(attendeeIds) {
            attendees = append(attendees, map[string]interface{}{
                "open_id": id,
            })
        }
        
        _, err = runtime.CallAPI("POST",
            fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", calendarId, eventId),
            map[string]interface{}{"user_id_type": "open_id"},
            map[string]interface{}{
                "attendees":         attendees,
                "need_notification": true,
            })
        
        if err != nil {
            // 3. 回滚:删除已创建的事件
            _, rollbackErr := runtime.RawAPI("DELETE",
                fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", calendarId, eventId),
                map[string]interface{}{"need_notification": false}, nil)
            
            if rollbackErr != nil {
                return output.Errorf(output.ExitAPI, "api_error",
                    "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup",
                    rollbackErr, eventId)
            }
            
            return output.Errorf(output.ExitAPI, "api_error",
                "failed to add attendees: %v; event rolled back successfully", err)
        }
    }
    
    return runtime.OutFormat(data, nil, formatCreateEvent)
}

身份和权限处理

User vs Bot 差异

Calendar 命令默认要求用户身份,防止自动回退到 Bot 身份:

go
func rejectCalendarAutoBotFallback(runtime *common.RuntimeContext) error {
    if runtime == nil || !runtime.IsBot() || hasExplicitBotFlag(runtime.Cmd) {
        return nil  // 用户身份或显式指定 bot,允许
    }
    
    if runtime.Factory == nil || !runtime.Factory.IdentityAutoDetected {
        return nil  // 身份未自动检测,允许
    }
    
    // 自动检测到 bot 身份时拒绝
    msg := "calendar commands require a valid user login by default; " +
           "when no valid user login state is available, auto identity falls back to bot " +
           "and may operate on the bot calendar instead of your own."
    hint := "restore user login: `lark-cli auth login --domain calendar`\n" +
            "intentional bot usage: rerun with `--as bot`"
    return output.ErrWithHint(output.ExitAuth, "calendar_user_login_required", msg, hint)
}

权限范围要求

go
// +agenda (查看日程)
Scopes: []string{"calendar:calendar.event:read"}

// +create (创建事件)
Scopes: []string{"calendar:calendar.event:create", "calendar:calendar.event:update"}

// +freebusy (查询忙闲)
Scopes: []string{"calendar:calendar.free_busy:read"}

// +room-find (查找会议室)
Scopes: []string{"calendar:calendar.free_busy:read"}

// +rsvp (回复邀请)
Scopes: []string{"calendar:calendar.event:reply"}

// +suggestion (建议时间)
Scopes: []string{"calendar:calendar.free_busy:read"}

身份特定逻辑

go
// +freebusy:bot 必须指定 --user-id
if userId == "" && runtime.IsBot() {
    return common.FlagErrorf("--user-id is required for bot identity")
}

// +create:非 bot 自动包含当前用户
if !runtime.IsBot() {
    currentUserId := runtime.UserOpenId()
    // 添加到参会人列表
}

// +suggestion:非 bot 自动加入参会人列表
if !runtime.IsBot() {
    userOpenId := runtime.UserOpenId()
    req.AttendeeUserIds = append(req.AttendeeUserIds, userOpenId)
}

错误处理

常见错误类型

go
// API 错误代码
const (
    larkErrCalendarTimeRangeExceeded = 193103  // 时间范围超过 40 天
    larkErrCalendarTooManyInstances  = 193104  // 返回超过 1000 条实例
)

// 检查错误代码
func isTimeRangeExceededError(err error) bool {
    if exitErr, ok := err.(*output.ExitError); ok {
        return exitErr.Detail.Code == larkErrCalendarTimeRangeExceeded
    }
    return false
}

func isTooManyInstancesError(err error) bool {
    if exitErr, ok := err.(*output.ExitError); ok {
        return exitErr.Detail.Code == larkErrCalendarTooManyInstances
    }
    return false
}

用户友好的错误消息

go
// 时间范围超过限制
"query failed: time range exceeds 40-day limit, please narrow the range"

// 实例过多
"query failed: more than 1000 instances in the time range, please narrow the range"

// 递归深度超限
"too many splits for instance_view"

// 身份问题
"calendar commands require a valid user login by default; when no valid user login state is available, " +
"auto identity falls back to bot and may operate on the bot calendar instead of your own. " +
"Run `lark-cli auth login --domain calendar` for your calendar, or rerun with `--as bot` if bot identity is intentional."

// 验证错误
"invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'"
"end time must be after start time"
"--user-id is required for bot identity"

实战:开发 +list-events 快捷命令

需求分析

创建一个 +list-events 命令,列出指定日期范围内的所有事件,支持:

  • 时间范围查询(默认今天)
  • 按标题过滤
  • 按状态过滤(confirmed/cancelled)
  • 支持多种输出格式

实现步骤

go
package calendar

import (
    "context"
    "fmt"
    "sort"
    "strconv"
    "strings"
    "time"
    
    "github.com/larksuite/cli/shortcuts/common"
)

var ListEvents = common.Shortcut{
    Service:     "calendar",
    Command:     "+list-events",
    Description: "List calendar events in a date range",
    Risk:        "read",
    
    Scopes:      []string{"calendar:calendar.event:read"},
    AuthTypes:   []string{"user", "bot"},
    HasFormat:   true,
    
    Flags: []common.Flag{
        {
            Name:     "start",
            Type:     "string",
            Desc:     "Start date (default: today)",
            Required: false,
        },
        {
            Name:     "end",
            Type:     "string",
            Desc:     "End date (default: same as start)",
            Required: false,
        },
        {
            Name:     "title",
            Type:     "string",
            Desc:     "Filter by event title (substring match)",
            Required: false,
        },
        {
            Name:     "status",
            Type:     "string",
            Desc:     "Filter by status (confirmed/cancelled)",
            Required: false,
            Enum:     []string{"confirmed", "cancelled"},
        },
        {
            Name:     "calendar-id",
            Type:     "string",
            Desc:     "Calendar ID (default: primary)",
            Default:  "primary",
        },
    },
    
    Validate: validateListEvents,
    DryRun:   dryRunListEvents,
    Execute:  executeListEvents,
}

func validateListEvents(ctx context.Context, runtime *common.RuntimeContext) error {
    // 检查身份
    if err := rejectCalendarAutoBotFallback(runtime); err != nil {
        return err
    }
    
    // 验证时间格式
    if start := runtime.Str("start"); start != "" {
        if _, err := common.ParseTime(start); err != nil {
            return fmt.Errorf("invalid start date: %w", err)
        }
    }
    
    if end := runtime.Str("end"); end != "" {
        if _, err := common.ParseTime(end); err != nil {
            return fmt.Errorf("invalid end date: %w", err)
        }
    }
    
    return nil
}

func dryRunListEvents(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
    d := common.NewDryRunAPI()
    d.Desc("List calendar events")
    d.GET("/open-apis/calendar/v4/calendars/:calendar_id/events/instance_view")
    d.Set("calendar_id", runtime.Str("calendar-id"))
    return d
}

func executeListEvents(ctx context.Context, runtime *common.RuntimeContext) error {
    calendarId := runtime.Str("calendar-id")
    
    // 解析时间范围
    startInput, endInput := resolveStartEnd(runtime)
    startTime, _ := common.ParseTime(startInput)
    endTime, _ := common.ParseTime(endInput, "end")
    
    // 调用 API(使用递归分割)
    items, err := fetchInstanceViewRange(ctx, runtime, calendarId,
        parseUnixTime(startTime), parseUnixTime(endTime), 0)
    if err != nil {
        return err
    }
    
    // 去重和排序
    items = dedupeAndSortItems(items)
    
    // 过滤
    titleFilter := runtime.Str("title")
    statusFilter := runtime.Str("status")
    
    var filtered []map[string]interface{}
    for _, item := range items {
        // 标题过滤
        if titleFilter != "" {
            summary, _ := item["summary"].(string)
            if !strings.Contains(strings.ToLower(summary), strings.ToLower(titleFilter)) {
                continue
            }
        }
        
        // 状态过滤
        if statusFilter != "" {
            status, _ := item["status"].(string)
            if status != statusFilter {
                continue
            }
        }
        
        filtered = append(filtered, item)
    }
    
    // 输出
    return runtime.OutFormat(filtered, &common.Meta{Count: len(filtered)}, func(w io.Writer) {
        if len(filtered) == 0 {
            fmt.Fprintln(w, "No events found")
            return
        }
        
        var rows []map[string]interface{}
        for _, e := range filtered {
            eventId, _ := e["event_id"].(string)
            summary, _ := e["summary"].(string)
            if summary == "" {
                summary = "(untitled)"
            }
            
            startMap, _ := e["start_time"].(map[string]interface{})
            startTs, _ := startMap["timestamp"].(string)
            startStr := convertToLocalTime(startTs)
            
            rows = append(rows, map[string]interface{}{
                "event_id": eventId,
                "summary":  summary,
                "start":    startStr,
            })
        }
        
        common.PrintTable(w, rows)
        fmt.Fprintf(w, "\n%d event(s) total\n", len(filtered))
    })
}

最佳实践

1. 时间处理

go
// ✓ 好:支持多种格式
startTime, _ := common.ParseTime(runtime.Str("start"))

// ✗ 不好:只支持一种格式
t, _ := time.Parse(time.RFC3339, runtime.Str("start"))

2. 大数据查询

go
// ✓ 好:递归分割处理 API 限制
items, err := fetchInstanceViewRange(ctx, runtime, calendarId, start, end, 0)

// ✗ 不好:一次性查询,可能超过 API 限制
items, err := runtime.CallAPI("GET", "/open-apis/calendar/v4/calendars/.../events/instance_view", ...)

3. 并发优化

go
// ✓ 好:使用信号量控制并发度
sem := make(chan struct{}, 10)

// ✗ 不好:无限制并发
go func() { ... }()

4. 事务性操作

go
// ✓ 好:失败时自动回滚
if err != nil {
    rollback()
    return err
}

// ✗ 不好:不处理失败状态
createEvent()
addAttendees()

5. 身份检查

go
// ✓ 好:防止自动回退到 bot
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
    return err
}

// ✗ 不好:不检查身份
// 直接使用 runtime.IsBot()

相关资源


下一步